diff --git a/__byond_var_id_padding.dm b/__byond_var_id_padding.dm new file mode 100644 index 00000000000..7d7f62da483 --- /dev/null +++ b/__byond_var_id_padding.dm @@ -0,0 +1,105 @@ +// BYOND bug workaround (VOIDCREW): DM numbers every variable name from one table, and a proc static, +// global or local that lands on id 65535 compiles wrong. A static or global fails with ": invalid +// variable", and a local gets a false "variable defined but not used" warning and reads the wrong value. +// Object vars and proc arguments on that id are fine. Once a build has more than 65535 names, about half +// of all changes put some static or local on the broken id. Seen on 516.1659 and 516.1667. +// +// This file is included first, so its 70,000 unused var names take ids 0 to 69999, and id 65535 always +// falls on one of them. A type holds at most about 65k vars, so they are split over seven subtypes. +// Nothing ever makes a /datum/byond_var_id_padding. Remove this once BYOND fixes it. + +#define VAR_ID_PAD_1(a, b, c, d, e) var/var_id_pad_##a##b##c##d##e +#define VAR_ID_PAD_10(a, b, c, d) VAR_ID_PAD_1(a, b, c, d, 0);VAR_ID_PAD_1(a, b, c, d, 1);VAR_ID_PAD_1(a, b, c, d, 2);VAR_ID_PAD_1(a, b, c, d, 3);VAR_ID_PAD_1(a, b, c, d, 4);VAR_ID_PAD_1(a, b, c, d, 5);VAR_ID_PAD_1(a, b, c, d, 6);VAR_ID_PAD_1(a, b, c, d, 7);VAR_ID_PAD_1(a, b, c, d, 8);VAR_ID_PAD_1(a, b, c, d, 9) +#define VAR_ID_PAD_100(a, b, c) VAR_ID_PAD_10(a, b, c, 0);VAR_ID_PAD_10(a, b, c, 1);VAR_ID_PAD_10(a, b, c, 2);VAR_ID_PAD_10(a, b, c, 3);VAR_ID_PAD_10(a, b, c, 4);VAR_ID_PAD_10(a, b, c, 5);VAR_ID_PAD_10(a, b, c, 6);VAR_ID_PAD_10(a, b, c, 7);VAR_ID_PAD_10(a, b, c, 8);VAR_ID_PAD_10(a, b, c, 9) +#define VAR_ID_PAD_1000(a, b) VAR_ID_PAD_100(a, b, 0);VAR_ID_PAD_100(a, b, 1);VAR_ID_PAD_100(a, b, 2);VAR_ID_PAD_100(a, b, 3);VAR_ID_PAD_100(a, b, 4);VAR_ID_PAD_100(a, b, 5);VAR_ID_PAD_100(a, b, 6);VAR_ID_PAD_100(a, b, 7);VAR_ID_PAD_100(a, b, 8);VAR_ID_PAD_100(a, b, 9) + +/datum/byond_var_id_padding + +/datum/byond_var_id_padding/part_0 + VAR_ID_PAD_1000(0, 0) + VAR_ID_PAD_1000(0, 1) + VAR_ID_PAD_1000(0, 2) + VAR_ID_PAD_1000(0, 3) + VAR_ID_PAD_1000(0, 4) + VAR_ID_PAD_1000(0, 5) + VAR_ID_PAD_1000(0, 6) + VAR_ID_PAD_1000(0, 7) + VAR_ID_PAD_1000(0, 8) + VAR_ID_PAD_1000(0, 9) + +/datum/byond_var_id_padding/part_1 + VAR_ID_PAD_1000(1, 0) + VAR_ID_PAD_1000(1, 1) + VAR_ID_PAD_1000(1, 2) + VAR_ID_PAD_1000(1, 3) + VAR_ID_PAD_1000(1, 4) + VAR_ID_PAD_1000(1, 5) + VAR_ID_PAD_1000(1, 6) + VAR_ID_PAD_1000(1, 7) + VAR_ID_PAD_1000(1, 8) + VAR_ID_PAD_1000(1, 9) + +/datum/byond_var_id_padding/part_2 + VAR_ID_PAD_1000(2, 0) + VAR_ID_PAD_1000(2, 1) + VAR_ID_PAD_1000(2, 2) + VAR_ID_PAD_1000(2, 3) + VAR_ID_PAD_1000(2, 4) + VAR_ID_PAD_1000(2, 5) + VAR_ID_PAD_1000(2, 6) + VAR_ID_PAD_1000(2, 7) + VAR_ID_PAD_1000(2, 8) + VAR_ID_PAD_1000(2, 9) + +/datum/byond_var_id_padding/part_3 + VAR_ID_PAD_1000(3, 0) + VAR_ID_PAD_1000(3, 1) + VAR_ID_PAD_1000(3, 2) + VAR_ID_PAD_1000(3, 3) + VAR_ID_PAD_1000(3, 4) + VAR_ID_PAD_1000(3, 5) + VAR_ID_PAD_1000(3, 6) + VAR_ID_PAD_1000(3, 7) + VAR_ID_PAD_1000(3, 8) + VAR_ID_PAD_1000(3, 9) + +/datum/byond_var_id_padding/part_4 + VAR_ID_PAD_1000(4, 0) + VAR_ID_PAD_1000(4, 1) + VAR_ID_PAD_1000(4, 2) + VAR_ID_PAD_1000(4, 3) + VAR_ID_PAD_1000(4, 4) + VAR_ID_PAD_1000(4, 5) + VAR_ID_PAD_1000(4, 6) + VAR_ID_PAD_1000(4, 7) + VAR_ID_PAD_1000(4, 8) + VAR_ID_PAD_1000(4, 9) + +/datum/byond_var_id_padding/part_5 + VAR_ID_PAD_1000(5, 0) + VAR_ID_PAD_1000(5, 1) + VAR_ID_PAD_1000(5, 2) + VAR_ID_PAD_1000(5, 3) + VAR_ID_PAD_1000(5, 4) + VAR_ID_PAD_1000(5, 5) + VAR_ID_PAD_1000(5, 6) + VAR_ID_PAD_1000(5, 7) + VAR_ID_PAD_1000(5, 8) + VAR_ID_PAD_1000(5, 9) + +/datum/byond_var_id_padding/part_6 + VAR_ID_PAD_1000(6, 0) + VAR_ID_PAD_1000(6, 1) + VAR_ID_PAD_1000(6, 2) + VAR_ID_PAD_1000(6, 3) + VAR_ID_PAD_1000(6, 4) + VAR_ID_PAD_1000(6, 5) + VAR_ID_PAD_1000(6, 6) + VAR_ID_PAD_1000(6, 7) + VAR_ID_PAD_1000(6, 8) + VAR_ID_PAD_1000(6, 9) + +#undef VAR_ID_PAD_1000 +#undef VAR_ID_PAD_100 +#undef VAR_ID_PAD_10 +#undef VAR_ID_PAD_1 diff --git a/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm b/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm index 8f5cad05fc2..1905f78ca0a 100644 --- a/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm +++ b/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm @@ -372,7 +372,7 @@ /turf/template_noop, /area/template_noop) "qU" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /obj/structure/chair/stool/directional/west, /turf/open/floor/bamboo/tatami, /area/ruin/powered{ @@ -853,7 +853,7 @@ /turf/open/misc/asteroid/sand/beach, /area/overmap_encounter/planetoid/beach) "Mn" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /obj/structure/chair/stool/directional/east, /turf/open/floor/bamboo/tatami, /area/ruin/powered{ diff --git a/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm b/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm index 611652c7469..74926bbff22 100644 --- a/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm +++ b/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm @@ -157,7 +157,7 @@ /area/overmap_encounter/planetoid/beach) "dG" = ( /obj/structure/chair, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 }, @@ -244,7 +244,7 @@ /obj/structure/chair{ dir = 4 }, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/iron/dark, /area/ruin/powered) "ia" = ( @@ -338,7 +338,7 @@ /turf/template_noop, /area/template_noop) "mf" = ( -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/carpet/neon/simple/violet/nodots, /area/ruin/powered) "ms" = ( @@ -365,7 +365,7 @@ /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "mK" = ( -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 1 }, @@ -532,7 +532,7 @@ /area/ruin/powered) "sv" = ( /obj/structure/chair/stool/directional/north, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/carpet/neon/simple/violet/nodots, /area/ruin/powered) "sP" = ( @@ -602,7 +602,7 @@ /area/ruin/powered) "ve" = ( /obj/structure/toilet, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /obj/machinery/light/small/directional/north, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) @@ -738,7 +738,7 @@ /obj/structure/chair/sofa/corp/right{ dir = 1 }, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "Cz" = ( @@ -817,7 +817,7 @@ /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 1 }, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) "Fw" = ( @@ -1065,7 +1065,7 @@ /turf/open/floor/iron/dark, /area/ruin/powered) "Pl" = ( -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/light/colour_cycle/dancefloor_a, /area/ruin/powered) "Pq" = ( @@ -1085,7 +1085,7 @@ /area/template_noop) "Qw" = ( /obj/structure/chair/sofa/corp/corner, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "QN" = ( @@ -1169,7 +1169,7 @@ "SV" = ( /obj/structure/mirror/directional/north, /obj/structure/sink/directional/south, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /obj/machinery/light/blacklight/directional/west, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) @@ -1218,7 +1218,7 @@ /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "Ux" = ( -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/light/colour_cycle/dancefloor_a, /area/ruin/powered) "UU" = ( @@ -1257,7 +1257,7 @@ /obj/structure/chair/sofa/corp/corner{ dir = 8 }, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) "VX" = ( @@ -1292,7 +1292,7 @@ /obj/structure/chair{ dir = 1 }, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 }, diff --git a/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm b/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm index 384d19ad3bf..4bb94e40177 100644 --- a/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm +++ b/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm @@ -18,13 +18,13 @@ /turf/open/floor/mineral/plastitanium/red, /area/ruin/syndielab) "ae" = ( -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /obj/structure/cable/layer1, /turf/open/floor/iron/dark/textured, /area/ruin/syndielab) "af" = ( /obj/structure/chair/sofa/corp/right, -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /obj/machinery/light/warm/directional/north, /obj/structure/cable/layer1, /turf/open/floor/iron/dark/textured, diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm index c702a41efa4..3dc2f3b3ac2 100644 --- a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm @@ -167,7 +167,7 @@ /obj/structure/railing{ dir = 4 }, -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/wood, /area/ruin/unpowered) "rc" = ( @@ -242,11 +242,11 @@ /turf/open/floor/wood, /area/ruin/unpowered) "DH" = ( -/mob/living/basic/trooper/pirate/ranged/space, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/carpet/red, /area/ruin/unpowered) "DS" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/wood, /area/ruin/unpowered) "DV" = ( @@ -296,7 +296,7 @@ /turf/open/floor/wood, /area/ruin/unpowered) "LT" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /obj/structure/chair/wood, /obj/effect/decal/cleanable/dirt, /turf/open/floor/wood, @@ -366,7 +366,7 @@ /turf/open/misc/dirt/jungle, /area/ruin/unpowered) "UX" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/wood, /area/ruin/unpowered) "Vf" = ( @@ -396,7 +396,7 @@ /turf/open/floor/carpet/red, /area/ruin/unpowered) "XA" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /obj/effect/decal/cleanable/dirt, /turf/open/floor/wood, /area/ruin/unpowered) diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm index 22c2ce8c137..1d7fb523f84 100644 --- a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm @@ -129,7 +129,7 @@ /area/ruin/powered) "G" = ( /obj/structure/cable, -/mob/living/basic/trooper/syndicate, +/obj/effect/spawner/human_npc/ruin/syndicate, /turf/open/floor/plating, /area/ruin/powered) "H" = ( @@ -148,7 +148,7 @@ /area/ruin/unpowered) "K" = ( /obj/structure/chair/plastic, -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /turf/open/floor/plating, /area/ruin/powered) "M" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm index ffeabbd76bb..67b2d679367 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm @@ -1749,7 +1749,7 @@ /area/ruin/space/has_grav/powered/mafia_club/back_office) "FW" = ( /obj/effect/landmark/bounty_lair_gatekeeper{ - gatekeeper_type = /mob/living/basic/trooper/russian/mafia/lieutenant + gatekeeper_type = /mob/living/carbon/human/npc/bounty_mafia/lieutenant }, /turf/open/floor/carpet/red, /area/ruin/space/has_grav/powered/mafia_club/back_office) @@ -2653,7 +2653,7 @@ /area/ruin/space/has_grav/powered/mafia_club/staff) "ZX" = ( /obj/effect/landmark/bounty_lair_gatekeeper{ - gatekeeper_type = /mob/living/basic/trooper/russian/mafia/lieutenant + gatekeeper_type = /mob/living/carbon/human/npc/bounty_mafia/lieutenant }, /turf/open/floor/iron/dark/textured, /area/ruin/space/has_grav/powered/mafia_club/back_office) diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm index 0189b72e62b..9c3bd502369 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm @@ -29,9 +29,7 @@ /area/template_noop) "ah" = ( /obj/structure/lattice/catwalk, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/template_noop, /area/template_noop) "ai" = ( @@ -60,7 +58,7 @@ /area/shuttle/ruin/caravan/freighter2) "al" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter3) "am" = ( @@ -74,7 +72,7 @@ dir = 4 }, /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter3) "ao" = ( @@ -85,7 +83,7 @@ /obj/effect/turf_decal/box/white/corners{ dir = 4 }, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/dark/airless, /area/shuttle/ruin/caravan/freighter3) "aD" = ( @@ -623,9 +621,7 @@ dir = 4 }, /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter2) "wC" = ( @@ -755,9 +751,7 @@ /area/ruin/space/has_grav) "Fr" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/plating/airless, /area/shuttle/ruin/caravan/freighter2) "FG" = ( @@ -956,9 +950,7 @@ "Nm" = ( /obj/machinery/atmospherics/components/unary/vent_pump/on, /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter2) "NJ" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm index cfaca1c8c3b..9dc7a53c439 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm @@ -36,7 +36,7 @@ /area/ruin/space/has_grav/derelictconstruction) "fa" = ( /obj/structure/lattice, -/mob/living/basic/trooper/pirate/ranged/space, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/template_noop, /area/space/nearstation) "gi" = ( @@ -438,7 +438,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/derelictconstruction) "Za" = ( -/mob/living/basic/trooper/pirate/melee/space, +/obj/effect/spawner/human_npc/ruin/pirate/space, /obj/structure/lattice/catwalk, /turf/template_noop, /area/ruin/space/has_grav/derelictconstruction) diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm index 9e743c22eb0..fd8752ec4a3 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm @@ -168,7 +168,7 @@ /turf/open/floor/mineral/plastitanium, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aG" = ( -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/mineral/plastitanium/red, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aH" = ( @@ -190,7 +190,7 @@ dir = 10 }, /obj/item/wrench, -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/mineral/plastitanium/red, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aK" = ( @@ -214,7 +214,7 @@ /area/ruin/space/has_grav/syndicate_forgotten_ship) "aN" = ( /obj/machinery/light/directional/south, -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/iron/dark, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aO" = ( @@ -299,7 +299,7 @@ /obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ dir = 4 }, -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/mineral/plastitanium, /area/ruin/space/has_grav/syndicate_forgotten_ship) "bd" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm index fd128120432..d16dfe16136 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm @@ -262,7 +262,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/garbagetruck/medicalwaste) "wh" = ( -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /turf/open/floor/plating, /area/ruin/space/has_grav/garbagetruck/medicalwaste) "wk" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm index 6575a90d588..a9db1cac58f 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm @@ -708,7 +708,7 @@ "JR" = ( /obj/effect/decal/cleanable/ash, /obj/effect/decal/cleanable/blood/splatter/oil, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/plating/dumpsterair, /area/ruin/space/has_grav/garbagetruck/squat) "JW" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm index 070986d9901..81402127f2c 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm @@ -68,7 +68,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "ao" = ( -/mob/living/basic/trooper/pirate/melee/space, +/obj/effect/spawner/human_npc/ruin/pirate/space, /turf/open/misc/asteroid, /area/ruin/space/has_grav/powered) "ap" = ( @@ -176,7 +176,7 @@ /turf/open/misc/asteroid, /area/ruin/space/has_grav/powered) "aL" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/misc/asteroid, /area/ruin/space/has_grav/powered) "aM" = ( @@ -406,7 +406,7 @@ /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "pn" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "rS" = ( @@ -523,7 +523,7 @@ /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "Ej" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "Fd" = ( @@ -541,7 +541,7 @@ /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "Hx" = ( -/mob/living/basic/trooper/pirate/ranged/space, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "JH" = ( @@ -640,7 +640,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "Uv" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "UQ" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm index f2e9240f637..a19a80247cc 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm @@ -189,7 +189,7 @@ pixel_x = -1; pixel_y = 5 }, -/mob/living/basic/trooper/syndicate/melee/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando, /turf/open/floor/iron/dark/airless, /area/ruin/space) "eU" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm index 6faae6d9085..ca04e714348 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm @@ -18,7 +18,7 @@ /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargobay) "ad" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /obj/item/ammo_casing/shotgun/buckshot/spent{ pixel_x = 3; pixel_y = 10 @@ -35,14 +35,14 @@ /area/ruin/space/has_grav/waystation/dorms) "ae" = ( /obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden/layer4, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /obj/structure/cable, /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargooffice) "af" = ( /obj/effect/turf_decal/loading_area/red, /obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden/layer4, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /obj/structure/cable, /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargobay) @@ -98,11 +98,11 @@ /turf/open/floor/iron/dark, /area/ruin/space/has_grav/waystation/securestorage) "ah" = ( -/mob/living/basic/trooper/syndicate/melee/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando, /turf/open/floor/iron/dark, /area/ruin/space/has_grav/waystation/securestorage) "ai" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargobay) "aj" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm index 7c2663ddb2e..a65c7b18f99 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm @@ -2017,7 +2017,7 @@ /obj/structure/chair/wood{ dir = 8 }, -/mob/living/basic/trooper/russian{ +/obj/effect/spawner/human_npc/ruin/russian{ dir = 8 }, /turf/open/floor/wood, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm index 986dda90c83..9cc06996557 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm @@ -81,7 +81,7 @@ /area/overmap_encounter/planetoid/wasteland) "K" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/floor/pod/dark, /area/overmap_encounter/planetoid/wasteland) "M" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm index 0d6dae8b6c3..fc1decc8833 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm @@ -101,7 +101,7 @@ "sr" = ( /obj/effect/decal/cleanable/wrapping, /obj/structure/cable, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/plating/rust, /area/ruin) "sV" = ( @@ -191,7 +191,7 @@ /area/ruin) "Fg" = ( /obj/structure/chair/comfy/beige, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /obj/structure/closet/crate/zone_loot/armory, /turf/open/floor/wood, /area/ruin) @@ -265,7 +265,7 @@ /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) "VS" = ( -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/plating, /area/ruin) "WB" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm index 60616fa2f46..254293218d9 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm @@ -22,8 +22,8 @@ /turf/open/floor/plating/rust, /area/ruin/unpowered) "af" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) @@ -50,9 +50,9 @@ /turf/open/floor/plating, /area/ruin/unpowered) "al" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ dir = 4; - faction = list("adobe") + npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) @@ -104,17 +104,17 @@ /turf/open/floor/plating/rust, /area/ruin/unpowered) "aw" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ dir = 4; - faction = list("adobe") + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /turf/open/floor/plating, /area/ruin/unpowered) "ax" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 4; - faction = list("adobe") + npc_faction = list("adobe") }, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) @@ -144,23 +144,23 @@ /area/ruin/unpowered) "aC" = ( /obj/structure/chair/plastic, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) "aD" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger{ +/obj/effect/spawner/human_npc/ruin/hermit/gunslinger{ dir = 4; - faction = list("adobe") + npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) "aE" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 1; - faction = list("adobe") + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /obj/structure/closet/crate/zone_loot/wardrobe, @@ -215,9 +215,9 @@ /obj/structure/chair/plastic{ dir = 4 }, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 4; - faction = list("adobe") + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /turf/open/misc/wasteland/lit, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm index d5efef03e0c..71ad41c0cac 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm @@ -54,7 +54,7 @@ /turf/open/floor/wood, /area/ruin/unpowered) "n" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 4 }, /turf/open/floor/wood{ diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm index 74e10504e99..1d83e0ededd 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm @@ -121,9 +121,9 @@ "hL" = ( /obj/effect/decal/cleanable/dirt, /obj/effect/decal/cleanable/blood/old, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - faction = list("saloon"); - name = "Whitesands Bartender" +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("saloon"); + npc_name = "Whitesands Bartender" }, /turf/open/floor/wood{ broken = 1 @@ -191,9 +191,9 @@ /area/ruin/unpowered) "mr" = ( /obj/structure/chair/stool/bar, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 8; - faction = list("saloon") + npc_faction = list("saloon") }, /turf/open/floor/wood{ broken = 1 @@ -205,9 +205,9 @@ /area/ruin/unpowered) "nE" = ( /obj/structure/chair/stool/bar, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 1; - faction = list("saloon") + npc_faction = list("saloon") }, /turf/open/floor/wood, /area/ruin/unpowered) @@ -477,9 +477,9 @@ "Ov" = ( /obj/structure/chair/stool/bar, /obj/effect/decal/cleanable/dirt, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 8; - faction = list("saloon") + npc_faction = list("saloon") }, /turf/open/floor/wood, /area/ruin/unpowered) @@ -559,9 +559,9 @@ /area/ruin/unpowered) "Xb" = ( /obj/item/chair/stool/bar, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ +/obj/effect/spawner/human_npc/ruin/hermit{ dir = 1; - faction = list("saloon") + npc_faction = list("saloon") }, /turf/open/floor/wood, /area/ruin/unpowered) diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm index 349d0f5e9f0..1ffa9ecc108 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm @@ -31,10 +31,8 @@ "g" = ( /obj/structure/bed, /obj/item/bedsheet/hos, -/mob/living/basic/trooper/nanotrasen/ranged{ - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0); - damage_coeff = list("brute" = 1, "fire" = 1, "toxin" = 1, "clone" = 1, "stamina" = 0, "oxygen" = 0); - name = "\improper NanoTrasen Stranded Security Officer" +/obj/effect/spawner/human_npc/ruin/nanotrasen{ + npc_name = "NanoTrasen Stranded Security Officer" }, /obj/effect/decal/cleanable/dirt, /turf/open/floor/plating{ @@ -42,11 +40,9 @@ }, /area/ruin/unpowered) "h" = ( -/mob/living/basic/trooper/nanotrasen/ranged{ - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0); - damage_coeff = list("brute" = 1, "fire" = 1, "toxin" = 1, "clone" = 1, "stamina" = 0, "oxygen" = 0); +/obj/effect/spawner/human_npc/ruin/nanotrasen{ dir = 4; - name = "\improper NanoTrasen Stranded Security Officer" + npc_name = "NanoTrasen Stranded Security Officer" }, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) @@ -75,11 +71,9 @@ /turf/open/misc/dust/lit, /area/overmap_encounter/planetoid/wasteland) "v" = ( -/mob/living/basic/trooper/nanotrasen/ranged{ - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0); - damage_coeff = list("brute" = 1, "fire" = 1, "toxin" = 1, "clone" = 1, "stamina" = 0, "oxygen" = 0); +/obj/effect/spawner/human_npc/ruin/nanotrasen{ dir = 8; - name = "\improper NanoTrasen Stranded Security Officer" + npc_name = "NanoTrasen Stranded Security Officer" }, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm index 28d2c66731e..f4832b7e84d 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm @@ -1,8 +1,7 @@ //MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE "aa" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - desc = "One of the few survivors on the planet with working weaponry. Doesn't seem as friendly as the bartenders you're used to."; - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("saloon") }, /turf/open/floor/wood{ broken = 1 @@ -51,8 +50,8 @@ /obj/machinery/atmospherics/pipe/smart/simple/scrubbers/hidden/layer4{ dir = 10 }, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("saloon") }, /turf/open/floor/plating{ burnt = 1 @@ -220,8 +219,8 @@ /turf/open/floor/plating, /area/ruin/unpowered) "hm" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/plating, /area/ruin/unpowered) @@ -430,8 +429,8 @@ }, /area/ruin/unpowered) "vR" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/iron/dark, /area/ruin/unpowered) @@ -443,8 +442,8 @@ /area/ruin/unpowered) "wL" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/plating, /area/ruin/unpowered) @@ -1039,8 +1038,8 @@ /turf/open/floor/wood, /area/ruin/unpowered) "ZU" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/plating{ broken = 1 diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm index 473b45b1284..eef0eeed63d 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm @@ -111,7 +111,7 @@ /turf/open/floor/plating, /area/ruin/unpowered) "kO" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/plating{ burnt = 1 }, @@ -424,7 +424,7 @@ /turf/open/floor/iron/dark, /area/ruin/unpowered) "Gs" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/plating{ broken = 1 }, @@ -461,7 +461,7 @@ dir = 8 }, /obj/structure/cable, -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/plating, /area/ruin/unpowered) "Iv" = ( @@ -483,7 +483,7 @@ /turf/open/floor/plating, /area/ruin/unpowered) "Ku" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/mineral/titanium{ broken = 1 }, @@ -639,7 +639,7 @@ /area/ruin/unpowered) "RQ" = ( /obj/effect/decal/cleanable/dirt/dust, -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/iron/dark, /area/ruin/unpowered) "RV" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm index 729906e4db5..b3f7d1cbf67 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm @@ -26,7 +26,7 @@ pixel_y = 9 }, /obj/effect/decal/cleanable/blood/tracks, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/floor/plastic, /area/ruin/powered) "ae" = ( @@ -92,7 +92,7 @@ /area/ruin/powered) "ew" = ( /obj/effect/decal/cleanable/dirt/dust, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor, +/obj/effect/spawner/human_npc/ruin/hermit, /turf/open/floor/plastic, /area/ruin/powered) "eH" = ( @@ -184,7 +184,7 @@ /turf/open/floor/plastic, /area/ruin/powered) "hw" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/floor/plastic, /area/ruin/powered) "hA" = ( @@ -470,7 +470,7 @@ /turf/open/floor/material, /area/ruin/powered) "EC" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) "Fi" = ( @@ -596,7 +596,7 @@ /area/ruin/powered) "NU" = ( /obj/effect/decal/cleanable/dirt/dust, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor, +/obj/effect/spawner/human_npc/ruin/hermit, /turf/open/floor/material, /area/ruin/powered) "Oo" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm index 6e0af1a52f6..fddbbd2cf2f 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm @@ -27,7 +27,7 @@ /turf/open/floor/plating, /area/ruin/unpowered) "ag" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/dark, /area/ruin/unpowered) "ah" = ( @@ -42,7 +42,7 @@ }, /area/ruin/unpowered) "ai" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/misc/dust/lit, /area/overmap_encounter/planetoid/wasteland) "aj" = ( @@ -191,7 +191,7 @@ /turf/closed/wall/r_wall/plastitanium/syndicate/nodiagonal, /area/ruin/unpowered) "CW" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/plating, /area/ruin/unpowered) "Dz" = ( diff --git a/_maps/voidcrew/ships/ship_hightide.dmm b/_maps/voidcrew/ships/ship_hightide.dmm index 4fe02ee2122..724818b48d5 100644 --- a/_maps/voidcrew/ships/ship_hightide.dmm +++ b/_maps/voidcrew/ships/ship_hightide.dmm @@ -976,11 +976,7 @@ /turf/open/floor/plating, /area/shuttle/voidcrew/hightide/maintenance/cargo) "RI" = ( -/mob/living/basic/trooper/pirate/ranged{ - desc = "Timmy is a classic pirate cosplayer. He was locked in this room by the previous owner. He's survived by eating the rats, this is why there are ZERO rats in the universe."; - health = 25; - name = "Timothy" - }, +/obj/effect/spawner/human_npc/ruin/pirate/cosplayer, /obj/effect/decal/cleanable/dirt/dust, /obj/effect/decal/cleanable/greenglow, /turf/open/floor/plating, diff --git a/_maps/voidcrew/shuttles/ruin_caravan_victim.dmm b/_maps/voidcrew/shuttles/ruin_caravan_victim.dmm new file mode 100644 index 00000000000..46e644d1e04 --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_caravan_victim.dmm @@ -0,0 +1,1225 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"aj" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 8 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/plasteel/twenty, +/obj/item/stack/sheet/plasteel/twenty, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"co" = ( +/turf/closed/wall/mineral/titanium/nodiagonal, +/area/shuttle/ruin/caravan/freighter1) +"cu" = ( +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"cC" = ( +/obj/machinery/firealarm/directional/north, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"cJ" = ( +/obj/structure/table, +/obj/item/storage/toolbox/mechanical, +/obj/item/multitool, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"dv" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/right{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"ex" = ( +/obj/structure/lattice, +/obj/item/stack/sheet/mineral/titanium, +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"eN" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 4 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"fJ" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/obj/effect/decal/cleanable/blood, +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"fR" = ( +/obj/effect/turf_decal/bot, +/obj/machinery/atmospherics/components/tank/air{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"hM" = ( +/obj/structure/rack, +/obj/item/tank/internals/oxygen, +/obj/item/radio, +/obj/item/clothing/mask/gas, +/obj/effect/turf_decal/tile/blue{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"hS" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"hZ" = ( +/obj/machinery/door/firedoor, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/airlock/command{ + name = "Bridge" + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"iB" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/mapping_helpers/burnt_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"jz" = ( +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/silver{ + amount = 25 + }, +/obj/item/stack/sheet/mineral/silver{ + amount = 25 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"jQ" = ( +/obj/structure/table, +/obj/machinery/cell_charger, +/obj/machinery/firealarm/directional/north, +/obj/item/stack/cable_coil{ + pixel_x = 12; + pixel_y = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"kg" = ( +/obj/machinery/door/poddoor{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"ll" = ( +/obj/machinery/power/port_gen/pacman{ + anchored = 1 + }, +/obj/item/wrench, +/obj/machinery/power/terminal, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"lD" = ( +/obj/structure/closet/crate/preopen, +/obj/item/stack/sheet/iron/fifty, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"lK" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/yellow{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"mb" = ( +/obj/machinery/light/small/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"mx" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"nj" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/blood, +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"nI" = ( +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"nO" = ( +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"oJ" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"pj" = ( +/obj/machinery/suit_storage_unit/standard_unit, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"px" = ( +/obj/machinery/light/directional/north, +/obj/structure/table, +/obj/item/stack/package_wrap, +/obj/item/crowbar, +/obj/item/flashlight{ + pixel_x = 1; + pixel_y = 5 + }, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"rt" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/blue/half/contrasted{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"rz" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 4 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"rC" = ( +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"su" = ( +/obj/structure/table, +/obj/machinery/microwave{ + pixel_y = 5 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"sJ" = ( +/obj/effect/decal/cleanable/dirt, +/obj/structure/table/reinforced, +/obj/item/paper_bin{ + pixel_x = 6; + pixel_y = 6 + }, +/obj/item/pen{ + pixel_x = 6; + pixel_y = 6 + }, +/obj/item/folder/yellow{ + pixel_x = -6 + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"tp" = ( +/obj/machinery/door/airlock{ + id_tag = "caravantrade1_cabin1"; + name = "Cabin 1" + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"tH" = ( +/obj/structure/chair/stool/directional/west, +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"tT" = ( +/obj/machinery/door/poddoor{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door" + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"uE" = ( +/obj/structure/table, +/obj/item/storage/box/donkpockets{ + pixel_x = 6; + pixel_y = 6 + }, +/obj/item/plate{ + pixel_x = -5; + pixel_y = -3 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"uZ" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 1 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/uranium{ + amount = 10 + }, +/obj/item/stack/sheet/mineral/uranium{ + amount = 10 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"vG" = ( +/obj/structure/table/reinforced, +/obj/machinery/button/door{ + id = "caravantrade1_bridge"; + name = "Ship Blast Door Control" + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 4 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"wC" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/gold{ + amount = 25 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"yv" = ( +/obj/effect/turf_decal/bot_white, +/obj/structure/closet/emcloset, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"zV" = ( +/obj/structure/lattice, +/obj/structure/broken_flooring/singular/directional/south, +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"Bh" = ( +/obj/machinery/power/shuttle_engine/heater{ + dir = 8 + }, +/obj/structure/window/reinforced/spawner/directional/east, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Bp" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"BC" = ( +/obj/structure/lattice, +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"BY" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"CA" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Da" = ( +/obj/machinery/door/poddoor{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door" + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Dg" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/trade1{ + dir = 8 + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"DV" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/shuttle/caravan/trade1{ + dir = 8 + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Eh" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 1 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"EO" = ( +/obj/effect/spawner/structure/window/reinforced/shuttle, +/obj/machinery/door/poddoor{ + id = "caravantrade1_bridge" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Fb" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/firealarm/directional/east, +/obj/effect/decal/cleanable/blood, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 8 + }, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg, +/obj/effect/turf_decal/tile/yellow{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Fu" = ( +/obj/effect/decal/cleanable/blood, +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper/sword, +/obj/effect/turf_decal/tile/blue{ + dir = 4 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"FQ" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Gq" = ( +/obj/structure/girder, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"He" = ( +/obj/machinery/door/airlock{ + id_tag = "caravantrade1_cabin2"; + name = "Cabin 2" + }, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Hz" = ( +/obj/machinery/door/firedoor, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/door_assembly/door_assembly_min{ + anchored = 1; + density = 0; + name = "broken airlock" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"HF" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"HY" = ( +/obj/machinery/light/small/directional/west, +/obj/machinery/firealarm/directional/west, +/obj/effect/decal/cleanable/dirt, +/obj/structure/rack, +/obj/item/storage/toolbox/emergency, +/obj/item/wrench, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Id" = ( +/obj/structure/rack, +/obj/item/storage/belt/utility, +/obj/structure/extinguisher_cabinet/directional/north, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ix" = ( +/obj/machinery/light/small/directional/south, +/obj/structure/bed{ + dir = 4 + }, +/obj/item/bedsheet{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/machinery/button/door/directional/west{ + id = "caravantrade1_cabin2"; + name = "Cabin Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"IA" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/obj/machinery/button/door/directional/west{ + id = "caravantrade1_bolt"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + pixel_y = 8; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/blue, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"IY" = ( +/obj/machinery/door/airlock{ + name = "Restroom" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/showroomfloor{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"JH" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"JU" = ( +/obj/item/stack/sheet/iron/fifty, +/obj/effect/mapping_helpers/burnt_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"JW" = ( +/turf/closed/wall/mineral/titanium, +/area/shuttle/ruin/caravan/freighter1) +"KD" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ld" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Lo" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 1 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/rglass{ + amount = 20 + }, +/obj/item/stack/sheet/rglass{ + amount = 20 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"LI" = ( +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"LL" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"LO" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 8 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Mc" = ( +/obj/machinery/light/small/directional/west, +/obj/structure/sign/warning/vacuum/directional/east, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Mr" = ( +/obj/machinery/light/small/directional/north, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"ME" = ( +/obj/effect/turf_decal/box/white/corners, +/obj/structure/closet/crate, +/obj/item/stack/sheet/glass/fifty, +/obj/item/stack/sheet/glass/fifty, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ni" = ( +/obj/machinery/light/small/directional/south, +/obj/structure/bed, +/obj/item/bedsheet, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/machinery/button/door/directional/west{ + id = "caravantrade1_cabin1"; + name = "Cabin Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"NQ" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravantrade1_bolt" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Op" = ( +/obj/machinery/door/firedoor, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/airlock/engineering{ + name = "Engine Room" + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"OW" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/mob_spawn/corpse/human/cargo_tech, +/obj/effect/decal/cleanable/blood, +/obj/effect/turf_decal/tile/blue, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Pf" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/power/apc/auto_name/directional/east, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/cable, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Qi" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/mob_spawn/corpse/human/cargo_tech, +/obj/effect/decal/cleanable/blood, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ql" = ( +/obj/machinery/light/small/directional/south, +/obj/effect/turf_decal/box/white/corners, +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/diamond{ + amount = 5 + }, +/obj/machinery/button/door/directional/south{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door Control" + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Qu" = ( +/obj/machinery/door/airlock{ + name = "Crew Quarters" + }, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"QH" = ( +/obj/effect/turf_decal/bot, +/obj/machinery/space_heater, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 4 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Rv" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Tb" = ( +/obj/machinery/power/smes, +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/obj/structure/cable, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"To" = ( +/obj/machinery/door/airlock{ + name = "Crew Cabins" + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Tq" = ( +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/titanium{ + amount = 20 + }, +/obj/item/stack/sheet/mineral/titanium{ + amount = 20 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Tu" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravantrade1_bolt" + }, +/obj/docking_port/mobile{ + callTime = 250; + dir = 2; + shuttle_id = "caravantrade1"; + movement_force = list("KNOCKDOWN" = 0, "THROW" = 0); + name = "Small Freighter"; + port_direction = 8; + preferred_direction = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Tx" = ( +/obj/machinery/light/small/directional/west, +/obj/effect/decal/cleanable/dirt, +/obj/structure/rack, +/obj/item/storage/medkit/regular, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"TY" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Uz" = ( +/obj/structure/toilet{ + dir = 4 + }, +/obj/structure/sink/directional/south, +/obj/machinery/light/small/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/showroomfloor{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"UK" = ( +/obj/structure/closet/secure_closet/freezer{ + locked = 0; + name = "fridge" + }, +/obj/item/reagent_containers/cup/glass/bottle/beer{ + pixel_x = -3; + pixel_y = 3 + }, +/obj/item/reagent_containers/cup/glass/bottle/beer, +/obj/item/reagent_containers/cup/glass/bottle/beer{ + pixel_x = 3; + pixel_y = -3 + }, +/obj/item/reagent_containers/cup/glass/waterbottle{ + pixel_x = -3; + pixel_y = 3 + }, +/obj/item/reagent_containers/cup/glass/waterbottle, +/obj/item/reagent_containers/cup/glass/waterbottle{ + pixel_x = 3; + pixel_y = -3 + }, +/obj/item/food/pizzaslice/margherita{ + pixel_x = -3; + pixel_y = 3 + }, +/obj/item/food/pizzaslice/margherita, +/obj/item/food/chocolatebar, +/obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"VA" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Xq" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/machinery/meter, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"XE" = ( +/obj/machinery/light/small/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"XM" = ( +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"XN" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Yt" = ( +/obj/item/stack/sheet/mineral/titanium, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"YG" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Zq" = ( +/obj/structure/closet/crate, +/obj/item/stack/sheet/iron/twenty, +/obj/item/stack/sheet/glass{ + amount = 10 + }, +/obj/item/stack/rods/ten, +/obj/item/storage/box/lights/bulbs, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"ZA" = ( +/obj/effect/turf_decal/bot_white, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"ZG" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/left{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) + +(1,1,1) = {" +Jv +ZG +LL +LL +LL +dv +JW +Jv +Jv +Jv +Jv +"} +(2,1,1) = {" +JW +Bh +Bh +Bh +Bh +Bh +co +ZG +LL +dv +Jv +"} +(3,1,1) = {" +co +QH +ll +Tb +Zq +fR +co +Bh +Bh +Bh +JW +"} +(4,1,1) = {" +co +oJ +YG +Pf +nO +Xq +co +uZ +jz +HF +co +"} +(5,1,1) = {" +co +Op +co +co +co +Op +co +wC +rC +Ql +co +"} +(6,1,1) = {" +co +cC +He +Ix +co +Bp +BY +TY +cu +XM +kg +"} +(7,1,1) = {" +EO +XE +co +co +co +Id +iB +Lo +hS +LO +Da +"} +(8,1,1) = {" +co +LI +tp +Ni +JW +px +JH +rz +hS +ME +tT +"} +(9,1,1) = {" +co +To +co +co +co +jQ +KD +JU +Tq +Rv +kg +"} +(10,1,1) = {" +co +lK +co +Uz +co +cJ +mx +Eh +lD +aj +tT +"} +(11,1,1) = {" +EO +mb +co +IY +co +co +iB +eN +Rv +BC +Ld +"} +(12,1,1) = {" +EO +fJ +Qi +FQ +CA +Hz +Yt +ZA +zV +nI +ex +"} +(13,1,1) = {" +EO +UK +tH +Fb +JH +co +yv +XN +BC +nI +nI +"} +(14,1,1) = {" +co +su +uE +co +Qu +co +co +Gq +nI +nI +Jv +"} +(15,1,1) = {" +co +co +co +co +Mr +pj +co +Jv +Jv +Jv +Jv +"} +(16,1,1) = {" +Tu +Mc +NQ +IA +rt +hM +EO +Jv +Jv +Jv +Jv +"} +(17,1,1) = {" +JW +co +co +co +hZ +co +co +Jv +Jv +Jv +Jv +"} +(18,1,1) = {" +Jv +co +Tx +nj +VA +HY +co +Jv +Jv +Jv +Jv +"} +(19,1,1) = {" +Jv +EO +sJ +Fu +OW +vG +EO +Jv +Jv +Jv +Jv +"} +(20,1,1) = {" +Jv +EO +EO +Dg +DV +EO +EO +Jv +Jv +Jv +Jv +"} +(21,1,1) = {" +Jv +Jv +EO +EO +EO +EO +Jv +Jv +Jv +Jv +Jv +"} diff --git a/_maps/voidcrew/shuttles/ruin_pirate_cutter.dmm b/_maps/voidcrew/shuttles/ruin_pirate_cutter.dmm new file mode 100644 index 00000000000..1007292411e --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_pirate_cutter.dmm @@ -0,0 +1,1177 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"cf" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"co" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"dS" = ( +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"es" = ( +/obj/machinery/power/port_gen/pacman{ + anchored = 1 + }, +/obj/item/wrench, +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"fb" = ( +/obj/structure/table, +/obj/item/reagent_containers/cup/glass/bottle/rum{ + pixel_x = 3; + pixel_y = 6 + }, +/obj/item/reagent_containers/cup/glass/bottle/rum, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ge" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"gS" = ( +/obj/structure/closet{ + name = "pirate outfits" + }, +/obj/item/clothing/head/collectable/pirate, +/obj/item/clothing/suit/costume/pirate, +/obj/item/clothing/under/costume/pirate, +/obj/item/clothing/shoes/jackboots, +/obj/item/clothing/head/costume/pirate/bandana, +/turf/open/floor/iron/dark/side{ + dir = 5 + }, +/area/shuttle/ruin/caravan/pirate) +"hi" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/button/door/directional/south{ + id = "caravanpirate_bolt_starboard"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"iM" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"iN" = ( +/obj/effect/spawner/human_npc/ruin/pirate, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"jc" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"jk" = ( +/obj/structure/table/reinforced, +/obj/machinery/recharger, +/obj/item/melee/baton, +/obj/effect/turf_decal/tile/red, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"jn" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/left{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"jx" = ( +/obj/machinery/suit_storage_unit/open, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ka" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/security{ + name = "Armory" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"kU" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"lD" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"lN" = ( +/obj/structure/chair/office{ + dir = 4 + }, +/obj/machinery/turretid{ + icon_state = "control_kill"; + lethal = 1; + locked = 0; + pixel_y = -30; + req_access = null + }, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"me" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_starboard" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"ms" = ( +/obj/structure/table, +/obj/item/storage/fancy/donut_box{ + pixel_y = 18 + }, +/obj/item/storage/box/donkpockets{ + pixel_x = -3; + pixel_y = 7 + }, +/obj/item/storage/box/donkpockets{ + pixel_x = -6 + }, +/obj/item/reagent_containers/cup/glass/bottle/rum{ + pixel_x = 8; + pixel_y = 3 + }, +/obj/structure/sign/poster/contraband/red_rum/directional/east, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"nN" = ( +/obj/structure/table, +/obj/item/retractor, +/obj/item/hemostat, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"nT" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"pe" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"pg" = ( +/obj/structure/closet{ + name = "pirate outfits" + }, +/obj/item/clothing/head/collectable/pirate, +/obj/item/clothing/suit/costume/pirate, +/obj/item/clothing/under/costume/pirate, +/obj/item/clothing/shoes/jackboots, +/obj/item/clothing/head/costume/pirate/bandana, +/obj/effect/turf_decal/tile/red{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ph" = ( +/obj/machinery/door/poddoor{ + id = "caravanpirate_bridge" + }, +/obj/effect/spawner/structure/window/reinforced/plasma/plastitanium, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"pR" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"qB" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_starboard" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"qU" = ( +/obj/structure/table, +/obj/machinery/light/small/directional/west, +/obj/item/stack/spacecash/c200, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"se" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"sR" = ( +/obj/structure/table, +/obj/machinery/microwave{ + pixel_y = 5 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"sW" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/button/door/directional/north{ + id = "caravanpirate_bolt_port"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"ti" = ( +/obj/structure/table, +/obj/machinery/cell_charger, +/obj/item/stack/cable_coil, +/obj/item/stock_parts/power_store/cell/high, +/obj/effect/turf_decal/bot, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"ua" = ( +/obj/structure/table, +/obj/item/storage/box/lethalshot, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ug" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 10; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"uV" = ( +/obj/structure/reagent_dispensers/watertank, +/obj/item/reagent_containers/cup/bucket, +/obj/item/mop, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"vT" = ( +/obj/structure/table, +/obj/item/circular_saw, +/obj/item/scalpel{ + pixel_y = 12 + }, +/obj/item/cautery{ + pixel_x = 4 + }, +/obj/machinery/light/small/directional/west, +/turf/open/floor/iron/white/side{ + dir = 8 + }, +/area/shuttle/ruin/caravan/pirate) +"wg" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"wh" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"wE" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/medical/glass{ + name = "Medbay" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"wP" = ( +/obj/structure/closet/crate/freezer/surplus_limbs, +/obj/effect/turf_decal/bot, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"wR" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 5; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"wV" = ( +/obj/machinery/light/small/directional/north, +/obj/structure/bed, +/obj/item/bedsheet/pirate, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"xb" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 4 + }, +/obj/machinery/space_heater, +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"xq" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/structure/cable, +/obj/machinery/power/apc/auto_name/directional/west, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"ya" = ( +/obj/structure/table/reinforced, +/obj/machinery/button/door{ + id = "caravanpirate_bridge"; + name = "Bridge Blast Door Control"; + pixel_x = -16 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"yR" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Aj" = ( +/obj/machinery/computer/shuttle/caravan/pirate{ + dir = 8 + }, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"An" = ( +/obj/item/stack/sheet/mineral/gold{ + amount = 25 + }, +/obj/item/stack/sheet/mineral/bananium{ + amount = 5 + }, +/obj/item/stack/sheet/mineral/silver{ + amount = 25 + }, +/obj/item/stack/sheet/mineral/uranium{ + amount = 10 + }, +/obj/item/stack/sheet/mineral/diamond{ + amount = 5 + }, +/obj/structure/closet/crate, +/obj/item/coin/silver, +/obj/item/coin/silver, +/obj/item/coin/silver, +/obj/item/coin/gold, +/obj/item/coin/gold, +/obj/effect/turf_decal/tile/blue, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"AS" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 9; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"AW" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 6 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"BX" = ( +/obj/structure/closet/crate/secure/loot, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"CI" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Di" = ( +/obj/structure/table, +/obj/item/storage/medkit/brute{ + pixel_x = 3; + pixel_y = 3 + }, +/obj/item/storage/medkit/fire, +/obj/machinery/firealarm/directional/south, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"DQ" = ( +/obj/structure/reagent_dispensers/fueltank, +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Eh" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Fn" = ( +/obj/machinery/light/small/directional/west, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"FB" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"FI" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/engineering{ + name = "Engineering" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"FX" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/right{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"Gj" = ( +/obj/structure/tank_dispenser/oxygen, +/obj/machinery/firealarm/directional/north, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"GL" = ( +/obj/structure/bed{ + dir = 4 + }, +/obj/item/bedsheet/pirate{ + dir = 4 + }, +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/turf_decal/tile/red{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ha" = ( +/obj/machinery/light/small/directional/south, +/obj/structure/bed, +/obj/item/bedsheet/pirate, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Hb" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 5 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"Hl" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 6; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"Hu" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/command{ + name = "Bridge" + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ir" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Iu" = ( +/obj/structure/table, +/obj/machinery/door/window/right/directional/south{ + name = "Weapon Storage" + }, +/obj/item/gun/energy/laser, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"JJ" = ( +/obj/structure/table/optable, +/obj/effect/turf_decal/bot, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"JP" = ( +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/pirate, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Kc" = ( +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/pirate) +"KL" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"Ll" = ( +/obj/structure/rack, +/obj/item/storage/toolbox/emergency, +/obj/item/weldingtool, +/obj/effect/turf_decal/tile/red{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Mk" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ms" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_port" + }, +/obj/docking_port/mobile{ + callTime = 150; + dir = 2; + shuttle_id = "caravanpirate"; + movement_force = list("KNOCKDOWN"=0,"THROW"=0); + name = "Pirate Cutter"; + port_direction = 8; + preferred_direction = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Ns" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"Oo" = ( +/obj/machinery/light/small/directional/west, +/obj/structure/sign/warning/vacuum/directional/west, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Or" = ( +/obj/structure/chair/office{ + dir = 8 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"OO" = ( +/obj/machinery/sleeper{ + dir = 1 + }, +/obj/effect/turf_decal/delivery, +/obj/machinery/airalarm/directional/south, +/obj/effect/mapping_helpers/airalarm/all_access, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"Po" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/dark/side{ + dir = 4 + }, +/area/shuttle/ruin/caravan/pirate) +"PC" = ( +/obj/machinery/power/terminal{ + dir = 1 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/iron/twenty, +/obj/item/stack/sheet/glass{ + amount = 10 + }, +/obj/item/storage/toolbox/mechanical, +/obj/item/flashlight{ + pixel_x = 3; + pixel_y = 3 + }, +/obj/item/stack/sheet/mineral/plasma{ + amount = 20 + }, +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Qb" = ( +/obj/structure/sign/departments/medbay/alt, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/pirate) +"Rb" = ( +/obj/structure/table, +/obj/structure/window/reinforced/spawner/directional/west, +/obj/machinery/door/window/left/directional/south{ + name = "Weapon Storage" + }, +/obj/item/grenade/smokebomb{ + pixel_x = -4 + }, +/obj/item/grenade/smokebomb{ + pixel_x = 2 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ry" = ( +/obj/structure/rack, +/obj/item/storage/bag/money/vault, +/mob/living/basic/parrot{ + faction = list("pirate"); + name = "Pegwing" + }, +/obj/effect/turf_decal/tile/blue{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"SU" = ( +/obj/machinery/power/shuttle_engine/heater{ + dir = 8 + }, +/obj/structure/window/reinforced/spawner/directional/east, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"UH" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/blue/half/contrasted{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Vh" = ( +/obj/structure/sign/departments/engineering, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/pirate) +"Vs" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"Vz" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/obj/machinery/firealarm/directional/south, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"VF" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/white/side{ + dir = 9 + }, +/area/shuttle/ruin/caravan/pirate) +"We" = ( +/obj/machinery/power/smes/super/full, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Wi" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_port" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Wo" = ( +/obj/machinery/light/small/directional/west, +/obj/structure/closet/crate/secure/loot, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 4 + }, +/obj/structure/extinguisher_cabinet{ + pixel_x = -27 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"WV" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"Xw" = ( +/obj/structure/table, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/item/ammo_box/a40mm, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"XF" = ( +/obj/structure/table, +/obj/item/coin/gold, +/obj/item/coin/silver, +/obj/item/coin/silver, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Yc" = ( +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"Ye" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Yi" = ( +/obj/effect/spawner/structure/window/reinforced/plasma/plastitanium, +/obj/machinery/door/poddoor{ + id = "caravanpirate_bridge" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Yy" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/machinery/meter, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Zp" = ( +/obj/machinery/atmospherics/components/tank/air, +/obj/effect/turf_decal/bot, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Zq" = ( +/obj/structure/bed{ + dir = 4 + }, +/obj/item/bedsheet/pirate{ + dir = 4 + }, +/obj/machinery/firealarm/directional/west, +/turf/open/floor/iron/dark/side{ + dir = 6 + }, +/area/shuttle/ruin/caravan/pirate) +"ZU" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 5 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) + +(1,1,1) = {" +Jv +Jv +Jv +jn +KL +FX +Jv +jn +KL +FX +Jv +Jv +Jv +"} +(2,1,1) = {" +Jv +Jv +Yc +SU +SU +SU +Kc +SU +SU +SU +Yc +Jv +Jv +"} +(3,1,1) = {" +Jv +AS +Kc +uV +DQ +xb +Kc +We +PC +es +Kc +ug +Jv +"} +(4,1,1) = {" +Jv +Kc +Zp +Yy +wg +pR +xq +iM +se +ZU +ti +Kc +Jv +"} +(5,1,1) = {" +Jv +Kc +Kc +FI +Vh +Kc +Kc +Kc +Vh +FI +Kc +Kc +Jv +"} +(6,1,1) = {" +Jv +Kc +jx +VF +vT +nN +Kc +ua +Fn +pe +jx +Kc +Jv +"} +(7,1,1) = {" +Yc +Kc +Kc +Vs +FB +JJ +Kc +Xw +WV +co +Kc +Kc +Yc +"} +(8,1,1) = {" +Ms +Oo +Wi +cf +kU +Di +Kc +Gj +ge +lD +me +Oo +qB +"} +(9,1,1) = {" +Yc +Kc +Kc +sW +FB +OO +Kc +Rb +iN +hi +Kc +Kc +Yc +"} +(10,1,1) = {" +Jv +Yi +jx +Ns +Hb +wP +Kc +Iu +AW +jc +jx +ph +Jv +"} +(11,1,1) = {" +Jv +Yc +Kc +Qb +wE +Kc +Kc +Kc +ka +Kc +Kc +Yc +Jv +"} +(12,1,1) = {" +Jv +Jv +Kc +gS +Po +Zq +Wo +GL +Mk +pg +Kc +Jv +Jv +"} +(13,1,1) = {" +Jv +Jv +Kc +wV +Ye +nT +wh +Ir +CI +Ha +Kc +Jv +Jv +"} +(14,1,1) = {" +Jv +Jv +wR +Kc +sR +ms +An +UH +Ry +Kc +Hl +Jv +Jv +"} +(15,1,1) = {" +Jv +Jv +Jv +Yc +Kc +Kc +Kc +Hu +Kc +Yc +Jv +Jv +Jv +"} +(16,1,1) = {" +Jv +Jv +Jv +Jv +Kc +fb +qU +yR +Kc +Jv +Jv +Jv +Jv +"} +(17,1,1) = {" +Jv +Jv +Jv +Jv +Kc +XF +Or +yR +Kc +Jv +Jv +Jv +Jv +"} +(18,1,1) = {" +Jv +Jv +Jv +Jv +Kc +BX +Eh +Vz +Kc +Jv +Jv +Jv +Jv +"} +(19,1,1) = {" +Jv +Jv +Jv +Jv +Kc +Ll +dS +jk +Kc +Jv +Jv +Jv +Jv +"} +(20,1,1) = {" +Jv +Jv +Jv +Jv +ph +JP +lN +ya +Yi +Jv +Jv +Jv +Jv +"} +(21,1,1) = {" +Jv +Jv +Jv +Jv +Yi +Yi +Aj +Yi +Yi +Jv +Jv +Jv +Jv +"} +(22,1,1) = {" +Jv +Jv +Jv +Jv +Jv +Yi +Yi +Yi +Jv +Jv +Jv +Jv +Jv +"} diff --git a/_maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm b/_maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm new file mode 100644 index 00000000000..31ca89403cd --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm @@ -0,0 +1,620 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"bh" = ( +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate3_bolt_port"; + name = "External Airlock"; + normalspeed = 0 + }, +/obj/docking_port/mobile{ + dir = 2; + shuttle_id = "caravansyndicate3"; + name = "Syndicate Drop Ship"; + port_direction = 8; + preferred_direction = 4 + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"bt" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/structure/sign/warning/vacuum/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate/combat, +/obj/item/storage/belt/military, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"bX" = ( +/obj/machinery/porta_turret/syndicate/energy{ + dir = 1 + }, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate3) +"co" = ( +/obj/machinery/power/shuttle_engine/heater{ + dir = 8 + }, +/obj/structure/window/reinforced/spawner/directional/east, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"dt" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/obj/machinery/power/port_gen/pacman{ + anchored = 1 + }, +/obj/item/wrench, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"dy" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/structure/sign/warning/vacuum/directional/north, +/obj/effect/decal/cleanable/dirt, +/obj/item/clothing/shoes/sneakers/black, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"eV" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/left{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"fU" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"gv" = ( +/obj/structure/table/reinforced, +/obj/machinery/recharger, +/obj/machinery/light/small/directional/west, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"gz" = ( +/obj/structure/table/reinforced, +/obj/item/storage/toolbox/emergency, +/obj/item/wrench, +/obj/machinery/light/small/directional/west, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"id" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 10 + }, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate/combat, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"im" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/syndicate3{ + dir = 8 + }, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"iN" = ( +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"kc" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"kk" = ( +/obj/effect/spawner/structure/window/reinforced/plasma/plastitanium, +/obj/machinery/door/poddoor{ + id = "caravansyndicate3_bridge" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"kR" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/machinery/light/small/directional/east, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate, +/obj/item/clothing/glasses/night, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"mq" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 10 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate/combat, +/obj/item/clothing/shoes/jackboots, +/obj/item/storage/belt/military, +/obj/item/crowbar/red, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"nv" = ( +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"oW" = ( +/obj/machinery/light/small/directional/west, +/obj/machinery/button/door/directional/west{ + id = "caravansyndicate3_bolt_port"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"pY" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 1 + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/light/small/directional/south, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"rQ" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 9 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate, +/obj/item/clothing/shoes/sneakers/black, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"sk" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"ts" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/syndicate_access, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"uv" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/pilot, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"uI" = ( +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate3) +"wl" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/syndicate_access, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"wS" = ( +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate3_bolt_starboard"; + name = "External Airlock"; + normalspeed = 0 + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"zB" = ( +/obj/structure/chair/comfy/shuttle, +/obj/machinery/light/small/directional/north, +/obj/machinery/power/apc/auto_name/directional/west, +/obj/effect/mapping_helpers/apc/syndicate_access, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"zP" = ( +/obj/machinery/computer/crew{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"DF" = ( +/obj/machinery/computer/shuttle/caravan/syndicate3{ + dir = 8 + }, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"GU" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"HI" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/obj/structure/closet/crate, +/obj/machinery/power/terminal{ + dir = 1 + }, +/obj/item/stack/sheet/iron/twenty, +/obj/item/stack/sheet/glass{ + amount = 10 + }, +/obj/item/stack/sheet/mineral/plastitanium{ + amount = 20 + }, +/obj/item/storage/box/lights/bulbs, +/obj/item/storage/toolbox/mechanical, +/obj/item/stack/sheet/mineral/plasma{ + amount = 20 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"Ib" = ( +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Ie" = ( +/obj/structure/table/reinforced, +/obj/machinery/button/door{ + id = "caravansyndicate3_bridge"; + name = "Bridge Blast Door Control"; + pixel_x = -16; + pixel_y = 5; + req_access = list("syndicate") + }, +/obj/machinery/button/door{ + id = "caravansyndicate3_bolt_bridge"; + name = "Bridge Bolt Control"; + normaldoorcontrol = 1; + pixel_x = -16; + pixel_y = -5; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"JK" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/machinery/light/small/directional/east, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/storage/box/syndie_kit/chameleon, +/obj/item/crowbar/red, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"JT" = ( +/obj/machinery/light/small/directional/west, +/obj/machinery/button/door/directional/west{ + id = "caravansyndicate3_bolt_starboard"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"KN" = ( +/obj/machinery/firealarm/directional/west, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"KW" = ( +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"LF" = ( +/obj/structure/chair/comfy/shuttle, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/syndicate_access, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Mc" = ( +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Nn" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 1 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Oe" = ( +/obj/machinery/power/smes/super/full, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"Pp" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 9 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/shoes/jackboots, +/obj/item/crowbar/red, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"Ry" = ( +/obj/machinery/door/airlock/hatch{ + name = "Ready Room" + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 4 + }, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"RT" = ( +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"SU" = ( +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate3_bolt_bridge"; + name = "Bridge" + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 8 + }, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Te" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/right{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"TT" = ( +/obj/machinery/turretid{ + ailock = 1; + desc = "A specially designed set of turret controls. Looks to be covered in protective casing to prevent AI interfacing."; + icon_state = "control_kill"; + lethal = 1; + name = "Shuttle turret control"; + pixel_y = 34; + req_access = list("syndicate") + }, +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"UI" = ( +/obj/machinery/porta_turret/syndicate/energy, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate3) +"UV" = ( +/obj/structure/table/reinforced, +/obj/item/storage/medkit/regular, +/obj/item/assembly/flash/handheld, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Vh" = ( +/obj/structure/chair/comfy/shuttle, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Vp" = ( +/obj/machinery/firealarm/directional/west, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"ZF" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 1 + }, +/obj/machinery/firealarm/directional/south, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) + +(1,1,1) = {" +Jv +eV +fU +fU +fU +Te +Jv +"} +(2,1,1) = {" +KW +co +co +co +co +co +KW +"} +(3,1,1) = {" +uI +uI +Oe +HI +dt +uI +uI +"} +(4,1,1) = {" +uI +zB +RT +nv +Mc +pY +uI +"} +(5,1,1) = {" +uI +LF +Ib +iN +Mc +ZF +uI +"} +(6,1,1) = {" +uI +Vh +Ib +iN +kc +Nn +uI +"} +(7,1,1) = {" +uI +kR +mq +iN +rQ +JK +uI +"} +(8,1,1) = {" +bX +uI +uI +Ry +uI +uI +UI +"} +(9,1,1) = {" +bh +oW +wl +iN +Vp +JT +wS +"} +(10,1,1) = {" +uI +dy +id +GU +Pp +bt +uI +"} +(11,1,1) = {" +uI +uI +uI +SU +uI +uI +uI +"} +(12,1,1) = {" +uI +gv +ts +iN +KN +gz +uI +"} +(13,1,1) = {" +kk +Ie +TT +uv +sk +UV +kk +"} +(14,1,1) = {" +kk +kk +im +DF +zP +kk +kk +"} +(15,1,1) = {" +Jv +kk +kk +kk +kk +kk +Jv +"} diff --git a/_maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm b/_maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm new file mode 100644 index 00000000000..9ca923f4561 --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm @@ -0,0 +1,156 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"cZ" = ( +/obj/machinery/porta_turret/syndicate/energy{ + dir = 4 + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate1) +"dc" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/machinery/turretid{ + ailock = 1; + desc = "A specially designed set of turret controls. Looks to be covered in protective casing to prevent AI interfacing."; + icon_state = "control_kill"; + lethal = 1; + name = "Shuttle turret control"; + pixel_x = 32; + pixel_y = -28; + req_access = list("syndicate") + }, +/obj/structure/cable, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/pilot, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) +"ie" = ( +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate1) +"qe" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/shuttle/caravan/syndicate1{ + dir = 8 + }, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) +"qu" = ( +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate1) +"tO" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/syndicate1, +/obj/machinery/button/door/directional/west{ + id = "caravansyndicate1_bolt"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"UZ" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate1) +"Ve" = ( +/obj/machinery/camera/xray/directional/west{ + c_tag = "External View"; + network = list("caravansyndicate1"); + pixel_x = 32 + }, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate1) +"Yi" = ( +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate1_bolt"; + name = "External Airlock"; + normalspeed = 0 + }, +/obj/effect/decal/cleanable/dirt, +/obj/docking_port/mobile{ + callTime = 50; + dir = 4; + shuttle_id = "caravansyndicate1"; + ignitionTime = 25; + name = "Syndicate Fighter"; + port_direction = 2; + preferred_direction = 4 + }, +/obj/structure/cable, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate1) +"YY" = ( +/obj/machinery/power/apc/auto_name/directional/west, +/obj/effect/mapping_helpers/apc/syndicate_access, +/obj/effect/mapping_helpers/apc/cell_10k, +/obj/machinery/computer/security{ + dir = 1; + network = list("caravansyndicate1") + }, +/obj/structure/cable, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) + +(1,1,1) = {" +Jv +Jv +UZ +qu +Yi +qu +UZ +Jv +Jv +"} +(2,1,1) = {" +Jv +UZ +qu +tO +dc +YY +qu +UZ +Jv +"} +(3,1,1) = {" +ie +qu +qu +qu +qe +qu +qu +qu +ie +"} +(4,1,1) = {" +qu +ie +Jv +cZ +Ve +cZ +Jv +ie +qu +"} +(5,1,1) = {" +cZ +Jv +Jv +Jv +Jv +Jv +Jv +Jv +cZ +"} diff --git a/code/_onclick/item_attack.dm b/code/_onclick/item_attack.dm index a81068c6572..6536eb1328e 100644 --- a/code/_onclick/item_attack.dm +++ b/code/_onclick/item_attack.dm @@ -498,11 +498,13 @@ attack_message_victim = "[user] [message_verb_continuous] you[message_hit_area] with [weapon]!" if(user == src) attack_message_victim = "You [message_verb_simple] yourself[message_hit_area] with [weapon]." - visible_message(span_danger("[attack_message_spectator]"),\ - span_userdanger("[attack_message_victim]"), null, COMBAT_MESSAGE_RANGE, user) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this hit join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger(spectator line), span_userdanger(victim line), null, COMBAT_MESSAGE_RANGE, user) + // original: to_chat(user, span_danger(attacker line)) + hv_hold_attack_message(attack_message_spectator, attack_message_victim, attack_message_attacker, user) if(is_blind()) to_chat(src, span_danger("Someone hits you[message_hit_area]!")) - to_chat(user, span_danger("[attack_message_attacker]")) + // VOIDCREW EDIT CHANGE END return 1 /// Overridable proc so subtypes can have unique targetted strike zone messages, return a string. diff --git a/code/controllers/subsystem/weather.dm b/code/controllers/subsystem/weather.dm index ffd151171cc..b2c35e62947 100644 --- a/code/controllers/subsystem/weather.dm +++ b/code/controllers/subsystem/weather.dm @@ -38,7 +38,8 @@ SUBSYSTEM_DEF(weather) mobs_by_z_cache_fire = times_fired for(var/mob/living/candidate as anything in GLOB.mob_living_list) // Weather is player-facing; ordinary planetary fauna should never enter its work list. - if(!candidate.mind && !candidate.ever_had_mind) + // Human NPCs (voidcrew/modules/human_npcs/) are people: weathered like players while awake. + if(!candidate.mind && !candidate.ever_had_mind && !human_npc_weathered(candidate)) continue var/candidate_z = candidate.z if(!candidate_z) // contained mobs report z = 0 diff --git a/code/datums/status_effects/debuffs/drunk.dm b/code/datums/status_effects/debuffs/drunk.dm index fc5c74f46d5..748c5852199 100644 --- a/code/datums/status_effects/debuffs/drunk.dm +++ b/code/datums/status_effects/debuffs/drunk.dm @@ -151,7 +151,8 @@ owner.apply_status_effect(/datum/status_effect/inebriated/tipsy, drunk_value) return - var/datum/mood_event/drunk/moodlet = owner.mob_mood.mood_events[id] + // VOIDCREW EDIT CHANGE: human NPCs have no mood datum (voidcrew/modules/human_npcs) + var/datum/mood_event/drunk/moodlet = owner.mob_mood?.get_mood_event(id) if(istype(moodlet)) moodlet.update_change(drunk_value) diff --git a/code/datums/status_effects/neutral.dm b/code/datums/status_effects/neutral.dm index a10592c7311..37064312e0b 100644 --- a/code/datums/status_effects/neutral.dm +++ b/code/datums/status_effects/neutral.dm @@ -544,7 +544,7 @@ //new you new stuff SSquirks.randomise_quirks(owner) owner.reagents.remove_all(1000) - owner.mob_mood.remove_temp_moods() //New you, new moods. + owner.mob_mood?.remove_temp_moods() //New you, new moods. // VOIDCREW EDIT CHANGE: human NPCs have no mood datum (voidcrew/modules/human_npcs) var/mob/living/carbon/human/human_mob = owner owner.add_mood_event("Eigentrip", /datum/mood_event/eigentrip) if(QDELETED(human_mob)) diff --git a/code/datums/weather/weather.dm b/code/datums/weather/weather.dm index b1820c4d40b..cf6f55fec98 100644 --- a/code/datums/weather/weather.dm +++ b/code/datums/weather/weather.dm @@ -425,7 +425,8 @@ // VOIDCREW EDIT START - PR #123: ship systems and overmap integration. /datum/weather/proc/can_weather_act_mob(mob/living/mob_to_check) // Preserve effects on abandoned player bodies while excluding ordinary fauna. - if(!mob_to_check.mind && !mob_to_check.ever_had_mind) + // Human NPCs (voidcrew/modules/human_npcs/) are weathered like players while awake. + if(!mob_to_check.mind && !mob_to_check.ever_had_mind && !human_npc_weathered(mob_to_check)) return var/turf/mob_turf = get_turf(mob_to_check) diff --git a/code/datums/wounds/_wounds.dm b/code/datums/wounds/_wounds.dm index 2985a817184..92cddaf9c94 100644 --- a/code/datums/wounds/_wounds.dm +++ b/code/datums/wounds/_wounds.dm @@ -217,14 +217,10 @@ return if(!silent && !demoted) - var/msg = span_danger("[victim]'s [limb.plaintext_zone] [occur_text]!") - var/vis_dist = COMBAT_MESSAGE_RANGE - - if(severity > WOUND_SEVERITY_SEVERE) - msg = "[msg]" - vis_dist = DEFAULT_MESSAGE_RANGE - - victim.visible_message(msg, span_userdanger("Your [limb.plaintext_zone] [occur_text]!"), vision_distance = vis_dist) + // VOIDCREW EDIT CHANGE START: the wound line joins the message of the hit that caused it (voidcrew/modules/hyperviolence/hitlines) + // original: victim.visible_message(msg, span_userdanger("Your [limb.plaintext_zone] [occur_text]!"), vision_distance = vis_dist) with msg bold and vis_dist DEFAULT_MESSAGE_RANGE above a severe wound, COMBAT_MESSAGE_RANGE otherwise + victim.hv_add_hit_line("[victim]'s [limb.plaintext_zone] [occur_text]!", HV_LINE_BOLD, "Your [limb.plaintext_zone] [occur_text]!", severity > WOUND_SEVERITY_SEVERE ? DEFAULT_MESSAGE_RANGE : COMBAT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END if(sound_effect) playsound(L.owner, sound_effect, sound_volume + (20 * severity), TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) diff --git a/code/datums/wounds/loss.dm b/code/datums/wounds/loss.dm index 4fb5cad387f..8946543abec 100644 --- a/code/datums/wounds/loss.dm +++ b/code/datums/wounds/loss.dm @@ -40,9 +40,10 @@ else occur_text = dismembered_part.get_dismember_message(wounding_type, outright) - var/msg = span_bolddanger("[victim]'s [dismembered_part.plaintext_zone] [occur_text]") - - victim.visible_message(msg, span_userdanger("Your [dismembered_part.plaintext_zone] [self_msg ? self_msg : occur_text]")) + // VOIDCREW EDIT CHANGE START: the severed-limb line joins the message of the hit that caused it (voidcrew/modules/hyperviolence/hitlines) + // original: victim.visible_message(span_bolddanger("[victim]'s [zone] [occur_text]"), span_userdanger("Your [zone] [self_msg ? self_msg : occur_text]")) at the default message range + victim.hv_add_hit_line("[victim]'s [dismembered_part.plaintext_zone] [occur_text]", HV_LINE_HUGE, "Your [dismembered_part.plaintext_zone] [self_msg ? self_msg : occur_text]", DEFAULT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END loss_wounding_type = wounding_type diff --git a/code/datums/wounds/scars/_scars.dm b/code/datums/wounds/scars/_scars.dm index 2fdd92b82d0..50b1e9cc101 100644 --- a/code/datums/wounds/scars/_scars.dm +++ b/code/datums/wounds/scars/_scars.dm @@ -101,6 +101,11 @@ /// Used when we finalize a scar from a healing cut /datum/scar/proc/lazy_attach(obj/item/bodypart/BP, datum/wound/W) LAZYADD(BP.scars, src) + // VOIDCREW EDIT ADDITION START: a slash wound's scar is made when the wound starts, holding the body it was on. + // Finalised on a severed limb, it must let go of that body, or the limb on the floor keeps a deleted + // mob from being garbage collected (hard deletes of human NPCs, voidcrew/modules/human_npcs) + victim = null + // VOIDCREW EDIT ADDITION END if(BP.owner) victim = BP.owner LAZYADD(victim.all_scars, src) diff --git a/code/datums/wounds/slash.dm b/code/datums/wounds/slash.dm index b4ce894f1ac..8aadff76e6a 100644 --- a/code/datums/wounds/slash.dm +++ b/code/datums/wounds/slash.dm @@ -56,6 +56,11 @@ var/datum/scar/highest_scar /datum/wound/slash/flesh/Destroy() + // VOIDCREW EDIT ADDITION START: a scar that was never put on the limb (this wound was replaced by a worse one) + // is deleted, not left registered on the limb holding the body it came from (voidcrew/modules/human_npcs) + if(highest_scar && !LAZYFIND(highest_scar.limb?.scars, highest_scar)) + qdel(highest_scar) + // VOIDCREW EDIT ADDITION END highest_scar = null return ..() diff --git a/code/game/objects/items/weaponry.dm b/code/game/objects/items/weaponry.dm index eb5d4e19b9e..de4e0c9b135 100644 --- a/code/game/objects/items/weaponry.dm +++ b/code/game/objects/items/weaponry.dm @@ -72,7 +72,7 @@ for further reading, please see: https://github.com/tgstation/tgstation/pull/301 playsound(loc, 'sound/mobs/non-humanoids/clown/hehe.ogg', 20) if (!isliving(target)) return - switch(target.mob_mood.sanity) + switch(target.mob_mood ? target.mob_mood.sanity : SANITY_NEUTRAL) // VOIDCREW EDIT CHANGE: no mood datum (human NPCs, voidcrew/modules/human_npcs) reads as neutral if (SANITY_INSANE to SANITY_CRAZY) force = 8 if (SANITY_UNSTABLE to SANITY_DISTURBED) diff --git a/code/modules/mob/living/basic/trooper/trooper.dm b/code/modules/mob/living/basic/trooper/trooper.dm index 32829923d23..779c2469457 100644 --- a/code/modules/mob/living/basic/trooper/trooper.dm +++ b/code/modules/mob/living/basic/trooper/trooper.dm @@ -32,8 +32,7 @@ /mob/living/basic/trooper/Initialize(mapload) . = ..() - if(use_dynamic_appearance) // VOIDCREW EDIT - apply_dynamic_human_appearance(src, mob_spawn_path = mob_spawner, r_hand = r_hand, l_hand = l_hand) // VOIDCREW EDIT: trooper appearance integration: respect static fork sprites (voidcrew/edits/modules/mob/living/basic/trooper/trooper.dm) + apply_dynamic_human_appearance(src, mob_spawn_path = mob_spawner, r_hand = r_hand, l_hand = l_hand) // VOIDCREW EDIT CHANGE: unconditional again; the static-sprite opt-out went with the human NPC migration (voidcrew/modules/human_npcs) if(LAZYLEN(loot) || corpse) LAZYOR(loot, corpse) loot = string_list(loot) diff --git a/code/modules/mob/living/carbon/carbon.dm b/code/modules/mob/living/carbon/carbon.dm index 32cd2f3cc6d..bd859b47efd 100644 --- a/code/modules/mob/living/carbon/carbon.dm +++ b/code/modules/mob/living/carbon/carbon.dm @@ -1118,7 +1118,7 @@ return TRUE if(HAS_TRAIT(src, TRAIT_DUMB)) return TRUE - if(mob_mood.sanity < SANITY_UNSTABLE) + if(mob_mood && mob_mood.sanity < SANITY_UNSTABLE) // VOIDCREW EDIT CHANGE: human NPCs have no mood datum (voidcrew/modules/human_npcs) return TRUE /mob/living/carbon/wash(clean_types) diff --git a/code/modules/mob/living/carbon/carbon_defense.dm b/code/modules/mob/living/carbon/carbon_defense.dm index 1a3629883b2..41d330e60f6 100644 --- a/code/modules/mob/living/carbon/carbon_defense.dm +++ b/code/modules/mob/living/carbon/carbon_defense.dm @@ -135,10 +135,11 @@ attack_message_victim = "[user] [message_verb_continuous] you[message_hit_area] with [weapon][extra_wound_details]!" if(user == src) attack_message_victim = "You [message_verb_simple] yourself[message_hit_area] with [weapon][extra_wound_details]!" - visible_message(span_danger("[attack_message_spectator]"),\ - span_userdanger("[attack_message_victim]"), null, COMBAT_MESSAGE_RANGE, user) - if(user != src) - to_chat(user, span_danger("[attack_message_attacker]")) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this hit join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger(spectator line), span_userdanger(victim line), null, COMBAT_MESSAGE_RANGE, user) + // original: if(user != src) to_chat(user, span_danger(attacker line)) + hv_hold_attack_message(attack_message_spectator, attack_message_victim, (user != src) ? attack_message_attacker : null, user) + // VOIDCREW EDIT CHANGE END return TRUE diff --git a/code/modules/mob/living/carbon/examine.dm b/code/modules/mob/living/carbon/examine.dm index 5921ac63cae..275cdb9e755 100644 --- a/code/modules/mob/living/carbon/examine.dm +++ b/code/modules/mob/living/carbon/examine.dm @@ -211,7 +211,7 @@ . += "[t_He] seem[p_s()] winded." if (getToxLoss() >= 10) . += "[t_He] seem[p_s()] sickly." - if(mob_mood.sanity <= SANITY_DISTURBED) + if(mob_mood && mob_mood.sanity <= SANITY_DISTURBED) // VOIDCREW EDIT CHANGE: human NPCs have no mood datum (voidcrew/modules/human_npcs) . += "[t_He] seem[p_s()] distressed." living_user.add_mood_event("empath", /datum/mood_event/sad_empath, src) if(is_blind()) diff --git a/code/modules/mob/living/carbon/human/_species.dm b/code/modules/mob/living/carbon/human/_species.dm index eccb8abe3de..873fc0a92f9 100644 --- a/code/modules/mob/living/carbon/human/_species.dm +++ b/code/modules/mob/living/carbon/human/_species.dm @@ -966,9 +966,11 @@ GLOBAL_LIST_EMPTY(features_by_species) atk_verb = attacking_bodypart.grappled_attack_verb atk_verb_continuous = attacking_bodypart.grappled_attack_verb_continuous - target.visible_message(span_danger("[user] [atk_verb_continuous] [target]!"), \ - span_userdanger("[user] [atk_verb_continuous] you!"), span_hear("You hear a sickening sound of flesh hitting flesh!"), COMBAT_MESSAGE_RANGE, user) - to_chat(user, span_danger("You [atk_verb] [target]!")) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this blow join it (voidcrew/modules/hyperviolence/hitlines) + // original: target.visible_message(span_danger("[user] [atk_verb_continuous] [target]!"), span_userdanger("[user] [atk_verb_continuous] you!"), span_hear("You hear a sickening sound of flesh hitting flesh!"), COMBAT_MESSAGE_RANGE, user) + // original: to_chat(user, span_danger("You [atk_verb] [target]!")) + target.hv_hold_attack_message("[user] [atk_verb_continuous] [target]!", "[user] [atk_verb_continuous] you!", "You [atk_verb] [target]!", user, "You hear a sickening sound of flesh hitting flesh!") + // VOIDCREW EDIT CHANGE END target.lastattacker = user.real_name target.lastattackerckey = user.ckey diff --git a/code/modules/mob/living/living_defense.dm b/code/modules/mob/living/living_defense.dm index 854ddf99a84..a4429fa1c58 100644 --- a/code/modules/mob/living/living_defense.dm +++ b/code/modules/mob/living/living_defense.dm @@ -110,8 +110,10 @@ if (SUPPRESSED_QUIET) to_chat(src, span_userdanger("You're shot by \a [proj][organ_hit_text]!")) if (SUPPRESSED_NONE) - visible_message(span_danger("[src] is hit by \a [proj][organ_hit_text]!"), \ - span_userdanger("You're hit by \a [proj][organ_hit_text]!"), null, COMBAT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE START: hold the hit line so the wound lines of this shot join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger("[src] is hit by \a [proj][organ_hit_text]!"), span_userdanger("You're hit by \a [proj][organ_hit_text]!"), null, COMBAT_MESSAGE_RANGE) + hv_hold_attack_message("[src] is hit by \a [proj][organ_hit_text]!", "You're hit by \a [proj][organ_hit_text]!") + // VOIDCREW EDIT CHANGE END if(is_blind()) to_chat(src, span_userdanger("You feel something hit you[organ_hit_text]!")) @@ -238,8 +240,10 @@ log_hit_combat(throwingdatum?.get_thrower(), thrown_item) return ..() - visible_message(span_danger("[src] is hit by [thrown_item]!"), - span_userdanger("You're hit by [thrown_item]!")) + // VOIDCREW EDIT CHANGE START: hold the hit line so the wound lines of this throw join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger("[src] is hit by [thrown_item]!"), span_userdanger("You're hit by [thrown_item]!")) + hv_hold_attack_message("[src] is hit by [thrown_item]!", "You're hit by [thrown_item]!", vision_range = DEFAULT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END if(!thrown_item.throwforce) log_hit_combat(throwingdatum?.get_thrower(), thrown_item) return @@ -416,13 +420,11 @@ playsound(src, user.attack_sound, 50, TRUE, TRUE) user.do_attack_animation(src) - visible_message( - span_danger("[user] [user.attack_verb_continuous] [src]!"), - span_userdanger("[user] [user.attack_verb_continuous] you!"), - null, - COMBAT_MESSAGE_RANGE, - user, - ) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this blow join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger("[user] [user.attack_verb_continuous] [src]!"), span_userdanger("[user] [user.attack_verb_continuous] you!"), null, COMBAT_MESSAGE_RANGE, user) + // original: to_chat(user, span_danger("You [user.attack_verb_simple] [src]!")) (sent after the armor check, not on a dismemberment) + hv_hold_attack_message("[user] [user.attack_verb_continuous] [src]!", "[user] [user.attack_verb_continuous] you!", "You [user.attack_verb_simple] [src]!", user) + // VOIDCREW EDIT CHANGE END var/dam_zone = dismembering_strike(user, pick(BODY_ZONE_CHEST, BODY_ZONE_PRECISE_L_HAND, BODY_ZONE_PRECISE_R_HAND, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) if(!dam_zone) //Dismemberment successful @@ -430,7 +432,7 @@ var/armor_block = run_armor_check(user.zone_selected, MELEE, armour_penetration = user.armour_penetration) - to_chat(user, span_danger("You [user.attack_verb_simple] [src]!")) + // VOIDCREW EDIT REMOVAL: the attacker's "You [verb] [src]!" line is held with the attack line above (voidcrew/modules/hyperviolence/hitlines) var/damage_done = apply_damage( damage = damage, damagetype = user.melee_damage_type, diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 5d65e684704..78941bbe241 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -208,8 +208,10 @@ playsound(src, fire_sound, fire_sound_volume, vary_fire_sound) /obj/item/gun/proc/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) - if(recoil && !tk_firing(user)) - shake_camera(user, recoil + 1, recoil) + // VOIDCREW EDIT REMOVAL START - gun_feel: voidcrew/modules/gun_feel kicks the camera away from the shot instead of shaking it at random + // if(recoil && !tk_firing(user)) + // shake_camera(user, recoil + 1, recoil) + // VOIDCREW EDIT REMOVAL END fire_sounds() if(suppressed || !message) return FALSE @@ -245,7 +247,7 @@ else if(!tk_firing(user)) user.visible_message( span_danger("[user] fires [src]!"), - span_danger("You fire [src]!"), + null, // VOIDCREW EDIT CHANGE - gun_feel: no chat line to the shooter on every shot (voidcrew/modules/gun_feel). ORIGINAL: span_danger("You fire [src]!"), span_hear("You hear a gunshot!"), vision_distance = COMBAT_MESSAGE_RANGE, ignored_mobs = user, @@ -376,6 +378,11 @@ handle_suicide(user, target, params) return + // VOIDCREW EDIT ADDITION START: a point-blank head shot at someone helpless is an execution (voidcrew/modules/hyperviolence/overlays_exec/executions.dm) + if(flag && hv_try_head_execution(user, target, params)) + return + // VOIDCREW EDIT ADDITION END + if(!can_shoot()) //Just because you can pull the trigger doesn't mean it can shoot. shoot_with_empty_chamber(user) return diff --git a/code/modules/projectiles/guns/ballistic.dm b/code/modules/projectiles/guns/ballistic.dm index 83f03c90e54..0d510ff1164 100644 --- a/code/modules/projectiles/guns/ballistic.dm +++ b/code/modules/projectiles/guns/ballistic.dm @@ -175,11 +175,11 @@ if(suppressed) playsound(src, suppressed_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0) if(play_click && click_on_low_ammo) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, TRUE, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound else playsound(src, fire_sound, fire_sound_volume, vary_fire_sound) if(play_click && click_on_low_ammo) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, vary_fire_sound, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, TRUE, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound /** diff --git a/code/modules/projectiles/guns/ballistic/revolver.dm b/code/modules/projectiles/guns/ballistic/revolver.dm index 06a8b50c566..c63d6205580 100644 --- a/code/modules/projectiles/guns/ballistic/revolver.dm +++ b/code/modules/projectiles/guns/ballistic/revolver.dm @@ -51,11 +51,11 @@ if(suppressed) playsound(src, suppressed_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0) if(play_click) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, TRUE, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound else playsound(src, fire_sound, fire_sound_volume, vary_fire_sound) if(play_click) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, vary_fire_sound, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, TRUE, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound /obj/item/gun/ballistic/revolver/verb/spin() set name = "Spin Chamber" diff --git a/code/modules/projectiles/guns/energy.dm b/code/modules/projectiles/guns/energy.dm index 734bce37122..2f3a69830ed 100644 --- a/code/modules/projectiles/guns/energy.dm +++ b/code/modules/projectiles/guns/energy.dm @@ -67,9 +67,9 @@ playing_sound.pitch = pitch_to_use if(suppressed) - playsound(src, playing_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0) + playsound(src, playing_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = gun_feel_fire_frequency()) // VOIDCREW EDIT CHANGE - gun_feel: a narrow random pitch instead of 0.73x-1.25x (voidcrew/modules/gun_feel/sounds.dm) else - playsound(src, playing_sound, fire_sound_volume, vary_fire_sound) + playsound(src, playing_sound, fire_sound_volume, vary_fire_sound, frequency = gun_feel_fire_frequency()) // VOIDCREW EDIT CHANGE - gun_feel: a narrow random pitch instead of 0.73x-1.25x (voidcrew/modules/gun_feel/sounds.dm) /obj/item/gun/energy/emp_act(severity) . = ..() diff --git a/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm b/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm index 0be0ca2b75f..e8cd4811902 100644 --- a/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm +++ b/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm @@ -656,7 +656,7 @@ . = ..() affected_mob.adjust_drowsiness(3 SECONDS * REM * seconds_per_tick) var/need_mob_update - switch(affected_mob.mob_mood.sanity_level) + switch(affected_mob.mob_mood ? affected_mob.mob_mood.sanity_level : SANITY_LEVEL_NEUTRAL) // VOIDCREW EDIT CHANGE: no mood datum (human NPCs, voidcrew/modules/human_npcs) reads as neutral if (SANITY_LEVEL_GREAT to SANITY_LEVEL_NEUTRAL) need_mob_update = affected_mob.adjustBruteLoss(-1.5 * REM * seconds_per_tick, updating_health = FALSE) if (SANITY_LEVEL_DISTURBED to SANITY_LEVEL_UNSTABLE) diff --git a/code/modules/reagents/chemistry/reagents/impure_reagents/impure_medicine_reagents.dm b/code/modules/reagents/chemistry/reagents/impure_reagents/impure_medicine_reagents.dm index 92065b29969..290bbd3cfd9 100644 --- a/code/modules/reagents/chemistry/reagents/impure_reagents/impure_medicine_reagents.dm +++ b/code/modules/reagents/chemistry/reagents/impure_reagents/impure_medicine_reagents.dm @@ -1146,7 +1146,7 @@ Basically, we fill the time between now and 2s from now with hands based off the holder.remove_reagent(/datum/reagent/drug/happiness, 5 * REM * seconds_per_tick) holder.remove_reagent(/datum/reagent/medicine/psicodine, 5 * REM * seconds_per_tick) - affected_mob.mob_mood.adjust_sanity(-7.5 * REM * seconds_per_tick, minimum = SANITY_INSANE) + affected_mob.mob_mood?.adjust_sanity(-7.5 * REM * seconds_per_tick, minimum = SANITY_INSANE) // VOIDCREW EDIT CHANGE: human NPCs have no mood datum (voidcrew/modules/human_npcs) if(affected_mob.mob_mood != null && affected_mob.mob_mood.sanity < (SANITY_CRAZY)) affected_mob.adjust_drowsiness_up_to(5 SECONDS, 30 SECONDS) if(SPT_PROB(25, seconds_per_tick)) diff --git a/code/modules/surgery/bodyparts/dismemberment.dm b/code/modules/surgery/bodyparts/dismemberment.dm index 0e25e1b981f..17012f9db74 100644 --- a/code/modules/surgery/bodyparts/dismemberment.dm +++ b/code/modules/surgery/bodyparts/dismemberment.dm @@ -15,7 +15,10 @@ var/obj/item/bodypart/affecting = limb_owner.get_bodypart(BODY_ZONE_CHEST) affecting.receive_damage(clamp(brute_dam/2 * affecting.body_damage_coeff, 15, 50), clamp(burn_dam/2 * affecting.body_damage_coeff, 0, 50), wound_bonus=CANT_WOUND) //Damage the chest based on limb's existing damage if(!silent) - limb_owner.visible_message(span_danger("[limb_owner]'s [name] is violently dismembered!")) + // VOIDCREW EDIT CHANGE START: the line joins the message of the hit that caused it (voidcrew/modules/hyperviolence/hitlines) + // original: limb_owner.visible_message(span_danger("[limb_owner]'s [name] is violently dismembered!")) + limb_owner.hv_add_hit_line("[limb_owner]'s [name] is violently dismembered!", HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END INVOKE_ASYNC(limb_owner, TYPE_PROC_REF(/mob, emote), "scream") playsound(get_turf(limb_owner), 'sound/effects/dismember.ogg', 80, TRUE) limb_owner.add_mood_event("dismembered_[body_zone]", /datum/mood_event/dismembered, src) diff --git a/code/modules/unit_tests/simple_animal_freeze.dm b/code/modules/unit_tests/simple_animal_freeze.dm index daefc29f871..457b208f62e 100644 --- a/code/modules/unit_tests/simple_animal_freeze.dm +++ b/code/modules/unit_tests/simple_animal_freeze.dm @@ -33,12 +33,6 @@ // mob set, grandfathered here so the freeze still catches genuinely new // simple animals. They are queued for the basic mob refactor like the rest. // VOIDCREW EDIT START: unit_tests retains the existing mining fauna during basic-mob migration. - /mob/living/simple_animal/hostile/asteroid/hermit, - /mob/living/simple_animal/hostile/asteroid/hermit/ranged, - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger, - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, - /mob/living/simple_animal/hostile/asteroid/hermit/survivor, - /mob/living/simple_animal/hostile/asteroid/hermit/survivor/random, /mob/living/simple_animal/hostile/asteroid/old_demon, // VOIDCREW EDIT END /mob/living/simple_animal/hostile/asteroid/polarbear, diff --git a/code/modules/unit_tests/surgeries.dm b/code/modules/unit_tests/surgeries.dm index 8d2901bf77f..3af5e2df669 100644 --- a/code/modules/unit_tests/surgeries.dm +++ b/code/modules/unit_tests/surgeries.dm @@ -130,4 +130,14 @@ basic_brute_heal.success(user, naked_patient, BODY_ZONE_CHEST) basic_brute_heal.success(user, clothed_patient, BODY_ZONE_CHEST) - TEST_ASSERT(naked_patient.getBruteLoss() < clothed_patient.getBruteLoss(), "Naked patient did not heal more from wounds tending than a clothed patient") + // VOIDCREW EDIT CHANGE START: Voidcrew's tend wounds works through clothing (VOIDCREW EDIT REMOVAL in + // code/modules/surgery/healing.dm), so both heal alike. Compared as raw limb damage: the tended limb is + // picked at random, and limbs count less towards getBruteLoss() than the head and chest. + var/naked_brute = 0 + for(var/obj/item/bodypart/part as anything in naked_patient.bodyparts) + naked_brute += part.brute_dam + var/clothed_brute = 0 + for(var/obj/item/bodypart/part as anything in clothed_patient.bodyparts) + clothed_brute += part.brute_dam + TEST_ASSERT(abs(naked_brute - clothed_brute) < 0.5, "Tending wounds healed a clothed patient differently from a naked one ([naked_brute] vs [clothed_brute] brute left)") + // VOIDCREW EDIT CHANGE END diff --git a/tgstation.dme b/tgstation.dme index cf7dcde4fe3..34c6bdf341f 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -18,6 +18,7 @@ // END_PREFERENCES // BEGIN_INCLUDE +#include "__byond_var_id_padding.dm" #include "__odlint.dm" #include "_maps\_basemap.dm" #include "code\__byond_version_compat.dm" @@ -33,6 +34,7 @@ #include "code\__DEFINES\_protect.dm" #include "code\__DEFINES\_tick.dm" #include "voidcrew\_DEFINES\core_integration.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. +#include "voidcrew\_DEFINES\hyperviolence.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use (hit lines in code/datums/wounds). #include "voidcrew\_DEFINES\modular_hooks.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. #include "code\__DEFINES\access.dm" #include "code\__DEFINES\achievements.dm" @@ -6681,6 +6683,7 @@ #include "voidcrew\_DEFINES\bounty_ai.dm" #include "voidcrew\_DEFINES\bounty_board.dm" #include "voidcrew\_DEFINES\bounty_bosses.dm" +#include "voidcrew\_DEFINES\bounty_chrome.dm" #include "voidcrew\_DEFINES\bounty_criminals.dm" #include "voidcrew\_DEFINES\bounty_identity.dm" #include "voidcrew\_DEFINES\bounty_kingpin.dm" @@ -6693,12 +6696,16 @@ #include "voidcrew\_DEFINES\drug_smuggling.dm" #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" +#include "voidcrew\_DEFINES\gorilla_gore.dm" +#include "voidcrew\_DEFINES\gun_feel.dm" #include "voidcrew\_DEFINES\hud.dm" +#include "voidcrew\_DEFINES\human_npcs.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\loot.dm" +#include "voidcrew\_DEFINES\mantis_gore.dm" #include "voidcrew\_DEFINES\maps.dm" #include "voidcrew\_DEFINES\maths.dm" #include "voidcrew\_DEFINES\metrics.dm" @@ -6918,13 +6925,14 @@ #include "voidcrew\edits\modules\mob\mob.dm" #include "voidcrew\edits\modules\mob\mob_defines.dm" #include "voidcrew\edits\modules\mob\living\basic\lavaland\legion\spawn_legions.dm" -#include "voidcrew\edits\modules\mob\living\basic\trooper\trooper.dm" +#include "voidcrew\edits\modules\mob\living\carbon\floored_fall.dm" #include "voidcrew\edits\modules\mob\living\carbon\inventory.dm" #include "voidcrew\edits\modules\mod\modules\modules_supply.dm" #include "voidcrew\edits\modules\modular_computers\file_system\programs\budgetordering.dm" #include "voidcrew\edits\modules\power\supermatter_anomalies.dm" #include "voidcrew\edits\modules\power\turbine\turbine.dm" #include "voidcrew\edits\modules\projectiles\boxes_magazines\_box_magazine.dm" +#include "voidcrew\edits\modules\projectiles\projectile_npc_allies.dm" #include "voidcrew\edits\modules\research\designs.dm" #include "voidcrew\edits\modules\research\designs\comp_board_designs.dm" #include "voidcrew\edits\modules\research\designs\machine_designs.dm" @@ -6934,6 +6942,7 @@ #include "voidcrew\edits\modules\shuttle\shuttle_consoles\navigation_computer.dm" #include "voidcrew\edits\modules\spells\spell_types\jaunt\bloodcrawl.dm" #include "voidcrew\edits\modules\spells\spell_types\jaunt\ethereal_jaunt.dm" +#include "voidcrew\edits\modules\surgery\organs\internal\cyberimp\augments_internal.dm" #include "voidcrew\edits\modules\surgery\organs\internal\heart\heart_anomalock.dm" #include "voidcrew\edits\modules\vehicles\mecha\mech_fabricator.dm" #include "voidcrew\edits\modules\wiremod\core\component.dm" @@ -7029,6 +7038,7 @@ #include "voidcrew\mapping\shuttles\syndicate\geneva.dm" #include "voidcrew\mapping\shuttles\syndicate\hyena.dm" #include "voidcrew\mapping\spawners\nanites.dm" +#include "voidcrew\modules\admin\chrome_test_kit.dm" #include "voidcrew\modules\admin\feature_testing.dm" #include "voidcrew\modules\admin\overmap_management.dm" #include "voidcrew\modules\admin\overmap_management_bans.dm" @@ -7052,6 +7062,7 @@ #include "voidcrew\modules\ambient_npcs\planet_peddler.dm" #include "voidcrew\modules\ambient_npcs\planet_sites.dm" #include "voidcrew\modules\ambient_npcs\recruit.dm" +#include "voidcrew\modules\ambient_npcs\rescue_reputation.dm" #include "voidcrew\modules\ambient_npcs\stranded.dm" #include "voidcrew\modules\ambient_npcs\stranded_lifeboat.dm" #include "voidcrew\modules\anomalies\core_rewards.dm" @@ -7094,11 +7105,14 @@ #include "voidcrew\modules\bounties\bounty_activities.dm" #include "voidcrew\modules\bounties\bounty_admin.dm" #include "voidcrew\modules\bounties\bounty_ai.dm" +#include "voidcrew\modules\bounties\bounty_ai_human.dm" #include "voidcrew\modules\bounties\bounty_ai_meek.dm" #include "voidcrew\modules\bounties\bounty_ai_normal.dm" #include "voidcrew\modules\bounties\bounty_board.dm" #include "voidcrew\modules\bounties\bounty_boss.dm" #include "voidcrew\modules\bounties\bounty_boss_kits.dm" +#include "voidcrew\modules\bounties\bounty_chrome.dm" +#include "voidcrew\modules\bounties\bounty_combat_sense.dm" #include "voidcrew\modules\bounties\bounty_companions.dm" #include "voidcrew\modules\bounties\bounty_criminal.dm" #include "voidcrew\modules\bounties\bounty_identity.dm" @@ -7108,13 +7122,23 @@ #include "voidcrew\modules\bounties\bounty_lair_maps.dm" #include "voidcrew\modules\bounties\bounty_lair_types.dm" #include "voidcrew\modules\bounties\bounty_looks.dm" +#include "voidcrew\modules\bounties\bounty_outfits.dm" #include "voidcrew\modules\bounties\bounty_outpost.dm" #include "voidcrew\modules\bounties\bounty_placement.dm" #include "voidcrew\modules\bounties\bounty_posting.dm" #include "voidcrew\modules\bounties\bounty_restraints.dm" +#include "voidcrew\modules\bounties\bounty_styles.dm" +#include "voidcrew\modules\bounties\bounty_suits.dm" #include "voidcrew\modules\bounties\bounty_turn_in.dm" #include "voidcrew\modules\bounties\bounty_types.dm" #include "voidcrew\modules\bounties\bounty_weapons.dm" +#include "voidcrew\modules\bounties\chrome\bounty_chrome_cascade.dm" +#include "voidcrew\modules\bounties\chrome\bounty_chrome_deadeye.dm" +#include "voidcrew\modules\bounties\chrome\bounty_chrome_gorilla.dm" +#include "voidcrew\modules\bounties\chrome\bounty_chrome_mantis.dm" +#include "voidcrew\modules\bounties\chrome\bounty_chrome_piledriver.dm" +#include "voidcrew\modules\bounties\chrome\bounty_chrome_redline.dm" +#include "voidcrew\modules\bounties\chrome\bounty_cyberpsycho.dm" #include "voidcrew\modules\captain_management\captain_management.dm" #include "voidcrew\modules\captain_management\command_transfer.dm" #include "voidcrew\modules\captain_management\ship_applications.dm" @@ -7170,24 +7194,44 @@ #include "voidcrew\modules\cryo\despawn.dm" #include "voidcrew\modules\cryo\machine.dm" #include "voidcrew\modules\cyberware\chrome_cradle.dm" +#include "voidcrew\modules\cyberware\chrome_hud.dm" +#include "voidcrew\modules\cyberware\chrome_wheel.dm" +#include "voidcrew\modules\cyberware\chrome_wheel_arms.dm" +#include "voidcrew\modules\cyberware\chrome_wheel_hud.dm" +#include "voidcrew\modules\cyberware\chrome_wheel_keybindings.dm" #include "voidcrew\modules\cyberware\cradle_ui.dm" +#include "voidcrew\modules\cyberware\cyberware_anim.dm" +#include "voidcrew\modules\cyberware\cyberware_anim2.dm" #include "voidcrew\modules\cyberware\cyberware_base.dm" #include "voidcrew\modules\cyberware\cyberware_component.dm" #include "voidcrew\modules\cyberware\cyberware_feedback.dm" #include "voidcrew\modules\cyberware\cyberware_salvage.dm" #include "voidcrew\modules\cyberware\cyberware_scan.dm" +#include "voidcrew\modules\cyberware\cyberware_scan_overlay.dm" #include "voidcrew\modules\cyberware\cyberware_stealth.dm" +#include "voidcrew\modules\cyberware\cyberware_zoom.dm" #include "voidcrew\modules\cyberware\parlor_fixtures.dm" #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" +#include "voidcrew\modules\cyberware\street_doc_catalog.dm" #include "voidcrew\modules\cyberware\ware_arm_utility.dm" #include "voidcrew\modules\cyberware\ware_legend.dm" +#include "voidcrew\modules\cyberware\ware_line_berserk.dm" +#include "voidcrew\modules\cyberware\ware_line_camo.dm" +#include "voidcrew\modules\cyberware\ware_line_governor.dm" +#include "voidcrew\modules\cyberware\ware_line_mantis.dm" +#include "voidcrew\modules\cyberware\ware_line_models_a.dm" +#include "voidcrew\modules\cyberware\ware_line_sandy.dm" +#include "voidcrew\modules\cyberware\ware_line_widowline.dm" +#include "voidcrew\modules\cyberware\ware_line_zoom.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" +#include "voidcrew\modules\cyberware\ware_models_b.dm" #include "voidcrew\modules\cyberware\ware_pro_combat.dm" #include "voidcrew\modules\cyberware\ware_pro_utility.dm" #include "voidcrew\modules\cyberware\ware_registry.dm" #include "voidcrew\modules\cyberware\ware_second_wind.dm" #include "voidcrew\modules\cyberware\ware_street.dm" +#include "voidcrew\modules\cyberware\ware_street_passives.dm" #include "voidcrew\modules\derelict_outposts\derelict_layout.dm" #include "voidcrew\modules\derelict_outposts\derelict_leash.dm" #include "voidcrew\modules\derelict_outposts\derelict_outpost.dm" @@ -7230,7 +7274,88 @@ #include "voidcrew\modules\dynamic_events\events\vent_clog.dm" #include "voidcrew\modules\dynamic_events\events\wormholes.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" +#include "voidcrew\modules\gun_feel\bullet_speed.dm" +#include "voidcrew\modules\gun_feel\casings.dm" +#include "voidcrew\modules\gun_feel\flinch.dm" +#include "voidcrew\modules\gun_feel\gun_feel.dm" +#include "voidcrew\modules\gun_feel\impacts.dm" +#include "voidcrew\modules\gun_feel\movable_physics.dm" +#include "voidcrew\modules\gun_feel\muzzle_flash.dm" +#include "voidcrew\modules\gun_feel\npc_blood.dm" +#include "voidcrew\modules\gun_feel\sounds.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" +#include "voidcrew\modules\human_npcs\human_npc.dm" +#include "voidcrew\modules\human_npcs\human_npc_admin.dm" +#include "voidcrew\modules\human_npcs\human_npc_ai.dm" +#include "voidcrew\modules\human_npcs\human_npc_barks.dm" +#include "voidcrew\modules\human_npcs\human_npc_combat_sense.dm" +#include "voidcrew\modules\human_npcs\human_npc_gear.dm" +#include "voidcrew\modules\human_npcs\human_npc_outfits.dm" +#include "voidcrew\modules\human_npcs\human_npc_outfits_pirates.dm" +#include "voidcrew\modules\human_npcs\human_npc_samples.dm" +#include "voidcrew\modules\human_npcs\human_npc_soft_crit.dm" +#include "voidcrew\modules\human_npcs\human_npc_spawning.dm" +#include "voidcrew\modules\human_npcs\human_npc_spike_harness.dm" +#include "voidcrew\modules\human_npcs\human_npc_states.dm" +#include "voidcrew\modules\human_npcs\human_npc_surrender.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_base.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_hermits.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_spawners.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_troopers.dm" +#include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\blood\_blood_defines.dm" +#include "voidcrew\modules\hyperviolence\blood\blood_pools.dm" +#include "voidcrew\modules\hyperviolence\blood\blood_spray.dm" +#include "voidcrew\modules\hyperviolence\blood\blood_weightless.dm" +#include "voidcrew\modules\hyperviolence\braingut\blast_gore.dm" +#include "voidcrew\modules\hyperviolence\braingut\brain_burst.dm" +#include "voidcrew\modules\hyperviolence\braingut\gut_spill.dm" +#include "voidcrew\modules\hyperviolence\braingut\gut_tether.dm" +#include "voidcrew\modules\hyperviolence\braingut\intestines.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_roll.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_types.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_wounds.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_ai.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_bar.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_effects.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_stages.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_users.dm" +#include "voidcrew\modules\hyperviolence\hitlines\hit_lines.dm" +#include "voidcrew\modules\hyperviolence\hitlines\npc_blood.dm" +#include "voidcrew\modules\hyperviolence\overlays_exec\executions.dm" +#include "voidcrew\modules\hyperviolence\overlays_exec\gore_overlays.dm" +#include "voidcrew\modules\hyperviolence\overlays_exec\sleeve_mask.dm" +#include "voidcrew\modules\hyperviolence\visuals\_defines.dm" +#include "voidcrew\modules\hyperviolence\visuals\_motion_core.dm" +#include "voidcrew\modules\hyperviolence\visuals\blood_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\death_falls.dm" +#include "voidcrew\modules\hyperviolence\visuals\execution_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\finisher_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\flying_gore.dm" +#include "voidcrew\modules\hyperviolence\visuals\gut_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\hit_reactions.dm" +#include "voidcrew\modules\hyperviolence\visuals\npc_motion.dm" +#include "voidcrew\modules\hyperviolence\wire\_wire_defines.dm" +#include "voidcrew\modules\hyperviolence\wire\wire_cut.dm" +#include "voidcrew\modules\hyperviolence\wire\wire_garrote.dm" +#include "voidcrew\modules\hyperviolence\wire\wire_held.dm" +#include "voidcrew\modules\hyperviolence\wire\wire_seams.dm" +#include "voidcrew\modules\hyperviolence\wire\wire_sever_fx.dm" +#include "voidcrew\modules\hyperviolence\wire\wire_users.dm" +#include "voidcrew\modules\hyperviolence_gorilla\chrome_puppet_rig.dm" +#include "voidcrew\modules\hyperviolence_gorilla\chrome_use_release.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_finisher.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_haymaker.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_looks.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_pound.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_pry.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_punch.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_slam.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_swing.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_throw.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_thrust.dm" +#include "voidcrew\modules\hyperviolence_gorilla\hv_finisher_chrome.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" @@ -7260,6 +7385,7 @@ #include "voidcrew\modules\loot\loot_guns.dm" #include "voidcrew\modules\loot\zone_loot.dm" #include "voidcrew\modules\loot\zone_mobs.dm" +#include "voidcrew\modules\loot\zone_mobs_arrival.dm" #include "voidcrew\modules\loot\zone_resolution.dm" #include "voidcrew\modules\loot\themes\_theme.dm" #include "voidcrew\modules\loot\themes\armory.dm" @@ -7280,6 +7406,16 @@ #include "voidcrew\modules\loot\uniques\research.dm" #include "voidcrew\modules\loot\uniques\syndicate.dm" #include "voidcrew\modules\loot\uniques\wardrobe.dm" +#include "voidcrew\modules\mantis_gore\mantis_blade.dm" +#include "voidcrew\modules\mantis_gore\mantis_charge.dm" +#include "voidcrew\modules\mantis_gore\mantis_core.dm" +#include "voidcrew\modules\mantis_gore\mantis_housing.dm" +#include "voidcrew\modules\mantis_gore\mantis_impale.dm" +#include "voidcrew\modules\mantis_gore\mantis_lunge_fx.dm" +#include "voidcrew\modules\mantis_gore\mantis_rig.dm" +#include "voidcrew\modules\mantis_gore\mantis_scissor.dm" +#include "voidcrew\modules\mantis_gore\mantis_swing.dm" +#include "voidcrew\modules\mantis_gore\mantis_wounds.dm" #include "voidcrew\modules\metrics\combat_metrics.dm" #include "voidcrew\modules\metrics\economy_metrics.dm" #include "voidcrew\modules\metrics\metrics_helpers.dm" @@ -7305,6 +7441,8 @@ #include "voidcrew\modules\missions\mission_pad.dm" #include "voidcrew\modules\missions\mission_target.dm" #include "voidcrew\modules\missions\player_bounty.dm" +#include "voidcrew\modules\missions\site_dressing.dm" +#include "voidcrew\modules\missions\spawn_cover.dm" #include "voidcrew\modules\missions\code\controllers\subsystem\missions.dm" #include "voidcrew\modules\missions\missions\big_game_hunt.dm" #include "voidcrew\modules\missions\missions\delivery.dm" @@ -7317,6 +7455,7 @@ #include "voidcrew\modules\missions\missions\recovery.dm" #include "voidcrew\modules\missions\missions\recovery_variants.dm" #include "voidcrew\modules\missions\missions\rescue.dm" +#include "voidcrew\modules\missions\missions\rescue_survivor.dm" #include "voidcrew\modules\missions\missions\research.dm" #include "voidcrew\modules\missions\missions\suppression.dm" #include "voidcrew\modules\missions\missions\survey.dm" @@ -7347,7 +7486,6 @@ #include "voidcrew\modules\mob\living\simple_animal\hostile\hivebot.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\killertomato.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mushroom.dm" -#include "voidcrew\modules\mob\living\simple_animal\hostile\pirate.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\wumborian_fugu.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\jungle\mega_arachnid.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\basilisk.dm" @@ -7356,7 +7494,6 @@ #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\ice_demon.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\polarbear.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\wolf.dm" -#include "voidcrew\modules\mob\living\simple_animal\hostile\wasteland\hermits.dm" #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" @@ -7391,6 +7528,9 @@ #include "voidcrew\modules\nanites\code\programs\supression.dm" #include "voidcrew\modules\nanites\code\programs\utility.dm" #include "voidcrew\modules\nanites\code\programs\weapon.dm" +#include "voidcrew\modules\nova_guns\nt_m96.dm" +#include "voidcrew\modules\nova_guns\rapier.dm" +#include "voidcrew\modules\nova_guns\zashch.dm" #include "voidcrew\modules\npc_scaling\damage.dm" #include "voidcrew\modules\npc_scaling\spawn_hooks.dm" #include "voidcrew\modules\npc_scaling\zone_profiles.dm" @@ -7405,6 +7545,8 @@ #include "voidcrew\modules\npc_ships\code\overmap_pathfinding.dm" #include "voidcrew\modules\npc_ships\code\pirate_crew.dm" #include "voidcrew\modules\npc_ships\code\pirate_crew_ai.dm" +#include "voidcrew\modules\npc_ships\code\pirate_crew_combat_sense.dm" +#include "voidcrew\modules\npc_ships\code\pirate_crew_human.dm" #include "voidcrew\modules\npc_ships\code\pirate_guns.dm" #include "voidcrew\modules\npc_ships\code\ship_key.dm" #include "voidcrew\modules\npc_ships\code\spawner_subsystem.dm" @@ -7416,6 +7558,7 @@ #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_bosses.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_corpses.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_equipment.dm" +#include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_humans.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_mobs.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_ships.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\nt_patrol.dm" @@ -7505,6 +7648,7 @@ #include "voidcrew\modules\player_outposts\outpost_prison_ambience.dm" #include "voidcrew\modules\player_outposts\outpost_prison_boss_moves.dm" #include "voidcrew\modules\player_outposts\outpost_prison_bounty.dm" +#include "voidcrew\modules\player_outposts\outpost_prison_breach.dm" #include "voidcrew\modules\player_outposts\outpost_prison_breakout.dm" #include "voidcrew\modules\player_outposts\outpost_prison_capture.dm" #include "voidcrew\modules\player_outposts\outpost_prison_changeling.dm" @@ -7516,10 +7660,12 @@ #include "voidcrew\modules\player_outposts\outpost_prison_dialogue.dm" #include "voidcrew\modules\player_outposts\outpost_prison_doors.dm" #include "voidcrew\modules\player_outposts\outpost_prison_economy.dm" +#include "voidcrew\modules\player_outposts\outpost_prison_experiment_forms.dm" #include "voidcrew\modules\player_outposts\outpost_prison_experiments.dm" #include "voidcrew\modules\player_outposts\outpost_prison_extension.dm" #include "voidcrew\modules\player_outposts\outpost_prison_extras.dm" #include "voidcrew\modules\player_outposts\outpost_prison_fixtures.dm" +#include "voidcrew\modules\player_outposts\outpost_prison_guard_combat_sense.dm" #include "voidcrew\modules\player_outposts\outpost_prison_guard_routine.dm" #include "voidcrew\modules\player_outposts\outpost_prison_guards.dm" #include "voidcrew\modules\player_outposts\outpost_prison_horror.dm" @@ -7527,6 +7673,7 @@ #include "voidcrew\modules\player_outposts\outpost_prison_leads.dm" #include "voidcrew\modules\player_outposts\outpost_prison_life.dm" #include "voidcrew\modules\player_outposts\outpost_prison_mail.dm" +#include "voidcrew\modules\player_outposts\outpost_prison_npc_bodies.dm" #include "voidcrew\modules\player_outposts\outpost_prison_panic.dm" #include "voidcrew\modules\player_outposts\outpost_prison_pastimes.dm" #include "voidcrew\modules\player_outposts\outpost_prison_prisoner.dm" @@ -7657,6 +7804,16 @@ #include "voidcrew\modules\ship_upgrades\ships\squab.dm" #include "voidcrew\modules\ship_upgrades\ships\test_modular.dm" #include "voidcrew\modules\ship_upgrades\workshop\bogatyr.dm" +#include "voidcrew\modules\shiptest_guns\rifles\designs.dm" +#include "voidcrew\modules\shiptest_guns\rifles\skm.dm" +#include "voidcrew\modules\shiptest_guns\rifles\woodsman.dm" +#include "voidcrew\modules\shiptest_guns\scorcher\ammo_57x39.dm" +#include "voidcrew\modules\shiptest_guns\scorcher\scorcher.dm" +#include "voidcrew\modules\shiptest_guns\shotguns\brimstone.dm" +#include "voidcrew\modules\shiptest_guns\shotguns\lever_actions.dm" +#include "voidcrew\modules\shiptest_guns\shotguns\slammer.dm" +#include "voidcrew\modules\shiptest_guns\sidearms\rattlesnake.dm" +#include "voidcrew\modules\shiptest_guns\sidearms\resolution.dm" #include "voidcrew\modules\shuttle\boards.dm" #include "voidcrew\modules\shuttle\design.dm" #include "voidcrew\modules\shuttle\emergency.dm" @@ -7702,6 +7859,7 @@ #include "voidcrew\modules\throwing\diagnostics.dm" #include "voidcrew\modules\trade\anomaly_charts.dm" #include "voidcrew\modules\trade\bag_rift_protection.dm" +#include "voidcrew\modules\trade\buyback_listed.dm" #include "voidcrew\modules\trade\modsuit_bench.dm" #include "voidcrew\modules\trade\outpost.dm" #include "voidcrew\modules\trade\outpost_amenities.dm" diff --git a/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx b/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx index 57008cc670e..cf51c16a304 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx @@ -370,10 +370,7 @@ const Rack = () => { if (!has_occupant) { return ( -
- The rack is dark. Drag yourself onto the slab to lie back and it wakes - up. -
+
The rack is dark.
); } @@ -477,7 +474,7 @@ const TileCard = (props: { ware: Ware }) => {
- T{ware.tier} {TIER_NAMES[ware.tier] || 'CHROME'} + {TIER_NAMES[ware.tier] || 'CHROME'} load {ware.load} {ware.capacity_bonus > 0 && +{ware.capacity_bonus} cap} @@ -520,10 +517,7 @@ const Detail = () => { if (!ware) { return ( -
- Pick a piece from the rack. Anything you own shows up on the body before - the rig touches you. -
+
Nothing selected.
); } @@ -548,7 +542,7 @@ const Detail = () => {
- TIER {ware.tier} · {TIER_NAMES[ware.tier] || 'CHROME'} + {TIER_NAMES[ware.tier] || 'CHROME'} {ware.state === 'installed' @@ -603,7 +597,7 @@ const Detail = () => { )} {!!overCapacity && (
- No neural headroom. Shed load or find a Governor. + No neural headroom.
)}
@@ -614,7 +608,6 @@ const Detail = () => { act('remove', { ref: ware.ref })} > EXTRACT @@ -623,11 +616,7 @@ const Detail = () => { act('install', { ref: ware.ref })} > INSTALL @@ -776,10 +765,7 @@ const StatusDeck = () => { style={{ width: `${progress * 100}%` }} /> -
- Hold still. Leaving the slab cancels the cycle. Nothing is lost, the - ware goes to the tray. -
+
Hold still.
); } @@ -789,19 +775,13 @@ const StatusDeck = () => { notes.push('Trade embargo. The rig will not work on you.'); } if (!has_occupant) { - notes.push('The slab is empty. Drag yourself onto it to lie back.'); + notes.push('The slab is empty.'); } else if (!occupant_is_user) { - notes.push( - `${occupant_name} is on the slab. The rig takes orders from its occupant only. You can still pop the parts tray.`, - ); + notes.push(`${occupant_name} is on the slab.`); } else if (!can_operate) { - notes.push( - 'The rig refuses: you have to be conscious and unrestrained to consent to chrome work.', - ); + notes.push('The rig needs you awake and free to say yes.'); } else { - notes.push( - 'Rig idle. Highlight a piece in the rack, check it on the body, then commit.', - ); + notes.push('Rig idle.'); } if (tray_count > 0) { notes.push( @@ -841,10 +821,7 @@ const Controls = () => { act('tuneup')} > {`TUNE-UP · ${tuneup_fee} CR`} @@ -852,7 +829,6 @@ const Controls = () => { act('eject_tray')} > {`EJECT TRAY${tray_count > 0 ? ` · ${tray_count}` : ''}`} @@ -860,7 +836,6 @@ const Controls = () => { act('eject_cash')} > EJECT CASH @@ -868,7 +843,6 @@ const Controls = () => { act('get_up')} > {occupant_is_user ? 'GET UP' : 'UNBUCKLE'} diff --git a/tools/gear_catalog/exceptions.json b/tools/gear_catalog/exceptions.json index 10192c66af9..0bda99761f2 100644 --- a/tools/gear_catalog/exceptions.json +++ b/tools/gear_catalog/exceptions.json @@ -23,6 +23,12 @@ "route": "/datum/design/c9mm/lathe", "reason": "Deliberate: plain reloads for a yellow gun. The gun is the gate." }, + { + "path": "/obj/item/ammo_box/magazine/m10mm", + "rule": "below_reputation", + "route": "/datum/design/vc_mag_m10mm", + "reason": "Deliberate: plain reloads for a yellow gun. The gun is the gate." + }, { "path": "/obj/item/ammo_box/magazine/m50", "rule": "below_reputation", diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 3c5396c196d..6d7ae0e45a1 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -8,7 +8,10 @@ ".45 loads", ".160 Smart loads", "4.6x30mm loads", + "5.7x39mm loads", ".310 rifle loads", + "8x50mmR loads", + "7.62x40mm loads", ".223 rifle loads", "7mm machine gun loads", ".50 sniper loads", @@ -70,13 +73,31 @@ "reason": "60 damage and a light blast with fire around the target. Hurts a room, cannot open a hull. Only the M-90 can fire it.", "tier": "yellow" }, + "/obj/item/ammo_box/a762x40": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "stripper clip (7.62x40mm)", + "rank": 4, + "reason": "Five loose 7.62x40mm rounds to refill SKM magazines. Follows the gun.", + "tier": "yellow" + }, + "/obj/item/ammo_box/a8x50r": { + "confidence": "high", + "group": "8x50mmR loads", + "hash": "", + "name": "stripper clip (8x50mmR)", + "rank": 2, + "reason": "Five loose 8x50mmR rounds to refill Woodsman magazines. Follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/c10mm": { "confidence": "medium", "group": "10mm loads", "hash": "50eb0251eb73bd7c", "name": "ammo box (10mm)", - "rank": 3, - "reason": "Plain 40 damage. The only guns that take it are the near-useless Liberator and, by hand, the Condor magazine, so a box does little.", + "rank": 7, + "reason": "Plain 40 damage. Fits the Zashch and Ansem magazines, and the Liberator. Every autolathe prints the box from round start.", "tier": "green" }, "/obj/item/ammo_box/c38": { @@ -237,6 +258,15 @@ "reason": "Reload for the Popup Ronin arm, 20 rounds of 20 damage. Follows the gun; fits nothing else.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/heater": { + "confidence": "high", + "group": "5.7x39mm loads", + "hash": "", + "name": "Heater magazine (5.7x39mm)", + "rank": 1, + "reason": "Plain 5.7x39mm rounds, 30 to a magazine, 20 damage with a little armor piercing. Follows the Heater.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/boltaction/jezail": { "confidence": "high", "group": "magazines built into a gun", @@ -312,6 +342,15 @@ "reason": "Built into the peashooter, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "holdout cylinder", + "rank": 8, + "reason": "Built into the Holdout revolver arm, never on its own. Rated with the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/cylinder/rev38/housecall": { "confidence": "high", "group": "magazines built into a gun", @@ -359,6 +398,24 @@ "reason": "Built into the M-90's underbarrel launcher, never on its own. Rated with the launcher.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/internal/nt_m96": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "NT M-96 internal magazine", + "rank": 11, + "reason": "Built into the NT M-96, never on its own. Rated with the gun.", + "tier": "green" + }, + "/obj/item/ammo_box/magazine/internal/shot/absolution": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Absolution internal tube", + "rank": 11, + "reason": "Built into the HP Absolution, never on its own. Rated with the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/shot/bounty": { "confidence": "high", "group": "magazines built into a gun", @@ -368,6 +425,24 @@ "reason": "Built into the hook shotgun, never on its own. Rated with the gun.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/internal/shot/brimstone": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Brimstone internal tube", + "rank": 11, + "reason": "Built into the HP Brimstone, never on its own. Rated with the gun.", + "tier": "green" + }, + "/obj/item/ammo_box/magazine/internal/shot/conflagration": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Conflagration internal tube", + "rank": 11, + "reason": "Built into the HP Conflagration, never on its own. Rated with the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/shot/dual/breacherslug": { "confidence": "high", "group": "magazines built into a gun", @@ -386,6 +461,15 @@ "reason": "Built into a green shotgun, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/internal/shot/flaming_arrow": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Flaming Arrow internal tube", + "rank": 11, + "reason": "Built into the HP Flaming Arrow, never on its own. Rated with the gun.", + "tier": "green" + }, "/obj/item/ammo_box/magazine/internal/shot/lethal": { "confidence": "high", "group": "magazines built into a gun", @@ -441,6 +525,42 @@ "reason": "Built into a green riot foam gun, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/m10mm": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm)", + "rank": 5, + "reason": "Ansem magazine, 8 plain 40 damage rounds. Follows the gun; the rounds print at every autolathe.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/m10mm/ap": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm AP)", + "rank": 2, + "reason": "35 damage with enough armor piercing to cancel a vest. The best 10mm against armored crews.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/m10mm/fire": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm incendiary)", + "rank": 4, + "reason": "20 damage and three fire stacks. Weaker than plain 10mm until the fire takes hold.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/m10mm/hp": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm HP)", + "rank": 3, + "reason": "50 damage on bare skin, far less through a vest. Good on NPCs and unarmored crews.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/m12g": { "confidence": "high", "group": "Bulldog drums", @@ -516,6 +636,15 @@ "reason": "Eight taser slugs in a burst-fire drum. A 10 second paralysis on the first hit, with the next already on its way.", "tier": "red" }, + "/obj/item/ammo_box/magazine/m12g_slammer": { + "confidence": "medium", + "group": "shotgun shells", + "hash": "", + "name": "Slammer box magazine", + "rank": 10, + "reason": "Six plain buckshot shells in a box magazine. The shells sell freely; the magazine only matters in a Slammer.", + "tier": "green" + }, "/obj/item/ammo_box/magazine/m223": { "confidence": "high", "group": ".223 rifle loads", @@ -534,6 +663,15 @@ "reason": "30 damage that ignores armor and flies through walls, in a 30 round magazine on a burst rifle. Far past the X-ray gun.", "tier": "red" }, + "/obj/item/ammo_box/magazine/m23": { + "confidence": "high", + "group": "8x50mmR loads", + "hash": "", + "name": "Model 23 magazine (8x50mmR)", + "rank": 1, + "reason": "Five 50 damage rounds that go through a vest. Follows the Woodsman.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/m38": { "confidence": "high", "group": ".38 loads", @@ -775,6 +913,15 @@ "reason": "40 damage on bare skin, far less through a vest. Good on NPCs and unarmored crews.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/m9mm_rattlesnake": { + "confidence": "high", + "group": "9mm loads", + "hash": "", + "name": "MP-84 magazine (9mm)", + "rank": 3, + "reason": "18 plain 30 damage 9mm rounds, between the Stechkin and Saber magazines. Only the MP-84 takes it.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/r10mm": { "confidence": "high", "group": "10mm loads", @@ -784,6 +931,51 @@ "reason": "Regal Condor magazine: 50 damage, armor piercing 40 and it hits the instant you fire. The round is most of why that gun is red.", "tier": "red" }, + "/obj/item/ammo_box/magazine/rapier": { + "confidence": "high", + "group": "4.6x30mm loads", + "hash": "", + "name": "M-94 'Rapier' magazine", + "rank": 4, + "reason": "Plain 4.6x30mm rounds, 30 to a magazine. Follows the gun.", + "tier": "green" + }, + "/obj/item/ammo_box/magazine/resolution": { + "confidence": "high", + "group": "4.6x30mm loads", + "hash": "", + "name": "PD46 magazine (4.6x30mm)", + "rank": 2, + "reason": "30 plain 4.6x30mm rounds, the Rapier magazine's load. Follows the PD46, as the WT-550 magazine follows its gun.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/skm": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "SKM magazine (7.62x40mm)", + "rank": 3, + "reason": "Plain SKM magazine, 20 rounds of 24 damage. Follows the gun.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/skm/drum": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "SKM drum (7.62x40mm)", + "rank": 1, + "reason": "75 rounds of automatic fire without a reload, half again what the red L6 SAW holds. This is what makes the SKM red.", + "tier": "red" + }, + "/obj/item/ammo_box/magazine/skm/extended": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "extended SKM magazine (7.62x40mm)", + "rank": 2, + "reason": "40 rounds. The SKM scores 108 with it, just under red.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/smartgun": { "confidence": "high", "group": ".160 Smart loads", @@ -1026,6 +1218,15 @@ "reason": "10 damage and one fire stack. The weakest WT-550 load.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/zashch": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "Zashch pistol magazine", + "rank": 8, + "reason": "Plain 40 damage 10mm rounds, 20 to a magazine. Follows the gun.", + "tier": "green" + }, "/obj/item/ammo_box/n762": { "confidence": "high", "group": "heavy sidearm loads", @@ -1109,12 +1310,30 @@ "reason": "Plain 60 damage in a Desert Eagle magazine. Follows the yellow gun.", "tier": "yellow" }, + "/obj/item/ammo_casing/a762x40": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "7.62x40mm bullet casing", + "rank": 5, + "reason": "One SKM round. Follows the gun.", + "tier": "yellow" + }, + "/obj/item/ammo_casing/a8x50r": { + "confidence": "high", + "group": "8x50mmR loads", + "hash": "", + "name": "8x50mmR bullet casing", + "rank": 3, + "reason": "One Woodsman round. Follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_casing/arrow": { "confidence": "high", "group": "arrows and crossbow bolts", "hash": "00f7f77a875f0218", "name": "arrow", - "rank": 4, + "rank": 5, "reason": "50 damage arrow, needs the fletching book. Follows the green bow.", "tier": "green" }, @@ -1123,7 +1342,7 @@ "group": "arrows and crossbow bolts", "hash": "26c61b2e12b85c94", "name": "holy arrow", - "rank": 4, + "rank": 5, "reason": "50 damage arrow, needs the fletching book. Follows the green bow.", "tier": "green" }, @@ -1132,7 +1351,7 @@ "group": "arrows and crossbow bolts", "hash": "2641ea5de62b9f09", "name": "plastic arrow", - "rank": 6, + "rank": 7, "reason": "5 damage and 50 stamina, weak against armor. The bow's less-lethal arrow.", "tier": "green" }, @@ -1142,7 +1361,7 @@ "hash": "656913dd1b8db374", "name": "poisonous arrow", "note": "A human who is hit gets 8 units of cyanide and 1 unit of stamina toxin added straight into their reagents; armor does not stop it.", - "rank": 3, + "rank": 4, "reason": "40 damage and 8 units of cyanide past any armor. Still one slow arrow at a time.", "tier": "green" }, @@ -1151,7 +1370,7 @@ "group": "arrows and crossbow bolts", "hash": "e9b3370054896d43", "name": "sticky arrow", - "rank": 5, + "rank": 6, "reason": "30 damage that almost always sticks and hurts to pull out.", "tier": "green" }, @@ -1160,8 +1379,8 @@ "group": "10mm loads", "hash": "7bbe2ad1ddb2010b", "name": "10mm bullet casing", - "rank": 3, - "reason": "Plain 40 damage. The only guns that take it are the near-useless Liberator and, by hand, the Condor magazine, so a box does little.", + "rank": 7, + "reason": "Plain 40 damage. Fits the Zashch and Ansem magazines, and the Liberator. Every autolathe prints the box from round start.", "tier": "green" }, "/obj/item/ammo_casing/c10mm/ap": { @@ -1169,8 +1388,8 @@ "group": "10mm loads", "hash": "5f8864970b7d1dc0", "name": "10mm armor-piercing bullet casing", - "rank": 2, - "reason": "35 damage that cancels a vest, but only a Liberator or a hand-filled Condor magazine can use it. No shop, ruin or played hull has it.", + "rank": 6, + "reason": "35 damage that cancels a vest. Loaded in the Ansem armor-piercing magazine.", "tier": "yellow" }, "/obj/item/ammo_casing/c10mm/reaper": { @@ -1264,6 +1483,15 @@ "reason": "15 damage that cancels a vest. A little better than plain rounds through armor, worse on bare skin.", "tier": "yellow" }, + "/obj/item/ammo_casing/c57x39mm": { + "confidence": "high", + "group": "5.7x39mm loads", + "hash": "", + "name": "5.7x39mm bullet casing", + "rank": 2, + "reason": "The plain Heater round. Follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_casing/c9mm": { "confidence": "medium", "group": "9mm loads", @@ -1759,6 +1987,15 @@ "reason": "Riot foam darts, 25 stamina each. Toy guns loaded with these are real stun weapons, level with the green riot toys.", "tier": "green" }, + "/obj/item/ammo_casing/harpoon": { + "confidence": "medium", + "group": "arrows and crossbow bolts", + "hash": "", + "name": "harpoon", + "rank": 3, + "reason": "60 damage that goes through a vest and sticks in the target. Fits only the harpoon gun, so it follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_casing/junk": { "confidence": "high", "group": "junk rounds", @@ -1842,7 +2079,7 @@ "group": "arrows and crossbow bolts", "hash": "6fc7032803ed668b", "name": "Sharpened Iron Rod", - "rank": 9, + "rank": 10, "reason": "Plain iron rod bolt for a green crossbow, sharpened from a rod. 30 damage, one at a time. The gun is the gate.", "tier": "none" }, @@ -1852,7 +2089,7 @@ "hash": "a52840a8c2b7a626", "name": "healium crystal bolt", "note": "Holds 6 seconds of healing and passes what's left to the bolt when fired, so a bolt that missed keeps its remainder. Embedded in an organic target it heals 5 each of brute, burn, toxin and oxygen per second, adds drowsiness that puts them to sleep for 3 seconds at a time, then falls out and is back at 6 seconds. / Deals no damage and embeds at 100% chance up close, losing 3% per tile; it can be pulled out in 1.5 seconds. Only the first such bolt in a target can put them to sleep, and later ones only heal. The listed proc just hands the remaining heal time back to the dropped or embedded bolt.", - "rank": 3, + "rank": 4, "reason": "No damage, but it nearly always sticks up close, past any armor, and heals 5 of each damage type a second for 6 seconds while the target falls asleep. A field heal or a sleep dart. Healium takes a crystallizer run and the crossbow is slow.", "tier": "yellow" }, @@ -1871,7 +2108,7 @@ "group": "arrows and crossbow bolts", "hash": "378189095d29a5e8", "name": "paper ball", - "rank": 7, + "rank": 8, "reason": "A ball of paper. 1 damage.", "tier": "none" }, @@ -2219,7 +2456,10 @@ "unrated": { "/obj/item/ammo_box": "abstract", "/obj/item/ammo_box/a40mm/rubber": "no route", + "/obj/item/ammo_box/a762x40/empty": "no route", + "/obj/item/ammo_box/a8x50r/empty": "no route", "/obj/item/ammo_box/magazine": "abstract", + "/obj/item/ammo_box/magazine/heater/empty": "no route", "/obj/item/ammo_box/magazine/internal": "abstract", "/obj/item/ammo_box/magazine/internal/boltaction": "abstract", "/obj/item/ammo_box/magazine/internal/boltaction/harpoon": "no route", @@ -2252,13 +2492,12 @@ "/obj/item/ammo_box/magazine/internal/shot/tube/slug": "no route", "/obj/item/ammo_box/magazine/internal/sks": "abstract", "/obj/item/ammo_box/magazine/internal/sks/empty": "no route", - "/obj/item/ammo_box/magazine/m10mm": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/ap": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/fire": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/hp": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/quiet_word": "needs another system", + "/obj/item/ammo_box/magazine/m12g_slammer/empty": "no route", + "/obj/item/ammo_box/magazine/m23/empty": "no route", "/obj/item/ammo_box/magazine/m38/empty": "no route", "/obj/item/ammo_box/magazine/m75": "needs another system", + "/obj/item/ammo_box/magazine/m9mm_rattlesnake/empty": "no route", + "/obj/item/ammo_box/magazine/rapier/empty": "no route", "/obj/item/ammo_box/magazine/recharge": "no route", "/obj/item/ammo_box/magazine/recharge/ctf": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/deagle": "no route", @@ -2277,8 +2516,13 @@ "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/green": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/red": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/yellow": "no route", + "/obj/item/ammo_box/magazine/resolution/empty": "no route", + "/obj/item/ammo_box/magazine/skm/drum/empty": "no route", + "/obj/item/ammo_box/magazine/skm/empty": "no route", + "/obj/item/ammo_box/magazine/skm/extended/empty": "no route", "/obj/item/ammo_box/magazine/sniper_rounds/surplus": "no route", "/obj/item/ammo_box/magazine/toy": "abstract", + "/obj/item/ammo_box/magazine/zashch/empty": "no route", "/obj/item/ammo_box/rocket": "no route", "/obj/item/ammo_box/strilka310/lionhunter": "no route", "/obj/item/ammo_casing/a223/phasic": "no route", @@ -2344,7 +2588,6 @@ "/obj/item/ammo_casing/glockroach": "literal only", "/obj/item/ammo_casing/gumball": "no route", "/obj/item/ammo_casing/gumball/harmful": "no route", - "/obj/item/ammo_casing/harpoon": "needs another system", "/obj/item/ammo_casing/hivebot": "literal only", "/obj/item/ammo_casing/junk/hunter": "no route", "/obj/item/ammo_casing/junk/incendiary": "no route", diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index f4c5b57f0ea..f6116cd316f 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -19,7 +19,7 @@ "hash": "c229d282cf893570", "name": "Strong-Arm empowered musculature implant", "note": "Humans only. With the implanted arm active, an empty-hand combat-mode left-click on a mob becomes a slam on a 5 s cooldown: the hand's unarmed damage roll plus 5 (plus 20 more against beast, special and mining mobs), doubled and run through melee armor, and the target is thrown 1 tile if the wearer is standing; it is not used while Hulk. One implant in each arm lets the wearer pry closed airlocks open bare-handed (6 s, instant if unpowered, refused if bolted or welded). An EMP puts the arm down for 9 s (4.5 s on a light pulse): slams fail, and half the time the wearer lunges onto the target and falls for 3 s, otherwise is paralyzed for 1 s.", - "rank": 6, + "rank": 14, "reason": "A slam for 20 to 30 damage every 5 s per arm, 60 to 70 against mining mobs and beasts, and a pair pries powered airlocks in 6 s. 1,200 cr for two from cargo. EMP makes the arm a liability for 9 s.", "tier": "yellow" }, @@ -28,7 +28,7 @@ "group": "Work tools", "hash": "5b81131a4a10a42c", "name": "Harpoon Spool", - "rank": 6, + "rank": 9, "reason": "Pulls a loose item or small creature to you from 6 tiles every 8 s, anything bigger drags you a tile. Can grab a dropped gun, little else. Load 2, 2,800 cr.", "tier": "green" }, @@ -37,7 +37,7 @@ "group": "Arm weapons and fists", "hash": "31fd669dadf0cf4c", "name": "Bunker Buster housing", - "rank": 5, + "rank": 10, "reason": "Two 60-damage rockets that floor the target and wreck doors and machines with no blast, about 10 on the gun score plus breaching. Two hands, two shots, then 600 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, @@ -51,6 +51,15 @@ "reason": "Every timed action takes 25% less time, surgery, welding, cuffing and repairs alike, plus a driver, wrench and cutters in the arm. Worth a slot for medics and engineers. Load 2, 1,800 cr.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Tinker's fingers", + "rank": 3, + "reason": "A driver, wrench and cutters in the arm, and every timed action 10% faster. The Fixer's Fingers at a tenth of the stack: load 1, 700 cr. Splice takes a pulled set back for 350 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber": { "confidence": "medium", "group": "Scouting and sight", @@ -66,7 +75,7 @@ "group": "Hacking and ship control", "hash": "52911504b95eb634", "name": "Icepick Jack", - "rank": 1, + "rank": 4, "reason": "Knocks hostile turrets out for 15 s, turns them for 10 s, and forces bolted or unpowered doors in 6 s. A boarding crew's key to player hulls, though jacking their turrets sets off an alarm. Load 3, 1 voucher.", "tier": "yellow" }, @@ -75,8 +84,8 @@ "group": "Arm weapons and fists", "hash": "b190c2719a5d40f7", "name": "Mantis Blade housing (right)", - "note": "Each housing extends a hardlight blade into the hand (it can't be dropped; retract stows it). An ability, usable from either housing with its blade out, dashes up to 5 tiles to a living target after a 0.3 s wind-up, lands 10 brute (armor applies, wounding) per extended blade on arrival and floors the target for 2 s. The 20 s cooldown is shared by both housings and is spent even if the dash falls short.", - "rank": 2, + "note": "Each housing extends a hardlight blade into the hand (it can't be dropped; retract stows it). An ability, usable from either housing with its blade out, dashes up to 5 tiles to a living target after a 0.3 s wind-up, lands 10 brute (armor applies, wounding) per extended blade on arrival and floors the target for 2 s; held as a rooted, telegraphed crouch (broken by one 10-damage hit or a stun, costing the full cooldown) it leaps up to 7 tiles. The dash never crosses chasms, lava or space. The 20 s cooldown is shared by both housings and is spent even if the dash falls short; a credited kill of a person or anything with 100+ max health drops it to 3 s, at most once per 8 s, once per body per 2 min. Gore (D31): 3 cuts on one limb within 8 s (5 under cover 15-34, never at 35+) tear its artery; with both blades out every 3rd cut adds a 10 brute follow-through and a 1.5 s stagger; a two-blade lunge into someone at 70% health or down holds them 0.8 s (1.5 s against a wall) and rips out for 2x8 brute; a head swing at someone in crit takes the head off after a 1.2 s interruptible wind-up (Atlas: 25 brute instead).", + "rank": 5, "reason": "Half of a pair: 20-force blades that can't be disarmed, plus a 5-tile lunge every 20 s that adds 20 damage and floors the target for 2 s. Load 6 the pair, 4 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, @@ -85,45 +94,113 @@ "group": "Arm weapons and fists", "hash": "91733b8b4ac9435a", "name": "Mantis Blade housing (left)", - "note": "Each housing extends a hardlight blade into the hand (it can't be dropped; retract stows it). An ability, usable from either housing with its blade out, dashes up to 5 tiles to a living target after a 0.3 s wind-up, lands 10 brute (armor applies, wounding) per extended blade on arrival and floors the target for 2 s. The 20 s cooldown is shared by both housings and is spent even if the dash falls short.", - "rank": 3, + "note": "Each housing extends a hardlight blade into the hand (it can't be dropped; retract stows it). An ability, usable from either housing with its blade out, dashes up to 5 tiles to a living target after a 0.3 s wind-up, lands 10 brute (armor applies, wounding) per extended blade on arrival and floors the target for 2 s; held as a rooted, telegraphed crouch (broken by one 10-damage hit or a stun, costing the full cooldown) it leaps up to 7 tiles. The dash never crosses chasms, lava or space. The 20 s cooldown is shared by both housings and is spent even if the dash falls short; a credited kill of a person or anything with 100+ max health drops it to 3 s, at most once per 8 s, once per body per 2 min. Gore (D31): 3 cuts on one limb within 8 s (5 under cover 15-34, never at 35+) tear its artery; with both blades out every 3rd cut adds a 10 brute follow-through and a 1.5 s stagger; a two-blade lunge into someone at 70% health or down holds them 0.8 s (1.5 s against a wall) and rips out for 2x8 brute; a head swing at someone in crit takes the head off after a 1.2 s interruptible wind-up (Atlas: 25 brute instead).", + "rank": 6, "reason": "Left half of the Mantis pair: 20-force blades that can't be disarmed, plus a 5-tile lunge every 20 s that adds 20 damage and floors the target for 2 s.", "tier": "red" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Scorch Blade housing (right)", + "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out. Its torn arteries are seared half shut (2 flow, clotting) instead of the Mantis's 3.5 that never clots; otherwise the Mantis lunge, leap, kill reset, impale, X-slash and scissor.", + "rank": 3, + "reason": "Half of a pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight. Load 8 the pair, 4 vouchers at reputation 22, one on the shelf per round.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Scorch Blade housing (left)", + "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out. Its torn arteries are seared half shut (2 flow, clotting) instead of the Mantis's 3.5 that never clots; otherwise the Mantis lunge, leap, kill reset, impale, X-slash and scissor.", + "rank": 4, + "reason": "Left half of the Scorch pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Shiv Blade housing (right)", + "note": "No lunge, and none of the Mantis gore moves: its cuts bleed like any knife (no torn arteries, X-slash, impale or scissor).", + "rank": 15, + "reason": "Half of a pair: 14-force blades that can't be disarmed, no lunge. Load 4 the pair, 2,400 cr at yellow reputation. Splice takes a pulled housing back for 600 cr.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Shiv Blade housing (left)", + "note": "No lunge, and none of the Mantis gore moves: its cuts bleed like any knife (no torn arteries, X-slash, impale or scissor).", + "rank": 16, + "reason": "Left half of the Shiv pair: 14-force blades that can't be disarmed, no lunge.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire": { "confidence": "high", "group": "Arm weapons and fists", "hash": "174a6ea665db7cac", "name": "Widowline spool housing", - "rank": 1, + "rank": 2, "reason": "20 damage a swing (15 brute, 5 burn) straight through all armor, reach 2, and it cuts neighbours for 75%. The best melee against armored crews, useless on walls and doors. Load 5, 3 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Widowline Arc spool housing", + "note": "The Widowline wire with a shock on every landed hit. A living target takes 5 more burn and a 2 s stagger (stacking to 4 s) unless it is shock immune or its suit is insulated; gloves do not matter. Silicons, mechs and robotic mobs take 25 more burn. A hit on a turret, ship laser turret, console or camera knocks it out for 5 s, once per 15 s per wire; outpost property is immune. The cleave does not shock. Its garrote always takes the head off, not only in hard crit; an Atlas frame, a sealed helmet, an armoured collar or plating still stops it. A wire pair counts as Arc if either wire is.", + "rank": 1, + "reason": "The Widowline's 20 a swing through all armor and reach 2, plus 5 burn and a stagger on the target, 25 extra burn on robots and mechs, and a turret or console shorted out for 5 s, and a garrote that always beheads. Load 6, 5 vouchers at reputation 22, one on the shelf per round.", + "tier": "red" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw": { "confidence": "high", "group": "Work tools", "hash": "29ec5344a41717f9", "name": "Rockjaw drill fist", "note": "Folds a mining drill out of the forearm. While the drill is out, ore from any rock the wearer cracks (with any tool) is swept 0.2 s later into an internal hopper with no size limit in the code instead of landing on the floor; a second button dumps the hopper at the wearer's feet. Sweeping is off during an EMP reboot, and the hopper's contents drop if the arm ware is destroyed.", - "rank": 5, + "rank": 7, "reason": "A mining drill in the forearm with a bottomless ore hopper. A miner's bag and pick in one arm slot. Load 2, 1,200 cr.", "tier": "green" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Deepcore drill fist", + "rank": 6, + "reason": "The Rockjaw's ore hopper on a faster drill, between the Rockjaw drill and a diamond drill. Load 3, 2,400 cr. Splice takes a pulled one back for 1,200 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin": { "confidence": "medium", "group": "Arm weapons and fists", "hash": "43903115df46709c", "name": "Popup Ronin housing", - "rank": 4, + "rank": 9, "reason": "A one-handed machine pistol in the arm that can't be disarmed: 20 damage, 3.3 shots a second, about a WT-550 on the gun score (47). 20-round magazines are 250 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Holdout housing", + "rank": 11, + "reason": "A .38 revolver in the arm that can't be disarmed: 25 damage, six shots, about 2.5 a second, reloaded by hand a round at a time with ordinary .38. Load 3, 2,400 cr at reputation 4. Splice takes a pulled housing back for 1,200 cr.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook": { "confidence": "high", "group": "Mobility and stealth", "hash": "d2c5b721e44bd009", "name": "Skyhook wrist winch", - "rank": 3, + "rank": 4, "reason": "Fires a line at any wall or anchored structure within 7 tiles and hauls you there, every 8 s. Real chase and escape mobility, at the cost of an arm slot. Load 3, loot only.", "tier": "yellow" }, @@ -132,7 +209,7 @@ "group": "Work tools", "hash": "4a6c47ce239cc051", "name": "integrated paperwork implant", - "rank": 7, + "rank": 11, "reason": "A pen, clipboard, lighter and laser pointer in your arm. Harmless.", "tier": "none" }, @@ -141,7 +218,7 @@ "group": "Work tools", "hash": "6e21944c25437900", "name": "surgical toolset implant", - "rank": 3, + "rank": 4, "reason": "A surgical kit in your arm. Saves carrying the tools. Late printing.", "tier": "green" }, @@ -150,7 +227,7 @@ "group": "Work tools", "hash": "2ffcab76bdd48728", "name": "morbid surgical toolset implant", - "rank": 4, + "rank": 5, "reason": "The surgical arm rebuilt for looks and a nasty mood. Same job as the plain one.", "tier": "green" }, @@ -169,7 +246,7 @@ "hash": "095577f719c51add", "name": "anti-drop implant", "note": "A toggle button: switching it on makes whatever is in the wearer's hands at that moment undroppable (stuns and disarms don't release it) until the button is pressed again or the item leaves the hand; items picked up afterwards are not locked. An EMP releases it and flings the held items up to 10 tiles in random directions, and as with every brain implant any EMP also stuns the wearer for 20 s (10 s on a light pulse).", - "rank": 6, + "rank": 8, "reason": "Locks what's in your hands so stuns and disarms can't take it. Same job as Gecko Grip at about 24 cr of material, but any EMP stuns you for 20 s and flings the item.", "tier": "green" }, @@ -179,7 +256,7 @@ "hash": "8ae45008ffefef4e", "name": "CNS rebooter implant", "note": "1 s after the wearer gains a stun, immobilize, paralysis or stamina crit it clears stun, knockdown, immobilize, paralysis and all stamina damage, then grants stun immunity, baton resistance and immunity to damage slowdown for 6 s (stamina is zeroed again at the end). Then locks out for 60 s. An EMP takes it offline for 9 s (4.5 s on a light pulse), and as with every brain implant also stuns the wearer for 20 s (10 s light).", - "rank": 2, + "rank": 3, "reason": "Once a minute a stun is cleared after 1 s, stamina resets, and you get 6 s of stun immunity. Undoes the first stun of a fight for one neural load. Any EMP stuns the wearer for 20 s and takes it offline for 9 s.", "tier": "yellow" }, @@ -189,7 +266,7 @@ "hash": "df0fb0f149203f80", "name": "CNS skillchip connector implant", "note": "Lets the wearer put a skillchip in or take one out without surgery: a 1.5 s action, inserting the chip in the active hand or, with an empty hand, choosing an installed chip from a radial menu. An EMP knocks installed chips onto the floor (one on a light pulse, two on a heavy one), and as with every brain implant stuns the wearer for 20 s (10 s light). With no chip installed the handler indexes the first chip of an empty or null list and its brain-damage branch is called without a brain, so it may error out before the reboot timer starts (unconfirmed).", - "rank": 7, + "rank": 8, "reason": "Swap skillchips without surgery. Skillchips do almost nothing, and any EMP stuns you for 20 s.", "tier": "none" }, @@ -199,7 +276,7 @@ "hash": "5bc666bd0e7ad39e", "name": "nutriment pump implant", "note": "While the wearer's nutrition is at or below 150 (250 for the PLUS version) it adds about 50 nutrition, then waits 5 s before it can do so again, which stops nutrition falling below that line. An EMP pushes bad-food toxin into the blood (5 units on a heavy pulse, 10 on the PLUS version).", - "rank": 5, + "rank": 6, "reason": "Keeps you just above starving. Removes the need for a kitchen for one neural load.", "tier": "green" }, @@ -209,7 +286,7 @@ "hash": "ce113e10a6c8deab", "name": "nutriment pump implant PLUS", "note": "While the wearer's nutrition is at or below 150 (250 for the PLUS version) it adds about 50 nutrition, then waits 5 s before it can do so again, which stops nutrition falling below that line. An EMP pushes bad-food toxin into the blood (5 units on a heavy pulse, 10 on the PLUS version).", - "rank": 4, + "rank": 5, "reason": "Keeps you fed before you get hungry. Removes food from a six-hour round for one neural load.", "tier": "green" }, @@ -229,7 +306,7 @@ "hash": "14f4a2c2517247c0", "name": "Herculean gravitronic spinal implant", "note": "Cuts the wearer's climbing, ladder, gym-machine, table-carry and vent-boulder lifting times to 80%, adds +1 speed and +4 range to everything they throw, adds 4 to tackle strength and lets them break boulders off a discovered ore vent with bare hands. An EMP deals 20 brute (10 light) to the chest. A gravity anomaly core can be slotted in to change it into the Atlas variant.", - "rank": 6, + "rank": 9, "reason": "Faster climbing, throws fly farther and faster, tackles hit harder, and you can break boulders off ore vents by hand. Nothing here wins a fight. About 85 cr of material.", "tier": "green" }, @@ -239,20 +316,40 @@ "hash": "ffff4012788ccee2", "name": "implantable thrusters set", "note": "A toggle that lets the wearer move under their own power in zero gravity, with a movement-speed bonus while floating and no stabilization, so they keep drifting when they stop. It draws on ambient air above 30 kPa for free, otherwise internals gas or the wearer's plasma store, and turns itself off if neither is available.", - "rank": 5, + "rank": 7, "reason": "A jetpack in your chest with no back slot used, and a speed bonus while floating. Free air when there's pressure, otherwise your tank. About 74 cr of material after 200 research points.", "tier": "green" }, + "/obj/item/organ/cyberimp/cyberware/adrenal_pump": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Adrenal pump", + "note": "When a stamina update leaves a conscious wearer at 70 or more stamina loss and the pump is ready, one tick later it takes 40 off, speeds the wearer up (movement -0.4) and tints them red for 3 s, and plays a loud heartbeat. It then waits 4 minutes; the wait is stored as a ready-at time on the organ, so a refit does not reset it. It ignores the stamina dumped by the Cascade and Redline crashes. It takes the spare heart slot, so it evicts the Clotter node, and goes off during an EMP reboot or brownout.", + "rank": 5, + "reason": "Once every 4 minutes a bad run of stamina damage is cut by 40 and the wearer gets 3 s of extra speed, enough to stay out of a stamina crit for one more volley. Load 2, 2,400 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/atlas": { "confidence": "medium", "group": "Armor and frame", "hash": "0a8132740babf01b", "name": "Atlas frame", "note": "Bones can't be broken (no dislocations or fractures) and limbs can't be dismembered; slashes, punctures, burns and bleeding still land normally. Items and mobs the wearer throws get +2 tiles of range and +1 speed, and any throw of the wearer by something else travels half as far. All of it drops during an EMP reboot or over-capacity brownout.", - "rank": 3, + "rank": 4, "reason": "No broken bones and no lost limbs, thrown things fly farther and you get thrown half as far. Slashes, burns and bleeding still land. Load 4, 2 vouchers at reputation 22.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/atlas/street": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Strut frame", + "note": "Bone wounds (dislocations, hairline and compound fractures) are refused while the ware is online; nothing else. Limbs can still be dismembered, throws are not boosted and the wearer's own knockback is not reduced. Drops during an EMP reboot or over-capacity brownout.", + "rank": 8, + "reason": "Bones can't break: no dislocations or fractures. Limbs still come off, and slashes, burns and bleeding land as usual. None of the Atlas Frame's throw boost or knockback cut. Load 2, 1,500 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/cargo_cavity": { "confidence": "high", "group": "Body upkeep", @@ -265,32 +362,80 @@ "/obj/item/organ/cyberimp/cyberware/cascade": { "confidence": "high", "group": "Operating system", - "hash": "875de17ed8afaa7f", + "hash": "", "name": "Cascade lattice", "rank": 1, - "reason": "Eight seconds a minute at about double speed, melee twice as fast and 40% dodge while moving, then six seconds of crash and 80 stamina. Load 12 of 20, 8 vouchers at reputation 22. An EMP mid-burst ends it.", + "reason": "Eight seconds a minute at about double speed, melee twice as fast, 60% dodge against blows (and shots while moving) and a bigger crit roll on its own hits, then six seconds of crash and 80 stamina. Load 12 of 20, 8 vouchers at reputation 22. An EMP mid-burst ends it.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/cascade/milspec": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Service reflex lattice", + "rank": 4, + "reason": "Seven seconds in every 90 at close to the lattice's speed, melee 40% quicker, 45% dodge against blows (and shots while moving) and a bigger crit roll on its own hits, then seven seconds of crash and 90 stamina. Load 9, 4 vouchers at reputation 11. An EMP mid-burst ends it.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/cascade/street": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Spliced reflex rig", + "rank": 7, + "reason": "Six seconds in every 120 at a clear speed boost, melee a quarter quicker, 30% dodge against blows (and shots while moving) and a small crit roll bonus on its own hits, then eight seconds of crash and 90 stamina. A short window on a long cooldown keeps it below the service lattice. Load 6, 3,600 cr at reputation 4. An EMP mid-burst ends it.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/chromatic_dermis": { "confidence": "high", "group": "Body upkeep", "hash": "ec8cfbbd975a51eb", "name": "Chromatic Dermis ink suite", "note": "Cosmetic only: draws a glowing outline of the chosen colour and pattern around the wearer that flares when they take damage, swing, use a chrome ability or when other chrome acts, dims when they are hungry, and goes dark while the ware is down. No mechanical effect.", - "rank": 8, + "rank": 9, "reason": "Glowing tattoos with no other effect. Load 0, 400 cr.", "tier": "none" }, + "/obj/item/organ/cyberimp/cyberware/clotter_node": { + "confidence": "medium", + "group": "Body upkeep", + "hash": "", + "name": "Clotter node", + "note": "While online, multiplies the blood a human wearer loses by 0.5, on life ticks and flat bleeds alike. It stacks multiplicatively with other bleed reducers and goes off during an EMP reboot or over-capacity brownout. It takes the spare heart slot, so it evicts the Adrenal pump.", + "rank": 3, + "reason": "Halves the blood lost to wounds. It buys a bleeding crew member time between medic visits and does nothing against burst damage. Load 1, 900 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/coolant": { "confidence": "medium", "group": "Armor and frame", "hash": "4ceed965097294a4", "name": "Coolant Loop lattice", - "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature), takes half burn damage (humans only), and loses 1 fire stack per second. It does not stop fire: a burning wearer is still burned at half strength. All three drop during an EMP reboot or over-capacity brownout.", + "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature), takes half burn damage (humans only), and loses 1 fire stack per second. Fire on a human only does harm through body heat, which the ware blocks, so a burning wearer loses clothes but takes no burn from the flames; burn dealt outright (lasers) lands at half. All three drop during an EMP reboot or over-capacity brownout.", "rank": 2, "reason": "Halves all burn damage on top of armor, lasers included, plus no heat damage and fire burns off fast. Half of every beam in the game for load 3. Bullets and blades ignore it. 3,600 cr with no reputation.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/coolant/street": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Heat-sink lining", + "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature) and loses 1 fire stack per second. It does not reduce burn damage dealt outright (lasers land at full strength), but fire on a human only does harm through body heat, which the ware blocks, so a burning wearer loses clothes and takes no burn from the flames. Both drop during an EMP reboot or over-capacity brownout.", + "rank": 9, + "reason": "No heat damage from hot air or lava country, and being set alight burns only your clothes, but laser burns land at full strength. The heat half of the Coolant Loops for load 1, 1,500 cr.", + "tier": "green" + }, + "/obj/item/organ/cyberimp/cyberware/cuffer_wrists": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Cuffer's wrists", + "note": "While online, handcuffing takes 75% of the usual time, the trait combat and insulated gloves also give. It takes the hands slot, so it evicts the Gecko Grip and the Medic's palms.", + "rank": 10, + "reason": "Cuffs go on in three quarters of the time with no glove needed. A small edge for bounty hunters and boarders. Load 1, 500 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/dead_channel": { "confidence": "medium", "group": "Nerves and stuns", @@ -304,11 +449,21 @@ "/obj/item/organ/cyberimp/cyberware/dermal_mesh": { "confidence": "high", "group": "Armor and frame", - "hash": "5b60dbb7a15c8084", + "hash": "", "name": "Dermal Mesh weave", - "note": "While online, adds physiology armor to a human wearer: melee 15, bullet 15, laser 10, wound 5. The armor is removed during an EMP reboot or an over-capacity brownout.", - "rank": 4, - "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear. A clear step up for load 2 and 2,000 cr. EMP switches it off for 9 s.", + "note": "While online, adds physiology armor to a human wearer: melee 15, bullet 15, laser 10, wound 5. Bullets and buckshot pellets from the front glance off at 15% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance are off during an EMP reboot or an over-capacity brownout.", + "rank": 5, + "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear, and one bullet in seven from the front glances off. A clear step up for load 2 and 2,000 cr. EMP switches it off for 9 s.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Hardweave plating", + "note": "While online, adds physiology armor to a human wearer: melee 20, bullet 20, laser 15, energy 10, wound 10. Bullets and buckshot pellets from the front glance off at 30% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance are off during an EMP reboot or an over-capacity brownout.", + "rank": 3, + "reason": "20 melee, 20 bullet, 15 laser, 10 energy and 10 wound armor on the whole body, on top of whatever you wear, and nearly a third of bullets from the front glance off. A size up from the Mesh and well short of the Slabskin Plate, which adds bomb, fire, acid and a bigger glance. Load 3, 3,000 cr at reputation 4. Strong for yellow against gunfire from the front, still useless against lasers and flanking. EMP switches it off for 9 s.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/doppler": { @@ -321,13 +476,33 @@ "reason": "Anything alive and moving within 9 tiles shows through walls, without taking your eye slot. Standing still hides you and the ping is audible. Thermals' little brother. Load 2, 1 voucher.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/doppler/street": { + "confidence": "medium", + "group": "Scouting and sight", + "hash": "", + "name": "Thumper ear", + "note": "A toggle: while on, living mobs within 5 tiles on the wearer's level that are moving show as blips through walls on the wearer's HUD, each lasting 3 s after its last step so standing still drops off the display; machines never show. It plays a sonar ping at the wearer on every life tick while any mover is tracked, louder than the Heartbeat Doppler's and doubled within 3 tiles, so nearby players can hear it.", + "rank": 3, + "reason": "Anything alive and moving within 5 tiles shows through walls for a moment after each step, without taking your eye slot. Standing still hides you and the ping carries. A shorter, louder Heartbeat Doppler. Load 1, 1,500 cr at reputation 4.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/cyberware/frostline": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Frostline lining", + "note": "While the ware is online the wearer takes no damage from cold air or body temperature. It does nothing for pressure, breathing or burns. The trait drops during an EMP reboot or over-capacity brownout.", + "rank": 10, + "reason": "No cold damage on ice planets and in the cold of space, but low pressure and no air still kill. Shares the seal slot with every air and heat lining, so it never sits beside a bladder. Load 1, 1,500 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/gastro": { "confidence": "high", "group": "Body upkeep", "hash": "26dc92f22c6385f6", "name": "Gastro Reactor", "note": "The wearer eats 35% faster, food that is contaminated (for example dropped on the floor) gives no infection or disgust mood, vomiting costs half the nutrition, everything they eat counts as 6 points higher quality (lifting rotten or gross food above the disgust line, though species-toxic food stays toxic) and disgust drains away at 3 per second. Off during an EMP reboot or over-capacity brownout.", - "rank": 6, + "rank": 7, "reason": "Eat anything, eat faster, no food poisoning. No bearing on a fight. Load 1, 400 cr.", "tier": "none" }, @@ -337,7 +512,7 @@ "hash": "e331ce1251c1f939", "name": "Gecko Grip palm pads", "note": "The wearer's hands refuse to drop held items while stunned, paralyzed, unconscious, in crit or stamina-crit (handcuffs still take the hands, and the dead drop them). They can walk over tables and climb obstacles 20% faster, and stepping onto a chasm edge shoves them back to the lip instead of dropping them, resetting after 30 s. An EMP opens the hands and hurls whatever is held up to 5 tiles (3 on a light pulse).", - "rank": 5, + "rank": 7, "reason": "Stuns and crit can't strip what you're holding, you walk over tables and a chasm edge throws you back. Load 1, 900 cr. An EMP flings your gun away.", "tier": "green" }, @@ -346,29 +521,59 @@ "group": "Mobility and stealth", "hash": "d87709dff38cd54e", "name": "Ghostskin weave", - "note": "A button turns on 10 s of optical camo (a faint outline that brightens when they move) with a 30 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked, hostile bullets, being cuffed, death or an EMP. Basic-mob AI won't target the wearer from more than 2 tiles away while it is up.", + "note": "A button turns on 10 s of optical camo (a faint outline that brightens when they move) with a 30 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked (an animal's bite, a mech or a xeno included), hostile bullets, a real electric shock, being cuffed, death or an EMP. Basic-mob AI won't target the wearer from more than 2 tiles away while it is up.", "rank": 2, "reason": "Ten seconds of near-invisibility every 30, and mobs farther than 2 tiles lose you. Attacking or being hit ends it. A free escape or free first shot in most fights, and it walks you through ruins. Load 5, 4 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/ghostskin/street": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Flickerskin weave", + "note": "A button turns on 7 s of optical camo (a faded outline at about a quarter opacity that brightens when they move) with a 45 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked (an animal's bite, a mech or a xeno included), hostile bullets, a real electric shock, being cuffed, death or an EMP. It does not grant the mob-hiding camo trait, so NPC AI still targets the wearer at any range and Chrome Read still resolves them.", + "rank": 6, + "reason": "Seven seconds of mostly faded camo every 45: people lose you in a crowd or a dim room, mobs and turrets still see you, and any swing or hit ends it. A cheap way to break line of sight from players. Load 2, 2,400 cr at yellow reputation.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms": { "confidence": "high", "group": "Arm weapons and fists", - "hash": "9c748c5bf556a0e0", + "hash": "", "name": "Gorilla Arms myomer lattice (right)", - "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 20 brute punch (armor rolled, +15 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback on the safe path, 4 s cooldown per arm; three punches on one limb inside 15 s snap it (compound fracture on an arm or leg, caved ribs on the chest) unless 40+ melee covers it. A mob held in an aggressive grab is hurled 4 tiles, or slammed into the floor (25) or onto a table (30), once per 10 s grab-move cooldown. Each arm has a Haymaker wheel chip: wind up 0.8 to 1.5 s, 20 to 30 brute and a 3 to 4 tile launch (into a wall: +10 and cracked ribs), 20 s shared cooldown. The pair adds Ground pound (stagger and daze everything adjacent bar crew, 45 s) and three finishers on a target in crit or stunned with both hands empty, picked by aim: head bursts the head, an arm tears it off, the chest breaks the back (floored until set); about 2 s of wind-up, interrupted by 10 damage, a step or the victim breaking free; shares the 20 s cooldown. Rock breaks, structures take 40, and the pair pries powered airlocks in 4 s (never trader outpost doors).", "rank": 7, - "reason": "Half of a pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s. A breaching tool more than a weapon. Load 6 the pair.", - "tier": "yellow" + "reason": "Half of a pair. With the haymaker, the grab-slam and three finishers it is a melee kill kit, not only a breaching tool: about Mantis-level against someone it can catch, and it ends downed targets outright. Counters are range, the wind-ups, 40+ melee armour and the Atlas frame. Load 6 the pair, 3 vouchers at advanced reputation.", + "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms/left": { "confidence": "high", "group": "Arm weapons and fists", - "hash": "25756d0541291f90", + "hash": "", "name": "Gorilla Arms myomer lattice (left)", - "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 20 brute punch (armor rolled, +15 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback on the safe path, 4 s cooldown per arm; three punches on one limb inside 15 s snap it (compound fracture on an arm or leg, caved ribs on the chest) unless 40+ melee covers it. A mob held in an aggressive grab is hurled 4 tiles, or slammed into the floor (25) or onto a table (30), once per 10 s grab-move cooldown. Each arm has a Haymaker wheel chip: wind up 0.8 to 1.5 s, 20 to 30 brute and a 3 to 4 tile launch (into a wall: +10 and cracked ribs), 20 s shared cooldown. The pair adds Ground pound (stagger and daze everything adjacent bar crew, 45 s) and three finishers on a target in crit or stunned with both hands empty, picked by aim: head bursts the head, an arm tears it off, the chest breaks the back (floored until set); about 2 s of wind-up, interrupted by 10 damage, a step or the victim breaking free; shares the 20 s cooldown. Rock breaks, structures take 40, and the pair pries powered airlocks in 4 s (never trader outpost doors).", "rank": 8, - "reason": "Left half of the Gorilla pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s.", + "reason": "Left half of the Gorilla pair: the punch, snaps, slam and Haymaker; the pair adds Ground pound and the three finishers. With the haymaker, the grab-slam and three finishers it is a melee kill kit, not only a breaching tool: about Mantis-level against someone it can catch, and it ends downed targets outright. Counters are range, the wind-ups, 40+ melee armour and the Atlas frame. Load 6 the pair, 3 vouchers at advanced reputation.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Brawler myomers (right)", + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 14 brute punch (armor rolled, +10 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback, 5 s cooldown per arm; four punches on one limb inside 15 s snap it unless 40+ melee covers it. 32 to structures every 1.5 s, which dents plain and reinforced airlocks; breaks rock. A mob held in an aggressive grab is slammed into the floor (18 brute, 20 stamina) or onto a table (22 brute, 30 stamina, 30 to the table) with a 1 s knockdown, once per 15 s grab-move cooldown; it is never hurled. The synced pair forces powered airlocks open in 7 s (instant if unpowered, refused if bolted or welded, and refused at trader outposts, where a pry or punch at a door counts as aggression), adds Ground pound (stagger up to 6 s and 10 stamina on everything adjacent bar crew, no daze, nothing moved, 60 s) and the limb rip finisher on a target in crit or stunned. No haymaker, head clap, backbreaker or flex. A Brawler arm beside a Gorilla arm syncs neither pair.", + "rank": 12, + "reason": "Half of a pair: a 14 damage punch every 5 s that snaps a limb on the fourth hit, a grab slam for 18 to 22 with a 1 s knockdown every 15 s, and 32 to structures, enough to break down an airlock. The pair forces powered airlocks in 7 s, staggers everything adjacent every 60 s and tears an arm off a downed target. Above the Strong-Arm, which slams a little harder but has no finisher or door damage; below Gorilla Arms, which hurl, hit harder, pry faster and have three finishers. Load 4 the pair, 2,000 cr at reputation 4. Either ripperdoc takes a pulled arm back for 500 cr.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Brawler myomers (left)", + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 14 brute punch (armor rolled, +10 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback, 5 s cooldown per arm; four punches on one limb inside 15 s snap it unless 40+ melee covers it. 32 to structures every 1.5 s, which dents plain and reinforced airlocks; breaks rock. A mob held in an aggressive grab is slammed into the floor (18 brute, 20 stamina) or onto a table (22 brute, 30 stamina, 30 to the table) with a 1 s knockdown, once per 15 s grab-move cooldown; it is never hurled. The synced pair forces powered airlocks open in 7 s (instant if unpowered, refused if bolted or welded, and refused at trader outposts, where a pry or punch at a door counts as aggression), adds Ground pound (stagger up to 6 s and 10 stamina on everything adjacent bar crew, no daze, nothing moved, 60 s) and the limb rip finisher on a target in crit or stunned. No haymaker, head clap, backbreaker or flex. A Brawler arm beside a Gorilla arm syncs neither pair.", + "rank": 13, + "reason": "Left half of the Brawler pair: a 14 damage punch every 5 s, an 18 to 22 grab slam every 15 s, 32 to structures. The pair forces airlocks, has a light ground pound and the limb rip.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/governor_delete": { @@ -380,6 +585,24 @@ "reason": "Adds 15 neural load for 35 in all, giving up the slot Cascade and Redline need. That runs Slabskin, Lazarus, Mantis, Hopper, Dead Channel, Coolant, Deadeye and Atlas at once. 5 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/governor_delete/milspec": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Governor Bypass", + "rank": 6, + "reason": "Raises the neural load limit from 20 to 30 for no load of its own, in the slot Cascade and Redline need. 3 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/governor_delete/street": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Governor Patch", + "rank": 9, + "reason": "Raises the neural load limit from 20 to 25 for no load of its own, in the slot Cascade and Redline need. 2,500 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/hemoglass": { "confidence": "medium", "group": "Body upkeep", @@ -395,10 +618,28 @@ "group": "Mobility and stealth", "hash": "2f8e33baec594340", "name": "Hopper piston calves", - "rank": 4, + "rank": 5, "reason": "Jump 4 tiles over tables, gaps and people every 8 s. Won't cross walls or windows. Strong in any fight. Load 3, 3,600 cr.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/hopper/milspec": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Vaulter pistons", + "rank": 3, + "reason": "Jump 5 tiles over tables, gaps and people every 6 s, and walk away from drops and wet floors like the Shock Coils. Won't cross walls or windows. Load 4, 3 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/hopper/street": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Spring calves", + "rank": 8, + "reason": "Jump 3 tiles over tables, gaps and people every 14 s. Won't cross walls or windows. The cheap rung under the Hopper, with none of the Shock Coils' trims. Load 2, 1,800 cr at reputation 4. Splice takes a pulled pair back for 900 cr.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/lazarus": { "confidence": "high", "group": "Revive and last stand", @@ -409,6 +650,26 @@ "reason": "Once every 6 minutes, dropping into crit stands you up at 30% health with stuns cleared after a 1 s seizure. A second life in the same fight. Load 5, 4 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/medic_palms": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Medic's palms", + "note": "While online, gives the wearer the quicker chemistry trait (syringes, pills, patches and the like take less time) and the quicker fireman carry (3 s instead of 5), the same traits as nitrile gloves, without using the glove slot. It takes the hands slot, so it evicts the Gecko Grip and the Cuffer's wrists.", + "rank": 8, + "reason": "Nitrile-glove speed on chemistry and carrying without the glove slot, so a medic can wear insulated or armored gloves instead. Load 1, 600 cr.", + "tier": "green" + }, + "/obj/item/organ/cyberimp/cyberware/muffler_ear": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Muffler ear", + "note": "While online, takes one point off the intensity of a bang, which is what earmuffs do: a flashbang or similar bang does nothing to the wearer, though the flash still blinds. It goes off during an EMP reboot or brownout. It takes the chrome ears slot, so it evicts the Heartbeat Doppler.", + "rank": 10, + "reason": "A flashbang's bang stops dropping the wearer, like earmuffs you can't take off. The flash still blinds. Load 1, 700 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/piledriver": { "confidence": "high", "group": "Mobility and stealth", @@ -427,13 +688,31 @@ "reason": "Twelve seconds in every 90: stun immune, 40% less brute and burn, 8 more on punches, and a roar that staggers everyone within 5 tiles. Then 100 stamina and slow legs. Load 10, 6 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/redline/milspec": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Vanguard core", + "rank": 5, + "reason": "Ten seconds in every 105: stun immune, 30% less brute and burn, 5 more on punches, and a roar that staggers everyone within 4 tiles. Then 100 stamina and slow legs. Load 8, 4 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/redline/street": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Hothead core", + "rank": 8, + "reason": "Eight seconds in every 90: 20% less brute and burn and 3 more on punches, with no stun immunity and no roar. Then 100 stamina and slow legs. Load 4, 3,000 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/registry_uplink": { "confidence": "low", "group": "Hacking and ship control", "hash": "3ab34be71b4333f9", "name": "Registry uplink", "note": "Gives a button that opens an outpost's management panel from anywhere, with no distance, docking, zone or console-power requirement, for each outpost where the wearer is currently the management user. It needs the wearer conscious and the ware running, and any open panel closes if the ware goes down or is removed.", - "rank": 3, + "rank": 6, "reason": "Opens the management panel of an outpost you run from anywhere. Convenience for outpost owners, no use to anyone else. Load 2, 2,800 cr.", "tier": "green" }, @@ -443,7 +722,7 @@ "hash": "332ab143dabb3d52", "name": "Rigger socket", "note": "Opens a working helm console on the ship the bearer is standing on, from anywhere aboard, subject to the console's normal crew access; broken, unpowered and view-only viewscreen consoles don't count. While the uplink is up the bearer has +1 movement slowdown, and it drops if they leave the hull, go unconscious, close the panel, lose the console, or the socket is EMP'd or browned out. The button has a 5 second cooldown.", - "rank": 2, + "rank": 5, "reason": "Fly your ship from anywhere aboard at a walking pace. Lets a pilot hide from boarders or fix things mid-fight, and it needs a working helm. Load 4, 3 vouchers at reputation 22.", "tier": "yellow" }, @@ -453,7 +732,7 @@ "hash": "e057ede2eff30ba2", "name": "Scrapper's Knuckles", "note": "Replaces the bearer's own punch (combat mode, left click, not a Hulk) with that hand's normal unarmed roll plus 4 flat damage against mobs, and double that total against machines and structures. Humans can still block it and armor still applies; it stops working while the ware is EMP'd or browned out.", - "rank": 9, + "rank": 17, "reason": "Half of a pair: punches do 4 more, double against machines. A cheap brawler's first step. Load 2 the pair, 800 cr.", "tier": "green" }, @@ -463,7 +742,7 @@ "hash": "1262168b74587722", "name": "Scrapper's Knuckles", "note": "Replaces the bearer's own punch (combat mode, left click, not a Hulk) with that hand's normal unarmed roll plus 4 flat damage against mobs, and double that total against machines and structures. Humans can still block it and armor still applies; it stops working while the ware is EMP'd or browned out.", - "rank": 10, + "rank": 18, "reason": "Left half of the Scrapper pair: punches do 4 more, double against machines.", "tier": "green" }, @@ -473,28 +752,38 @@ "hash": "7266f264ffd401f9", "name": "Second Wind bladder", "note": "When the surroundings have under 16 kPa of oxygen and the bearer isn't on tank internals, the bladder supplies the breath itself from a 180 second reserve, spending 8 seconds of reserve per breath and refilling 16 seconds per breath taken in breathable air. It replaces only the breath, and it announces itself with a gasp, a HUD alert and a loud breathing loop; it does nothing for no-breathers or while EMP'd or browned out.", - "rank": 5, + "rank": 7, "reason": "Three minutes of air when you lose your internals, refills as you breathe. A safety net against vacuum. Load 1, 900 cr.", "tier": "green" }, + "/obj/item/organ/cyberimp/cyberware/second_wind/deep": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Deep-lung bladder", + "note": "Works exactly like the Second Wind bladder: when the surroundings have under 16 kPa of oxygen and the bearer isn't on tank internals, it supplies the breath itself, spending 8 seconds of reserve per breath. The reserve is 360 seconds (180) and refills 24 seconds per breath taken in breathable air (16). It announces itself with a gasp, a HUD alert and a loud breathing loop; it does nothing for no-breathers or while EMP'd or browned out.", + "rank": 6, + "reason": "Six minutes of air when you lose your internals, refilling faster than the Second Wind. A longer safety net against vacuum, not a way to work in it. Load 2, 1,800 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/shock_coils": { "confidence": "medium", "group": "Nerves and stuns", "hash": "d453bb62463e3dcb", "name": "Shock Coil calf pistons", "note": "Halves knockdown durations and cuts stun durations by 20%, stops slipping on water-wet floors (water only), and cancels fall damage from dropping down levels with no power cost; falls of 2 or more levels still stagger the bearer for up to 10 seconds. All of it switches off while the ware is EMP'd or browned out.", - "rank": 4, + "rank": 6, "reason": "Knockdowns are half as long, stuns 20% shorter, no slipping on water or fall damage. The best cheap chrome: load 1, 500 cr. Evicted by Hopper.", "tier": "green" }, "/obj/item/organ/cyberimp/cyberware/slabskin": { "confidence": "high", "group": "Armor and frame", - "hash": "7b785a3adf8b7c37", + "hash": "", "name": "Slabskin plate", - "note": "While online it adds built-in armor to the bearer (melee 25, bullet 25, laser 25, energy 20, bomb 25, fire 20, acid 20, wound 15), and any brute or burn hit of 5 or more throws sparks and often a ricochet sound so others can see the plate. The armor switches off while the ware is EMP'd or browned out.", + "note": "While online it adds built-in armor to the bearer (melee 25, bullet 25, laser 25, energy 20, bomb 25, fire 20, acid 20, wound 15), and any brute or burn hit of 5 or more throws sparks and often a ricochet sound so others can see the plate. Bullets and buckshot pellets from the front glance off at 45% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance switch off while the ware is EMP'd or browned out.", "rank": 1, - "reason": "25 armor against melee, bullets and lasers on the whole body, multiplied on top of whatever you wear, plus bomb, fire, acid and wounds. About a second armor vest that covers your head and limbs. Load 6, 3 vouchers at reputation 22. EMP switches it off for 9 s.", + "reason": "25 armor against melee, bullets and lasers on the whole body, multiplied on top of whatever you wear, plus bomb, fire, acid and wounds, and almost half of bullets from the front glance off. About a second armor vest that covers your head and limbs, and the best answer to a gunline in the game. Load 6, 3 vouchers at reputation 22. EMP switches it off for 9 s.", "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/slipwire": { @@ -503,16 +792,46 @@ "hash": "83d2c1a6b828b267", "name": "Slipwire reflex shunt", "note": "While the bearer has moved within the last second, a hostile projectile has a 15% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. Also gives a small speed bonus (-0.1 slowdown); with other dodge sources only the highest chance counts, and everything here is off while the ware is EMP'd or browned out.", - "rank": 3, + "rank": 4, "reason": "15% of bullets miss while you're moving, never from an adjacent shooter, and you run slightly faster. Takes the slot Dead Channel wants. Load 4, 2 vouchers.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/slipwire/milspec": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Weaver shunt", + "note": "While the bearer has moved within the last second, a hostile projectile has a 20% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. Also gives a speed bonus (-0.15 slowdown). With other dodge sources only the highest chance counts, so a Cascade window's 40% still wins, and everything here is off while the ware is EMP'd or browned out.", + "rank": 2, + "reason": "20% of bullets miss while you're moving, never from an adjacent shooter, and you run a little faster than a Slipwire. Takes the slot Dead Channel wants. Load 5, 3 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/slipwire/street": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Twitchwire", + "note": "While the bearer has moved within the last second, a hostile projectile has an 8% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. No speed bonus. With other dodge sources only the highest chance counts, and it is off while the ware is EMP'd or browned out.", + "rank": 9, + "reason": "8% of bullets miss while you're moving, never from an adjacent shooter, and nothing else. A cheap taste of the Slipwire in the slot Dead Channel wants. Load 2, 1,800 cr at reputation 4.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/cyberware/subvocal_mic": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Subvocal mic", + "note": "While online, a radio line the wearer speaks (headset, department or intercom) is spoken at range 0 instead of 1: the wearer and anyone on their tile hear it, a neighbour one tile away gets the usual garbled eavesdrop copy, and nobody further hears it. Ordinary talk is unchanged. It goes off during an EMP reboot or brownout.", + "rank": 10, + "reason": "Radio talk is not overheard by anyone standing close. A small edge for sneaking and boarding. Load 1, 600 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/eyes/hud/diagnostic": { "confidence": "high", "group": "Scouting and sight", "hash": "bc1a71fec9d46258", "name": "diagnostic HUD implant", - "rank": 5, + "rank": 6, "reason": "A diagnostic HUD with no glasses. Shows machine and robot health, for engineers.", "tier": "green" }, @@ -521,7 +840,7 @@ "group": "Scouting and sight", "hash": "4beab53dff56918d", "name": "medical HUD implant", - "rank": 3, + "rank": 4, "reason": "A health HUD with no glasses, same job as the green goggles but the eye slot stays free. About 30 cr of material, or found on two hulls.", "tier": "green" }, @@ -530,7 +849,7 @@ "group": "Scouting and sight", "hash": "91681c47158b6422", "name": "security HUD implant", - "rank": 4, + "rank": 5, "reason": "A security HUD with no glasses. Names and wanted status, nothing that wins a fight.", "tier": "green" }, @@ -539,7 +858,7 @@ "group": "Body upkeep", "hash": "81096fd3e55913d0", "name": "breathing tube implant", - "rank": 3, + "rank": 4, "reason": "Use internals with no mask, which frees the mask slot, and you can't be choked. About 2 cr of material after 200 research points.", "tier": "green" } diff --git a/tools/gear_catalog/ratings/explosives.json b/tools/gear_catalog/ratings/explosives.json index 719ee90c508..b0cefb79cfd 100644 --- a/tools/gear_catalog/ratings/explosives.json +++ b/tools/gear_catalog/ratings/explosives.json @@ -462,6 +462,7 @@ "/obj/item/grenade/hypnotic": "no route", "/obj/item/grenade/primer": "no route", "/obj/item/grenade/primer/stingbang": "no route", + "/obj/item/grenade/smokebomb/bounty_boss": "no route", "/obj/item/grenade/spawnergrenade": "no route", "/obj/item/grenade/spawnergrenade/buzzkill": "no route", "/obj/item/grenade/spawnergrenade/cat": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 079fdd4cbed..30e11abdea1 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -73,6 +73,17 @@ "score": 219, "tier": "red" }, + "/obj/item/gun/ballistic/automatic/battle_rifle": { + "confidence": "low", + "group": "rifles", + "hash": "", + "name": "NT BR-38 battle rifle", + "note": "Right-click scopes in at 2x view range. After ten shots each shot has a 1 in 10 chance to wear the rifle down a stage, up to five; at the last stage it misfires three shots in four. A multitool (20 s) or a weapon recharger sets it right, and an EMP wears it down at once. An emag lifts its shots from 30 to 40 damage, and a fully worn emagged rifle blows up five seconds after the next trigger pull.", + "rank": 13, + "reason": "Fires 30 damage .38 rounds from a 15 round magazine, with a 2x scope. It wears itself out after ten shots until someone recalibrates it, which keeps it just under the yellow line.", + "score": 36, + "tier": "green" + }, "/obj/item/gun/ballistic/automatic/c20r/toy/unrestricted": { "confidence": "high", "group": "toys and novelties", @@ -99,7 +110,7 @@ "group": "smgs", "hash": "d9b40f6fa856925a", "name": "C-20r SMG", - "rank": 4, + "rank": 8, "reason": "Puts a vested man down in about a second, with 24 rounds, from one hand.", "score": 79, "tier": "yellow" @@ -109,11 +120,31 @@ "group": "smgs", "hash": "bb032c110b509c59", "name": "Popup Ronin machine-pistol", - "rank": 6, + "rank": 10, "reason": "20 damage at 3.3 a second, WT-550 numbers with some spread. Lives in the arm, so it can't be disarmed, and reloads are hard to get.", "score": 47, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/heater": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "Heater", + "rank": 4, + "reason": "20 damage 5.7mm rounds at 6.7 a second from a 30 round magazine, with a little spread. Level with the Saber, and it fits in a bag.", + "score": 106, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/automatic/heater/scorcher": { + "confidence": "low", + "group": "smgs", + "hash": "", + "name": "Scorcher", + "rank": 3, + "reason": "A Heater (20 damage 5.7mm at 6.7 a second, 30 to a magazine) with a flamethrower under it: a held spray of 5-tile plumes, five a second, that burn and set people alight, eight seconds of spray to a tank of welding fuel. The flamer alone is the MOD module's yellow, together they edge over the red line.", + "score": 115, + "tier": "red" + }, "/obj/item/gun/ballistic/automatic/l6_saw/toy/unrestricted": { "confidence": "high", "group": "toys and novelties", @@ -168,7 +199,7 @@ "group": "smgs", "hash": "57d5080462a9a158", "name": "Type U3 Uzi", - "rank": 5, + "rank": 9, "reason": "Fires the C-20r's bullet at a lower rate, with 32 rounds. Solid, not dangerous.", "score": 70, "tier": "yellow" @@ -178,7 +209,7 @@ "group": "sidearms", "hash": "dc518916bb509d96", "name": "Makarov pistol", - "rank": 10, + "rank": 12, "reason": "A pocket pistol about level with a laser gun: 30 damage, 12 rounds, printable ammo.", "score": 42, "tier": "yellow" @@ -188,11 +219,38 @@ "group": "smgs", "hash": "517391feb1e23558", "name": "Stechkin APS machine pistol", - "rank": 3, + "rank": 6, "reason": "A burst pistol firing the C-20r's bullets. Its 15 rounds go in 3 s.", "score": 105, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/pistol/clandestine": { + "confidence": "high", + "group": "sidearms", + "hash": "", + "name": "Ansem pistol", + "rank": 11, + "reason": "Does 40 damage a shot where the Makarov does 30, from an 8 round magazine. The 10mm rounds print at every autolathe.", + "score": 56, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/automatic/pistol/clandestine/fisher": { + "confidence": "medium", + "group": "sidearms", + "hash": "", + "name": "Ansem/SC pistol", + "note": "Right-click fires the underbarrel disruptor, twice before it recharges. Hitting something with the pistol, or throwing it, disrupts it for 25 s. The suppressor is part of the gun and cannot come off.", + "rank": 10, + "reason": "An Ansem with a suppressor built in and a SC/FISHER light disruptor under the barrel (right-click), which knocks out lights, cameras, turrets and APCs for 15 s.", + "score": 60, + "score_inputs": { + "points": { + "value": 4, + "why": "Suppressor built in, plus the SC/FISHER disruptor under the barrel." + } + }, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/pistol/deagle": { "confidence": "high", "group": "sidearms", @@ -245,7 +303,7 @@ "hash": "4f9a01f4056d7085", "name": "Liberator", "note": "Single-shot barrel that takes a 2 s do-after to unscrew or screw back on. Every shot knocks 70-140 off its 100 integrity: on a fresh gun the first shot shatters it about 58% of the time (hurting the firing hand for up to 20 and sometimes embedding shrapnel), otherwise the next shot always does; point-blank doubles the shot's damage and aiming at someone held at gunpoint adds x1.5.", - "rank": 17, + "rank": 22, "reason": "Holds one round, and the barrel usually shatters within two shots. Its design disk is rare.", "score": 0, "score_inputs": { @@ -265,7 +323,7 @@ "group": "sidearms", "hash": "ed8c5fd09e92dcda", "name": "M1911", - "rank": 12, + "rank": 15, "reason": "Fires a Makarov's bullet with 8 rounds, one vested man per magazine at best.", "score": 35, "tier": "green" @@ -275,7 +333,7 @@ "group": "sidearms", "hash": "58fd32966ac76c57", "name": "M1911", - "rank": 13, + "rank": 16, "reason": "An M1911 that starts without its magazine, and the same gun once loaded.", "score": 35, "tier": "green" @@ -285,11 +343,22 @@ "group": "sidearms", "hash": "cfb7e46635e265f8", "name": "Makarov pistol", - "rank": 11, + "rank": 13, "reason": "A Makarov that starts without its magazine, and the same gun once loaded.", "score": 42, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/pistol/rattlesnake": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "MP-84 'Rattlesnake' machine pistol", + "note": "Right-click in hand cycles single shots, three-round bursts and full auto. Spawns on single shots. Takes a suppressor.", + "rank": 2, + "reason": "A machine pistol that fires 24 damage 9mm at 10 a second on full auto, from an 18 round magazine it empties in under two seconds. Wide spread and a hard kick, but it sits in one hand and fits a belt. Well past the Stechkin and the Saber, under the M-90.", + "score": 137, + "tier": "red" + }, "/obj/item/gun/ballistic/automatic/pistol/toy": { "confidence": "high", "group": "toys and novelties", @@ -311,22 +380,64 @@ "score": 13, "tier": "green" }, + "/obj/item/gun/ballistic/automatic/pistol/zashch": { + "confidence": "medium", + "group": "sidearms", + "hash": "", + "name": "'Zashch' heavy pistol", + "rank": 17, + "reason": "Plain 40 damage 10mm rounds, one a second, 20 to a magazine. The slow cycle keeps it green.", + "score": 22, + "tier": "green" + }, "/obj/item/gun/ballistic/automatic/proto/unrestricted": { "confidence": "high", "group": "smgs", "hash": "3a82e5bc193f1d07", "name": "Nanotrasen Saber SMG", - "rank": 2, + "rank": 5, "reason": "A hard-hitting automatic with 21 rounds that empties in 4 s.", "score": 105, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/rapier": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "M-94 'Rapier' submachinegun", + "rank": 11, + "reason": "Fires 16 damage bullets at 3.3 a second from a 30 round magazine, a WT-550 with a fifth less damage. It scores just under the yellow line, and it takes a suppressor.", + "score": 37, + "tier": "green" + }, + "/obj/item/gun/ballistic/automatic/resolution": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "PD46 'Resolution'", + "note": "Right-click in hand folds or unfolds the stock. Folded it is normal sized with spread 11 and a little kick; unfolded it is bulky with spread 4 and no extra kick. Spawns folded. Takes a suppressor.", + "rank": 7, + "reason": "Full auto 4.6x30mm at 6.7 a second from a 30 round magazine: twice the WT-550's rate with half again its magazine. Between the C-20r and the Saber.", + "score": 93, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/automatic/skm": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "SKM-24", + "note": "Fires automatically while the trigger is held. Takes a suppressor and a bayonet. Also takes a 40 round magazine and a 75 round drum.", + "rank": 6, + "reason": "Automatic at 4 shots a second, 24 damage rounds that mostly ignore a vest, from a 20 round magazine. The 40 round magazine lifts it to 108, still under red; the 75 round drum is what makes it red.", + "score": 86, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/smartgun": { "confidence": "medium", "group": "smgs", "hash": "df8d939590e38d5f", "name": "Abielle Smart-SMG", - "rank": 7, + "rank": 12, "reason": "Fires 10 damage homing rounds. Its spread of 40 is the drawback the homing fixes. Upstream tg prices it as junk.", "score": 32, "score_inputs": { @@ -368,12 +479,23 @@ "score": 39, "tier": "green" }, + "/obj/item/gun/ballistic/automatic/woodsman": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "Model 23 Woodsman", + "note": "Right-click scopes in at 2x view range. Takes a bayonet.", + "rank": 7, + "reason": "Semi-automatic: one 50 damage round that goes through a vest every 0.75 s, from a 5 round magazine, with a 2x scope. Beats the Sakhno by not needing the bolt worked, and stays far below the automatic rifles.", + "score": 53, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/wt550": { "confidence": "high", "group": "smgs", "hash": "2ea01a015382d859", "name": "WT-550 Autorifle", - "rank": 6, + "rank": 10, "reason": "Fires 20 damage bullets at 3.3 a second, about what a laser gun does, yet it's sold as a top yellow gun.", "score": 47, "tier": "yellow" @@ -384,7 +506,7 @@ "hash": "9b6b11da9005d066", "name": "bow", "note": "Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands (e.g. onto the back).", - "rank": 10, + "rank": 16, "reason": "Does 50 damage per arrow, drawn by hand.", "score": 7, "score_inputs": { @@ -401,7 +523,7 @@ "hash": "4231c1aae8664849", "name": "shortbow", "note": "Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands. Same as the bow; only the damage multiplier differs. / Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands (e.g. onto the back).", - "rank": 14, + "rank": 20, "reason": "Does 18 damage per arrow. Its recipe book comes in a 1,000 cr cargo kit.", "score": 3, "score_inputs": { @@ -427,7 +549,7 @@ "group": "sidearms", "hash": "b18390c40543a655", "name": ".38 revolver", - "rank": 14, + "rank": 19, "reason": "Does 25 damage and weak wounds, with six shots then a slow reload.", "score": 18, "tier": "green" @@ -437,7 +559,7 @@ "group": "sidearms", "hash": "616fe18d05a3ece6", "name": "Colt Detective Special", - "rank": 15, + "rank": 20, "reason": "A .38 revolver by another name: 25 damage and six shots.", "score": 18, "tier": "green" @@ -469,6 +591,15 @@ }, "tier": "yellow" }, + "/obj/item/gun/ballistic/revolver/cyberware_holdout": { + "confidence": "medium", + "group": "sidearms", + "hash": "", + "name": "holdout revolver", + "rank": 14, + "reason": "A .38 revolver folded into the forearm: 25 damage, six shots, about 2.5 a second. Can't be disarmed while deployed, and every reload is a round at a time by hand.", + "tier": "yellow" + }, "/obj/item/gun/ballistic/revolver/golden": { "confidence": "medium", "group": "sidearms", @@ -574,7 +705,7 @@ "group": "rifles", "hash": "a0e09364d4321fcb", "name": "Sakhno Precision Rifle", - "rank": 6, + "rank": 9, "reason": "A surplus rifle with real rounds: AP 10 keeps its 60 damage useful through armor.", "score": 48, "tier": "yellow" @@ -585,18 +716,35 @@ "hash": "07e5a8b46b6618fc", "name": "Donk Co. Jezail", "note": "The bolt must be cycled between shots and be open to load. Sawing it off (needs a saw; a live round in the gun goes off in the user's face) makes it belt-sized, cuts damage by a quarter and sets spread to 50. Accepts a bayonet.", - "rank": 7, + "rank": 10, "reason": "A precision rifle with 4 rounds in a huge frame.", "score": 44, "tier": "yellow" }, + "/obj/item/gun/ballistic/rifle/boltaction/harpoon": { + "confidence": "low", + "group": "rifles", + "hash": "", + "name": "ballistic harpoon gun", + "note": "Holds one harpoon, so every shot needs the bolt worked and a new harpoon loaded by hand. A hit nearly always embeds and hurts when the target moves, until someone pulls it out.", + "rank": 12, + "reason": "One harpoon at a time: 60 damage that goes through a vest and sticks in the target. It scores just over the yellow line, and every shot is a reload.", + "score": 41, + "score_inputs": { + "points": { + "value": 10, + "why": "Every hit embeds and keeps hurting; the formula counts a single 60 damage shot only." + } + }, + "tier": "yellow" + }, "/obj/item/gun/ballistic/rifle/boltaction/pipegun": { "confidence": "medium", "group": "rifles", "hash": "09224ff4494cd405", "name": "pipegun", "note": "Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 12, + "rank": 18, "reason": "Fires one hand-loaded round at a time.", "score": 5, "score_inputs": { @@ -613,7 +761,7 @@ "hash": "f1fd463d783a4af9", "name": "pipe pistol", "note": "Self-loading; one trigger pull empties its whole magazine in a rapid burst. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. Anyone can fire it (hulks included) and a point-blank shot throws the target back 3 tiles. / Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 16, + "rank": 21, "reason": "Fires three weak rounds in a burst, then needs a hand reload.", "score": 3, "score_inputs": { @@ -630,7 +778,7 @@ "hash": "6a105958c2af93f0", "name": "regal pipegun", "note": "Bolt action loaded with reaper rounds, which are hitscan (instant) junk rounds. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. Anyone can fire it (hulks included) and a point-blank shot throws the target back 3 tiles. / Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 8, + "rank": 11, "reason": "Fires hitscan shots of 60 with 4 rounds. Crafting it needs a one-use book.", "score": 41, "score_inputs": { @@ -663,18 +811,28 @@ "group": "rifles", "hash": "7673980e5008c01b", "name": "Sakhno M2442 Army", - "rank": 9, + "rank": 15, "reason": "Does 60 damage on bare targets and about 24 through a vest, with five shots and a bolt to work.", "score": 15, "tier": "green" }, + "/obj/item/gun/ballistic/rifle/nt_m96": { + "confidence": "medium", + "group": "sidearms", + "hash": "", + "name": "NT M-96", + "rank": 18, + "reason": "Fires 22 damage 9mm rounds every half second, ten in the gun and a little spread. A self-loader a step below the Makarov.", + "score": 22, + "tier": "green" + }, "/obj/item/gun/ballistic/rifle/rebarxbow": { "confidence": "low", "group": "rifles", "hash": "2ed82f9db0fc22be", "name": "heated rebar crossbow", "note": "Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target.", - "rank": 13, + "rank": 19, "reason": "Does 30 damage then needs a 3 s draw. Better ammo exists, which the score doesn't cover.", "score": 3, "score_inputs": { @@ -691,7 +849,7 @@ "hash": "04bae29a51921f7d", "name": "stressed rebar crossbow", "note": "Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target. Here the redraw time is 1.5 ticks (about 0.15 s) because the value lacks SECONDS, and 25% of shots aimed at someone else misfire into the shooter. / Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target.", - "rank": 11, + "rank": 17, "reason": "Redraws almost instantly at the cost of shooting yourself one time in four.", "score": 7, "score_inputs": { @@ -726,7 +884,7 @@ "name": "Dardo-RE Rocket Launcher", "note": "Single shot: the tube is emptied by every shot and needs a new rocket. Each shot throws a backblast behind the user (60-degree arc, light blast and fire out to 6 tiles; the user is protected), and the rocket explodes on impact with a bigger blast against walls and objects and a 1% chance to gib a living target outright.", "rank": 4, - "reason": "Blows holes in ships, one rocket at a time. No shop, ruin or played hull has one.", + "reason": "Blows holes in ships, one rocket at a time. Turns up in red-zone armory and Syndicate caches.", "score": 114, "score_inputs": { "points": { @@ -745,7 +903,7 @@ "group": "shotguns", "hash": "8f3e8cf98b213a9b", "name": "shotgun", - "rank": 9, + "rank": 10, "reason": "A pump shotgun loaded with beanbags. Lethal shells print with Ballistic Ammunition.", "score": 20, "score_inputs": { @@ -765,7 +923,7 @@ "group": "shotguns", "hash": "8f3e8cf98b213a9b", "name": "shotgun", - "rank": 7, + "rank": 8, "reason": "A self-racking pump shotgun, with beanbags as issued.", "score": 33, "score_inputs": { @@ -838,6 +996,40 @@ }, "tier": "yellow" }, + "/obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly": { + "confidence": "medium", + "group": "shotguns", + "hash": "", + "name": "cycler shotgun", + "note": "Racks itself after every shot, so it never needs pumping. Has two 4-shell tubes, buckshot in one and slugs in the other by default; using it in hand switches the active tube (or racks, if the chamber is empty), and the shell already chambered still fires first. A point-blank shot throws the target back 2 tiles.", + "rank": 5, + "reason": "The cycler shotgun shipped with buckshot in one tube and slugs in the other. It takes rubber like the plain cycler.", + "score": 54, + "score_inputs": { + "judged": { + "value": 54, + "why": "The same gun as the plain cycler, which takes rubber shells too, and rubber is its best load; it scores the same." + } + }, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/shotgun/brimstone": { + "confidence": "medium", + "group": "shotguns", + "hash": "", + "name": "HP Brimstone", + "note": "Holding the trigger and pumping fires each fresh shell as soon as it is chambered (slamfire), so it never needs a click per shot. Sawing it off makes it one-handed with much worse spread (25) and more kick.", + "rank": 12, + "reason": "A five-shell buckshot pump that fires as fast as you can work it while the trigger is held. Still plain buckshot from a short tube.", + "score": 14, + "score_inputs": { + "rate": { + "value": 2.0, + "why": "Fires once per pump with the trigger held; the automatic fire only waits on the pump, which is 0.4 s apart. The pump keypress caps it like clicking a semi-auto." + } + }, + "tier": "green" + }, "/obj/item/gun/ballistic/shotgun/bulldog/unrestricted": { "confidence": "medium", "group": "shotguns", @@ -855,7 +1047,7 @@ "hash": "0c9baf65ab7c8e64", "name": "double-barreled shotgun", "note": "Holds two shells with no pumping between shots, and a point-blank shot throws the target back 3 tiles. A saw-type tool (3 seconds) can shorten it; sawing with a shell chambered fires it at the sawer, and the sawn-off gun fits a belt instead of the back, gains recoil and takes a 25 spread penalty.", - "rank": 6, + "rank": 7, "reason": "Two shots then a reload, with beanbags as issued. It takes lethal shells.", "score": 36, "score_inputs": { @@ -880,7 +1072,7 @@ "hash": "0a407fc564167813", "name": "breaching shotgun", "note": "Double-barrel (3-tile point-blank throw, can be sawn off) chambered for breaching shells, which do 5 damage to creatures but carry a 200x demolition modifier. The code comment says one shell breaks a window or grille and two breach an airlock door.", - "rank": 11, + "rank": 12, "reason": "Loaded to open doors: a door key, not a weapon.", "score": 14, "score_inputs": { @@ -897,7 +1089,7 @@ "hash": "0a9911c1c6748c4a", "name": "hunting shotgun", "note": "Double-barrel (3-tile point-blank throw, can be sawn off) loaded with 12g slugs; nothing else differs from the plain double-barrel.", - "rank": 8, + "rank": 9, "reason": "Holds two slugs of 25. AP 30 helps, but two shots is the problem.", "score": 28, "score_inputs": { @@ -908,13 +1100,46 @@ }, "tier": "green" }, + "/obj/item/gun/ballistic/shotgun/flaming_arrow": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "HP Flaming Arrow", + "note": "Lever-action: worked by hand after every shot like a pump. 18 .38 rounds loaded one at a time. Sawn off it holds 7. Racking it one-handed or sawn off spins it.", + "rank": 14, + "reason": "Fires 25 damage .38 rounds from an 18-round tube, one lever stroke a shot. A long green carbine, below the BR-38.", + "score": 21, + "tier": "green" + }, + "/obj/item/gun/ballistic/shotgun/flaming_arrow/absolution": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "HP Absolution", + "note": "Lever-action: worked by hand after every shot. 8 .357 rounds loaded one at a time; the tube stays 8 when sawn off.", + "rank": 8, + "reason": "Eight 60 damage .357 rounds with a lever stroke each: a revolver with a long tube, a step under the Unica.", + "score": 50, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration": { + "confidence": "low", + "group": "shotguns", + "hash": "", + "name": "HP Conflagration", + "note": "Lever-action shotgun with a 1.75 damage multiplier on every shell. Six shells, five sawn off. Racking it one-handed or sawn off spins it.", + "rank": 6, + "reason": "Buckshot at 1.75 times the usual damage, six to a tube, one lever stroke a shot. Hits like a combat shotgun per shell but cycles by hand.", + "score": 41, + "tier": "yellow" + }, "/obj/item/gun/ballistic/shotgun/hook": { "confidence": "medium", "group": "shotguns", "hash": "d9f220632d6f2013", "name": "hook modified sawn-off shotgun", "note": "Right-click fires an attached hook (1 charge, back in about 8 seconds) that drags the first unanchored thing it hits to the shooter's tile, knocking a creature down for half a second and freezing the shooter's movement until it arrives; the hook deals stamina damage and no brute damage. Left-click fires the shotgun, and the barrel hook gives it a sharp melee attack that can butcher corpses; a point-blank shot throws the target back 2 tiles.", - "rank": 5, + "rank": 6, "reason": "A sawn-off shotgun loaded with rubber, plus a hook that drags the target in.", "score": 49, "score_inputs": { @@ -930,7 +1155,7 @@ "group": "shotguns", "hash": "bdd1ea5bf292ed3f", "name": "shotgun", - "rank": 12, + "rank": 13, "reason": "A pump shotgun loaded with four buckshot shells.", "score": 8, "tier": "green" @@ -940,7 +1165,7 @@ "group": "shotguns", "hash": "b60ba70b55c1b7d5", "name": "Donk Co. Musket", - "rank": 13, + "rank": 14, "reason": "Fires one shell, loaded by hand.", "score": 0, "score_inputs": { @@ -967,7 +1192,7 @@ "hash": "b2de977a12875c5b", "name": "riot shotgun", "note": "Pump-action shotgun loaded with rubber shot, whose pellets ricochet off walls (ricochet chance falls 4 points per tile travelled); a point-blank shot throws the target back 2 tiles. A saw-type tool (3 seconds) can shorten it; sawing with a shell chambered fires it at the sawer, and the sawn-off gun fits a belt instead of the back, gains recoil and takes a 25 spread penalty.", - "rank": 10, + "rank": 11, "reason": "Weaker against a vest than a 400 cr disabler: a slow pump loaded with rubber. It takes lethal shells.", "score": 19, "score_inputs": { @@ -982,6 +1207,17 @@ }, "tier": "green" }, + "/obj/item/gun/ballistic/shotgun/slammer": { + "confidence": "low", + "group": "shotguns", + "hash": "", + "name": "Slammer", + "note": "Pump shotgun fed from six-shell box magazines, with tactical reloads. 1.75 damage multiplier on every shell. Magazines can be hand-filled with any 12 gauge shell.", + "rank": 5, + "reason": "Buckshot at 1.75 times the usual damage, pumped by hand, but it reloads six shells at once from a box magazine.", + "score": 41, + "tier": "yellow" + }, "/obj/item/gun/ballistic/shotgun/toy": { "confidence": "high", "group": "toys and novelties", @@ -1909,10 +2145,10 @@ }, "method": "Placed against the pilot's gun ratings with the same score: the formula where a gun has stats, and a judged score where its power is not in the stats. Bands: green under 40, yellow 40 to 109, red 110 and up.", "unrated": { + "/obj/item/flamethrower/full/tank/bounty_boss": "no route", "/obj/item/gun": "abstract", "/obj/item/gun/ballistic": "abstract", "/obj/item/gun/ballistic/automatic": "abstract", - "/obj/item/gun/ballistic/automatic/battle_rifle": "no route", "/obj/item/gun/ballistic/automatic/c20r": "needs another system", "/obj/item/gun/ballistic/automatic/c20r/sc_c20r": "needs another system", "/obj/item/gun/ballistic/automatic/c20r/toy": "toy", @@ -1932,19 +2168,18 @@ "/obj/item/gun/ballistic/automatic/laser/ctf/yellow": "admin", "/obj/item/gun/ballistic/automatic/m90": "needs another system", "/obj/item/gun/ballistic/automatic/mini_uzi/chimpgun": "needs another system", - "/obj/item/gun/ballistic/automatic/pistol/clandestine": "leftover", - "/obj/item/gun/ballistic/automatic/pistol/clandestine/fisher": "leftover", - "/obj/item/gun/ballistic/automatic/pistol/clandestine/quiet_word": "leftover", "/obj/item/gun/ballistic/automatic/pistol/contraband": "leftover", "/obj/item/gun/ballistic/automatic/pistol/deagle/contraband": "leftover", "/obj/item/gun/ballistic/automatic/pistol/deagle/ctf": "admin", "/obj/item/gun/ballistic/automatic/pistol/fire_mag": "leftover", "/obj/item/gun/ballistic/automatic/pistol/m1911/chimpgun": "needs another system", + "/obj/item/gun/ballistic/automatic/pistol/rattlesnake/no_mag": "no route", "/obj/item/gun/ballistic/automatic/pistol/riot": "leftover", "/obj/item/gun/ballistic/automatic/pistol/stickman": "literal only", "/obj/item/gun/ballistic/automatic/pistol/suppressed": "leftover", "/obj/item/gun/ballistic/automatic/pistol/toy/riot/clandestine": "toy", "/obj/item/gun/ballistic/automatic/proto": "leftover", + "/obj/item/gun/ballistic/automatic/resolution/no_mag": "no route", "/obj/item/gun/ballistic/automatic/tommygun/chimpgun": "needs another system", "/obj/item/gun/ballistic/bow/divine": "leftover", "/obj/item/gun/ballistic/bow/divine/with_quiver": "leftover", @@ -1960,7 +2195,6 @@ "/obj/item/gun/ballistic/revolver/reverse": "leftover", "/obj/item/gun/ballistic/revolver/reverse/mateba": "leftover", "/obj/item/gun/ballistic/rifle": "abstract", - "/obj/item/gun/ballistic/rifle/boltaction/harpoon": "leftover", "/obj/item/gun/ballistic/rifle/boltaction/pipegun/pistol/prime": "leftover", "/obj/item/gun/ballistic/rifle/enchanted": "leftover", "/obj/item/gun/ballistic/rifle/lionhunter": "leftover", @@ -1974,7 +2208,6 @@ "/obj/item/gun/ballistic/rocketlauncher/unrestricted/nanotrasen": "leftover", "/obj/item/gun/ballistic/shotgun/automatic/combat/compact": "leftover", "/obj/item/gun/ballistic/shotgun/automatic/dual_tube/bounty": "leftover", - "/obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly": "leftover", "/obj/item/gun/ballistic/shotgun/bulldog": "needs another system", "/obj/item/gun/ballistic/shotgun/ctf": "admin", "/obj/item/gun/ballistic/shotgun/ctf/blue": "admin", diff --git a/tools/gear_catalog/ratings/melee.json b/tools/gear_catalog/ratings/melee.json index a7568c8fc05..ad2dbd69153 100644 --- a/tools/gear_catalog/ratings/melee.json +++ b/tools/gear_catalog/ratings/melee.json @@ -1161,19 +1161,48 @@ "hash": "c34e783490904319", "name": "Widowline monowire", "note": "Each landed hit adds 5 burn damage and cleaves every living mob next to the target that the target can see and that is not on the wielder's crew team, for 75% of the hit's damage in brute and burn. Its armour penetration is the full 100.", - "rank": 1, + "rank": 2, "reason": "Rated with its arm housing: 25 a second through any armor at 2 tiles, and the hit carries into every enemy beside the target. Hidden until used. Needs surgery.", "tier": "red" }, + "/obj/item/melee/cyberware_monowire/live": { + "confidence": "medium", + "group": "arm blades", + "hash": "", + "name": "Widowline Arc monowire", + "note": "Each landed hit also shocks the target for 5 burn and a short stagger (not through shock immunity or insulated suits), adds 25 burn on silicons, mechs and robotic mobs, and shorts a turret, ship laser turret, console or camera for 5 s, once per 15 s. The cleave neighbours take the plain cleave only.", + "rank": 1, + "reason": "Rated with its arm housing: the Widowline wire with a shock, a stagger and a machine-killing short on every landed hit. Needs surgery.", + "tier": "red" + }, "/obj/item/melee/energy/blade/hardlight/cyberware_mantis": { "confidence": "medium", "group": "arm blades", "hash": "e09509a45772dc39", "name": "mantis blade", - "rank": 2, + "rank": 4, "reason": "Rated with its arm housing: 20 force blades that cannot be disarmed, and a 5 tile lunge every 20 s that floors the target for 2 s. Needs surgery.", "tier": "red" }, + "/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch": { + "confidence": "medium", + "group": "arm blades", + "hash": "", + "name": "scorch blade", + "note": "Each landed cut adds 2 fire stacks and sets the target alight. Corpses are left alone and the wielder is never lit.", + "rank": 3, + "reason": "Rated with its arm housing: the Mantis blade and lunge, with every cut and the lunge setting the target alight. Needs surgery.", + "tier": "red" + }, + "/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street": { + "confidence": "medium", + "group": "arm blades", + "hash": "", + "name": "shiv blade", + "rank": 5, + "reason": "Rated with its arm housing: 14 force blades that cannot be disarmed, no lunge. Needs surgery.", + "tier": "yellow" + }, "/obj/item/melee/energy/sword/pirate": { "confidence": "high", "group": "energy blades", @@ -1315,7 +1344,7 @@ "group": "arm blades", "hash": "63a21bf583afc355", "name": "synthetic arm blade", - "rank": 3, + "rank": 6, "reason": "20 force blade grown on a hacked limb grower. Huge, no parry. The route was not fully traced.", "tier": "green" }, diff --git a/tools/gear_catalog/ratings/modsuits.json b/tools/gear_catalog/ratings/modsuits.json index c788db2588f..2a6731b4f8e 100644 --- a/tools/gear_catalog/ratings/modsuits.json +++ b/tools/gear_catalog/ratings/modsuits.json @@ -1428,6 +1428,12 @@ "/obj/item/mod/control/pre_equipped/administrative": "no route", "/obj/item/mod/control/pre_equipped/apocryphal": "no route", "/obj/item/mod/control/pre_equipped/apocryphal/officer": "no route", + "/obj/item/mod/control/pre_equipped/bounty_boss": "no route", + "/obj/item/mod/control/pre_equipped/bounty_boss/atmospheric": "no route", + "/obj/item/mod/control/pre_equipped/bounty_boss/civilian": "no route", + "/obj/item/mod/control/pre_equipped/bounty_boss/engineering": "no route", + "/obj/item/mod/control/pre_equipped/bounty_boss/magnate": "no route", + "/obj/item/mod/control/pre_equipped/bounty_boss/security": "no route", "/obj/item/mod/control/pre_equipped/chrono": "no route", "/obj/item/mod/control/pre_equipped/corporate": "no route", "/obj/item/mod/control/pre_equipped/cosmohonk": "leftover", diff --git a/tools/gear_catalog/ratings/organs.json b/tools/gear_catalog/ratings/organs.json index fba7540a1e9..c3b3e88ea39 100644 --- a/tools/gear_catalog/ratings/organs.json +++ b/tools/gear_catalog/ratings/organs.json @@ -429,7 +429,7 @@ "group": "Eyes", "hash": "970e50f04a7a2db8", "name": "eyes", - "rank": 18, + "rank": 23, "reason": "Plain replacement eyes.", "tier": "none" }, @@ -438,7 +438,7 @@ "group": "Eyes", "hash": "07ec6668fecdcd61", "name": "moth eyes", - "rank": 19, + "rank": 24, "reason": "Plain replacement eyes.", "tier": "none" }, @@ -447,7 +447,7 @@ "group": "Eyes", "hash": "3e1ed2dde4757134", "name": "fung-eye", - "rank": 8, + "rank": 11, "reason": "Night vision you can toggle, with no neural load. Placed in one jungle ruin. Same job as night vision goggles.", "tier": "yellow" }, @@ -456,7 +456,7 @@ "group": "Eyes", "hash": "84a3161d476535dd", "name": "robotic eyes", - "rank": 11, + "rank": 16, "reason": "A working pair of robotic eyes, no better than your own.", "tier": "none" }, @@ -465,7 +465,7 @@ "group": "Eyes", "hash": "7cc5a72a291dbd8a", "name": "basic robotic eyes", - "rank": 13, + "rank": 18, "reason": "Cheap replacement eyes, weak to flashes.", "tier": "none" }, @@ -474,7 +474,7 @@ "group": "Eyes", "hash": "4ba3d5231be60398", "name": "basic robotic moth eyes", - "rank": 14, + "rank": 19, "reason": "Cheap replacement eyes, weak to flashes.", "tier": "none" }, @@ -483,8 +483,35 @@ "group": "Eyes", "hash": "8ea026e0bf1e0602", "name": "Deadeye link", - "rank": 5, - "reason": "Tag a target and your next three shots curve onto it, every 25 s. Bullets stop missing for a burst, but the target gets a warning and walls stop it. Load 3, 2 vouchers with no reputation.", + "rank": 7, + "reason": "Tag a target and your next three shots curve onto it, every 25 s. Bullets stop missing for a burst, but the target gets a warning and walls stop it. Also zooms about 14 tiles out at a slight slowdown, though nobody can shoot while zoomed. Load 3, 2 vouchers at yellow reputation.", + "tier": "yellow" + }, + "/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec": { + "confidence": "medium", + "group": "Eyes", + "hash": "", + "name": "marksman link", + "rank": 3, + "reason": "Tag a target and your next four shots curve onto it, every 18 s, out to 10 tiles. Also zooms the view about 19 tiles out at full speed, though nobody can shoot while zoomed. Load 4, 3 vouchers at advanced reputation.", + "tier": "red" + }, + "/obj/item/organ/eyes/robotic/cyberware/longsight": { + "confidence": "medium", + "group": "Eyes", + "hash": "", + "name": "longsight optics", + "rank": 9, + "reason": "Zooms the view about 19 tiles out at full speed and names implants out to 9 tiles. Scouting only, no help in a fight. Load 2, 2,400 cr at yellow reputation.", + "tier": "yellow" + }, + "/obj/item/organ/eyes/robotic/cyberware/lookout": { + "confidence": "medium", + "group": "Eyes", + "hash": "", + "name": "lookout optics", + "rank": 6, + "reason": "Zooms the view about 24 tiles out at full speed, sees in the dark, and names every implant on a body out to 11 tiles every 8 s. Pure scouting: no shooting while zoomed. Load 3, 3 vouchers at advanced reputation.", "tier": "yellow" }, "/obj/item/organ/eyes/robotic/cyberware/nightshade": { @@ -493,8 +520,8 @@ "hash": "030567541e7e32a4", "name": "Nightshade optics", "note": "Gives night vision with a green tint and puts a glow, visible only to the wearer, on every living body in view even in the dark (corpses glow dimmer). Flash protection stays at the robotic default, and its chrome scan only counts implants without naming them.", - "rank": 6, - "reason": "Night vision with no flash weakness and a glow on every living body in view. Beats night vision goggles for 600 cr and load 1.", + "rank": 8, + "reason": "Night vision with no flash weakness and a glow on every living body in view. Beats night vision goggles for 600 cr and load 1. Also zooms about 9 tiles out at a walk.", "tier": "yellow" }, "/obj/item/organ/eyes/robotic/cyberware/prospector": { @@ -502,16 +529,34 @@ "group": "Eyes", "hash": "5f70c88fa7552793", "name": "Prospector Suite optics", - "rank": 7, - "reason": "A pulse every 10 s shows ore through rock and loot caches within 9 tiles. Great for mining and ruin crews, no help in a fight. Load 2, 2 vouchers.", + "rank": 10, + "reason": "A pulse every 10 s shows ore through rock and loot caches within 9 tiles. Great for mining and ruin crews, no help in a fight. Also zooms about 14 tiles out at a slight slowdown. Load 2, 1 voucher at yellow reputation.", "tier": "yellow" }, + "/obj/item/organ/eyes/robotic/cyberware/prospector/street": { + "confidence": "high", + "group": "Eyes", + "hash": "", + "name": "assay optics", + "rank": 12, + "reason": "A 5-tile ore pulse through rock every 15 s with no cache marks, plus a short zoom that slows you. A cheap start for miners. Load 1, 900 cr.", + "tier": "green" + }, + "/obj/item/organ/eyes/robotic/cyberware/spyglass": { + "confidence": "high", + "group": "Eyes", + "hash": "", + "name": "spyglass optics", + "rank": 13, + "reason": "A short zoom, about 9 tiles, that slows you while you look, and a chrome read that only counts implants. Binoculars reach further but take both hands. Load 1, 400 cr.", + "tier": "green" + }, "/obj/item/organ/eyes/robotic/flashlight": { "confidence": "high", "group": "Eyes", "hash": "31bf05befe1e2bdf", "name": "flashlight eyes", - "rank": 17, + "rank": 22, "reason": "Flashlights strapped into your skull. You can't see through them. A joke.", "tier": "none" }, @@ -520,7 +565,7 @@ "group": "Eyes", "hash": "121bc2c724450f5d", "name": "high luminosity eyes", - "rank": 15, + "rank": 20, "reason": "A coloured torch in your face. Useful in the dark, costs a load.", "tier": "none" }, @@ -529,7 +574,7 @@ "group": "Eyes", "hash": "880f3fcfed561cbf", "name": "high luminosity moth eyes", - "rank": 16, + "rank": 21, "reason": "A coloured torch in your face. Useful in the dark, costs a load.", "tier": "none" }, @@ -538,7 +583,7 @@ "group": "Eyes", "hash": "1719a4848fac0fdb", "name": "robotic moth eyes", - "rank": 12, + "rank": 17, "reason": "A working pair of robotic eyes, no better than your own.", "tier": "none" }, @@ -547,7 +592,7 @@ "group": "Eyes", "hash": "5d54a8f40d09d8b4", "name": "shielded robotic eyes", - "rank": 9, + "rank": 14, "reason": "Flash immunity with no tint, and EMP proof. Better sunglasses that don't take the slot. About 2 cr of material.", "tier": "green" }, @@ -556,7 +601,7 @@ "group": "Eyes", "hash": "617e9ddf30f1f6f4", "name": "shielded robotic moth eyes", - "rank": 10, + "rank": 15, "reason": "Moth looks on shielded eyes. Flash immunity with no tint.", "tier": "green" }, @@ -565,7 +610,7 @@ "group": "Eyes", "hash": "2abaf20763c58a3e", "name": "thermal eyes", - "rank": 3, + "rank": 4, "reason": "Same sight as thermal goggles, every living thing through walls, but sunglasses over them cancel the flash weakness. Better than the goggles, and they print with no reputation. An EMP flashes them.", "tier": "yellow" }, @@ -574,7 +619,7 @@ "group": "Eyes", "hash": "1436defdd6c14505", "name": "thermal moth eyes", - "rank": 4, + "rank": 5, "reason": "Same as thermal eyes with moth looks. Living things through walls, flash weak.", "tier": "yellow" }, @@ -601,7 +646,7 @@ "group": "Eyes", "hash": "927d6a5cf2f19d2f", "name": "snail eyes", - "rank": 20, + "rank": 25, "reason": "Plain replacement eyes.", "tier": "none" }, diff --git a/tools/gear_catalog/ratings/storage.json b/tools/gear_catalog/ratings/storage.json index e4dbdf64bb9..9c01b42d1ad 100644 --- a/tools/gear_catalog/ratings/storage.json +++ b/tools/gear_catalog/ratings/storage.json @@ -125,6 +125,15 @@ "reason": "Uniforms and a coat for one job. Nothing here helps a fight.", "tier": "none" }, + "/obj/item/storage/bag/harpoon_quiver": { + "confidence": "medium", + "group": "field and syndicate kits", + "hash": "", + "name": "harpoon quiver", + "rank": 6, + "reason": "Forty harpoons for the harpoon gun. Rated with the ammo it carries.", + "tier": "yellow" + }, "/obj/item/storage/bag/mail": { "confidence": "high", "group": "work bags", @@ -589,7 +598,7 @@ "group": "field and syndicate kits", "hash": "9e9840567ff3fcc0", "name": "box of standard firing pins", - "rank": 9, + "rank": 10, "reason": "Five spare firing pins, for guns found without one. Sold singly for 250 credits.", "tier": "green" }, @@ -598,7 +607,7 @@ "group": "field and syndicate kits", "hash": "713a9f86445310bf", "name": "box of syndicate firing pins", - "rank": 10, + "rank": 11, "reason": "Five pins that only fire for someone with a firearms implant only one ruin has. Mostly a way to ruin a gun.", "tier": "none" }, @@ -661,7 +670,7 @@ "group": "field and syndicate kits", "hash": "8d5f14f14f1a4229", "name": "floor tile camo box", - "rank": 7, + "rank": 8, "reason": "Floor-tile camouflage with combat gloves, boots and balaclava. The gloves are the useful part.", "tier": "green" }, @@ -742,7 +751,7 @@ "group": "field and syndicate kits", "hash": "2f42fea1a3c77160", "name": "Followers of the Chaplain Kit", - "rank": 12, + "rank": 13, "reason": "Chaplain hoodies for a small cult. Clothes.", "tier": "none" }, @@ -922,7 +931,7 @@ "group": "field and syndicate kits", "hash": "be444c6cc9f90033", "name": "boxed tracking implant kit", - "rank": 6, + "rank": 7, "reason": "Two tracking implants, two beacon implants and a locator, for keeping tabs on a crewmate or a captive. Costs mining points.", "tier": "green" }, @@ -1723,7 +1732,7 @@ "group": "field and syndicate kits", "hash": "b8e1d69b73958aa5", "name": "lockbox", - "rank": 13, + "rank": 14, "reason": "An empty lockbox.", "tier": "none" }, @@ -1732,7 +1741,7 @@ "group": "field and syndicate kits", "hash": "072f8dd47e6d407a", "name": "lockbox of mindshield implants", - "rank": 11, + "rank": 12, "reason": "Three mindshield implants. With no antagonists there is nothing for them to stop.", "tier": "none" }, @@ -1948,7 +1957,7 @@ "group": "field and syndicate kits", "hash": "50d9821f50f1e319", "name": "emergency space suits", - "rank": 8, + "rank": 9, "reason": "Two orange space suits, masks, oxygen, pickaxes and a shelter capsule in a wall cabinet. Two ruins have one.", "tier": "green" }, @@ -1957,7 +1966,7 @@ "group": "field and syndicate kits", "hash": "50d9821f50f1e319", "name": "emergency space suits", - "rank": 8, + "rank": 9, "reason": "Two orange space suits, masks, oxygen, pickaxes and a shelter capsule in a wall cabinet. Two ruins have one.", "tier": "green" }, @@ -2014,7 +2023,6 @@ "/obj/item/storage/bag/garment": "no route", "/obj/item/storage/bag/garment/engineering_chief": "no route", "/obj/item/storage/bag/garment/warden": "no route", - "/obj/item/storage/bag/harpoon_quiver": "no route", "/obj/item/storage/bag/ore/cyborg": "no route", "/obj/item/storage/bag/plants/cyborg": "no route", "/obj/item/storage/bag/quiver": "no route", diff --git a/tools/gear_catalog/ratings/tools.json b/tools/gear_catalog/ratings/tools.json index 80c6cfaaa21..53f65496042 100644 --- a/tools/gear_catalog/ratings/tools.json +++ b/tools/gear_catalog/ratings/tools.json @@ -1446,7 +1446,7 @@ "group": "Mining tools", "hash": "16ae32694e632739", "name": "manual mining scanner", - "rank": 19, + "rank": 20, "reason": "Manual ore scanner. Click for a look at nearby ore. Common on hulls.", "tier": "none" }, @@ -1539,7 +1539,7 @@ "group": "Mining tools", "hash": "c2024f595b1ac958", "name": "ore sensor", - "rank": 20, + "rank": 21, "reason": "Crafted bone ore sensor. Small range and it needs brimdust to make.", "tier": "none" }, @@ -1557,7 +1557,7 @@ "group": "Mining tools", "hash": "971c85bf93572b1b", "name": "pickaxe", - "rank": 14, + "rank": 15, "reason": "Plain pick. Prints at round start and hits for 15, but it's heavy to carry.", "tier": "none" }, @@ -1566,7 +1566,7 @@ "group": "Mining tools", "hash": "b938dac87ca2790e", "name": "diamond-tipped pickaxe", - "rank": 7, + "rank": 8, "reason": "Digs in about a third of the normal time. 1,200 cr at the green waystation, a fair first upgrade.", "tier": "green" }, @@ -1584,7 +1584,7 @@ "group": "Mining tools", "hash": "a8467bba7110047c", "name": "mining drill", - "rank": 10, + "rank": 11, "reason": "Digs at a little over half the normal time. Cheap and printable after 40 research points. The usual first upgrade.", "tier": "green" }, @@ -1593,10 +1593,19 @@ "group": "Mining tools", "hash": "093655e5e5394734", "name": "rockjaw drill", - "rank": 8, + "rank": 9, "reason": "Implant drill. Half-time digging with your hands free. The implant is the real purchase.", "tier": "green" }, + "/obj/item/pickaxe/drill/cyberware/deepcore": { + "confidence": "medium", + "group": "Mining tools", + "hash": "", + "name": "deepcore drill", + "rank": 7, + "reason": "Implant drill. Digs in under a third of the usual time with your hands free, between the rockjaw drill and a diamond drill. The implant is the real purchase.", + "tier": "green" + }, "/obj/item/pickaxe/drill/diamonddrill": { "confidence": "medium", "group": "Mining tools", @@ -1620,7 +1629,7 @@ "group": "Mining tools", "hash": "971c85bf93572b1b", "name": "emergency disembarkation tool", - "rank": 15, + "rank": 16, "reason": "Plain-speed pick from emergency kits. Hits for 15, but it's heavy to carry.", "tier": "none" }, @@ -1629,7 +1638,7 @@ "group": "Mining tools", "hash": "e44656dab7dcaa9d", "name": "improvised pickaxe", - "rank": 17, + "rank": 18, "reason": "Taped-together pick at a third of the normal speed. Better than bare hands.", "tier": "none" }, @@ -1638,7 +1647,7 @@ "group": "Mining tools", "hash": "f0d10d8ea21ed503", "name": "compact pickaxe", - "rank": 14, + "rank": 15, "reason": "Plain-speed pick in a smaller size.", "tier": "none" }, @@ -1647,7 +1656,7 @@ "group": "Mining tools", "hash": "0dc2d79772498eca", "name": "rusty pickaxe", - "rank": 16, + "rank": 17, "reason": "Plain-speed pick from a scrap crate.", "tier": "none" }, @@ -1656,7 +1665,7 @@ "group": "Mining tools", "hash": "d955462ef7f3820e", "name": "silver-plated pickaxe", - "rank": 9, + "rank": 10, "reason": "Half-time digging for 1,000 mining points.", "tier": "green" }, @@ -1674,7 +1683,7 @@ "group": "Mining tools", "hash": "dab2a22ec6f7f472", "name": "material sniffer", - "rank": 22, + "rank": 23, "reason": "Crafted from an analyzer and some cable. Points at loose sheets of iron and glass. Minor.", "tier": "none" }, @@ -1701,7 +1710,7 @@ "group": "Mining tools", "hash": "adad44b7c8924f21", "name": "ventpointer", - "rank": 21, + "rank": 22, "reason": "Points at nearby ore vents. 800 mining points for a shortcut you can walk.", "tier": "none" }, @@ -2195,7 +2204,7 @@ "group": "Mining tools", "hash": "a8c7ee47f9855f4e", "name": "shovel", - "rank": 18, + "rank": 19, "reason": "Plain shovel, 8 force. Prints at round start.", "tier": "none" }, @@ -2215,7 +2224,7 @@ "hash": "39a661a6f4799dbe", "name": "dull bone shovel", "note": "With combat mode on, each hit on an organic mob (and each thrown hit) adds a second hit equal to its own force (10; 8 for the dull version) to the same limb, and does nothing extra to robots or other non-organic mobs. Digs at tool speed 0.3 (0.8 dull).", - "rank": 11, + "rank": 12, "reason": "Dull bone shovel: each hit on an organic lands twice for 16. Slower dig. One ruin.", "tier": "green" }, @@ -2224,7 +2233,7 @@ "group": "Mining tools", "hash": "6d36ec3489795240", "name": "spade", - "rank": 18, + "rank": 19, "reason": "Garden spade.", "tier": "none" }, @@ -2386,7 +2395,7 @@ "group": "Mining tools", "hash": "949739cf35df0bb0", "name": "advanced automatic mining scanner", - "rank": 12, + "rank": 13, "reason": "Shows ore within 7 tiles on its own. 300 cr at the green waystation. Right price, right place.", "tier": "green" }, @@ -2395,7 +2404,7 @@ "group": "Mining tools", "hash": "7c5b6654d9cb653b", "name": "automatic mining scanner", - "rank": 13, + "rank": 14, "reason": "Shows ore within 4 tiles on its own, more slowly. Common on hulls.", "tier": "green" }, diff --git a/voidcrew/_DEFINES/ambient_npcs.dm b/voidcrew/_DEFINES/ambient_npcs.dm index 96b157d6c1e..39afd5ce9f0 100644 --- a/voidcrew/_DEFINES/ambient_npcs.dm +++ b/voidcrew/_DEFINES/ambient_npcs.dm @@ -12,6 +12,10 @@ /// Trait source for everything the core puts on an ambient NPC #define AMBIENT_NPC_TRAIT "ambient_npc" +/// On an item an ambient NPC holds only for show (a mop, a parcel): it goes when they put it away +#define TRAIT_AMBIENT_PROP "ambient_prop" +/// Units poured into an ambient NPC's glass +#define AMBIENT_DRINK_POUR 30 // ===== SUBSYSTEM ===== @@ -24,8 +28,8 @@ #define AMBIENT_OUTPOST_TRANSIENT_CAP 8 /// While players are on a concourse, someone steps off the lift at most this often (to replace someone who left) #define AMBIENT_OUTPOST_ARRIVAL_EVERY (10 SECONDS) -/// People made in place at the trader outposts per tick of SSambient_npcs, all outposts together -#define AMBIENT_OUTPOST_SETTLE_PER_FIRE 2 +/// People made in place at the trader outposts per tick of SSambient_npcs, all outposts together. One: a human body costs about 11 ms to build. +#define AMBIENT_OUTPOST_SETTLE_PER_FIRE 1 /// Nobody is made in place until this long after the round starts, out of its busiest moments #define AMBIENT_OUTPOST_SETTLE_DELAY (20 SECONDS) /// Random tiles tried when looking for somewhere to make someone, or to set them going @@ -41,7 +45,7 @@ #define AMBIENT_OUTPOST_KILLED_SLOT_TIME (20 MINUTES) /// Most cash the people of one trader outpost drop when killed, all of them together, in a round #define AMBIENT_OUTPOST_CASH_CAP 300 -/// A body at a trader outpost is taken away this long after death while players are there; at once when nobody is +/// A body at a trader outpost may be taken away this long after death, once nobody is near it (SShuman_npcs corpse rules) #define AMBIENT_OUTPOST_BODY_TIME (5 MINUTES) // ===== DEATH ===== @@ -88,9 +92,7 @@ #define AMBIENT_SITE_EDGE_MARGIN 5 /// Random turfs tried when looking for a site's spot #define AMBIENT_SITE_SPOT_TRIES 60 -/// A planet NPC's health is scaled by band at the spawn site, never on the type -#define AMBIENT_HEALTH_MULT_YELLOW 1.25 -#define AMBIENT_HEALTH_MULT_RED 1.5 +// No health multipliers by band: planet NPCs get tougher through the kit their outfit tables give them. /// A site whose NPCs are not out right now; they come back on the next visit #define AMBIENT_SITE_DORMANT "dormant" @@ -111,6 +113,13 @@ #define AMBIENT_RECRUITS_PENDING_PER_SHIP 1 /// Strays one ship may take aboard in a round #define AMBIENT_RECRUITS_PER_SHIP_ROUND 2 +/// A rescued stray's thank-you gifts, after the one they give on coming aboard, come this far apart... +#define AMBIENT_STRAY_GIFT_LOW (75 MINUTES) +#define AMBIENT_STRAY_GIFT_HIGH (120 MINUTES) +/// ...and stop after this many +#define AMBIENT_STRAY_GIFTS_MAX 3 +/// With no crew about when one is due, they look again this much later +#define AMBIENT_STRAY_GIFT_RETRY (3 MINUTES) // ===== DIALOGUE (2.3) ===== diff --git a/voidcrew/_DEFINES/bounties.dm b/voidcrew/_DEFINES/bounties.dm index e4413b2c683..ebb6fc1ec3b 100644 --- a/voidcrew/_DEFINES/bounties.dm +++ b/voidcrew/_DEFINES/bounties.dm @@ -28,12 +28,14 @@ #define BOUNTY_ARCHETYPE_BOSS "boss" // ===== FIGHTING STYLES ===== -// A normal criminal fights in one of the first six; a meek one always carries the holdout. +// A normal criminal fights in one of the first eight; a meek one always carries the holdout. #define BOUNTY_STYLE_BRAWLER "brawler" #define BOUNTY_STYLE_KNIFE "knife" #define BOUNTY_STYLE_PISTOL "pistol" #define BOUNTY_STYLE_SHOTGUN "shotgun" +#define BOUNTY_STYLE_SMG "smg" +#define BOUNTY_STYLE_REVOLVER "revolver" #define BOUNTY_STYLE_CLUB "club" #define BOUNTY_STYLE_BOTTLE "bottle" #define BOUNTY_STYLE_HOLDOUT "holdout" @@ -46,6 +48,8 @@ #define BOUNTY_KIT_DEMOLITIONIST "demolitionist" #define BOUNTY_KIT_GHOST "ghost" #define BOUNTY_KIT_HEAVY "heavy" +/// Its kit is its chrome (chrome/bounty_cyberpsycho.dm): the rarest +#define BOUNTY_KIT_CYBERPSYCHO "cyberpsycho" // ===== PLACEMENT KINDS ===== @@ -70,10 +74,10 @@ // What capture_state() (P2) returns, listed in the order it checks them. The pad pays by these (P5). #define BOUNTY_STATE_DEAD "dead" -/// At or below the downed line, cuffed or not +/// In crit (tg's TRAIT_CRITICAL_CONDITION, soft or hard), cuffed or not #define BOUNTY_STATE_DOWNED "downed" #define BOUNTY_STATE_RESTRAINED "restrained" -/// Stamina crit, knocked down, paralysed, or surrendered (TRAIT_BOUNTY_SURRENDERED) +/// Stamina crit, knocked down, stunned, paralysed (tg's *surrender too), unconscious or asleep #define BOUNTY_STATE_STUNNED "stunned" /// Standing and free: the pad refuses it #define BOUNTY_STATE_FREE "free" @@ -117,14 +121,12 @@ /// Trait source for everything the bounty packages put on a mob #define BOUNTY_TRAIT "bounty" -/// A normal criminal has given up (P3): it can't attack, and capture_state() (P2) reads it as BOUNTY_STATE_STUNNED -#define TRAIT_BOUNTY_SURRENDERED "bounty_surrendered" // ===== SIGNALS ===== -/// Sent by P2 on a criminal when its health falls to the downed line: () +/// Sent by P2 on a criminal when it goes into tg's crit (soft or hard): () #define COMSIG_BOUNTY_CRIMINAL_DOWNED "bounty_criminal_downed" -/// Sent by P2 on a criminal when it gets back up after the recovery time: () +/// Sent by P2 on a criminal when it comes out of crit, or gets up from stamina crit: () #define COMSIG_BOUNTY_CRIMINAL_RECOVERED "bounty_criminal_recovered" /// Sent by P2 on a criminal when restraints go on: (mob/user) #define COMSIG_BOUNTY_CRIMINAL_RESTRAINED "bounty_criminal_restrained" diff --git a/voidcrew/_DEFINES/bounty_ai.dm b/voidcrew/_DEFINES/bounty_ai.dm index 9aa30f159d7..258de038b5f 100644 --- a/voidcrew/_DEFINES/bounty_ai.dm +++ b/voidcrew/_DEFINES/bounty_ai.dm @@ -1,24 +1,9 @@ // # Bounty hunting: criminal AI defines // // Owner: P3 AI (voidcrew/modules/bounties/bounty_ai.dm, bounty_ai_meek.dm, bounty_ai_normal.dm, -// bounty_activities.dm, bounty_companions.dm). Only P3 edits this file. The shared enums are in -// bounties.dm. The numbers come from balance/combat.md section 6 and spec section 12.3 (C4, C6, -// C7); the rest are the spec's section 3 values. - -// ===== TRAITS ===== - -// P2 declares these two in bounty_criminals.dm with the same values. The guarded copies here let this -// package build on its own; once both are merged, these can go. -#ifndef TRAIT_BOUNTY_SPRINTING -/// A meek criminal running flat out, not winded, cornered or aiming. P2's projectile dodge reads it. -#define TRAIT_BOUNTY_SPRINTING "bounty_sprinting" -#endif -#ifndef TRAIT_BOUNTY_HELD -/// Restrained or downed (P2): the AI plans nothing -#define TRAIT_BOUNTY_HELD "bounty_held" -#endif -/// Trait source for what the criminal AI puts on a mob (TRAIT_BOUNTY_SPRINTING) -#define BOUNTY_AI_TRAIT "bounty_ai" +// bounty_styles.dm, bounty_activities.dm, bounty_companions.dm). Only P3 edits this file. The shared +// enums are in bounties.dm. Criminals and companions are real human bodies (voidcrew/modules/human_npcs/): +// nothing here sets health, stamina or speed. A pace is only a walk or a run (ai_set_pace()). // ===== WHAT THEY ARE DOING ===== @@ -37,7 +22,7 @@ /// In a locker or a crate #define BOUNTY_HIDE_CONTAINER "container" -/// Passing as a potted plant +/// Holding up a potted plant (tg's own disguise: the plant's tactical component) #define BOUNTY_HIDE_PLANT "plant" /// Standing still on a dark tile #define BOUNTY_HIDE_DARK "dark" @@ -52,18 +37,29 @@ #define BB_BOUNTY_LEADER "bb_bounty_leader" /// Their fighting style's key (BOUNTY_STYLE_*) #define BB_BOUNTY_STYLE "bb_bounty_style" -/// The real item they fight with (a typepath: bounty_real_weapon()), or null for bare fists -#define BB_BOUNTY_WEAPON "bb_bounty_weapon" -/// Rounds left in the magazine -#define BB_BOUNTY_AMMO "bb_bounty_ammo" -/// world.time a reload ends -#define BB_BOUNTY_RELOAD_UNTIL "bb_bounty_reload_until" /// world.time the current wind-up ends #define BB_BOUNTY_WINDUP_UNTIL "bb_bounty_windup_until" -/// world.time of the last shot +/// world.time the current wind-up began +#define BB_BOUNTY_WINDUP_FROM "bb_bounty_windup_from" +/// Their stamina loss when the wind-up began: more by the end of it and they fumble +#define BB_BOUNTY_WINDUP_STAMINA "bb_bounty_windup_stamina" +/// world.time they last took a hit +#define BB_BOUNTY_HIT_AT "bb_bounty_hit_at" +/// world.time of the last shot or throw #define BB_BOUNTY_LAST_SHOT "bb_bounty_last_shot" +/// world.time they may next pull the trigger or throw +#define BB_BOUNTY_NEXT_SHOT "bb_bounty_next_shot" /// world.time a ranged fighter may next step back from someone too close #define BB_BOUNTY_BACKSTEP_AT "bb_bounty_backstep_at" +/// Trigger pulls still to come in the burst under way (a burst is that many separate pulls, bounty_styles.dm) +#define BB_BOUNTY_BURST_LEFT "bb_bounty_burst_left" +/// world.time their style's gun last came out of its holster, off their back or out of a bag (the draw) +#define BB_BOUNTY_DRAWN_AT "bb_bounty_drawn_at" +/// Shots fired in the pistol's current string, and the magazine they came from (a new one starts the count again) +#define BB_BOUNTY_STRING_SHOTS "bb_bounty_string_shots" +#define BB_BOUNTY_STRING_MAGAZINE "bb_bounty_string_magazine" +/// world.time the body may take its next step by hand (bounty_ai_step()): its last step, AI walk or by hand, plus its own move delay +#define BB_BOUNTY_NEXT_STEP_AT "bb_bounty_next_step_at" // ===== ACTIVITY STEPS ===== @@ -76,8 +72,11 @@ // ===== SHARED ===== -/// Speed while going about their business, and while blended in (the fugitive and every decoy move alike) +/// The pace while going about their business, and while blended in (the fugitive and every decoy walk +/// alike). Anything quicker than this is a run (ai_set_pace()). #define BOUNTY_CALM_SPEED 2.5 +/// The pace of anyone running: running, as a person runs. There is no faster sprint. +#define BOUNTY_RUN_SPEED 1.5 /// How often they look around for hunters #define BOUNTY_NOTICE_INTERVAL (0.5 SECONDS) /// Held items at least this strong count as a weapon drawn @@ -96,29 +95,23 @@ #define BOUNTY_ACTIVITY_RANGE 8 /// How far exploring looks for things to look at #define BOUNTY_EXPLORE_RANGE 10 -/// Glasses a criminal leaves on tables at the bar before a finished glass just goes +/// Glasses a criminal leaves about at the bar (on a table or the floor) before a finished glass just goes #define BOUNTY_BAR_GLASSES_MAX 2 +/// Drunk this much (tg's drunkenness), they nurse their drink instead of drinking it +#define BOUNTY_BAR_SOBER_LIMIT 30 /// Spots an activity remembers as out of reach #define BOUNTY_FAILED_SPOTS_MAX 8 /// How long a trader outpost's public floor is reused between its patrons before it is worked out again #define BOUNTY_OUTPOST_FLOOR_CACHE (1 MINUTES) +/// A wind-up left this long past its end is stale: the next shot telegraphs again +#define BOUNTY_WINDUP_STALE (1 SECONDS) +/// After a fumble, this long before they try again +#define BOUNTY_FUMBLE_PAUSE (1 SECONDS) // ===== MEEK (combat.md 6.1, spec C6) ===== -/// Speed while sprinting: the tg trooper's, faster than a running person (1.5) -#define BOUNTY_MEEK_SPRINT_SPEED 1.1 -/// A lizard sprints a little faster (spec section 2) -#define BOUNTY_MEEK_LIZARD_SPRINT_BONUS 0.1 -/// Speed while winded: clearly slower than a runner -#define BOUNTY_MEEK_WINDED_SPEED 2.2 -/// How long they sprint before they are winded -#define BOUNTY_MEEK_SPRINT_TIME (8 SECONDS) -/// Resting still, the sprint comes back in this long -#define BOUNTY_MEEK_REST_TIME (8 SECONDS) -/// Sprint back per second spent hidden (full in about 5 s) -#define BOUNTY_MEEK_RECOVER_HIDDEN 1.5 -/// Sprint back per second spent unseen but not hidden -#define BOUNTY_MEEK_RECOVER_WALKING 0.3 +/// Stamina loss at which tg's own slowdown starts (human updatehealth(): 40): they are winded +#define BOUNTY_MEEK_WINDED_STAMINA 40 /// Someone armed, cuffs or a warrant in hand this close is noticed #define BOUNTY_MEEK_NOTICE_RANGE 6 /// Someone running straight at them this close is noticed @@ -149,7 +142,7 @@ #define BOUNTY_MEEK_CORNERED_TIME (0.8 SECONDS) /// The aim before the shots: the telegraph #define BOUNTY_MEEK_PISTOL_WINDUP (0.5 SECONDS) -/// Shots in the holdout's volley, a magazine at most +/// Trigger pulls in the holdout's volley, a magazine at most #define BOUNTY_MEEK_PISTOL_SHOTS 8 /// Between them #define BOUNTY_MEEK_PISTOL_GAP (0.2 SECONDS) @@ -169,9 +162,6 @@ // ===== NORMAL (combat.md 6.2, spec C7) ===== -/// Speed in a fight, by style -#define BOUNTY_NORMAL_SPEED_MELEE 1.25 -#define BOUNTY_NORMAL_SPEED_RANGED 1.4 /// Noticed at this range (someone armed, cuffs or a warrant in hand) #define BOUNTY_NORMAL_NOTICE_RANGE 6 /// Someone with a weapon drawn this close, while they are aware of them, starts the fight @@ -180,22 +170,21 @@ #define BOUNTY_NORMAL_AWARE_FORGET (15 SECONDS) /// Fighting, but nobody on the grudge list seen for this long: the fight is over #define BOUNTY_NORMAL_CALM_AFTER (30 SECONDS) -/// Share of max health at or below which they may give up +/// Surrender (defaults CR-10, /datum/human_npc_surrender/bounty): at or below this share of health, #define BOUNTY_SURRENDER_BELOW 0.4 -/// Percent chance on the hit that crosses the line +/// a percent chance on the first roll of a fight, #define BOUNTY_SURRENDER_CHANCE 60 -/// Less for each companion still standing +/// less this for each companion still standing, #define BOUNTY_SURRENDER_PER_COMPANION 20 -/// Percent chance on later hits +/// then this on later rolls, #define BOUNTY_SURRENDER_RETRY 25 -/// At most this often +/// at most this often, #define BOUNTY_SURRENDER_RETRY_GAP (5 SECONDS) -/// Hunters needed around them +/// with this many hunters around them, #define BOUNTY_SURRENDER_HUNTERS 2 -/// Within this range, in view +/// within this range, in view #define BOUNTY_SURRENDER_HUNTER_RANGE 7 -/// Below this share of health with no hunter near, they break off and retreat. The spec's 25% is the -/// downed line, so a standing criminal is never below it; the surrender line is used instead. +/// Below this share of health with no hunter near, they break off and retreat #define BOUNTY_NORMAL_RETREAT_BELOW 0.4 /// How far they try to get away when retreating #define BOUNTY_NORMAL_RETREAT_DISTANCE 10 @@ -204,58 +193,83 @@ #define BOUNTY_COMPANIONS_MIN 0 #define BOUNTY_COMPANIONS_MAX 2 -/// Health before the style's multiplier -#define BOUNTY_COMPANION_HEALTH 110 /// Below this share of health a companion may run (a BOUNTY_COMPANION_FLEE_CHANCE roll, once) #define BOUNTY_COMPANION_FLEE_BELOW 0.3 #define BOUNTY_COMPANION_FLEE_CHANCE 50 +/// Up from a stamina crit with hunters around: the chance a companion gives up rather than runs +#define BOUNTY_COMPANION_GIVE_UP_CHANCE 50 /// How far a companion keeps from its criminal while nothing is happening #define BOUNTY_COMPANION_FOLLOW 2 /// How often a companion calls the rest of the gang in (the trooper's 30 s) #define BOUNTY_REINFORCE_COOLDOWN (30 SECONDS) +/// A companion joining a fight gets up first: no weapon out, no blow or shot, for this long, so their criminal draws first +#define BOUNTY_COMPANION_DRAW_DELAY (1 SECONDS) +/// A companion's gun is backup: pistol strings this long (a criminal's are BOUNTY_PISTOL_STRING_SHOTS)... +#define BOUNTY_COMPANION_PISTOL_STRING 3 +/// ...and SMG bursts this long (a criminal's are BOUNTY_SMG_BURST) +#define BOUNTY_COMPANION_SMG_BURST 2 +/// Company keys (bounty_roll_company()): one companion's fighting style (BOUNTY_STYLE_*)... +#define BOUNTY_COMPANY_STYLE "style" +/// ...and their chrome keys (BOUNTY_CHROME_*), or null for none +#define BOUNTY_COMPANY_CHROME "chrome" -// ===== STYLES (combat.md 6.2) ===== -// Every blow and shot is the real weapon's (bounty_weapons.dm): the item in their hand is each -// style's held_look, and a Wanted criminal carries its heavy_look where it has one. +// ===== STYLES (combat.md 6.2, bounty_styles.dm) ===== +// Every style is a real item the fighter carries and draws: blows and shots are the item's own, through +// the fighter's hands. These only set the timing: how often they pull the trigger or throw, the +// telegraph, and the distance they keep. -#define BOUNTY_BRAWLER_HEALTH 1.2 -#define BOUNTY_BRAWLER_STAMINA 0.8 -#define BOUNTY_BRAWLER_INTERVAL (1 SECONDS) -/// Chance a punch shoves the target back a tile and staggers them for a second +/// Chance a brawler's blow is a real shove instead of a punch #define BOUNTY_BRAWLER_SHOVE_CHANCE 20 -#define BOUNTY_BRAWLER_STAGGER (1 SECONDS) -#define BOUNTY_KNIFE_HEALTH 1 -#define BOUNTY_KNIFE_INTERVAL (0.8 SECONDS) +/// Getting a holstered or slung gun out at the start of a fight: the line, then at least this long before the first shot +#define BOUNTY_DRAW_TIME (0.6 SECONDS) +/// A burst whose next pull is this late was cut off (the AI stopped meanwhile): what is left of it never fires +#define BOUNTY_BURST_STALE (0.5 SECONDS) -#define BOUNTY_PISTOL_HEALTH 0.9 -#define BOUNTY_PISTOL_INTERVAL (0.25 SECONDS) -/// Keeps about this far away +/// Between trigger pulls: the Stechkin set to single shots with its own fire selector, twice a second +#define BOUNTY_PISTOL_INTERVAL (0.5 SECONDS) +/// Every bounty pistol (criminals, companions, the kingpin's goons, Club Volga's mobsters and the don) fires strings of this many shots... +#define BOUNTY_PISTOL_STRING_SHOTS 6 +/// ...with this pause on top of the interval before the next string. A reload after an empty magazine is separate. +#define BOUNTY_PISTOL_STRING_PAUSE (1.5 SECONDS) +/// A gun fires at anyone it can see this far off, while its holder still walks in to its style's own distance (BOUNTY_PISTOL_RANGE). The shotgun's spread and the bottle's throw keep them to their own. +#define BOUNTY_GUN_FIRE_RANGE 7 +/// Walks in to about this far away #define BOUNTY_PISTOL_RANGE 5 #define BOUNTY_PISTOL_MIN_RANGE 3 -/// The reload: the window to close in -#define BOUNTY_PISTOL_RELOAD (2 SECONDS) /// Raising the gun before the first shot of a string #define BOUNTY_PISTOL_WINDUP (0.2 SECONDS) /// Shots further apart than this start a new string #define BOUNTY_PISTOL_STRING_GAP (3 SECONDS) -#define BOUNTY_SHOTGUN_HEALTH 1 +/// The WT-550 at every tier: a burst is this many trigger pulls... +#define BOUNTY_SMG_BURST 4 +/// ...this far apart, the gun's own full-auto rate +#define BOUNTY_SMG_BURST_GAP (0.3 SECONDS) +/// From a burst's last round to the next burst's spin-up +#define BOUNTY_SMG_INTERVAL (0.5 SECONDS) +/// The gun brought up level before each burst +#define BOUNTY_SMG_WINDUP (0.3 SECONDS) +#define BOUNTY_SMG_MIN_RANGE 2 + +/// A .38 revolver: one heavy shot a second, six in the cylinder, a speedloader to reload +#define BOUNTY_REVOLVER_INTERVAL (1 SECONDS) +#define BOUNTY_REVOLVER_MIN_RANGE 3 +/// The hammer thumbed back before the first shot of a string +#define BOUNTY_REVOLVER_WINDUP (0.3 SECONDS) + #define BOUNTY_SHOTGUN_INTERVAL (1.2 SECONDS) #define BOUNTY_SHOTGUN_RANGE 2 /// The pump before each blast #define BOUNTY_SHOTGUN_WINDUP (0.3 SECONDS) -/// Loading the tube again: the window to close in -#define BOUNTY_SHOTGUN_RELOAD (3 SECONDS) - -#define BOUNTY_CLUB_HEALTH 1.1 -#define BOUNTY_CLUB_INTERVAL (1.1 SECONDS) +/// Spare shells on the bandolier +#define BOUNTY_SHOTGUN_SHELLS 8 -#define BOUNTY_BOTTLE_HEALTH 0.9 #define BOUNTY_BOTTLE_INTERVAL (1.2 SECONDS) #define BOUNTY_BOTTLE_RANGE 4 #define BOUNTY_BOTTLE_MIN_RANGE 2 -/// Tiles a bottle flies -#define BOUNTY_BOTTLE_REACH 6 /// The throwing arc before each throw #define BOUNTY_BOTTLE_WINDUP (0.3 SECONDS) +/// Bottles in the bag, by tier: Petty, and above +#define BOUNTY_BOTTLES_PETTY 4 +#define BOUNTY_BOTTLES_WANTED 6 diff --git a/voidcrew/_DEFINES/bounty_bosses.dm b/voidcrew/_DEFINES/bounty_bosses.dm index 67ac1ea79c1..8a720d57902 100644 --- a/voidcrew/_DEFINES/bounty_bosses.dm +++ b/voidcrew/_DEFINES/bounty_bosses.dm @@ -1,30 +1,36 @@ // # Bounty hunting: mini-boss defines // // Owner: P4 bosses (voidcrew/modules/bounties/bounty_boss.dm, bounty_boss_kits.dm). Only P4 edits -// this file. The shared enums are in bounties.dm. The numbers are the combat balance review's +// this file. The shared enums are in bounties.dm. The ability numbers are the combat balance review's // (balance/combat.md section 6.3, folded into spec.md 12.3 C8). A number marked (P4) is not in the -// review and was picked by P4. +// review and was picked by P4; one marked (phase D) came with the move to real human bodies and is a +// starting value for the balance pass (defaults 1.16). +// +// A boss is a real human (voidcrew/modules/human_npcs/): its health, stamina and stuns are tg's. There +// are no health tables, stamina pools or damage coefficients here, and there must never be. // ===== COMMON RULES ===== -/// Percent of max health at or below which a boss is tired: stamina works fully, it slows, its cooldowns stretch, and it can be stunned -#define BOUNTY_BOSS_TIRED_BELOW 40 -/// Stamina damage coefficient while fresh (the Heavy's is BOUNTY_HEAVY_STAMINA_FRESH). 0 after review H3: combat.md's 0.2 only slowed a fresh boss under steady disabler fire and pre-loaded its pool, so capture took one hit instead of five. -#define BOUNTY_BOSS_STAMINA_FRESH 0 -/// Stamina damage coefficient once tired -#define BOUNTY_BOSS_STAMINA_TIRED 1 -/// Stamina damage it takes to stamina-crit a tired boss (the Heavy's is BOUNTY_HEAVY_STAMINA), counted from zero when it tires. max_stamina stays P2's 100 because of the stamcrit removal quirk; the pool is folded into the coefficient instead. -#define BOUNTY_BOSS_STAMINA 150 +/// Brute and burn a boss takes, as a multiple of a person's, through its physiology (npc_damage_taken, defaults 1.5). Its armour does the rest. (phase D) +#define BOUNTY_BOSS_DAMAGE_TAKEN 0.6 +/// Its combat stimulant, used through combat sense's rule (human_npc_combat_sense.dm) when it tires in a fight, never while one is still in its blood (C8; it used to inject at the start of a fight, defaults 1.6, CR-4). Ephedrine: baton resistance, speed and quicker recovery from stuns, never immunity, so a crew can still stun and cuff a worn-out boss. (phase D) +#define BOUNTY_BOSS_STIMULANT /obj/item/reagent_containers/hypospray/medipen/stimpack +/// How many of BOUNTY_BOSS_STIMULANT it carries: one for when it first tires, one for a long fight (an ephedrine pen lasts about 100 s). (phase D) +#define BOUNTY_BOSS_STIMULANTS 2 +/// Its combat stimpack, one per kit beside BOUNTY_BOSS_STIMULANTS: stimulants and omnizine, used through the same combat sense rule as the others, never while a stimulant is still in its blood +#define BOUNTY_BOSS_COMBAT_STIMULANT /obj/item/reagent_containers/hypospray/medipen/stimpack/traitor +/// The cell in the standard core of a mini-boss's MODsuit (bounty_suits.dm): about 2 hours of a suit running at tg's base drain. The suit doesn't run while its wearer is dormant. +#define BOUNTY_BOSS_MODSUIT_CELL /obj/item/stock_parts/power_store/cell/bluespace +/// Worn out: stamina loss over this share of its max stamina... (phase D, CB-2) +#define BOUNTY_BOSS_WORN_OUT_STAMINA 0.5 +/// ...or health under this share of its max health. (phase D, CB-2) +#define BOUNTY_BOSS_WORN_OUT_HEALTH 0.4 +/// Worn out, it falls back from its target this long before its second wind. (phase D) +#define BOUNTY_BOSS_FALLBACK_TIME (8 SECONDS) /// Below this pressure (kPa) on either side, a wall, window or door is not interior (P4) #define BOUNTY_BOSS_INTERIOR_MIN_PRESSURE 20 /// More than this pressure difference (kPa) between its sides, and a wall, window or door is not interior (P4) #define BOUNTY_BOSS_INTERIOR_MAX_PRESSURE_GAP 50 -/// A dodge or miss message is shown at most this often (P4) -#define BOUNTY_BOSS_MESSAGE_COOLDOWN (1 SECONDS) -/// How much slower a tired boss moves -#define BOUNTY_BOSS_TIRED_SLOWDOWN 0.3 -/// Ability cooldowns are this much longer while tired (P4: the spec says "longer" and gives no number) -#define BOUNTY_BOSS_TIRED_COOLDOWN_MULT 1.5 /// Pause after one ability before the next may start, so telegraphs never overlap #define BOUNTY_BOSS_ABILITY_GAP_MIN (1 SECONDS) #define BOUNTY_BOSS_ABILITY_GAP_MAX (2 SECONDS) @@ -45,6 +51,46 @@ /// A hunter who hit the boss this recently is its first choice of target (P4) #define BOUNTY_BOSS_REVENGE_TIME (10 SECONDS) +// ===== THE POSSE (OC-8): ONE MORE COMPANION PER EXTRA HUNTER ===== + +/// Most companions a posse brings in for one boss (four or more hunters). (phase D) +#define BOUNTY_BOSS_REINFORCEMENTS_MAX 3 +/// Time between two companions coming in, and between tries to find a spot out of view. (phase D) +#define BOUNTY_BOSS_REINFORCE_DELAY (6 SECONDS) +/// A companion comes in at least this far from the boss... (phase D) +#define BOUNTY_BOSS_REINFORCE_MIN_DIST 5 +/// ...and no further than this. (phase D) +#define BOUNTY_BOSS_REINFORCE_MAX_DIST 14 +/// A spot any player within this many tiles can see is in view. (phase D) +#define BOUNTY_BOSS_REINFORCE_SIGHT 13 + +// ===== HURT FROM WHERE IT CAN'T REACH (review r1 F4, r2) ===== + +/// Someone who hurt it from where it can't get at them (behind glass, a shut door or a gap, or off its site) is shunned this long: it doesn't go after them, and it keeps out of their sight +#define BOUNTY_BOSS_SHUN_TIME (60 SECONDS) +/// How far it walks for cover, in steps +#define BOUNTY_BOSS_COVER_RANGE 7 +/// How far a shooter is taken to see, for cover, at the least: one farther off sees as far as a clear line from them goes (a scope, or a shot from across the site) +#define BOUNTY_BOSS_COVER_SIGHT 9 +/// How far it works out its walk to someone behind something, in tiles, and how long that walk holds while it stands on the same tile. Someone farther off is in reach if the walk gets it at least half this much closer to them. +#define BOUNTY_BOSS_REACH_RANGE 10 +#define BOUNTY_BOSS_REACH_RETHINK (2 SECONDS) +/// It says a line as it breaks for cover at most this often +#define BOUNTY_BOSS_COVER_BARK_COOLDOWN (30 SECONDS) + +// ===== GORILLA ARMS THROUGH GLASS ===== + +/// The tell before its Gorilla Arms go at a window, grille, table or door: the fist drawn back, the pistons heard, the tile lit. At least 0.8 s (decision 5). +#define BOUNTY_BOSS_SMASH_WINDUP (0.9 SECONDS) +/// What one Gorilla Arms punch does to a structure, used only to count the punches something takes. The punch itself is the implant's (CYBERWARE_GORILLA_STRUCTURE_DAMAGE, which ware_military_arms.dm undefines); a test checks the two agree. +#define BOUNTY_BOSS_SMASH_BLOW 40 +/// It only goes at something it breaks in this many punches; anything tougher, it goes round or takes cover +#define BOUNTY_BOSS_SMASH_MOST_BLOWS 6 +/// The pause after the tell before its next ability may start +#define BOUNTY_BOSS_SMASH_RECOVER (1 SECONDS) +/// The tell's glow and floor mark: the piston punch's orange +#define BOUNTY_BOSS_SMASH_COLOR "#ff8a1f" + // ===== SUMMONED THINGS: CAPS AND LIFETIMES (P4) ===== /// Fire pools (flamer floors and molotovs) burning at once, server-wide @@ -57,22 +103,25 @@ #define BOUNTY_BOSS_BARRICADES_PER_BOSS 2 /// How long a Heavy barricade stands before it folds away #define BOUNTY_BOSS_BARRICADE_LIFE (90 SECONDS) +/// Fire stacks (tg's) a boss's burning floor adds every second to anyone standing in it. (phase D, CB-8) +#define BOUNTY_BOSS_FIRE_TICK_STACKS 1 + +// ===== EACH KIT'S ANSWER TO A DISABLER (C8) ===== +// Real gear a player can own too, worn and locked to the body like the rest of the kit (D3). Measured in +// the port's boss model: a solo hunter with a disabler stamcritted any of them in about two seconds before. + +/// The Juggernaut's tower shield, held from the start and raised as its fight starts: raised, it stops every shot from the half of the map the Juggernaut faces (the Phalanx buckler's own rule, loot/uniques/armory.dm). Flank it. +#define BOUNTY_JUGGERNAUT_SHIELD /obj/item/shield/riot/phalanx_buckler +/// The Heavy's coat: an ablative trenchcoat with its hood up, energy and laser armour 60 on every limb and its head, and half of all beams that hit it reflected (tg's own). Bullets go through it. Bring lethal, or a lot of shots. +#define BOUNTY_HEAVY_COAT /obj/item/clothing/suit/hooded/ablative +/// The Demolitionist's implant: tg's CNS rebooter, one neural load (cyberware_component.dm). Its first stamina crit of a minute lasts a second, then six seconds of stun immunity; cuff it on the second. +#define BOUNTY_DEMOLITIONIST_REBOOTER /obj/item/organ/cyberimp/brain/anti_stun +/// The Ghost's cover from stun fire (combat sense): how many steps it runs for cover, and how many runs it makes in one exchange of fire. Corner it. +#define BOUNTY_GHOST_COVER_STEPS 5 +#define BOUNTY_GHOST_COVER_MOVES 4 // ===== JUGGERNAUT ===== -/// Health for 1, 2, 3 and 4+ engaged hunters -#define BOUNTY_JUGGERNAUT_HEALTH_1 300 -#define BOUNTY_JUGGERNAUT_HEALTH_2 950 -#define BOUNTY_JUGGERNAUT_HEALTH_3 1600 -#define BOUNTY_JUGGERNAUT_HEALTH_4 2300 -#define BOUNTY_JUGGERNAUT_BRUTE_MOD 0.7 -#define BOUNTY_JUGGERNAUT_BURN_MOD 0.8 -#define BOUNTY_JUGGERNAUT_SPEED 1.9 -/// Its fists -#define BOUNTY_JUGGERNAUT_MELEE_MIN 20 -#define BOUNTY_JUGGERNAUT_MELEE_MAX 26 -#define BOUNTY_JUGGERNAUT_MELEE_AP 15 -#define BOUNTY_JUGGERNAUT_MELEE_COOLDOWN (1.1 SECONDS) /// Charge: a roar, a stamp and a cracked-floor line #define BOUNTY_JUGGERNAUT_CHARGE_WINDUP (0.8 SECONDS) #define BOUNTY_JUGGERNAUT_CHARGE_COOLDOWN (8 SECONDS) @@ -82,63 +131,70 @@ #define BOUNTY_JUGGERNAUT_CHARGE_STEP 1 #define BOUNTY_JUGGERNAUT_CHARGE_DAMAGE 25 #define BOUNTY_JUGGERNAUT_CHARGE_KNOCKDOWN (2 SECONDS) +/// Armour penetration of the charge +#define BOUNTY_JUGGERNAUT_CHARGE_AP 15 /// Percent chance that someone else in the way is caught too #define BOUNTY_JUGGERNAUT_CHARGE_BYSTANDER 25 -/// Charged into something it can't break: it stands reeling this long, taking more damage -#define BOUNTY_JUGGERNAUT_WALL_STAGGER (2 SECONDS) -#define BOUNTY_JUGGERNAUT_WALL_VULNERABLE 1.25 -/// Slam: both fists up, a red ring -#define BOUNTY_JUGGERNAUT_SLAM_WINDUP (0.7 SECONDS) +/// Charged into something it can't break: brute to its head through its helmet, stamina, a moment's paralysis and a knockdown, as tg's tackle splat does (CB-6). (phase D) +#define BOUNTY_JUGGERNAUT_WALL_BRUTE 15 +#define BOUNTY_JUGGERNAUT_WALL_STAMINA 30 +#define BOUNTY_JUGGERNAUT_WALL_PARALYZE (1 SECONDS) +#define BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN (3 SECONDS) +/// Slam: both fists up, a red ring. 0.7 -> 0.8 s after review r2, long enough to step out of on sight. +#define BOUNTY_JUGGERNAUT_SLAM_WINDUP (0.8 SECONDS) #define BOUNTY_JUGGERNAUT_SLAM_COOLDOWN (7 SECONDS) #define BOUNTY_JUGGERNAUT_SLAM_DAMAGE 20 #define BOUNTY_JUGGERNAUT_SLAM_KNOCKDOWN (1.5 SECONDS) // ===== PYROMANIAC ===== -#define BOUNTY_PYROMANIAC_HEALTH_1 580 -#define BOUNTY_PYROMANIAC_HEALTH_2 1300 -#define BOUNTY_PYROMANIAC_HEALTH_3 1950 -#define BOUNTY_PYROMANIAC_HEALTH_4 2550 -/// Fireproof, not laser-proof: fire and heat can't hurt it, and lasers (BURN) hit it at this -#define BOUNTY_PYROMANIAC_BRUTE_MOD 0.9 -#define BOUNTY_PYROMANIAC_BURN_MOD 0.9 -#define BOUNTY_PYROMANIAC_SPEED 1.8 -/// Its torch: burn damage -#define BOUNTY_PYROMANIAC_MELEE_MIN 10 -#define BOUNTY_PYROMANIAC_MELEE_MAX 14 -#define BOUNTY_PYROMANIAC_MELEE_AP 15 -#define BOUNTY_PYROMANIAC_MELEE_COOLDOWN (1 SECONDS) -/// Flamer: a pilot light hiss, an orange cone -#define BOUNTY_PYROMANIAC_FLAMER_WINDUP (0.5 SECONDS) +/// Its flamethrower: tg's, igniter and full plasma tank fitted, held from the start (it doesn't holster) +#define BOUNTY_PYROMANIAC_FLAMETHROWER /obj/item/flamethrower/full/tank/bounty_boss +/// The bottle in its other hand: tg's molotov, filled with welding fuel +#define BOUNTY_PYROMANIAC_MOLOTOV_BOTTLE /obj/item/reagent_containers/cup/glass/bottle/molotov/bounty_boss +/// Flamer: the pilot light, an orange line on the floor. 0.5 -> 0.8 s after review r2, long enough to step out of on sight. +#define BOUNTY_PYROMANIAC_FLAMER_WINDUP (0.8 SECONDS) #define BOUNTY_PYROMANIAC_FLAMER_COOLDOWN (5 SECONDS) #define BOUNTY_PYROMANIAC_FLAMER_RANGE 3 -/// Full width of the cone, in degrees -#define BOUNTY_PYROMANIAC_FLAMER_ARC 90 +/// Tank pressure (kPa) under which its flamethrower is dry and it won't sweep +#define BOUNTY_PYROMANIAC_FLAMER_EMPTY ONE_ATMOSPHERE +/// The flamethrower stays lit this long after a sweep, then it shuts the valve +#define BOUNTY_PYROMANIAC_FLAMER_SHUTOFF (1 SECONDS) #define BOUNTY_PYROMANIAC_FLAMER_DAMAGE 18 -/// How long the floor the cone covered keeps burning +/// Fire stacks (tg's) the jet puts on anyone on its line. (phase D, CB-8) +#define BOUNTY_PYROMANIAC_FLAMER_STACKS 4 +/// How long the floor the line covered keeps burning #define BOUNTY_PYROMANIAC_FLAMER_FLOOR (4 SECONDS) +/// Sweeps a plasma tank feeds before the flamethrower runs dry and it changes tanks +#define BOUNTY_PYROMANIAC_FLAMER_SWEEPS 4 +/// Full plasma tanks it carries in its bag for the change +#define BOUNTY_PYROMANIAC_SPARE_TANKS 2 +/// The tank change: it stops to wrench the empty off and fit a full one, at least this long, open the whole time +#define BOUNTY_PYROMANIAC_RELOAD_TIME (3 SECONDS) /// Molotov: a lit rag, the throw, a ring where it lands #define BOUNTY_PYROMANIAC_MOLOTOV_WINDUP (0.7 SECONDS) #define BOUNTY_PYROMANIAC_MOLOTOV_COOLDOWN (7 SECONDS) #define BOUNTY_PYROMANIAC_MOLOTOV_RANGE 7 #define BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE 10 +/// Fire stacks (tg's) the bursting bottle puts on anyone it splashes. (phase D, CB-8) +#define BOUNTY_PYROMANIAC_MOLOTOV_STACKS 3 #define BOUNTY_PYROMANIAC_MOLOTOV_POOL (8 SECONDS) -/// Set alight by a boss's fire: burn damage every second for this long, renewed while standing in the flames -#define BOUNTY_BOSS_BURNING_TIME (4 SECONDS) -#define BOUNTY_BOSS_BURNING_DAMAGE 3 +/// Smoke (C9): tg's smoke grenade, two in its pockets, on tg's shortest timed fuse (a screwdriver setting a player can pick too) +#define BOUNTY_PYROMANIAC_SMOKE /obj/item/grenade/smokebomb/bounty_boss +#define BOUNTY_PYROMANIAC_SMOKE_FUSE (3 SECONDS) +/// It takes a grenade out and stands this long, then pulls the pin and throws (the Heavy's barrier grenade wind-up) +#define BOUNTY_PYROMANIAC_SMOKE_WINDUP (0.6 SECONDS) +/// Longer than the cloud lasts (tg's 16 s), so it never stacks one on the last +#define BOUNTY_PYROMANIAC_SMOKE_COOLDOWN (20 SECONDS) +/// The furthest shooter it throws at; the nearest is anyone past its flamer's reach +#define BOUNTY_PYROMANIAC_SMOKE_RANGE 7 +/// How long after a hit from someone past its flamer's reach it still answers with smoke (stun fire counts while its combat sense says it is under fire) +#define BOUNTY_PYROMANIAC_SMOKE_HURT_WINDOW (3 SECONDS) // ===== DEMOLITIONIST ===== -#define BOUNTY_DEMOLITIONIST_HEALTH_1 870 -#define BOUNTY_DEMOLITIONIST_HEALTH_2 1750 -#define BOUNTY_DEMOLITIONIST_HEALTH_3 2300 -#define BOUNTY_DEMOLITIONIST_HEALTH_4 2850 -#define BOUNTY_DEMOLITIONIST_BRUTE_MOD 0.85 -#define BOUNTY_DEMOLITIONIST_BURN_MOD 0.85 -#define BOUNTY_DEMOLITIONIST_SPEED 1.7 -/// Its crowbar: the look in its hand and every number of its blows (bounty_real_weapon()) -#define BOUNTY_DEMOLITIONIST_CROWBAR /obj/item/crowbar/red -#define BOUNTY_DEMOLITIONIST_MELEE_COOLDOWN (1 SECONDS) +/// Its crowbar: carried in its bag until the fight starts +#define BOUNTY_DEMOLITIONIST_CROWBAR /obj/item/crowbar/large /// It keeps this far from its target unless someone is right on it #define BOUNTY_DEMOLITIONIST_KEEP_MIN 4 #define BOUNTY_DEMOLITIONIST_KEEP_MAX 6 @@ -148,9 +204,8 @@ #define BOUNTY_DEMOLITIONIST_GRENADE_COOLDOWN (4 SECONDS) #define BOUNTY_DEMOLITIONIST_GRENADE_RANGE 8 #define BOUNTY_DEMOLITIONIST_GRENADE_RADIUS 2 -/// Brute to anyone caught, against bomb armour +/// Damage to a mech's hull it catches, against bomb armour. People in the ring take tg's light explosion (CB-10). #define BOUNTY_DEMOLITIONIST_GRENADE_DAMAGE 35 -#define BOUNTY_DEMOLITIONIST_GRENADE_KNOCKDOWN (1 SECONDS) /// Damage to structures within BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_RADIUS #define BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_DAMAGE 60 #define BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_RADIUS 1 @@ -160,79 +215,54 @@ #define BOUNTY_DEMOLITIONIST_BREACH_WINDUP (0.6 SECONDS) #define BOUNTY_DEMOLITIONIST_BREACH_FUSE (5 SECONDS) #define BOUNTY_DEMOLITIONIST_BREACH_COOLDOWN (18 SECONDS) +/// Damage to a mech's hull beside the charge. People beside it take tg's light explosion (CB-10). #define BOUNTY_DEMOLITIONIST_BREACH_DAMAGE 25 /// It breaches toward a target it can't see within this range #define BOUNTY_DEMOLITIONIST_BREACH_RANGE 8 // ===== GHOST ===== -#define BOUNTY_GHOST_HEALTH_1 250 -#define BOUNTY_GHOST_HEALTH_2 850 -#define BOUNTY_GHOST_HEALTH_3 1700 -#define BOUNTY_GHOST_HEALTH_4 2450 -#define BOUNTY_GHOST_BRUTE_MOD 1 -#define BOUNTY_GHOST_BURN_MOD 1 -#define BOUNTY_GHOST_SPEED 1.2 -/// Its blade: the look in its hand and every number of its blows (bounty_real_weapon()) +/// Its blade: a real combat knife, carried in a pocket until the fight starts, and every cut it lands is the knife's (CB-9) #define BOUNTY_GHOST_KNIFE /obj/item/knife/combat -#define BOUNTY_GHOST_MELEE_COOLDOWN (0.7 SECONDS) -/// Each cut bleeds this much brute a second for BOUNTY_GHOST_BLEED_TIME, up to BOUNTY_GHOST_BLEED_CAP a second -#define BOUNTY_GHOST_BLEED_PER_CUT 1 -#define BOUNTY_GHOST_BLEED_CAP 2 -#define BOUNTY_GHOST_BLEED_TIME (12 SECONDS) -/// Dash: a blur line to the target -#define BOUNTY_GHOST_DASH_WINDUP (0.3 SECONDS) +/// Dash: a blur line to the target, the Cascade lattice firing as it goes (OC-6 ruling). 0.3 -> 0.6 s after review r2: at 0.3 s only someone already moving could step off the line. +#define BOUNTY_GHOST_DASH_WINDUP (0.6 SECONDS) #define BOUNTY_GHOST_DASH_COOLDOWN (5 SECONDS) #define BOUNTY_GHOST_DASH_RANGE 5 -/// The dash's cut, its biggest hit (P4: combat.md B4 asks for the dash to be the biggest hit), and its armour penetration -#define BOUNTY_GHOST_DASH_DAMAGE 20 -#define BOUNTY_GHOST_DASH_AP 10 -/// Cloak: a shimmer, then a faint distortion +/// Cloak: a shimmer, then a faint distortion. Only how it looks: nothing misses it for it (OC-6 ruling, CB-12) #define BOUNTY_GHOST_CLOAK_WINDUP (0.3 SECONDS) #define BOUNTY_GHOST_CLOAK_TIME (6 SECONDS) #define BOUNTY_GHOST_CLOAK_COOLDOWN (12 SECONDS) #define BOUNTY_GHOST_CLOAK_ALPHA 50 -/// Percent of hits that miss it while it is cloaked -#define BOUNTY_GHOST_CLOAK_MISS 50 -/// Percent of projectiles it dodges while moving -#define BOUNTY_GHOST_DODGE 30 -/// It counts as moving for this long after a step -#define BOUNTY_GHOST_MOVING_WINDOW (1 SECONDS) /// Hit and run: after this many cuts it breaks off for a while #define BOUNTY_GHOST_CUTS_BEFORE_BREAKOFF 2 #define BOUNTY_GHOST_BREAKOFF_TIME (4 SECONDS) // ===== HEAVY ===== -#define BOUNTY_HEAVY_HEALTH_1 340 -#define BOUNTY_HEAVY_HEALTH_2 970 -#define BOUNTY_HEAVY_HEALTH_3 1400 -#define BOUNTY_HEAVY_HEALTH_4 1850 -#define BOUNTY_HEAVY_BRUTE_MOD 0.55 -#define BOUNTY_HEAVY_BURN_MOD 0.65 -#define BOUNTY_HEAVY_SPEED 2.2 -/// 0 after review H3, as BOUNTY_BOSS_STAMINA_FRESH (combat.md had 0.15) -#define BOUNTY_HEAVY_STAMINA_FRESH 0 -#define BOUNTY_HEAVY_STAMINA 180 -/// Its belt-fed gun: the look in its hand, the rounds of its burst and the blow of its butt (bounty_real_weapon()) -#define BOUNTY_HEAVY_GUN /obj/item/gun/ballistic/automatic/l6_saw -#define BOUNTY_HEAVY_MELEE_COOLDOWN (1.1 SECONDS) +/// Its belt-fed gun: a real L6 held from the start (it has no sling, so it never holsters), with a real spare box (CB-13) +#define BOUNTY_HEAVY_GUN /obj/item/gun/ballistic/automatic/l6_saw/unrestricted +/// Its spare box magazine +#define BOUNTY_HEAVY_SPARE_AMMO /obj/item/ammo_box/magazine/m7mm /// Suppressive burst: a spin-up whirr, a laser sight and a red cone #define BOUNTY_HEAVY_BURST_WINDUP (0.7 SECONDS) #define BOUNTY_HEAVY_BURST_COOLDOWN (4 SECONDS) #define BOUNTY_HEAVY_BURST_RANGE 8 #define BOUNTY_HEAVY_BURST_SHOTS 10 -/// How long the burst takes to fire -#define BOUNTY_HEAVY_BURST_TIME (1.5 SECONDS) +/// How long the burst takes to fire. Each pull is a click, at least 2 deciseconds apart (a mob clicks once a decisecond at most). (phase D) +#define BOUNTY_HEAVY_BURST_TIME (2 SECONDS) /// Full width of the cone the rounds go into, in degrees #define BOUNTY_HEAVY_BURST_ARC 30 /// Extra width, in degrees, of the cone marked on the floor, so edge tiles a round can cross are marked too (P4) #define BOUNTY_HEAVY_BURST_MARK_MARGIN 10 -/// The rounds' damage to windows, grilles, tables and barricades it may break, as a share of their damage (none to anything else) -#define BOUNTY_HEAVY_BURST_DEMOLITION 0.25 -/// Barricade: tg's security barrier, dropped in front of it +/// A hit of at least this much damage while it aims makes it fumble the burst. (phase D, CB-13) +#define BOUNTY_HEAVY_FUMBLE_DAMAGE 8 +/// Barricade: a real barrier grenade, thrown in front of it (CB-26) #define BOUNTY_HEAVY_BARRICADE_WINDUP (0.6 SECONDS) #define BOUNTY_HEAVY_BARRICADE_COOLDOWN (20 SECONDS) +/// Barrier grenades it carries. (phase D) +#define BOUNTY_HEAVY_BARRIERS 2 +/// Its barrier grenades' fuse, set short. (phase D) +#define BOUNTY_HEAVY_BARRIER_FUSE (1 SECONDS) /// Percent of projectiles that pass its barricade (combat.md's number; tg's barrier lets 20 through) #define BOUNTY_HEAVY_BARRICADE_PASS 50 @@ -246,3 +276,11 @@ #define BB_BOUNTY_BOSS_PREY "BB_bounty_boss_prey" /// world.time it looks for prey again #define BB_BOUNTY_BOSS_HUNT_AT "BB_bounty_boss_hunt_at" +/// Who it falls back from while worn out +#define BB_BOUNTY_BOSS_FALL_BACK_FROM "BB_bounty_boss_fall_back_from" +/// world.time a Heavy's belt change in progress gives up +#define BB_BOUNTY_BOSS_RELOAD_UNTIL "BB_bounty_boss_reload_until" +/// Where it is walking to, to go through the window, door or wall in its way +#define BB_BOUNTY_BOSS_SPOT "BB_bounty_boss_spot" +/// Where it is walking to, out of the sight of someone it can't get at +#define BB_BOUNTY_BOSS_COVER "BB_bounty_boss_cover" diff --git a/voidcrew/_DEFINES/bounty_chrome.dm b/voidcrew/_DEFINES/bounty_chrome.dm new file mode 100644 index 00000000000..8a9357d9daa --- /dev/null +++ b/voidcrew/_DEFINES/bounty_chrome.dm @@ -0,0 +1,56 @@ +// ===== BOUNTY CHROME (voidcrew/modules/bounties/bounty_chrome.dm) ===== + +/// Percent chance a criminal of each tier carries chrome at all +#define BOUNTY_CHROME_CHANCE_PETTY 10 +#define BOUNTY_CHROME_CHANCE_WANTED 35 +#define BOUNTY_CHROME_CHANCE_MOST_WANTED 60 +/// Percent chance a Wanted or Most Wanted carrier has a second piece, if one fits +#define BOUNTY_CHROME_SECOND_CHANCE 30 +/// A companion's chance is its criminal's tier chance divided by this, and it carries one piece at most +#define BOUNTY_CHROME_COMPANION_DIVISOR 3 +/// The archetype a companion rolls chrome as (the criminal archetypes are BOUNTY_ARCHETYPE_*) +#define BOUNTY_CHROME_COMPANION "companion" + +/// The chrome keys, one per /datum/bounty_chrome_piece subtype +#define BOUNTY_CHROME_CASCADE "cascade" +#define BOUNTY_CHROME_MANTIS "mantis" +#define BOUNTY_CHROME_GORILLA "gorilla" +#define BOUNTY_CHROME_REDLINE "redline" +#define BOUNTY_CHROME_PILEDRIVER "piledriver" +#define BOUNTY_CHROME_LAZARUS "lazarus" +#define BOUNTY_CHROME_DEADEYE "deadeye" + +/// The shortest tell a chrome move may have before it fires the implant (owner decision 5) +#define BOUNTY_CHROME_TELL_MIN (0.8 SECONDS) +/// After a fight starts, how long before the first chrome move may start +#define BOUNTY_CHROME_OPENING (1.5 SECONDS) +/// The shortest gap between two chrome moves of one NPC +#define BOUNTY_CHROME_MOVE_GAP (4 SECONDS) +/// The outline a tell shows, unless the move picks its own colour +#define BOUNTY_CHROME_TELL_COLOR "#ff2079" +/// Trait source for standing still through a tell +#define BOUNTY_CHROME_TRAIT "bounty_chrome" + +/// Why a tell was cut short (/datum/bounty_chrome_move/proc/cancel()) +#define BOUNTY_CHROME_CANCEL_DOWNED "downed" +#define BOUNTY_CHROME_CANCEL_STUNNED "stunned" +#define BOUNTY_CHROME_CANCEL_RESTRAINED "restrained" +#define BOUNTY_CHROME_CANCEL_EMP "emp" +#define BOUNTY_CHROME_CANCEL_DEATH "death" +#define BOUNTY_CHROME_CANCEL_GONE "gone" + +/// Sent on a carbon whose chrome a pair of cyber optics just read, if it felt the read (cyberware_scan.dm): (mob/living/carbon/reader) +#define COMSIG_CARBON_CHROME_READ "carbon_chrome_read" +/// How soon a bounty NPC answers another chrome read after answering one +#define BOUNTY_CHROME_READ_NOTICE_COOLDOWN (20 SECONDS) + +/// A malfunction (bounty_chrome_malfunction_fx()): how often it twitches and spits sparks, its outline's name, and its chrome blue +#define BOUNTY_CHROME_MALFUNCTION_PULSE (0.4 SECONDS) +#define BOUNTY_CHROME_MALFUNCTION_FILTER "bounty_chrome_malfunction" +#define BOUNTY_CHROME_MALFUNCTION_COLOR "#7fd4ff" +/// A colour matrix turning tg's yellow arcs a hot chrome blue, their brightest parts near white +#define BOUNTY_CHROME_MALFUNCTION_MATRIX list(0.25, 0.45, 0.55, 0.25, 0.45, 0.55, 0.25, 0.45, 0.55, 0, 0, 0) + +/// The Bounty Panel's chrome choices (bounty_admin.dm): roll it as usual, or every piece that fits +#define BOUNTY_ADMIN_CHROME_ROLL "roll" +#define BOUNTY_ADMIN_CHROME_ALL "all" diff --git a/voidcrew/_DEFINES/bounty_criminals.dm b/voidcrew/_DEFINES/bounty_criminals.dm index e3e81d76cb4..bf663b5999e 100644 --- a/voidcrew/_DEFINES/bounty_criminals.dm +++ b/voidcrew/_DEFINES/bounty_criminals.dm @@ -1,137 +1,22 @@ // # Bounty hunting: criminal body defines // -// Owner: P2 body (voidcrew/modules/bounties/bounty_criminal.dm, bounty_restraints.dm). Only P2 edits -// this file. The shared enums are in bounties.dm. The numbers are balance/combat.md section 6, as -// consolidated in spec.md section 12.3 (C1-C9). +// Owner: P2 body (voidcrew/modules/bounties/bounty_criminal.dm, bounty_restraints.dm, bounty_outfits.dm, +// bounty_ai_human.dm). Only P2 edits this file. The shared enums are in bounties.dm. +// +// A criminal is a real human NPC (human NPC migration, phase D): its health, stamina, crit, cuffs and +// breakout times are tg's, so there are no health pools, stamina pools, downed lines, recovery timers +// or slip times here, and there must never be. // // Unit tests compile before voidcrew/_DEFINES and cannot see these: a test uses the literal value, // with a comment naming the define. // ===== TRAITS ===== -/** - * On a criminal while it is restrained or downed: its AI must plan nothing. P3's controller can - * check it in its first subtree. P2 also adds TRAIT_AI_PAUSED under BOUNTY_BODY_HELD_TRAIT at the - * same time, which stops the controller outright. - */ -#define TRAIT_BOUNTY_HELD "bounty_held" -/// A meek criminal sprinting, and neither winded nor cornered: P3 adds and removes it (source BOUNTY_TRAIT). P2 dodges projectiles while it is on (BOUNTY_MEEK_DODGE). -#define TRAIT_BOUNTY_SPRINTING "bounty_sprinting" -/** - * A script is walking the criminal somewhere on purpose (a walk-out, a cutscene): the leash lets its - * steps through. Godmode counts the same, which covers P6's walk out to the hangar lift. - */ +/// A script is walking the criminal somewhere on purpose (a cutscene): the leash lets its steps through. Walking out (npc_walk_out()) counts the same. #define TRAIT_BOUNTY_SCRIPTED_MOVE "bounty_scripted_move" -/// Trait source for what the body always has (no teleports, no mob swaps) +/// Trait source for what the body always has (no teleports, no mob swaps). A convict a crew talks down loses them. #define BOUNTY_BODY_TRAIT "bounty_body" -/// Trait source for being downed: floored, incapacitated, immobilized -#define BOUNTY_BODY_DOWNED_TRAIT "bounty_body_downed" -/// Trait source for the restraints holding a criminal still -#define BOUNTY_BODY_CUFFS_TRAIT "bounty_body_cuffs" -/// Trait source for TRAIT_BOUNTY_HELD and TRAIT_AI_PAUSED while restrained or downed -#define BOUNTY_BODY_HELD_TRAIT "bounty_body_held" - -// ===== BODY ===== - -/// sentience_type of every criminal: no sentience potion, no ghost role -#define BOUNTY_CRIMINAL_NO_SENTIENCE 0 - -/// Health of a meek criminal, every tier -#define BOUNTY_MEEK_HEALTH 120 -/// Health of a normal criminal by tier, before the style's multiplier (C7) -#define BOUNTY_NORMAL_HEALTH_PETTY 140 -#define BOUNTY_NORMAL_HEALTH_WANTED 180 -#define BOUNTY_NORMAL_HEALTH_MOST 220 -/// P0's name for the normal criminal's health: the Wanted value -#define BOUNTY_NORMAL_HEALTH BOUNTY_NORMAL_HEALTH_WANTED - -/// Health multiplier by fighting style (combat.md 6.2) -#define BOUNTY_STYLE_HEALTH_BRAWLER 1.2 -#define BOUNTY_STYLE_HEALTH_KNIFE 1 -#define BOUNTY_STYLE_HEALTH_PISTOL 0.9 -#define BOUNTY_STYLE_HEALTH_SHOTGUN 1 -#define BOUNTY_STYLE_HEALTH_CLUB 1.1 -#define BOUNTY_STYLE_HEALTH_BOTTLE 0.9 -/// A brawler takes stamina damage x this -#define BOUNTY_STYLE_STAMINA_BRAWLER 0.8 - -/// Fire and heat (L3): a criminal on fire, or hotter than this, burns like a suited person, BOUNTY_CRIMINAL_BURN_DAMAGE a second. The mercy line and the automated floor still hold it. Mini-bosses keep P4's own fire rules. -#define BOUNTY_CRIMINAL_MAX_TEMP SPACE_SUIT_MAX_TEMP_PROTECT -#define BOUNTY_CRIMINAL_BURN_DAMAGE 3 - -/// An explosion counts as a player's when the explosive was last touched by one, or someone with a mind stands within this many tiles -#define BOUNTY_EXPLOSION_WITNESS_RANGE 7 - -/// Pace of a meek criminal: its sprint (P3 slows it while winded) -#define BOUNTY_BODY_MEEK_SPEED 1.1 -/// Pace of a normal criminal with a melee style, and with a ranged one (pistol, bottle) -#define BOUNTY_BODY_NORMAL_SPEED_MELEE 1.25 -#define BOUNTY_BODY_NORMAL_SPEED_RANGED 1.4 - -// ===== STAMINA ===== -// max_stamina stays at 100 on every criminal: basic mobs leave stamina crit by comparing raw stamina -// with a percentage (stamcrit.dm), which only agree at 100. A pool of any other size is the stamina -// damage coefficient: 100 / pool. - -#define BOUNTY_CRIMINAL_MAX_STAMINA 100 -/// Stamina crit at this much stamina: a hair under 100, so a pool that divides evenly (three 30-point disabler shots on a 90 pool) still crits through float rounding -#define BOUNTY_CRIMINAL_STAMCRIT_AT 99.9 -/// A meek criminal's stamina pool: three disabler shots or two batons (C5) -#define BOUNTY_MEEK_STAMINA 90 -/// A meek criminal's slowdown with no stamina left (tg's is 3): one hit must not end the chase (C5) -#define BOUNTY_MEEK_STAMINA_SLOWDOWN 0.5 -/// How long stamina crit lasts after the last stamina hit: meek (C5), and everyone else (tg's default, as simulated) -#define BOUNTY_MEEK_STAMCRIT_TIME (12 SECONDS) -#define BOUNTY_NORMAL_STAMCRIT_TIME (10 SECONDS) -/// P0's name for the stamina crit time -#define BOUNTY_STAMCRIT_TIME BOUNTY_NORMAL_STAMCRIT_TIME -/// P0's names for the stamina coefficients. The pool sets the real one (100 / pool); these stay 1. -#define BOUNTY_MEEK_STAMINA_COEFF 1 -#define BOUNTY_NORMAL_STAMINA_COEFF 1 - -/// Percent of projectiles a sprinting meek criminal ducks (C5) -#define BOUNTY_MEEK_DODGE 50 -/// No ducking within this long of being shot -#define BOUNTY_MEEK_DODGE_AFTER_HIT (1 SECONDS) - -// ===== DOWNED AND RECOVERY (C1, C3) ===== - -/// Percent of max health at or below which a criminal is downed -#define BOUNTY_DOWNED_BELOW 25 -/// P0's name for the downed line, as a fraction -#define BOUNTY_DOWNED_FRACTION (BOUNTY_DOWNED_BELOW / 100) -/// Percent of max health a downed criminal gets up with (more if it was healed while down) -#define BOUNTY_RECOVER_TO 35 -/// How long a downed, uncuffed criminal stays down, by archetype -#define BOUNTY_RECOVER_TIME_MEEK (60 SECONDS) -#define BOUNTY_RECOVER_TIME_NORMAL (75 SECONDS) -#define BOUNTY_RECOVER_TIME_BOSS (45 SECONDS) -/// The last stretch of the recovery, when it groans and tries to rise -#define BOUNTY_BODY_STIRRING_TELL (10 SECONDS) -/// A mini-boss that gets up shrugs off stamina for this long (C1); P4 shows it -#define BOUNTY_BODY_RALLY_TIME (20 SECONDS) - -// ===== AUTOMATED DAMAGE (AR-D3) ===== - -/// On its site, damage with nobody's mind behind it (turrets, traps, fire, fauna) stops at this percent of max health -#define BOUNTY_AUTOMATED_FLOOR 40 - -// ===== RESTRAINTS (C2) ===== - -/// Putting restraints on a downed, stunned or surrendered criminal -#define BOUNTY_CUFF_TIME (2 SECONDS) -/// Taking them off -#define BOUNTY_UNCUFF_TIME (2 SECONDS) -/// Cable restraints and zipties slip after this long; handcuffs never do -#define BOUNTY_CABLE_SLIP (3 MINUTES) -#define BOUNTY_ZIPTIE_SLIP (5 MINUTES) -/// Joke cuffs and zipties -#define BOUNTY_FAKE_CUFF_SLIP (5 SECONDS) -/// A mini-boss gets out of cable and zipties in this share of the time (combat.md 6.4) -#define BOUNTY_BOSS_SLIP_MULT 0.5 -/// The visible struggle starts this long before they slip -#define BOUNTY_SLIP_WARNING (20 SECONDS) // ===== LEASH AND TARGETS (AR-D1, AR-D2) ===== @@ -141,3 +26,28 @@ #define BOUNTY_BODY_FALLBACK_LEASH 40 /// Most mobs a criminal remembers as fair game; the oldest go first #define BOUNTY_GRUDGE_MAX 12 + +// ===== CUFFS, TOUGHNESS, SELF-TREATMENT ===== + +/// A criminal answers a reach for its wrists this long after tg's cuffing starts: a person's reaction, and inside tg's 4 second do_after, so stepping away breaks it +#define BOUNTY_CUFF_REACTION_DELAY (0.5 SECONDS) +/// Sent on a criminal when someone starts putting cuffs on it, before its own reaction: (mob/living/user). Return COMPONENT_BOUNTY_CUFF_HANDLED to take the reaction over. +#define COMSIG_BOUNTY_CUFF_REACHED "bounty_cuff_reached" + #define COMPONENT_BOUNTY_CUFF_HANDLED (1<<0) +/// A cuffed criminal that started working at its cuffs this recently and then lost them got itself out +#define BOUNTY_SELF_FREED_WINDOW (2 MINUTES) +/// Brute and burn a criminal takes, as a multiple of a person's (physiology, npc_damage_taken), by tier. Armour does the rest. +#define BOUNTY_TOUGHNESS_PETTY 1 +#define BOUNTY_TOUGHNESS_WANTED 0.9 +#define BOUNTY_TOUGHNESS_MOST_WANTED 0.8 +/// A bounty NPC treats itself when its health is at or below this share of its max, with nothing it can see attacking it +#define BOUNTY_TREAT_SELF_BELOW 0.7 +/// Between two goes at treating itself +#define BOUNTY_TREAT_SELF_COOLDOWN (20 SECONDS) +/// Blackboard key: the medical stack it is using on itself has been clicked on it once this go (bounty_ai_human.dm) +#define BB_BOUNTY_TREAT_APPLIED "bounty_treat_applied" + +// ===== SUITS (bounty_suits.dm) ===== + +/// Open tiles of a planet site, picked at random, whose air is read when a criminal is made there: any one that would hurt a person dresses it for that +#define BOUNTY_SITE_AIR_SAMPLES 6 diff --git a/voidcrew/_DEFINES/bounty_kingpin.dm b/voidcrew/_DEFINES/bounty_kingpin.dm index 5ebae7d5de0..e87faf04ce7 100644 --- a/voidcrew/_DEFINES/bounty_kingpin.dm +++ b/voidcrew/_DEFINES/bounty_kingpin.dm @@ -23,16 +23,11 @@ // ===== HIS BODY ===== -#define BOUNTY_KINGPIN_HEALTH 200 -/// Stamina damage that stamcrits him: 5 disabler hits in 10 seconds, or 3 baton hits -#define BOUNTY_KINGPIN_STAMINA 150 -/// Downed to up, uncuffed (the 10 s stirring tell is P2's, inside this) -#define BOUNTY_KINGPIN_RECOVERY (60 SECONDS) -/// After he gets up: stamina does nothing to him and he won't surrender (combat.md 5.6) -#define BOUNTY_KINGPIN_RALLY (20 SECONDS) -/// Below this percent of his health, with his crew down, he may surrender +/// Brute and burn he takes, as a multiple of a person's (the human NPC base's npc_damage_taken, boss rank). His armoured coat does the rest. +#define BOUNTY_KINGPIN_DAMAGE_TAKEN 0.6 +/// At or below this percent of his health, with his crew down and a hunter in sight, he may give up #define BOUNTY_KINGPIN_SURRENDER_BELOW 40 -/// Percent chance on the hit that crosses the line, and on each later hit +/// Percent chance on his first roll, and on each later one #define BOUNTY_KINGPIN_SURRENDER_FIRST 60 #define BOUNTY_KINGPIN_SURRENDER_LATER 25 /// At most one surrender roll per this @@ -40,13 +35,14 @@ // ===== HIS REVOLVER ===== -/// The revolver in his hand: its look, its rounds and its cylinder (bounty_real_weapon()) +/// The revolver he carries in his holster: a real .357, drawn when the guns come out #define BOUNTY_KINGPIN_REVOLVER /obj/item/gun/ballistic/revolver +/// His spare rounds: real .357 speedloaders, two in the holster and one in a pocket +#define BOUNTY_KINGPIN_SPEEDLOADER /obj/item/ammo_box/a357 /// The aim line before each shot; he never fires without it #define BOUNTY_KINGPIN_REVOLVER_AIM (0.5 SECONDS) /// From one aim to the next #define BOUNTY_KINGPIN_REVOLVER_COOLDOWN (1.2 SECONDS) -#define BOUNTY_KINGPIN_REVOLVER_RELOAD (3 SECONDS) // ===== THE SHOOTOUT ===== @@ -75,53 +71,48 @@ /// A fixed crew of six (kingpin.md 3.4), one per map post #define BOUNTY_KINGPIN_GOONS 6 -#define BOUNTY_GOON_HEALTH 150 -/// A goon's stamina pool, the way P2 sets a criminal's: max_stamina 100, with a coefficient of 100 / BOUNTY_GOON_HEALTH, so 5 disabler hits stamcrit him. tg's basic mobs compare raw points with this percentage when the crit ends, so it must be 100. -#define BOUNTY_GOON_MAX_STAMINA 100 -/// Percent of it that stamcrits him. Just under 100, for float rounding. -#define BOUNTY_GOON_STAMCRIT_AT 99.9 #define BOUNTY_GOON_WEAPON_PISTOL "pistol" #define BOUNTY_GOON_WEAPON_SMG "smg" #define BOUNTY_GOON_WEAPON_SHOTGUN "shotgun" -/// The guns in the goons' hands: their looks, their rounds and their magazines (bounty_real_weapon()) +/// The real guns the goons carry, and their real spares #define BOUNTY_GOON_PISTOL_GUN /obj/item/gun/ballistic/automatic/pistol/aps +#define BOUNTY_GOON_PISTOL_MAG /obj/item/ammo_box/magazine/m9mm_aps #define BOUNTY_GOON_SMG_GUN /obj/item/gun/ballistic/automatic/mini_uzi -#define BOUNTY_GOON_SHOTGUN_GUN /obj/item/gun/ballistic/shotgun/automatic/combat -/// What the shotguns are loaded with +#define BOUNTY_GOON_SMG_MAG /obj/item/ammo_box/magazine/uzim9mm +/// The combat shotgun, its tube loaded with buckshot (bounty_kingpin.dm) +#define BOUNTY_GOON_SHOTGUN_GUN /obj/item/gun/ballistic/shotgun/automatic/combat/bounty_kingpin +/// What the shotguns are loaded with, and the loose shells a shotgun goon carries in his bandolier #define BOUNTY_GOON_SHOTGUN_AMMO /obj/item/ammo_casing/shotgun/buckshot +#define BOUNTY_GOON_SHOTGUN_SHELLS 8 -#define BOUNTY_GOON_PISTOL_COOLDOWN (0.3 SECONDS) -#define BOUNTY_GOON_PISTOL_RELOAD (2 SECONDS) +/// Between two trigger pulls of a goon's pistol: the Stechkin set to single shots with its own selector, in strings of +/// BOUNTY_PISTOL_STRING_SHOTS with BOUNTY_PISTOL_STRING_PAUSE on top between them, as every bounty pistol fires +#define BOUNTY_GOON_PISTOL_COOLDOWN (0.5 SECONDS) -/// Shots in a burst, and the gap between them -#define BOUNTY_GOON_SMG_BURST 5 -#define BOUNTY_GOON_SMG_BURST_GAP (0.1 SECONDS) +/// Trigger pulls in a burst (each pull is the Uzi's own two-round burst), and the gap between them +#define BOUNTY_GOON_SMG_BURST 2 +#define BOUNTY_GOON_SMG_BURST_GAP (0.3 SECONDS) /// The spin-up: the gun comes up level, with a sound #define BOUNTY_GOON_SMG_WINDUP (0.3 SECONDS) #define BOUNTY_GOON_SMG_COOLDOWN (1 SECONDS) -#define BOUNTY_GOON_SMG_RELOAD (2.5 SECONDS) /// Tiles: he walks in to this before he fires #define BOUNTY_GOON_SHOTGUN_RANGE 2 /// The pump before each blast #define BOUNTY_GOON_SHOTGUN_WINDUP (0.3 SECONDS) #define BOUNTY_GOON_SHOTGUN_COOLDOWN (1.2 SECONDS) -#define BOUNTY_GOON_SHOTGUN_RELOAD (3 SECONDS) -/// Tiles from the sofa a goon ever walks +/// Tiles from the sofa a goon ever walks while his crew holds the lounge #define BOUNTY_GOON_LEASH 7 -/// Percent of the goons left who flee when he falls (downed, cuffed, surrendered or dead) +/// Percent of the goons left who run when he falls (in crit, cuffed, given up or dead) #define BOUNTY_GOON_FLEE_ON_BOSS 50 -/// A fleeing goon is gone after this -#define BOUNTY_GOON_FLEE_TIME (6 SECONDS) -/// A goon's body fades after this (the outpost never unloads) -#define BOUNTY_GOON_FADE (60 SECONDS) -/// How long a goon's body takes to fade, and a leaving goon to walk out -#define BOUNTY_GOON_FADE_OUT (2 SECONDS) -#define BOUNTY_GOON_LEAVE_TIME (5 SECONDS) -/// Between a goon's steps in a fight +/// The quickest a goon takes a step in a fight; his own body's slowdown (wounds, armour) can make it slower #define BOUNTY_GOON_STEP (0.35 SECONDS) +/// A reload by hand gives up after this: a tube is fed a shell at a time +#define BOUNTY_KINGPIN_RELOAD_GIVE_UP (15 SECONDS) +/// Blackboard key on him and his goons: their guns are out (the crew is drawing or fighting), so a spent gun is reloaded +#define BB_BOUNTY_KINGPIN_ARMED "bounty_kingpin_armed" // ===== TALKING ===== @@ -158,7 +149,7 @@ #define BOUNTY_KINGPIN_PAY_MAX 3500 /// Vouchers on a full-share turn-in; red adds BOUNTY_RED_VOUCHER_BONUS #define BOUNTY_KINGPIN_VOUCHERS 1 -/// Wanted dead or alive: restrained, stunned or downed is alive +/// Wanted dead or alive: alive pays in full however he is brought in (cuffed, stunned or in crit); dead pays 80% #define BOUNTY_KINGPIN_PAY_ALIVE 100 #define BOUNTY_KINGPIN_PAY_DEAD 80 /// He goes on the board no earlier than 45 minutes into the round, and only with 3+ active ships diff --git a/voidcrew/_DEFINES/bounty_lairs.dm b/voidcrew/_DEFINES/bounty_lairs.dm index 1044e6fdab2..be54e560c2f 100644 --- a/voidcrew/_DEFINES/bounty_lairs.dm +++ b/voidcrew/_DEFINES/bounty_lairs.dm @@ -58,9 +58,12 @@ /// A second pass that keeps the goons in their rooms, once every zone_mobs marker has had time to spawn (its resolver retries for up to a minute) #define BOUNTY_LAIR_GOON_LEASH_DELAY (65 SECONDS) -// ===== P12 ===== +/// ===== P12 ===== // The mafia club's people (bounty_lair_mafia.dm): balance/lairs.md sections 3-5 and 10, spec section 15. // Unit tests can't see these; they use the literal values with the define named in a comment. +// The goons, the lieutenants and the don are real human NPCs (voidcrew/modules/human_npcs/): their health is a +// person's, their toughness is what they wear and npc_damage_taken, and they fire the real guns in their hands. +// Only the don's mech is a mob of its own. /// The faction every club mob shares: goons, lieutenants, the mech and the don #define BOUNTY_MAFIA_FACTION "bounty_mafia" @@ -71,8 +74,10 @@ #define BOUNTY_MAFIA_BARK_COOLDOWN (12 SECONDS) /// Chance a second, per mob, that an idle goon says something while players are about #define BOUNTY_MAFIA_IDLE_BARK_CHANCE 1 +/// After someone starts cuffing a club member who can still move, how long before it steps away (tg's cuffing takes 4 seconds) +#define BOUNTY_MAFIA_CUFF_REACTION (0.6 SECONDS) -/// Sent on the mech as the don climbs out, before the mech's death: (mob/living/basic/bounty_lair_boss/mafia_don/don) +/// Sent on the mech as the don climbs out, before the mech's death: (mob/living/carbon/human/npc/bounty_mafia/don/don) #define COMSIG_BOUNTY_MAFIA_DON_EJECTED "bounty_mafia_don_ejected" /// Sent on the don when his trophy drops: (obj/item/bounty_proof/trophy/mafia_don/trophy) #define COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED "bounty_mafia_trophy_dropped" @@ -81,30 +86,30 @@ #define BB_BOUNTY_MAFIA_MOVE_TO "BB_bounty_mafia_move_to" // ----- mobsters ----- -// Every blow and round is the real item's in their hand (bounty_real_weapon()): its look, damage and magazine. +// A volley is one aim (if the gun has one), then VOLLEY pulls of the trigger VOLLEY_GAP apart, then a pause before +// the next. The gun's own burst and fire rate apply to every pull: an Uzi pull is its two-round burst. A pistol's +// volley is a string of single shots, as every bounty pistol fires: the Stechkin is set to single fire with its own +// selector, BOUNTY_PISTOL_STRING_SHOTS shots BOUNTY_PISTOL_INTERVAL apart, then BOUNTY_PISTOL_STRING_PAUSE on top. #define BOUNTY_MOBSTER_KNIFE /obj/item/knife/kitchen -#define BOUNTY_MOBSTER_KNIFE_HEALTH 120 -#define BOUNTY_MOBSTER_KNIFE_COOLDOWN (0.8 SECONDS) -#define BOUNTY_MOBSTER_KNIFE_SPEED 1.2 #define BOUNTY_MOBSTER_PISTOL_GUN /obj/item/gun/ballistic/automatic/pistol/aps -#define BOUNTY_MOBSTER_PISTOL_HEALTH 120 -/// A pistol fires this many shots each time it fires, this far apart; a goon only decides to shoot every half second -#define BOUNTY_MOBSTER_PISTOL_BURST 2 -#define BOUNTY_MOBSTER_PISTOL_BURST_GAP (0.25 SECONDS) -#define BOUNTY_MOBSTER_PISTOL_COOLDOWN (0.5 SECONDS) -#define BOUNTY_MOBSTER_PISTOL_RELOAD (2 SECONDS) -#define BOUNTY_MOBSTER_PISTOL_SPEED 1.4 +#define BOUNTY_MOBSTER_PISTOL_MAGAZINE /obj/item/ammo_box/magazine/m9mm_aps +#define BOUNTY_MOBSTER_PISTOL_VOLLEY BOUNTY_PISTOL_STRING_SHOTS +#define BOUNTY_MOBSTER_PISTOL_VOLLEY_GAP BOUNTY_PISTOL_INTERVAL +/// From a string's last shot to the next string's first +#define BOUNTY_MOBSTER_PISTOL_STRING_PAUSE (BOUNTY_PISTOL_INTERVAL + BOUNTY_PISTOL_STRING_PAUSE) +/// Between the human NPC base's own shots for a club member (an elite firing wild from the floor) +#define BOUNTY_MOBSTER_PISTOL_COOLDOWN (0.8 SECONDS) #define BOUNTY_MOBSTER_SMG_GUN /obj/item/gun/ballistic/automatic/mini_uzi -#define BOUNTY_MOBSTER_SMG_HEALTH 150 -#define BOUNTY_MOBSTER_SMG_BURST 5 -#define BOUNTY_MOBSTER_SMG_BURST_GAP (0.1 SECONDS) +#define BOUNTY_MOBSTER_SMG_MAGAZINE /obj/item/ammo_box/magazine/uzim9mm +/// The SMG spins up before each volley ("aiming!"), and a hit then spoils it #define BOUNTY_MOBSTER_SMG_WINDUP (0.3 SECONDS) +/// Each pull is the Uzi's two-round burst +#define BOUNTY_MOBSTER_SMG_VOLLEY 3 +#define BOUNTY_MOBSTER_SMG_VOLLEY_GAP (0.3 SECONDS) #define BOUNTY_MOBSTER_SMG_COOLDOWN (1 SECONDS) -#define BOUNTY_MOBSTER_SMG_RELOAD (2.5 SECONDS) -#define BOUNTY_MOBSTER_SMG_SPEED 1.4 /// The shout before a room's first shot #define BOUNTY_MOBSTER_ALERT (0.5 SECONDS) @@ -125,25 +130,20 @@ // ----- lieutenants ----- +/// Both lieutenants take this much of any brute or burn, through their bodies (elite rank), on top of their armour +#define BOUNTY_LIEUTENANT_DAMAGE_TAKEN 0.8 + #define BOUNTY_LIEUTENANT_TOMMY_GUN /obj/item/gun/ballistic/automatic/tommygun -#define BOUNTY_LIEUTENANT_TOMMY_HEALTH 350 -#define BOUNTY_LIEUTENANT_TOMMY_BURST 6 -#define BOUNTY_LIEUTENANT_TOMMY_BURST_GAP (0.1 SECONDS) +#define BOUNTY_LIEUTENANT_TOMMY_MAGAZINE /obj/item/ammo_box/magazine/tommygunm45 #define BOUNTY_LIEUTENANT_TOMMY_WINDUP (0.3 SECONDS) +/// Each pull is one round of the Thompson +#define BOUNTY_LIEUTENANT_TOMMY_VOLLEY 6 +#define BOUNTY_LIEUTENANT_TOMMY_VOLLEY_GAP (0.1 SECONDS) #define BOUNTY_LIEUTENANT_TOMMY_COOLDOWN (1 SECONDS) -#define BOUNTY_LIEUTENANT_TOMMY_RELOAD (3 SECONDS) -#define BOUNTY_LIEUTENANT_TOMMY_SPEED 1.4 -/// Brute fights with his fists: a human's punch -#define BOUNTY_LIEUTENANT_BRUTE_HEALTH 400 -#define BOUNTY_LIEUTENANT_BRUTE_COOLDOWN (1 SECONDS) -/// Percent chance a blow knocks the hunter down +/// Brute fights with his fists: a person's punch. A landed punch knocks the hunter down this often (percent), for this long. #define BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN_CHANCE 25 #define BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN (1 SECONDS) -#define BOUNTY_LIEUTENANT_BRUTE_SPEED 1.2 - -/// Both lieutenants take this much of any brute or burn damage -#define BOUNTY_LIEUTENANT_DAMAGE_MOD 0.8 /// The lieutenants' names for the role var #define BOUNTY_LIEUTENANT_TOMMY "tommy" @@ -171,10 +171,11 @@ #define BOUNTY_MECH_STOMP_THROW 3 #define BOUNTY_MECH_STOMP_THROW_SPEED 2 -/// The lock-on: a marker under each hunter it can see, then one rocket per marker +/// The lock-on: a marker under each hunter it can see, then one rocket per marker. Each burst is tg's light blast +/// (ex_act(EXPLODE_LIGHT)) on everyone standing on its marker, and nothing else: no explosion(), no breach. #define BOUNTY_MECH_ROCKET_LOCK (0.8 SECONDS) +/// A rocket's damage to what it hits in flight in the arena (a table, a window, a door), before BOUNTY_MECH_ROCKET_DEMOLITION #define BOUNTY_MECH_ROCKET_DAMAGE 70 -#define BOUNTY_MECH_ROCKET_KNOCKDOWN (1 SECONDS) #define BOUNTY_MECH_ROCKET_COOLDOWN (9 SECONDS) /// Damage a rocket does to a table, window or door it hits in the arena, as a multiple of its damage #define BOUNTY_MECH_ROCKET_DEMOLITION 8 @@ -210,12 +211,19 @@ /// His name when his posting's record gives none: the name P10's card shows for Club Volga #define BOUNTY_DON_NAME "Arkady Sokolov" -/// His gold pistol: its look, its rounds and its magazine (bounty_real_weapon()); he fires strings of BOUNTY_MOBSTER_PISTOL_BURST as a goon does +/// The don takes this much of any brute or burn, through his body (boss rank) +#define BOUNTY_DON_DAMAGE_TAKEN 0.6 +/// His gold pistol, and the magazines he carries for it #define BOUNTY_DON_GUN /obj/item/gun/ballistic/automatic/pistol/deagle/gold -#define BOUNTY_DON_HEALTH 250 -#define BOUNTY_DON_COOLDOWN (0.5 SECONDS) -#define BOUNTY_DON_RELOAD (2 SECONDS) -/// He raises the pistol this long before each string of shots +#define BOUNTY_DON_MAGAZINE /obj/item/ammo_box/magazine/m50 +/// He raises the pistol this long before the first string of each magazine #define BOUNTY_DON_RAISE (0.2 SECONDS) -#define BOUNTY_DON_SPEED 1.4 +/// Strings of single shots, as every bounty pistol fires (the gold Desert Eagle has no burst) +#define BOUNTY_DON_VOLLEY BOUNTY_PISTOL_STRING_SHOTS +#define BOUNTY_DON_VOLLEY_GAP BOUNTY_PISTOL_INTERVAL +/// From a string's last shot to the next string's first +#define BOUNTY_DON_STRING_PAUSE (BOUNTY_PISTOL_INTERVAL + BOUNTY_PISTOL_STRING_PAUSE) +/// Between the human NPC base's own shots for him (firing wild from the floor) +#define BOUNTY_DON_COOLDOWN (0.5 SECONDS) +/// Climbing out of the wreck, he takes this long to get his bearings before he acts #define BOUNTY_DON_EJECT_STAGGER (2 SECONDS) diff --git a/voidcrew/_DEFINES/bounty_outpost.dm b/voidcrew/_DEFINES/bounty_outpost.dm index 97017714e57..faa545816d5 100644 --- a/voidcrew/_DEFINES/bounty_outpost.dm +++ b/voidcrew/_DEFINES/bounty_outpost.dm @@ -17,12 +17,8 @@ #define BOUNTY_DECOY_FLEE_DISTANCE 5 /// How long a decoy keeps backing off #define BOUNTY_DECOY_FLEE_TIME (4 SECONDS) -/// Move delay of someone walking out to the hangar: a walk, not a run -#define BOUNTY_OUTPOST_EXIT_DELAY 4 -/// Anyone still walking out after this fades where they are -#define BOUNTY_OUTPOST_EXIT_TIMEOUT (45 SECONDS) -/// How long the fade out takes -#define BOUNTY_OUTPOST_FADE_TIME (1 SECONDS) +// Walking out to the hangar lift is the human NPC base's npc_walk_out() (human_npc_states.dm): a walk +// at the body's own pace, with no timeout and no fade. They leave on the lift or out of sight. /// Trait source for everything P6 puts on a mob #define BOUNTY_OUTPOST_TRAIT "bounty_outpost" diff --git a/voidcrew/_DEFINES/cyberware.dm b/voidcrew/_DEFINES/cyberware.dm index 02a5ffdaa13..cf6eb52c4f0 100644 --- a/voidcrew/_DEFINES/cyberware.dm +++ b/voidcrew/_DEFINES/cyberware.dm @@ -11,12 +11,19 @@ #define CYBERWARE_BIOTECH_CAPACITY 15 #define TRAIT_BIOTECH_NEURAL "biotech_neural" -/// Mob-overlay height worn chrome draws at: between the character-setup -/// underwear/undershirt layer (BODY_LAYER, 32) and the eyes (EYES_LAYER, 31), -/// so a spine rig reads over a bra or undershirt while every EQUIPPED -/// clothing layer (UNIFORM_LAYER 28 and up) still covers it. Fractional on -/// purpose, it must never collide with a real layer index. -#define CYBERWARE_WORN_LAYER 31.5 +/// Mob-overlay height worn chrome draws at: just over the jumpsuit +/// (UNIFORM_LAYER, 28) and under the worn ID (ID_LAYER, 27), so arm plates and +/// a spine rig read over normal clothes while hands (BODYPARTS_HIGH_LAYER 25), +/// gloves, shoes and every suit, vest or armour layer (SUIT_LAYER 19 and up) +/// still cover them. Mob layers count down: a lower number draws on top. +/// Fractional on purpose, it must never collide with a real layer index, nor +/// with a fresh stump (HV_STUMP_FRESH_LAYER, 27.5). +#define CYBERWARE_WORN_LAYER 27.6 +/// Mob layer the lit nodes of a spine rig (Cascade, Redline) shine at: just over +/// whatever is worn on the back (BACK_LAYER 14, and every suit and cloak under +/// it), still under hair (HAIR_LAYER 13) and headgear. Only what glows goes up +/// here; the housing and dark nodes stay at CYBERWARE_WORN_LAYER. +#define CYBERWARE_SPINE_SHINE_LAYER 13.7 // ---- Organ slots ------------------------------------------------------- // Minted here, never in DNA.dm. Slot uniqueness is global per mob, so @@ -150,3 +157,100 @@ /// Minimum environmental O2 partial pressure (kPa) the bladder considers /// breathable, mirroring the lungs' safe_oxygen_min of 16. #define CYBERWARE_BREATHABLE_O2_KPA 16 + +// ---- Optics zoom (cyberware_zoom.dm) ----------------------------------- +// Every set of chrome optics can zoom: tg's scope component pans the camera +// toward the cursor. A zoomed bearer can't fire a gun and their clicks reach +// nothing on the map. + +/// Held by a bearer while their chrome optics are zoomed. Source is the optics. +#define TRAIT_CYBER_ZOOMED "cyber_zoomed" + +// ---- Chrome wheel (chrome_wheel.dm) ------------------------------------ + +/// Name of the "Chrome wheel" keybind; the release poll reads the player's +/// keys for it to catch modifier binds, which never get a keyUp of their own. +#define CHROME_WHEEL_KEYBIND "chrome_wheel" +/// A wheel key let go sooner than this was a tap, and the wheel stays open +/// for a click instead of closing on release. +#define CHROME_WHEEL_HOLD_TIME (0.3 SECONDS) + +/// Wheel states. CLOSED, HELD (open while the key is down) or CLICK (open +/// after a tap, until the next click or press). +#define CHROME_WHEEL_CLOSED 0 +#define CHROME_WHEEL_HELD 1 +#define CHROME_WHEEL_CLICK 2 + +/// Sockets: where a ware's chips sit on the ring. Each owns a run of fixed +/// positions on its region's arc, so one socket's chrome changing never moves +/// another socket's chips. Hand chrome rides its arm's socket. +#define CHROME_WHEEL_SOCKET_EYES "eyes" +#define CHROME_WHEEL_SOCKET_HEAD "head" +#define CHROME_WHEEL_SOCKET_OS "os" +#define CHROME_WHEEL_SOCKET_SKIN "skin" +#define CHROME_WHEEL_SOCKET_LEFT_ARM "l_arm" +#define CHROME_WHEEL_SOCKET_RIGHT_ARM "r_arm" +#define CHROME_WHEEL_SOCKET_LEGS "legs" + +/// Radius in pixels of the ring the chips sit on. +#define CHROME_WHEEL_RADIUS 100 +/// Degrees between neighbouring chips on the ring. +#define CHROME_WHEEL_STEP 14.5 +/// A socket with more chips than its arc holds starts another row this much +/// further out, at the same angles. +#define CHROME_WHEEL_ROW_GAP 26 +/// The ring's centre sits this many pixels above the centre of the view. +#define CHROME_WHEEL_CENTRE_Y 2 +/// Mouse distance from the ring centre inside which nothing is pointed at. +#define CHROME_WHEEL_DEADZONE 34 +/// A chip this close to the mouse is the one under it. +#define CHROME_WHEEL_PICK_RANGE 12 +/// Otherwise the mouse points at the first-row chip whose bearing from the ring +/// centre is nearest its own, if one lies within this many degrees. +#define CHROME_WHEEL_POINT_TOLERANCE 13 + +/// After a pick the wheel stays up this long for the lock and fade. The pick +/// itself lands at once. +#define CHROME_WHEEL_LINGER_TIME (0.2 SECONDS) +/// A wheel opened from the Chrome button fires what a click picks, so a click +/// further than this from the ring's centre picks by bearing no more: out past +/// the second row, a click just shuts the wheel. +#define CHROME_WHEEL_FIRE_REACH (CHROME_WHEEL_RADIUS + CHROME_WHEEL_ROW_GAP + 24) +/// Reserves Arm chrome's place on the wheel (GLOB.chrome_wheel_reserved), as an +/// organ slot reserves its ware's. +#define CHROME_WHEEL_ARMS_KEY "chrome_arms" +/// The region an Arm chrome chip lights: both arms. +#define CHROME_WHEEL_REGION_ARMS "arms" +/// The Chrome button's and Arm chrome's own art. +#define CHROME_HUD_ICON 'voidcrew/modules/cyberware/icons/chrome_hud.dmi' + +#define CHROME_WHEEL_ICON 'voidcrew/modules/cyberware/icons/chrome_wheel.dmi' +/// 240x240 art centred on the ring: body, arcs, leaders, glows, backdrop. +#define CHROME_WHEEL_RING_ICON 'voidcrew/modules/cyberware/icons/chrome_wheel_ring.dmi' +/// Corner brackets borrowed from the chrome read. +#define CHROME_WHEEL_BRACKET_ICON 'voidcrew/modules/cyberware/icons/chrome_read.dmi' + +/// Wheel colours, the chrome read's own. +#define CHROME_WHEEL_HUD_COLOR CYBERWARE_COLOR_TIER_2 +#define CHROME_WHEEL_PANEL_COLOR "#060e12" +#define CHROME_WHEEL_NAME_COLOR "#e6fbff" +#define CHROME_WHEEL_DIM_COLOR "#8fa1a8" +#define CHROME_WHEEL_OFFLINE_COLOR "#ff5a4a" +#define CHROME_WHEEL_OFFLINE_NAME_COLOR "#7d8a90" +/// The pick's brackets (radial.dmi's module_selected cyan). +#define CHROME_WHEEL_PICKED_COLOR "#00ffff" +/// Deployed or armed (radial.dmi's module_active green). +#define CHROME_WHEEL_DEPLOYED_COLOR "#00ff00" + +// ---- Phase 2 models: seal, frame and nervous street models ------------- +// ware_models_b.dm. Hardweave, the Strut frame, the Deep-lung bladder, the +// Heat-sink and Frostline linings, the Twitchwire and Weaver shunts, and the +// Thumper ear. + +/// Held by a bearer whose skeleton simply does not break. Blocks the whole +/// bone wound series, dislocations, hairline fractures, compound fractures, +/// at the wound system's own gate, and nothing else. Every other wound series +/// lands on its bearer exactly as it lands on anyone. Granted by the Atlas +/// frame (ware_military_body.dm) and the Strut frame (ware_models_b.dm); the +/// gate itself is in ware_military_body.dm. +#define TRAIT_CYBERWARE_UNBREAKABLE_BONES "cyberware_unbreakable_bones" diff --git a/voidcrew/_DEFINES/gorilla_gore.dm b/voidcrew/_DEFINES/gorilla_gore.dm new file mode 100644 index 00000000000..31e6ec17bff --- /dev/null +++ b/voidcrew/_DEFINES/gorilla_gore.dm @@ -0,0 +1,361 @@ +// Gorilla Arms x hyperviolence (voidcrew/modules/hyperviolence_gorilla/). Balance notes for +// developers; none of these numbers go in player text. Unit tests can't see these (they compile +// before voidcrew/_DEFINES/), so tests use the literal values with a comment naming the define. + +/// 32x32 pictures: knuckle sparks, piston pumps and heat, scuffs, the pry seam, snapped bones, dents, +/// the finisher masks. +#define GORILLA_GORE_ICON 'voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi' +/// 64x64 pictures drawn at -16,-16 on the floor tile in front of a wall: the wall impacts. +#define GORILLA_GORE_LARGE_ICON 'voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi' + +/// Layer for sparks and flashes over the whole body. +#define GORILLA_ANIM_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) +/// Layer for the piston pump and heat over the worn arm plates: just above the plates (under suits). +#define GORILLA_ANIM_PLATE_LAYER (-(CYBERWARE_WORN_LAYER - 0.05)) + +// ---- The punch (gorilla_punch.dm) ---- + +/// Wound bonus on a Gorilla piston punch; the Brawler keeps its own. +#define GORILLA_PUNCH_WOUND_BONUS 15 +/// Wound bonus on a Brawler punch. +#define BRAWLER_PUNCH_WOUND_BONUS 10 +/// Brute after armour a punch must deal to strain the limb it hit. +#define GORILLA_STRAIN_MIN_DAMAGE 8 +/// How long a limb holds strain after its last hit. +#define GORILLA_STRAIN_MEMORY (15 SECONDS) +/// Melee cover on a limb (after penetration) that stops a forced snap: #509's aorta scale. +#define GORILLA_SNAP_ARMOUR 40 + +// ---- Shared cooldowns, kept on the bearer (TIMER_COOLDOWN) so both arms share them ---- + +/// The haymaker and every finisher: the pistons are spent. +#define COOLDOWN_GORILLA_PISTONS "gorilla_pistons" +/// The hurl and the slam: one grab move at a time. +#define COOLDOWN_GORILLA_GRAB_MOVE "gorilla_grab_move" +/// The ground pound. +#define COOLDOWN_GORILLA_POUND "gorilla_pound" +/// The deploy flex (cosmetic). +#define COOLDOWN_GORILLA_FLEX "gorilla_flex" + +#define GORILLA_PISTONS_COOLDOWN (20 SECONDS) +/// After a finisher that was called off before the kill. +#define GORILLA_PISTONS_INTERRUPTED (10 SECONDS) +/// After a haymaker held too long and vented. +#define GORILLA_PISTONS_VENTED (5 SECONDS) +#define GORILLA_GRAB_MOVE_COOLDOWN (10 SECONDS) +#define GORILLA_POUND_COOLDOWN (45 SECONDS) +#define GORILLA_FLEX_COOLDOWN (10 SECONDS) +/// A powered airlock forced by the synced Gorilla pair. +#define GORILLA_PRY_TIME (4 SECONDS) + +// ---- The haymaker (gorilla_haymaker.dm) ---- + +#define GORILLA_HAYMAKER_MIN_CHARGE (0.8 SECONDS) +#define GORILLA_HAYMAKER_FULL_CHARGE (1.5 SECONDS) +/// Held this long, the pistons vent and the charge is lost. +#define GORILLA_HAYMAKER_VENT_AFTER (3 SECONDS) +/// A press of Use chrome shorter than this is a tap: the charge waits for a click. +#define GORILLA_HAYMAKER_TAP (0.3 SECONDS) +/// Pixels the bearer leans back at full charge, seen from the side. The lean builds to it over the wind-up. +#define GORILLA_HAYMAKER_LEAN 4 +#define GORILLA_HAYMAKER_MIN_DAMAGE 20 +#define GORILLA_HAYMAKER_MAX_DAMAGE 30 +#define GORILLA_HAYMAKER_MIN_RANGE 3 +#define GORILLA_HAYMAKER_MAX_RANGE 4 +#define GORILLA_HAYMAKER_SPEED 3 +/// The real body stays on show this long as the wind-up's puppets come up (chrome_rig/conceal_after()). +#define GORILLA_HAYMAKER_RIG_HOLD (0.04 SECONDS) +/// The punching arm is half drawn back this far into the wind-up... +#define GORILLA_HAYMAKER_DRAW (0.4 SECONDS) +/// ...and all the way back by this, a throw's length before the earliest blow. +#define GORILLA_HAYMAKER_DRAWN (0.75 SECONDS) +/// The throw: the blow lands this long after the fist is thrown. One server tick. +#define GORILLA_HAYMAKER_THROW (0.05 SECONDS) +/// The thrown fist is on what it hits this soon, a moment before the blow is due... +#define GORILLA_HAYMAKER_OUT (0.03 SECONDS) +/// ...stays there while the blow lands, so the blow's sparks find it there whichever reaches a client first... +#define GORILLA_HAYMAKER_DWELL (0.15 SECONDS) +/// ...carries on past it... +#define GORILLA_HAYMAKER_FOLLOW (0.15 SECONDS) +/// ...and the arms come back to rest. +#define GORILLA_HAYMAKER_RETURN (0.22 SECONDS) +/// A wind-up held too long or called off: the arms come down. +#define GORILLA_HAYMAKER_LOWER (0.25 SECONDS) +/// Pixels the body steps in behind the throw: as few as put the fist on the body. +#define GORILLA_HAYMAKER_STEP_MIN 12 +#define GORILLA_HAYMAKER_STEP_MAX 16 + +// ---- The punching arm (gorilla_swing.dm, gorilla_thrust.dm) ---- + +/// The pace of one beat of a swing: even, fast then settling, or eased in and out. +#define CHROME_SWING_LINEAR 0 +#define CHROME_SWING_EASE_OUT 1 +#define CHROME_SWING_EASE_IN_OUT 2 + +/// A piston punch: the fist draws back... +#define GORILLA_THRUST_BACK (0.04 SECONDS) +/// ...the piston drives it out... +#define GORILLA_THRUST_OUT (0.04 SECONDS) +/// ...and it stays on what it hit while the blow lands, so the sparks find it there whichever reaches a client first. +#define GORILLA_THRUST_DWELL (0.08 SECONDS) +/// The arm comes back to rest. +#define GORILLA_THRUST_REST (0.14 SECONDS) +/// How long after the click a piston punch lands: every punch, with the fist a moment into its stay. +#define GORILLA_THRUST_LANDS (0.1 SECONDS) +/// The first time an arm's picture is shown it may still be on its way to a client: the real arm stays on show this long, in place of the draw-back, and the fist is driven out in what is left before it is due. +#define GORILLA_THRUST_HOLD (0.05 SECONDS) +/// Pixels the body lunges behind a piston punch: tg's lunge at the least, further when the fist would stop short. +#define GORILLA_THRUST_LUNGE_MIN 8 +#define GORILLA_THRUST_LUNGE_MAX 16 +/// Degrees the body tips into a piston punch, as tg's lunge tips it. +#define GORILLA_THRUST_TIP 15 +/// How long a punching arm is drawn, as a share of its own length: the body steps in until the fist lands with the arm no longer than the first, and the arm is never drawn longer than the second (what a fist takes to reach the shin of a body beside its bearer). +#define GORILLA_SWING_EASY_LENGTH 1.9 +#define GORILLA_SWING_MAX_LENGTH 3.3 +/// The most pixels a body steps in behind a fist thrown up or down the screen: further, and it stands on whoever it hits. +#define GORILLA_SWING_STEP_UPRIGHT 12 +/// Pixels from the middle of a stretched fist to its knuckles: it stops this far short of the body it lands on. +#define GORILLA_FIST_KNUCKLES 2.5 + +// ---- Grab and slam ---- + +#define GORILLA_SLAM_FLOOR_DAMAGE 25 +#define GORILLA_SLAM_FLOOR_STAMINA 30 +#define GORILLA_SLAM_TABLE_DAMAGE 30 +#define GORILLA_SLAM_TABLE_STAMINA 40 +#define GORILLA_SLAM_KNOCKDOWN (2 SECONDS) +#define GORILLA_SLAM_TABLE_BREAK 50 +// The slam's beats (gorilla_slam.dm), each a whole number of GORILLA_SLAM_STEPs. From the click: the grip, the hoist, the held +// beat at the top and the drive down, then the impact. Gorilla Arms: 0.44 s to the impact and 0.70 s in all. +#define GORILLA_SLAM_GRIP_TIME (0.08 SECONDS) +#define GORILLA_SLAM_HOIST_TIME (0.2 SECONDS) +#define GORILLA_SLAM_TOP_TIME (0.06 SECONDS) +#define GORILLA_SLAM_DRIVE_TIME (0.1 SECONDS) +/// After the impact: how long the body takes to come to rest, the bearer to stand back up, and the puppets to go. +#define GORILLA_SLAM_REBOUND_TIME (0.12 SECONDS) +#define GORILLA_SLAM_RISE_TIME (0.18 SECONDS) +#define GORILLA_SLAM_SETTLE_TIME (0.26 SECONDS) +/// How long a body takes to come down on its feet when the slam is called off. +#define GORILLA_SLAM_SET_DOWN_TIME (0.15 SECONDS) +/// The length of one straight step of the slam's puppets, and of the swap between the bearer's two rigs when he turns. +#define GORILLA_SLAM_STEP (0.02 SECONDS) +#define GORILLA_SLAM_SWAP (0.01 SECONDS) +/// The most and the least his piston arms are drawn to keep a fist on the body, as shares of their length. +#define GORILLA_SLAM_MAX_REACH 2.2 +#define GORILLA_SLAM_MIN_REACH 0.4 +/// Where the middle of the body is at the top of the lift, in pixels from the bearer: in front of him and up, seen from the +/// side; aside and up, seen from the front or back. Gorilla Arms press it overhead, flat. +#define GORILLA_SLAM_TOP_SIDE_X 4 +#define GORILLA_SLAM_TOP_SIDE_Y 22 +#define GORILLA_SLAM_TOP_FRONT_X 3 +#define GORILLA_SLAM_TOP_FRONT_Y 28 +/// Seen from behind, the floor in front of him is up the screen too, about where a body held overhead is drawn. So he goes +/// up on his toes at the top and holds it this high, and it has somewhere to come down from. +#define GORILLA_SLAM_TOP_BACK_Y 34 +#define GORILLA_SLAM_TOP_BACK_RISE 4 +/// How far up tg draws a body lying on a table. +#define GORILLA_SLAM_TABLE_TOP 12 +/// Pixels a slammed body bounces off what it lands on, and the degrees it tips as it does. +#define GORILLA_SLAM_BOUNCE 4 +#define GORILLA_SLAM_BOUNCE_TIP 6 +/// Drops of blood thrown out round a body the slam cut open: the fewest and the most, and how far from it they land. +#define GORILLA_SLAM_DROPS_MIN 5 +#define GORILLA_SLAM_DROPS_MAX 7 +#define GORILLA_SLAM_DROPS_NEAR 9 +#define GORILLA_SLAM_DROPS_FAR 19 +/// What holds the bearer where he stands for the slam. +#define GORILLA_SLAM_TRAIT "gorilla_slam" + +// ---- Brawler myomers (ware_line_models_a.dm): the street slam, pound, smash and pry ---- + +#define BRAWLER_SLAM_FLOOR_DAMAGE 18 +#define BRAWLER_SLAM_FLOOR_STAMINA 20 +#define BRAWLER_SLAM_TABLE_DAMAGE 22 +#define BRAWLER_SLAM_TABLE_STAMINA 30 +#define BRAWLER_SLAM_KNOCKDOWN (1 SECONDS) +#define BRAWLER_SLAM_TABLE_BREAK 30 +/// The Brawler's shorter haul: 0.40 s to the impact. It brings the body to the shoulder, tipped back and not yet flat. +#define BRAWLER_SLAM_HOIST_TIME (0.16 SECONDS) +#define BRAWLER_SLAM_TOP_SIDE_X 11 +#define BRAWLER_SLAM_TOP_SIDE_Y 13 +#define BRAWLER_SLAM_TOP_FRONT_X 2 +#define BRAWLER_SLAM_TOP_FRONT_Y 18 +#define BRAWLER_SLAM_TOP_TURN 62 +#define BRAWLER_SLAM_BOUNCE 2 +#define BRAWLER_GRAB_MOVE_COOLDOWN (15 SECONDS) +/// Damage per structure smash: over a plain airlock's deflection (21) and a reinforced one's (30). +#define BRAWLER_STRUCTURE_DAMAGE 32 +/// A powered airlock forced by the synced pair. The Gorilla takes GORILLA_PRY_TIME. +#define BRAWLER_PRY_TIME (7 SECONDS) +#define BRAWLER_POUND_COOLDOWN (60 SECONDS) +#define BRAWLER_POUND_STAGGER (6 SECONDS) +#define BRAWLER_POUND_STAMINA 10 +/// What a click while grabbing turned into (gorilla_arms/proc/try_slam()). Not a slam: carry on with the punch. +#define GORILLA_SLAM_NO 0 +/// A slam was done. +#define GORILLA_SLAM_DONE 1 +/// A slam onto a table that can't happen now: leave the click to tg's own table smash. +#define GORILLA_SLAM_PASS 2 + +// ---- Throws (gorilla_throw.dm) ---- + +/// What stopped a planned knockback short of its full reach. +#define GORILLA_THROW_CLEAR 0 +/// A wall: the body hits it. +#define GORILLA_THROW_WALL 1 +/// A mob standing in the way: the body hits it. +#define GORILLA_THROW_MOB 2 +/// Glass, a door, a railing, a hazard or no gravity: the body stops short and hits nothing. +#define GORILLA_THROW_SHORT 3 +/// Most decals a Gorilla throw leaves along its path. +#define GORILLA_TRAIL_MAX 4 +/// Most blood drops and splats one tile of a bleeding body's skid gets. +#define GORILLA_SKID_SPLATS 4 + +// What a wall impact draws, by the injury it caused (owner D38). +/// No skin broken: a dent and chips. +#define GORILLA_SPLAT_DENT 1 +/// Skin broken, moderate: a half-moon on the floor and a thin smear on the wall's edge. +#define GORILLA_SPLAT_EDGE 2 +/// Severe bleeding or a torn artery: a spray up the wall and runs. +#define GORILLA_SPLAT_SPRAY 3 + +// How hard a Gorilla throw meets a wall (owner D48). The crash goes through tg's damage and wound rolls. +/// The one-tile shove off a punch (both lines): a dent, no damage, no knockdown. +#define GORILLA_IMPACT_SHOVE 1 +/// The grab hurl. +#define GORILLA_IMPACT_HURL 2 +/// The haymaker. +#define GORILLA_IMPACT_HAYMAKER 3 +/// A hurl's blunt hit on the part that meets the wall, and its wound bonus. +#define GORILLA_WALL_HURL_BLUNT 6 +#define GORILLA_WALL_HURL_BLUNT_WOUND 15 +/// A hurl's split skin on the wall's edge (a slash roll), and its wound bonus. +#define GORILLA_WALL_HURL_EDGE 6 +#define GORILLA_WALL_HURL_EDGE_WOUND 5 +/// A haymaker's blunt hit and its wound bonus. +#define GORILLA_WALL_HAYMAKER_BLUNT 16 +#define GORILLA_WALL_HAYMAKER_BLUNT_WOUND 25 +/// A haymaker's split skin and its wound bonus. +#define GORILLA_WALL_HAYMAKER_EDGE 8 +#define GORILLA_WALL_HAYMAKER_EDGE_WOUND 15 +/// A part already beaten adds a point of wound bonus per this much brute on it... +#define GORILLA_WALL_HURT_PER_POINT 4 +/// ...up to this many. +#define GORILLA_WALL_HURT_MAX 15 +/// The knockdown of a hard crash, as tg's own wall crash. +#define GORILLA_WALL_KNOCKDOWN (2 SECONDS) + +// ---- Ground pound ---- + +#define GORILLA_POUND_CROUCH (0.45 SECONDS) +/// How long the fists take from overhead to the floor +#define GORILLA_POUND_DRIVE (0.08 SECONDS) +/// How long the bearer takes to stand back up after a ground pound +#define GORILLA_POUND_RISE (0.3 SECONDS) +#define GORILLA_POUND_STAMINA 20 +/// The longest stagger a pound leaves. +#define GORILLA_POUND_STAGGER (10 SECONDS) +#define GORILLA_POUND_DAZE (3 SECONDS) +/// Loose items this far from the pound hop a tile outward. +#define GORILLA_POUND_ITEM_REACH 2 + +// ---- Finishers (gorilla_finisher.dm) ---- + +/// Damage on the bearer that makes them let go. +#define GORILLA_FINISHER_INTERRUPT 10 +/// Melee cover on the head that turns the head clap's burst into a crumpled helmet: #509's skull gate. +#define GORILLA_CLAP_HELMET_ARMOUR 30 +/// Melee cover on an arm that turns the limb rip into a dislocation. +#define GORILLA_RIP_ARMOUR 40 +/// The limb rip's look. Pixels the bearer steps in toward the body, and degrees he leans back from his feet as he +/// hauls (seen from the side). +#define GORILLA_RIP_LEAN 5 +#define GORILLA_RIP_LEAN_BACK 12 +/// How far his piston arms push out to take the wrist, as a share of their length. They hold it there as he hauls. +#define GORILLA_RIP_REACH 1.7 +/// The hauled arm, as a share of its length: the most it stretches to meet his fists, how much further the haul +/// pulls it, and the most it ever stretches. +#define GORILLA_RIP_MAX_STRETCH 1.8 +#define GORILLA_RIP_HAUL 0.35 +#define GORILLA_RIP_MAX_HAUL 2.1 +/// Seen from the side, degrees the arm comes up off the floor into his fists. +#define GORILLA_RIP_TILT 30 +/// The haul's steps, and the pixels the body comes up off the floor with the arm. +#define GORILLA_RIP_HAUL_STEPS 6 +#define GORILLA_RIP_LIFT 3 +/// Melee cover on the chest that turns the broken back into cracked ribs. +#define GORILLA_BACK_ARMOUR 40 +/// How long a broken back's fold stays drawn after the finisher, and how long it then takes to ease out flat. +#define GORILLA_FOLD_TIME (10 SECONDS) +#define GORILLA_FOLD_SETTLE (0.6 SECONDS) +/// The head clap's burst: one short line of blood this many tiles long. +#define GORILLA_CLAP_SPRAY_TILES 2 +/// How far the head clap throws the brain (the eyes, ears and tongue drop on the spot or a tile out). +#define GORILLA_CLAP_BRAIN_MIN 1 +#define GORILLA_CLAP_BRAIN_MAX 2 +/// The head clap's look. How far the held body is brought in toward the bearer (a share of a tile) and how many pixels +/// up it is held until the burst, seen from the side, the front and the back: close enough for his fists to close on its head. +#define GORILLA_CLAP_HOLD_SIDE 0.875 +#define GORILLA_CLAP_HOLD_FRONT 0.7 +#define GORILLA_CLAP_HOLD_BACK 0.85 +#define GORILLA_CLAP_LIFT_SIDE 1 +#define GORILLA_CLAP_LIFT_FRONT 15 +#define GORILLA_CLAP_LIFT_BACK 3 +/// The most his piston arms push out to reach the head, as a share of their length, and the pixels they come off it before the clap. +#define GORILLA_CLAP_MAX_REACH 2.1 +#define GORILLA_CLAP_WIND 3 +/// How long the burst body takes to fall from where it was held. +#define GORILLA_CLAP_DROP_TIME (0.2 SECONDS) +/// The head clap's hold: the fists work the head and the body hangs from it, so it goes wherever they take it. Pixels they shake +/// it side to side and lift it in the squeeze, lift it on for the toss, and drive it back down on the clap. Cut back wherever his +/// arms couldn't stay on the head. +#define GORILLA_CLAP_SHAKE 3 +#define GORILLA_CLAP_HEAVE 2 +#define GORILLA_CLAP_TOSS_LIFT 2 +#define GORILLA_CLAP_SLAM 2 +/// Degrees the hanging body swings behind its head for each pixel the head is shaken aside, and the most it swings. +#define GORILLA_CLAP_SWAY 2.5 +#define GORILLA_CLAP_MAX_SWAY 7 +/// The limb rip's haul: the most degrees the body turns up about its feet toward his fists as the arm is pulled, and how long +/// it takes to fall back flat when the arm gives. +#define GORILLA_RIP_RAISE 22 +#define GORILLA_RIP_FALL_TIME (0.25 SECONDS) +/// How long the let-go body takes to slide back to where tg lays it (tg's own offset slide). +#define GORILLA_RIP_LET_GO (0.2 SECONDS) +/// How long nothing moves at the start of the plant, while the puppets' pictures reach the client. The plant's own +/// 0.3 s still ends inside its stage. +#define GORILLA_RIP_START_HOLD (0.15 SECONDS) +/// Seen from behind, how long the torn arm takes to come down with his fist and hang beside him. +#define GORILLA_RIP_HANG_TIME (0.25 SECONDS) +/// The limb rip's blood: how many drops burst from the socket as the arm comes away, and the furthest (pixels) the pool +/// under the shoulder is laid from the middle of the body's tile. +#define GORILLA_RIP_BURST_DROPS 12 +#define GORILLA_RIP_POOL_REACH 10 +/// The limb rip's blow with the torn arm: up over his head, then down on theirs. The hit lands as it comes down. +#define GORILLA_CLUB_RAISE (0.2 SECONDS) +#define GORILLA_CLUB_STRIKE (0.1 SECONDS) +/// How long before the arm is all the way down the blow lands (recorded 0 to 0.12 s late without it). +#define GORILLA_CLUB_HIT_LEAD (0.05 SECONDS) +/// How far his arms push out on that blow, and the most the torn arm in his fists stretches to reach, as shares of their length. +#define GORILLA_CLUB_REACH 1.6 +#define GORILLA_CLUB_MAX_STRETCH 1.5 +/// Pixels toward the viewer a backbroken body lies on the bearer's tile, so he doesn't hide it, and the offset's source. +#define GORILLA_BACK_IN_FRONT 10 +#define GORILLA_LAID_IN_FRONT_OFFSET "gorilla_laid_in_front" +/// How long a broken back stays folded over the knee before it is rolled off. +#define GORILLA_BACK_FOLD_HOLD (0.3 SECONDS) +/// The shove off the knee onto the tile ahead: how long the body is in the air, and how many pixels it rises on the way. +#define GORILLA_BACK_SHOVE_TIME (0.45 SECONDS) +#define GORILLA_BACK_SHOVE_RISE 6 +/// How long the bearer's arms take to push out after it. +#define GORILLA_BACK_SHOVE_PUSH (0.12 SECONDS) + +// The landed piston punch, the haymaker's hit and the backbreaker's break (gorilla_punch.dm's sound procs). +/// A sound key (/datum/sound_effect/gorilla_piston_punch): one of three punches at random. +#define GORILLA_PUNCH_SOUND "gorilla_piston_punch" +#define GORILLA_HAYMAKER_SOUND 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_haymaker.ogg' +#define GORILLA_BACKBREAKER_SOUND 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg' + +/// Filter sources. +#define GORILLA_PUPPET_HIDE "gorilla_puppet_hide" diff --git a/voidcrew/_DEFINES/gun_feel.dm b/voidcrew/_DEFINES/gun_feel.dm new file mode 100644 index 00000000000..51798c3b82e --- /dev/null +++ b/voidcrew/_DEFINES/gun_feel.dm @@ -0,0 +1,164 @@ +// Tunables for voidcrew/modules/gun_feel: recoil kick, muzzle flash, flying casings, sounds and bullet speeds. + +// ---- Recoil kick ---- +// Kick strengths are in tiles of camera travel (1 = 32 px). Keep them well under 1. + +/// Random spread, in degrees, around "straight back" for each kick. +#define GUN_FEEL_RECOIL_DEVIATION 22.5 +/// Hard cap on any single kick, in tiles. +#define GUN_FEEL_MAX_KICK 1.5 +/// Tiles of kick added per point of a gun's upstream `recoil` var (nagant, sawn-offs, the sniper rifle...). +#define GUN_FEEL_LEGACY_RECOIL_KICK 0.15 +/// Animation tag for the kick, so a new shot supersedes the last kick instead of stacking on it. +#define GUN_FEEL_RECOIL_ANIM_TAG "gun_feel_recoil" + +// Gun classes, picked once per gun from its type and size. A subtype can preset `gun_feel_class`. +#define GUN_FEEL_CLASS_NONE "none" +#define GUN_FEEL_CLASS_PISTOL "pistol" +#define GUN_FEEL_CLASS_REVOLVER "revolver" +#define GUN_FEEL_CLASS_SMG "smg" +#define GUN_FEEL_CLASS_RIFLE "rifle" +#define GUN_FEEL_CLASS_SHOTGUN "shotgun" +#define GUN_FEEL_CLASS_MARKSMAN "marksman" +#define GUN_FEEL_CLASS_HEAVY "heavy" +#define GUN_FEEL_CLASS_LAUNCHER "launcher" +#define GUN_FEEL_CLASS_ENERGY "energy" +#define GUN_FEEL_CLASS_ENERGY_LONG "energy_long" + +// Camera kick per class, in tiles. +#define GUN_FEEL_KICK_PISTOL 0.12 +#define GUN_FEEL_KICK_REVOLVER 0.18 +#define GUN_FEEL_KICK_SMG 0.08 +#define GUN_FEEL_KICK_RIFLE 0.12 +#define GUN_FEEL_KICK_SHOTGUN 0.3 +#define GUN_FEEL_KICK_MARKSMAN 0.35 +#define GUN_FEEL_KICK_HEAVY 0.18 +#define GUN_FEEL_KICK_LAUNCHER 0.4 + +// ---- Muzzle flash ---- + +#define GUN_FEEL_FLASH_ICON 'icons/obj/weapons/guns/projectiles_muzzle.dmi' +#define GUN_FEEL_FLASH_ICON_STATE "muzzle_bullet" +/// How long the flash sprite shows. muzzle_bullet is two frames of 0.1s. +#define GUN_FEEL_FLASH_TIME (0.2 SECONDS) +/// How long the flash light stays on after the last shot. +#define GUN_FEEL_FLASH_LIGHT_TIME (0.3 SECONDS) +#define GUN_FEEL_FLASH_LIGHT_RANGE 3 +#define GUN_FEEL_FLASH_LIGHT_POWER 1.2 +#define GUN_FEEL_FLASH_LIGHT_COLOR COLOR_VERY_SOFT_YELLOW +// Where the barrel sits relative to the shooter's centre, in pixels, by aim direction. +#define GUN_FEEL_FLASH_REACH_SIDE 12 +#define GUN_FEEL_FLASH_REACH_UP 8 +#define GUN_FEEL_FLASH_REACH_DOWN 6 +#define GUN_FEEL_FLASH_HAND_HEIGHT -2 +/// The bright core of muzzle_bullet sits this many pixels behind the icon centre. +#define GUN_FEEL_FLASH_HOTSPOT 4 + +// ---- Flying casings ---- + +/// Most casings allowed in flight at once. Past this, casings drop the old way. +#define GUN_FEEL_CASING_CAP 60 +/// Degrees clockwise from the shooter's facing that casings leave at: out to the right and a little back. +#define GUN_FEEL_CASING_EJECT_ANGLE 110 +#define GUN_FEEL_CASING_EJECT_SCATTER 25 +/// Height, in pixels, a casing leaves the gun at. +#define GUN_FEEL_CASING_START_HEIGHT 8 +// Launch speeds in pixels per physics step (0.05 s). +#define GUN_FEEL_CASING_SPEED_MIN 2.5 +#define GUN_FEEL_CASING_SPEED_MAX 4 +#define GUN_FEEL_CASING_LIFT_MIN 2 +#define GUN_FEEL_CASING_LIFT_MAX 3.5 + +// ---- Movable physics (shared by anything using /datum/component/movable_physics) ---- +// Per physics step (0.05 s), in pixels. + +#define MOVABLE_PHYSICS_GRAVITY 0.75 +#define MOVABLE_PHYSICS_AIR_DRAG 0.03 +#define MOVABLE_PHYSICS_GROUND_FRICTION 0.45 +/// Share of vertical speed kept on a floor bounce. +#define MOVABLE_PHYSICS_RESTITUTION 0.4 +/// Share of horizontal speed kept on a floor bounce. +#define MOVABLE_PHYSICS_BOUNCE_KEEP 0.65 +/// Share of horizontal speed kept when glancing off a wall. +#define MOVABLE_PHYSICS_WALL_KEEP 0.5 +/// A bounce slower than this just settles. +#define MOVABLE_PHYSICS_MIN_BOUNCE 1.2 +/// Anything still moving after this long settles where it is. +#define MOVABLE_PHYSICS_TIMEOUT (3 SECONDS) + +// ---- Sounds ---- + +/// A random playback speed of 1 +- spread, as a multiplier of the recording's own rate. +/// BYOND reads a sound frequency between -100 and 100 as a multiplier, so this works whatever the file's sample rate. +#define GUN_FEEL_PITCH(spread) (1 + (rand() * 2 - 1) * (spread)) +/// Pitch spread for energy weapon shots. Powder guns play at their recorded pitch every time. +#define GUN_FEEL_ENERGY_PITCH_SPREAD 0.06 +/// Pitch spread for bullet impacts and near misses. +#define GUN_FEEL_IMPACT_PITCH_SPREAD 0.08 + +// Sound effect keys for /datum/sound_effect. +#define SFX_GUN_FEEL_BULLET_FLESH "gun_feel_bullet_flesh" +#define SFX_GUN_FEEL_BULLET_MISS "gun_feel_bullet_miss" +#define SFX_GUN_FEEL_HIT_METAL "gun_feel_hit_metal" +#define SFX_GUN_FEEL_HIT_GLASS "gun_feel_hit_glass" +#define SFX_GUN_FEEL_HIT_STONE "gun_feel_hit_stone" +#define SFX_GUN_FEEL_HIT_WOOD "gun_feel_hit_wood" +#define SFX_GUN_FEEL_HIT_SNOW "gun_feel_hit_snow" + +// What a bullet hit is made of, for its impact sound. +#define GUN_FEEL_SURFACE_FLESH "flesh" +#define GUN_FEEL_SURFACE_METAL "metal" +#define GUN_FEEL_SURFACE_GLASS "glass" +#define GUN_FEEL_SURFACE_STONE "stone" +#define GUN_FEEL_SURFACE_WOOD "wood" +#define GUN_FEEL_SURFACE_SNOW "snow" + +/// Volume of a bullet hitting flesh. Fixed, like Shiptest, instead of scaling with damage. +#define GUN_FEEL_FLESH_HIT_VOLUME 100 +/// Volume of a suppressed bullet hitting anything. Matches upstream vol_by_damage(). +#define GUN_FEEL_SUPPRESSED_HIT_VOLUME 5 +/// One thing hit by several pellets or bullets within this many deciseconds makes one impact sound. +#define GUN_FEEL_IMPACT_SOUND_WINDOW 1 + +// ---- Near misses ---- + +/// A bullet must be at least this many tiles from where it was fired before it whizzes past anyone. +#define GUN_FEEL_NEAR_MISS_MIN_TRAVEL 3 +#define GUN_FEEL_NEAR_MISS_VOLUME 70 +/// Flinch strength, in pixels, for a bullet passing close by. +#define GUN_FEEL_NEAR_MISS_FLINCH 1 +/// One mob hears at most one whizz in this window (a shotgun blast is one whizz, not six). +#define GUN_FEEL_NEAR_MISS_COOLDOWN (0.1 SECONDS) + +// ---- Hit flinch ---- + +/// Flinch strength, in pixels, is BASE + damage * PER_DAMAGE, rounded and capped at MAX. +#define GUN_FEEL_FLINCH_BASE 1 +#define GUN_FEEL_FLINCH_PER_DAMAGE 0.08 +#define GUN_FEEL_FLINCH_MAX 3 +/// Vertical flinch is this share of the horizontal one; a sprite reads sideways jolts better. +#define GUN_FEEL_FLINCH_VERTICAL 0.5 +#define GUN_FEEL_FLINCH_OUT_TIME (0.1 SECONDS) +#define GUN_FEEL_FLINCH_BACK_TIME (0.25 SECONDS) +/// No new flinch starts until the last one has played out, so automatic fire never stacks them. +#define GUN_FEEL_FLINCH_COOLDOWN (0.35 SECONDS) + +// ---- Resting casings ---- + +/// Casings on the floor lie at a random multiple of this angle. +#define GUN_FEEL_CASING_ROTATION_STEP 22.5 +/// Pixel scatter for a casing that lands on a floor without flying there. +#define GUN_FEEL_CASING_DROP_SCATTER 8 + +// Bullet speeds, in tiles per decisecond (tiles a second / 10). Shiptest's speeds, converted from its +// deciseconds-per-tile values (code/__DEFINES/projectiles.dm there). tg's default bullet does 1.25. +/// Pistols and revolvers: 25 tiles a second. +#define GUN_FEEL_SPEED_PISTOL 2.5 +/// SMGs, rifles and machine guns: 33 tiles a second. +#define GUN_FEEL_SPEED_RIFLE 3.33 +/// Buckshot, slugs and other shotgun rounds: 28.6 tiles a second. +#define GUN_FEEL_SPEED_SHOTGUN 2.86 +/// Rubber shot, a little slower like Shiptest's rubber modifier: 22 tiles a second. +#define GUN_FEEL_SPEED_SHOTGUN_RUBBER 2.22 +/// .50 sniper rounds: 50 tiles a second. +#define GUN_FEEL_SPEED_SNIPER 5 diff --git a/voidcrew/_DEFINES/human_npcs.dm b/voidcrew/_DEFINES/human_npcs.dm new file mode 100644 index 00000000000..f638a23ad71 --- /dev/null +++ b/voidcrew/_DEFINES/human_npcs.dm @@ -0,0 +1,163 @@ +// Human NPCs: real /mob/living/carbon/human bodies driven by an AI (voidcrew/modules/human_npcs/). +// Unit tests are included before voidcrew/_DEFINES, so nothing here is visible to them. + +/// Returns TRUE if `thing` is a human NPC body. +#define ishumannpc(thing) istype(thing, /mob/living/carbon/human/npc) + +// npc_rank: who may land hyperviolence aimed crits and gun executions (owner decision D4). +/// An ordinary NPC. Its hits never become aimed crits or executions. +#define HUMAN_NPC_RANK_REGULAR 0 +/// An elite NPC. Lands aimed crits and executions the way a player does. +#define HUMAN_NPC_RANK_ELITE 1 +/// A boss NPC. Lands aimed crits and executions the way a player does. +#define HUMAN_NPC_RANK_BOSS 2 + +/// On every item locked to a human NPC's body (human_npc_gear.dm). +#define TRAIT_HUMAN_NPC_GEAR "human_npc_gear" +/// On a consumable issued to a human NPC: it may leave the body, but never becomes loot (human_npc_gear.dm). +#define TRAIT_HUMAN_NPC_CONSUMABLE "human_npc_consumable" +/// On locked gear that went somewhere it may not be, and is deleted on the next tick unless it comes back. +#define TRAIT_HUMAN_NPC_GEAR_LOST "human_npc_gear_lost" + +/// Default for how far a human NPC spots hostiles, in tiles (BB_AGGRO_RANGE). +#define HUMAN_NPC_SIGHT_RANGE 9 + +/// Human NPCs the spawn queue makes per fire of SShuman_npcs. One human costs about 11 ms to make. +#define HUMAN_NPC_SPAWNS_PER_FIRE 1 +/// How often SShuman_npcs looks over the corpses it tracks. +#define HUMAN_NPC_CORPSE_SWEEP_INTERVAL (30 SECONDS) +/// Default time a human NPC corpse lies before it may be cleaned up (npc_corpse_lifetime). +#define HUMAN_NPC_CORPSE_LIFETIME (10 MINUTES) +/// A corpse or loose body part with a player this close is never cleaned up. +#define HUMAN_NPC_CORPSE_WATCH_RANGE 10 +/// Corpses deleted per fire at most, so a big sweep doesn't stall a tick. +#define HUMAN_NPC_CORPSE_DELETES_PER_FIRE 2 + +// Blackboard keys (human_npc_ai.dm). +/// Deciseconds between two pulls of the trigger. +#define BB_HUMAN_NPC_SHOT_DELAY "human_npc_shot_delay" +/// world.time the NPC may next pull the trigger. +#define BB_HUMAN_NPC_NEXT_SHOT "human_npc_next_shot" +/// Furthest a gunner shoots from, in tiles. Further than this it closes in. +#define BB_HUMAN_NPC_FIRE_RANGE "human_npc_fire_range" +/// A gunner steps back from a target closer than this, in tiles. 0 never steps back. +#define BB_HUMAN_NPC_KEEP_AWAY "human_npc_keep_away" +/// world.time the NPC may next try to reload or change weapons, after a try that got nowhere. +#define BB_HUMAN_NPC_ARMING_RETRY_AT "human_npc_arming_retry_at" +/// world.time a reload in progress gives up. +#define BB_HUMAN_NPC_RELOAD_UNTIL "human_npc_reload_until" +/// The item the NPC is drawing (human_npc_draw). +#define BB_HUMAN_NPC_DRAW_ITEM "human_npc_draw_item" + +// ===== Phase A2: shared pieces for pirates, ruin NPCs, hermits and criminals ===== + +// npc_target_stat: the worst state a target can be in and still get attacked (BB_TARGET_MINIMUM_STAT). +/// Pirates, troopers, hermits, raiders, NT customs: keep shooting someone in crit, as tg troopers do. +#define HUMAN_NPC_FINISHES_DOWNED HARD_CRIT +/// Criminals, companions, bosses, prisoners, guards: stop once a target is in crit. +#define HUMAN_NPC_SPARES_DOWNED CONSCIOUS + +// Bark kinds (human_npc_barks.dm). Families supply the lines through a /datum/human_npc_voice. +/// Entered crit. Whispered in soft crit; hard crit gets the emote only. +#define HUMAN_NPC_BARK_DOWNED "downed" +/// Left crit, or got up from stamcrit. +#define HUMAN_NPC_BARK_RECOVER "recover" +/// Now and then while it crawls in soft crit. Falls back to the downed lines. +#define HUMAN_NPC_BARK_PLEAD "plead" +/// Handcuffs went on. +#define HUMAN_NPC_BARK_CUFFED "cuffed" +/// Started working at its cuffs (tg's cuff_resist). +#define HUMAN_NPC_BARK_SLIPPING "slipping_cuffs" +/// Its cuffs came off, by its own work or someone else's. +#define HUMAN_NPC_BARK_UNCUFFED "uncuffed" +/// Broke out of a grab or off someone's shoulders by resisting. +#define HUMAN_NPC_BARK_PULLED_FREE "pulled_free" +/// Gave up (human_npc_surrender.dm). Always said. +#define HUMAN_NPC_BARK_SURRENDER "surrender" +/// Died. Emote only: the dead don't talk. +#define HUMAN_NPC_BARK_DEATH "death" +/// Default seconds between two barks from one NPC. +#define HUMAN_NPC_BARK_COOLDOWN (4 SECONDS) +/// How often an NPC crawling in soft crit pleads, at most. +#define HUMAN_NPC_PLEAD_INTERVAL (12 SECONDS) + +// Sites (human_npc_outfits.dm): what the air where an NPC is dressed asks of its kit. +/// Too little pressure to live in: a sealed suit and helmet and its own air. +#define HUMAN_NPC_SITE_VACUUM (1<<0) +/// Pressure is fine but the air isn't: a mask and a tank. +#define HUMAN_NPC_SITE_BAD_AIR (1<<1) +/// Cold enough to freeze an unprotected person: cold-weather wear. +#define HUMAN_NPC_SITE_COLD (1<<2) +/// Air colder than this, in kelvin, is a cold site. +#define HUMAN_NPC_SITE_COLD_TEMP (T0C - 10) +/// Oxygen partial pressure below this, in kPa, is not breathable (tg's safe minimum). +#define HUMAN_NPC_SITE_MIN_OXYGEN 16 +/// How much better than its lungs' limits (kPa) the air must be before an NPC on internals closes its valve. +#define HUMAN_NPC_AIR_MARGIN 2 +/// A queued NPC dressed by an outfit table waits this long for its spawn zone to resolve, then takes green. +#define HUMAN_NPC_ZONE_WAIT (60 SECONDS) + +// Leaving (human_npc_states.dm). +/// How often SShuman_npcs checks whether an NPC walking out may be deleted. +#define HUMAN_NPC_LEAVER_SWEEP_INTERVAL (1 SECONDS) + +// Signals sent on a human NPC. +/// It gave up: tg's *surrender is running. () +#define COMSIG_HUMAN_NPC_SURRENDERED "human_npc_surrendered" +/// Its surrender's paralysis ended and nobody had cuffed it. () +#define COMSIG_HUMAN_NPC_SURRENDER_ENDED "human_npc_surrender_ended" +/// It entered crit. () +#define COMSIG_HUMAN_NPC_DOWNED "human_npc_downed" +/// It left crit, or got up from stamcrit. () +#define COMSIG_HUMAN_NPC_RECOVERED "human_npc_recovered" +/// It walked out and is about to be deleted (human_npc_states.dm). () +#define COMSIG_HUMAN_NPC_LEFT "human_npc_left" + +// More blackboard keys. +/// A mob it is running from (surrender flight, soft-crit crawl). +#define BB_HUMAN_NPC_FLEE_FROM "human_npc_flee_from" +/// world.time it stops fleeing after deciding not to give up again. +#define BB_HUMAN_NPC_FLEE_UNTIL "human_npc_flee_until" +/// The item it is using on itself (human_npc_treat_self). +#define BB_HUMAN_NPC_TREAT_ITEM "human_npc_treat_item" +/// world.time it may next treat itself. +#define BB_HUMAN_NPC_NEXT_TREAT "human_npc_next_treat" +/// The turf it is walking out to (npc_walk_out()). +#define BB_HUMAN_NPC_EXIT "human_npc_exit" + +// ===== Combat sense (human_npc_combat_sense.dm) ===== +// Armed hostile NPCs that opt in (npc_combat_sense) inject a stimulant they carry when they tire, and +// stop standing in the open when they are hit by stun fire. Defaults for /datum/npc_combat_sense. + +/// Stamina loss, as a share of what stamcrits the body, at which it reaches for a stimulant it carries. +#define HUMAN_NPC_SENSE_STIM_SHARE 0.5 +/// How long after its last stun hit it keeps reacting to stun fire. +#define HUMAN_NPC_SENSE_REACT_TIME (8 SECONDS) +/// The furthest it walks to cover, in steps. +#define HUMAN_NPC_SENSE_COVER_STEPS 4 +/// How long it keeps its head down in cover before it looks out again. +#define HUMAN_NPC_SENSE_PEEK_AFTER (2.5 SECONDS) +/// The longest it stays in cover while it is winded (stamina loss past the stimulant share). +#define HUMAN_NPC_SENSE_MAX_HOLD (10 SECONDS) +/// How many times it runs for cover in one exchange of fire before it stands and fights. +#define HUMAN_NPC_SENSE_COVER_MOVES 3 +/// A fighter that hits from this many tiles or fewer closes in on a stun shooter instead of taking cover. +#define HUMAN_NPC_SENSE_CLOSE_IN_REACH 3 +/// It gives up on reaching its cover after this long per step of the way, plus a second. +#define HUMAN_NPC_SENSE_COVER_STEP_TIME (0.6 SECONDS) +/// How long it leaves its stimulants alone after a try that got nowhere (no hand free, the pen gone). +#define HUMAN_NPC_SENSE_STIM_RETRY (5 SECONDS) + +/// Blackboard: the cover turf it is walking to. +#define BB_HUMAN_NPC_COVER "human_npc_cover" +/// Blackboard: the stimulant pen it is using on itself. +#define BB_HUMAN_NPC_STIM_ITEM "human_npc_stim_item" +/// Blackboard: whoever shot it with stun fire, for the close-in. +#define BB_HUMAN_NPC_STUN_SHOOTER "human_npc_stun_shooter" + +/// Bark kind: it injects a stimulant it carries (faction pirate voices already have emotes for it). +#define HUMAN_NPC_BARK_STIMULANT "stimulant" +/// Bark kind: it runs for cover from stun fire. +#define HUMAN_NPC_BARK_TAKE_COVER "take_cover" +/// Bark kind: it charges whoever shot it with stun fire. +#define HUMAN_NPC_BARK_CHARGE "charge" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm new file mode 100644 index 00000000000..323d88097c0 --- /dev/null +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -0,0 +1,108 @@ +// Hyperviolence (voidcrew/modules/hyperviolence/): shared defines for the gore parts. + +/// A plain bold line in the combined hit message. +#define HV_LINE_BOLD 1 +/// A big bold-italic line, for severed limbs, burst heads and kills. +#define HV_LINE_HUGE 2 + +// ---- Stump and skull-wound overlays, gun executions (overlays_exec/) ---- + +/// Icon file for stump and skull-wound overlays (art from CM-SS13, CC BY-SA 3.0). +#define HV_GORE_ICON 'voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi' +/// Mob overlay height of a healed stump: under the jumpsuit (UNIFORM_LAYER 28), over the body. +/// Fractional so it never collides with a real overlays_standing index. +#define HV_STUMP_LAYER 28.5 +/// Mob overlay height of a fresh stump and the neck stump: over the jumpsuit, under ID, belt and suit. +#define HV_STUMP_FRESH_LAYER 27.5 +/// Mob overlay height of a skull wound: over hair (HAIR_LAYER 13), under masks and hats. +#define HV_SKULL_WOUND_LAYER 12.5 +/// How long a torn-off limb leaves a wet, bleeding stump before it reads as healed over. +#define HV_STUMP_FRESH_TIME (5 MINUTES) +/// Alpha of the blood layer on a healed-over stump. +#define HV_STUMP_OLD_ALPHA 140 + +/// Brute damage one hit must deal to a head to open a skull wound. +#define HV_SKULL_OPEN_HIT 20 +/// Head brute damage for the light, medium and heavy skull wound. Below the light value it closes. +#define HV_SKULL_LIGHT_BRUTE 20 +#define HV_SKULL_MEDIUM_BRUTE 45 +#define HV_SKULL_HEAVY_BRUTE 80 + +/// Wind-up before an execution shot. +#define HV_EXECUTION_WINDUP (1.5 SECONDS) +/// Multiplier on the damage of an execution round, fired with the barrel against the head. The round +/// deals it through tg's normal damage; nothing else is added. +#define HV_EXECUTION_POINT_BLANK_MULT 2 +/// Damage an execution round must deal to the head (after armour) to burst it instead of holing it. +#define HV_EXECUTION_BURST_DAMAGE 50 +/// Colour of the scorched rim round a laser execution's hole through the skull. +#define HV_BURN_HOLE_COLOR "#2a1b15" +/// The headshot art a burn hole is drawn with: a hole in and a hole out, no spatter. +#define HV_BURN_HOLE_STATE "medium_headshot" +/// A chambered round must deal at least this much to count as an execution round. +#define HV_EXECUTION_MIN_DAMAGE 10 +/// do_after interaction key for an execution wind-up. +#define DOAFTER_SOURCE_HV_EXECUTION "hv_execution" + +/// How long a dying body convulses (hv_death_throes(), _core.dm). +#define HV_DEATH_THROES_TIME (0.6 SECONDS) + +// Finishers (voidcrew/modules/hyperviolence/finishers/): a boss kills someone who is down, in stages. + +/// Blackboard key: the mob's finisher action (/datum/action/cooldown/mob_cooldown/hv_finisher) +#define BB_HV_FINISHER_ACTION "BB_hv_finisher_action" +/// Blackboard key: the downed victim the mob is closing on to finish +#define BB_HV_FINISHER_VICTIM "BB_hv_finisher_victim" +/// Offset source for a finisher's poses +#define HV_FINISHER_OFFSET "hv_finisher" + +/// Why a finisher ended. The killing blow landed and every stage ran. +#define HV_FINISHER_DONE 1 +/// The victim was pulled clear, or the attacker was moved off them. +#define HV_FINISHER_LOST_GRIP 2 +/// The attacker took enough damage to let go. +#define HV_FINISHER_DAMAGED 3 +/// The attacker died, was deleted or was knocked out. +#define HV_FINISHER_ATTACKER_DOWN 4 +/// The victim died or was deleted before the killing blow. +#define HV_FINISHER_VICTIM_GONE 5 +/// It ran past its longest possible length. A safety net, never the plan. +#define HV_FINISHER_TIMED_OUT 6 + +// Brain burst, gut spill and gut tether (voidcrew/modules/hyperviolence/braingut/). The guts come out with tg's chest organ spill, so they have no odds of their own. + +/// A blow must deal at least this much (after armour) before it can burst a downed victim's head instead of knocking it off whole. +#define HV_HEAD_BURST_MIN_DAMAGE 30 +/// The head must already be this far gone (fraction of its damage limit) before a blow can burst it. +#define HV_HEAD_BURST_MIN_HEAD_DAMAGE 0.5 +/// A burst head throws its brain at least this many tiles along the blow. +#define HV_BRAIN_FLING_MIN 1 +/// A burst head throws its brain at most this many tiles along the blow. +#define HV_BRAIN_FLING_MAX 3 +/// How many gib decals a burst head leaves, besides the core. +#define HV_HEAD_BURST_GIBS 4 +/// Percent chance that a heavy explosion bursts the head of a victim who is dead or in hard crit. +#define HV_HEAD_BURST_EXPLOSION_CHANCE 10 + +/// How far the guts can lie from their owner before the owner is dragged against them. +#define HV_GUT_TETHER_RANGE 3 +/// How far apart the guts and their owner can get before the tether tears. +#define HV_GUT_TETHER_SNAP_RANGE 7 +/// How long guts dragged in on the tether take to cross a tile. +#define HV_GUT_DRAG_STEP (0.1 SECONDS) +/// Minimum time between the pain of two tugs on the tether. +#define HV_GUT_YANK_COOLDOWN (2 SECONDS) +/// Brute damage to the chest from one tug on the tether. +#define HV_GUT_YANK_DAMAGE 4 + +// Aimed critical wounds (voidcrew/modules/hyperviolence/crits/). Each crit wound sits in its own +// series so it can stack with tg's ordinary bleeds on the same limb. The jaw and skull crits use +// tg's blunt bone series instead, since they are the worst head fractures. +/// A torn-out eye. +#define WOUND_SERIES_HV_EYE "wound_series_hv_eye" +/// A severed tongue. +#define WOUND_SERIES_HV_TONGUE "wound_series_hv_tongue" +/// A slit throat. +#define WOUND_SERIES_HV_CAROTID "wound_series_hv_carotid" +/// A torn aorta. +#define WOUND_SERIES_HV_AORTA "wound_series_hv_aorta" diff --git a/voidcrew/_DEFINES/keybindings.dm b/voidcrew/_DEFINES/keybindings.dm index 921f55734d9..90380ab18c9 100644 --- a/voidcrew/_DEFINES/keybindings.dm +++ b/voidcrew/_DEFINES/keybindings.dm @@ -1,2 +1,8 @@ //Carbon #define COMSIG_KB_CARBON_TAKESHIPPART_DOWN "keybinding_carbon_takeshippart_down" + +//Cyberware (voidcrew/modules/cyberware/chrome_wheel_keybindings.dm) +#define COMSIG_KB_LIVING_CHROME_WHEEL_DOWN "keybinding_living_chrome_wheel_down" +#define COMSIG_KB_LIVING_USE_CHROME_DOWN "keybinding_living_use_chrome_down" +#define COMSIG_KB_LIVING_USE_OS_CHROME_DOWN "keybinding_living_use_os_chrome_down" +#define COMSIG_KB_LIVING_DEPLOY_ARM_CHROME_DOWN "keybinding_living_deploy_arm_chrome_down" diff --git a/voidcrew/_DEFINES/mantis_gore.dm b/voidcrew/_DEFINES/mantis_gore.dm new file mode 100644 index 00000000000..de955bfb4a8 --- /dev/null +++ b/voidcrew/_DEFINES/mantis_gore.dm @@ -0,0 +1,177 @@ +// Mantis Blades x hyperviolence (voidcrew/modules/mantis_gore/). Balance notes for developers; none of +// these numbers go in player text. + +/// 32x32 pictures: glints, the coil glow, trails, the impale tips, the scorch seam. +#define MANTIS_GORE_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore.dmi' +/// 64x64 pictures: the flick. +#define MANTIS_GORE_LARGE_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore_large.dmi' +/// 96x96 pictures of the drops a torn artery throws, for each of the eight ways they fly. The wound is in the +/// middle across and MANTIS_ARTERY_WOUND_RISE pixels above the middle: the drops fall further than they rise. +#define MANTIS_ARTERY_ICON 'voidcrew/modules/mantis_gore/icons/mantis_artery.dmi' +#define MANTIS_ARTERY_WOUND_RISE 16 +/// 96x96 pictures centred on the bearer: the arms and blades of each beat of the impale and the scissor, drawn +/// around the victim by the blade rig (mantis_rig.dm). +#define MANTIS_GORE_REACH_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi' +/// Layer for blade drawn in front of the victim (and the bearer). +#define MANTIS_OVER_VICTIM_LAYER ABOVE_MOB_LAYER +/// The posed held blades. +#define MANTIS_LEFTHAND_ICON 'voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi' +#define MANTIS_RIGHTHAND_ICON 'voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi' +/// Layer for pictures on the blades: just over the held blades (cyberware_anim.dm's swing layer). +#define MANTIS_ANIM_BLADE_LAYER (-(HANDS_LAYER - 0.5)) +/// Layer for pictures over the whole body: over everything it wears. +#define MANTIS_ANIM_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) + +// What a blade of the line can do, on /obj/item/melee/energy/blade/hardlight/cyberware_mantis/var/gore_moves. +/// Repeated cuts on one limb tear its artery. +#define MANTIS_MOVE_ARTERY (1<<0) +/// A two-blade lunge skewers someone already hurt. +#define MANTIS_MOVE_IMPALE (1<<1) +/// A head swing at someone dying takes the head off. +#define MANTIS_MOVE_SCISSOR (1<<2) +/// Every third cut with both blades out is a crossed double slash. +#define MANTIS_MOVE_XSLASH (1<<3) +#define MANTIS_MOVES_ALL (MANTIS_MOVE_ARTERY|MANTIS_MOVE_IMPALE|MANTIS_MOVE_SCISSOR|MANTIS_MOVE_XSLASH) + +// Poses of the held blades. Each is an in-hand state "[base_icon_state]_[pose]". +#define MANTIS_POSE_GUARD "guard" +#define MANTIS_POSE_LUNGE "lunge" +#define MANTIS_POSE_RAISE "raise" +#define MANTIS_POSE_COIL "coil" + +// Fields of mantis_rig_beat(). +#define MANTIS_RIG_ORDER 1 +#define MANTIS_RIG_BEARER_W 2 +#define MANTIS_RIG_BEARER_Z 3 +#define MANTIS_RIG_TARGET_W 4 +#define MANTIS_RIG_TARGET_Z 5 +#define MANTIS_RIG_TILT 6 +#define MANTIS_RIG_RAISE 7 + +/// Trait and offset sources. +#define MANTIS_IMPALE_TRAIT "mantis_impale" +#define MANTIS_CHARGE_TRAIT "mantis_charge" + +/// The wound series of a torn limb artery, so it stacks with ordinary cuts on the same limb. +#define WOUND_SERIES_HV_LIMB_ARTERY "wound_series_hv_limb_artery" + +// ---- Arterial gashes ---- +/// Cuts on one limb add up while each lands within this long of the last. +#define MANTIS_CUT_WINDOW (8 SECONDS) +/// A cut must deal this much brute after armour to count. +#define MANTIS_CUT_MIN_DAMAGE 8 +/// Cuts that tear the artery on a limb covered by less than MANTIS_COVER_LIGHT (after armour penetration). +#define MANTIS_TEAR_CUTS_BARE 3 +/// Cuts that tear it under cover from MANTIS_COVER_LIGHT up to MANTIS_COVER_STOP. +#define MANTIS_TEAR_CUTS_COVERED 5 +#define MANTIS_COVER_LIGHT 15 +/// Cover at or above this never tears. +#define MANTIS_COVER_STOP 35 +/// Blood flow at or above which a torn limb artery spurts. +#define MANTIS_ARTERY_OPEN_FLOW 1.5 + +// ---- Kill credit and the lunge reset ---- +/// A death this long after the bearer's last cut counts as their kill. +#define MANTIS_KILL_CREDIT_WINDOW (5 SECONDS) +/// A kill puts the lunge's cooldown down to this, never lower. +#define MANTIS_KILL_RESET_FLOOR (3 SECONDS) +/// At most one reset per bearer in this long. +#define MANTIS_KILL_RESET_COOLDOWN (8 SECONDS) +/// A body that gave a reset gives no other for this long (revive loops). +#define MANTIS_KILL_RESET_VICTIM_LOCKOUT (2 MINUTES) +/// Anything that isn't a person must have at least this much max health to count. +#define MANTIS_KILL_RESET_MIN_HEALTH 100 + +// ---- X-slash ---- +/// Every this many landed cuts on one target is a crossed double slash. +#define MANTIS_XSLASH_EVERY 3 +/// The count holds while each cut lands within this long of the last. +#define MANTIS_XSLASH_WINDOW (4 SECONDS) +#define MANTIS_XSLASH_STAGGER (1.5 SECONDS) + +// ---- Impaling lunge ---- +#define MANTIS_IMPALE_HOLD (0.8 SECONDS) +#define MANTIS_IMPALE_PIN_HOLD (1.5 SECONDS) +/// Brute per blade when they rip free. +#define MANTIS_IMPALE_RIP_DAMAGE 8 +#define MANTIS_IMPALE_RIP_WOUND_BONUS 15 +/// Damage the bearer can take while holding someone up before they lose the grip. +#define MANTIS_IMPALE_BREAK_DAMAGE 15 +/// A target at or under this share of max health is hurt enough to skewer. +#define MANTIS_IMPALE_HEALTH_FRACTION 0.7 +// The beats of the impale (mantis_impale.dm), from the approved storyboard. The bearer's lunge pose shows +// while the lunging body glides in; the blades go in as it lands, and the hold, the pull-back, the rip and +// the drop follow. Where the two bodies sit in each beat is mantis_rig_beat()'s. +/// The victim sliding onto the blades, after the lunging body lands. +#define MANTIS_IMPALE_CONTACT_BEAT (0.05 SECONDS) +/// From the blades going in to the hoist, held up. Short, so the hold fills the 0.8 s. +#define MANTIS_IMPALE_ENTER_BEAT (0.1 SECONDS) +/// Each step of the hoist from the stab up to the hold, and of the pull-back. +#define MANTIS_IMPALE_STEP (0.05 SECONDS) +#define MANTIS_IMPALE_PULL_STEP (0.1 SECONDS) +/// Held up they sag a pixel this long into the hold, for this long, and once more this long after the first. +#define MANTIS_IMPALE_SAG_AFTER (0.15 SECONDS) +#define MANTIS_IMPALE_SAG_FOR (0.1 SECONDS) +/// Facing south, how high (pixels) the victim is drawn up onto the blades before the bearer's body rises with them, +/// a pixel for a pixel, so the bearer's head and shoulders show over the victim's head. +#define MANTIS_IMPALE_SOUTH_RISE_FROM 14 +#define MANTIS_IMPALE_SAG_EVERY (0.2 SECONDS) +/// From the pull-back to the rip-out. +#define MANTIS_IMPALE_RIP_BEAT (0.2 SECONDS) +/// How long the bearer holds the last pose after the blades are out, before they are drawn as usual again, +/// and how long the body takes to fall from the blades to the floor. +#define MANTIS_IMPALE_DROP_BEAT (0.3 SECONDS) +/// From the pull-back to the blades coming out and the body dropping. +#define MANTIS_IMPALE_PULL_BEAT (0.3 SECONDS) + +/// Pixels under a body's middle that it is tipped about: its feet (mantis_tip_matrix()). +#define MANTIS_TIP_FEET (ICON_SIZE_Y / 2 - 4) + +/// How long the lunging body takes to glide in after it has arrived. The hit shows as it lands. +#define MANTIS_LUNGE_GLIDE 1.5 +/// How long the blade rig's pictures take to fade into the real bearer when a move ends (mantis_rig.dm). +#define MANTIS_RIG_HANDBACK (0.15 SECONDS) + +// ---- Charged leap ---- +/// Held shorter than this, it is the ordinary lunge. +#define MANTIS_CHARGE_TAP (0.35 SECONDS) +/// Held this long, it reaches the furthest. +#define MANTIS_CHARGE_FULL (1.2 SECONDS) +/// Pixels the body sinks into the crouch. +#define MANTIS_CHARGE_CROUCH_DROP 5 +/// Held this long, the bearer gives up and straightens. +#define MANTIS_CHARGE_MAX_HOLD (3 SECONDS) +/// After straightening, this long before they can crouch again. +#define MANTIS_CHARGE_REARM (1 SECONDS) +/// One hit this big while crouched breaks it. +#define MANTIS_CHARGE_BREAK_DAMAGE 10 +/// Extra tiles a full charge reaches over the lunge. +#define MANTIS_CHARGE_EXTRA_RANGE 2 + +// ---- Scissor ---- +#define MANTIS_SCISSOR_WINDUP (1.2 SECONDS) +/// Pixels the bearer leans in for the cut, and further when the head lies on the far side of the body. +/// Drawn by the blade rig; this is the bearer's own lean when the rig can't draw them. +#define MANTIS_SCISSOR_LEAN 12 +#define MANTIS_SCISSOR_FAR_LEAN 16 +/// The scissor's swing: the blades come down from the raise, close on the neck, and shut through it this long apart. +#define MANTIS_SCISSOR_SWING_STEP (0.1 SECONDS) +/// The scissor's beats after the cut: the blades shut through the neck, then the bearer straightens over +/// MANTIS_SCISSOR_STRAIGHTEN. +#define MANTIS_SCISSOR_RECOVER_BEAT (0.25 SECONDS) +#define MANTIS_SCISSOR_STRAIGHTEN (0.25 SECONDS) +#define MANTIS_SCISSOR_COOLDOWN (10 SECONDS) +/// Brute to the neck when the head won't come off. +#define MANTIS_SCISSOR_FALLBACK_DAMAGE 25 +/// Where a head cut off by the scissor is drawn on its tile: pixels out from the middle toward the side the +/// head lay, where the neck was, then where it comes to rest, and how far below the middle. +#define MANTIS_HEAD_ROLL_FROM 7 +#define MANTIS_HEAD_ROLL_TO 11 +#define MANTIS_HEAD_ROLL_DROP 5 + +// ---- Blood on the blades ---- +/// How long a drenched blade drips. +#define MANTIS_DRENCH_TIME (60 SECONDS) +#define MANTIS_DRIP_INTERVAL (1.5 SECONDS) +/// Most drops one drench leaves on the floor. +#define MANTIS_DRIP_MAX 20 diff --git a/voidcrew/_DEFINES/missions.dm b/voidcrew/_DEFINES/missions.dm index 5f3388d0b53..add1428d01a 100644 --- a/voidcrew/_DEFINES/missions.dm +++ b/voidcrew/_DEFINES/missions.dm @@ -97,3 +97,10 @@ /obj/effect/zone_mobs/undead = 3,\ /obj/effect/zone_mobs/bug = 3,\ ) + +/// zone_mobs wave arrivals: a drop pod lands the mob +#define ZONE_MOBS_ARRIVE_POD "pod" +/// zone_mobs wave arrivals: the mob digs up out of the ground +#define ZONE_MOBS_ARRIVE_EMERGE "emerge" +/// How far a wave's arrivals scatter from the spot that called them +#define ZONE_MOBS_WAVE_SCATTER 4 diff --git a/voidcrew/_DEFINES/outpost_prison_boss_moves.dm b/voidcrew/_DEFINES/outpost_prison_boss_moves.dm index 906ab86a8cd..82ad61d7f76 100644 --- a/voidcrew/_DEFINES/outpost_prison_boss_moves.dm +++ b/voidcrew/_DEFINES/outpost_prison_boss_moves.dm @@ -59,13 +59,12 @@ /// Cuffed this long before they start on the cuffs #define PRISON_BOSS_SLIP_DELAY (3 SECONDS) -/// Working at the cuffs +/// The telegraph before they work at the cuffs (tg breakout time follows) #define PRISON_BOSS_SLIP_TIME (5 SECONDS) /// Stopped before they were free: they may try again after this #define PRISON_BOSS_SLIP_RETRY (15 SECONDS) -/// The shimmer afterwards (field cloak: 6 seconds, half of all hits miss) +/// The shimmer afterwards: a look only; a Ghost dodges with real chrome #define PRISON_BOSS_SHIMMER_TIME (6 SECONDS) -#define PRISON_BOSS_SHIMMER_MISS 50 #define PRISON_BOSS_SHIMMER_ALPHA 90 // ===== HEAVY: A BARRICADE ===== diff --git a/voidcrew/_DEFINES/outpost_prison_bounty.dm b/voidcrew/_DEFINES/outpost_prison_bounty.dm index 96787c78a25..053ff4c8ba6 100644 --- a/voidcrew/_DEFINES/outpost_prison_bounty.dm +++ b/voidcrew/_DEFINES/outpost_prison_bounty.dm @@ -43,23 +43,15 @@ // ===== BODY ===== -/// A bounty prisoner's health, by tier. The stamina pool follows it. -#define BOUNTY_PRISONER_HEALTH_PETTY 100 -#define BOUNTY_PRISONER_HEALTH_WANTED 120 -#define BOUNTY_PRISONER_HEALTH_MOST_WANTED 150 +/// How much of a blow a bounty prisoner takes, by tier (human NPC npc_damage_taken, through physiology; defaults PR-16) +#define BOUNTY_PRISONER_DAMAGE_TAKEN_PETTY 1 +#define BOUNTY_PRISONER_DAMAGE_TAKEN_WANTED 0.9 +#define BOUNTY_PRISONER_DAMAGE_TAKEN_MOST_WANTED 0.8 /// A bounty prisoner arrives hurt as they were caught, but with at least this percent of their health (spec 12.3 C11): downed deliveries otherwise crash wings. #define BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH 50 // ===== DANGER, BY TIER ===== -/// Multiplier on their punches -#define BOUNTY_PRISON_PUNCH_MULT_PETTY 1 -#define BOUNTY_PRISON_PUNCH_MULT_WANTED 1.25 -#define BOUNTY_PRISON_PUNCH_MULT_MOST_WANTED 1.5 -/// Multiplier on their shiv blows -#define BOUNTY_PRISON_SHIV_MULT_PETTY 1 -#define BOUNTY_PRISON_SHIV_MULT_WANTED 1.15 -#define BOUNTY_PRISON_SHIV_MULT_MOST_WANTED 1.3 /// Multiplier on their mood losses #define BOUNTY_PRISON_MOOD_SCALE_PETTY 1 #define BOUNTY_PRISON_MOOD_SCALE_WANTED 1.1 @@ -76,10 +68,6 @@ #define BOUNTY_PRISON_BREAKOUT_MULT_PETTY 1 #define BOUNTY_PRISON_BREAKOUT_MULT_WANTED 1.25 #define BOUNTY_PRISON_BREAKOUT_MULT_MOST_WANTED 1.5 -/// Multiplier on how long cuffing them takes -#define BOUNTY_PRISON_CUFF_MULT_PETTY 1 -#define BOUNTY_PRISON_CUFF_MULT_WANTED 1 -#define BOUNTY_PRISON_CUFF_MULT_MOST_WANTED 1 /// How much likelier a Wanted or Most Wanted prisoner who is not meek is to hit back when hit (like a grumpy one) #define BOUNTY_PRISON_HIT_BACK_MULT_WANTED 1.5 #define BOUNTY_PRISON_HIT_BACK_MULT_MOST_WANTED 1.5 @@ -112,5 +100,3 @@ #define BOUNTY_PRISON_MEEK_DART_STEP 2 /// Steps a dart takes at most, waits for the door to swing open or for someone to get out of the way included, before they give up on it #define BOUNTY_PRISON_MEEK_DART_STEPS 10 -/// Their speed while loose (others keep their own, 2) -#define BOUNTY_PRISON_MEEK_LOOSE_SPEED 1.6 diff --git a/voidcrew/_DEFINES/outpost_prison_changeling.dm b/voidcrew/_DEFINES/outpost_prison_changeling.dm index 85ef0049b44..09f9a24ca23 100644 --- a/voidcrew/_DEFINES/outpost_prison_changeling.dm +++ b/voidcrew/_DEFINES/outpost_prison_changeling.dm @@ -20,6 +20,8 @@ #define OUTPOST_CHANGELING_EARLY_BURST 3 /// Seconds a host may spend out of the cell block before the specimen is lost #define OUTPOST_CHANGELING_HOST_LOST_AFTER 60 +/// A host with a body, down on their bunk, is knocked flat this long by each heave of the specimen +#define OUTPOST_CHANGELING_HOST_KNOCKDOWN (6 SECONDS) /// The host's sentence is held at least this long, so they are not released mid-incubation #define OUTPOST_CHANGELING_HOST_SENTENCE_HOLD 600 diff --git a/voidcrew/_DEFINES/outpost_prison_conditions.dm b/voidcrew/_DEFINES/outpost_prison_conditions.dm index c60ed91ded9..257ee6f7797 100644 --- a/voidcrew/_DEFINES/outpost_prison_conditions.dm +++ b/voidcrew/_DEFINES/outpost_prison_conditions.dm @@ -66,9 +66,6 @@ #define PRISON_RAT_AFTER (3 MINUTES) #define PRISON_RAT_CHANCE 20 #define PRISON_RAT_MAX 2 -/// Brute per 5 seconds on unsafe air, never below PRISON_AIR_HEALTH_FLOOR percent health -#define PRISON_AIR_DAMAGE 1 -#define PRISON_AIR_HEALTH_FLOOR 30 /// Unsafe air: pressure (kPa), temperature (K) and plasma (mol) limits #define PRISON_AIR_PRESSURE_MIN 60 #define PRISON_AIR_PRESSURE_MAX 150 diff --git a/voidcrew/_DEFINES/outpost_prison_experiments.dm b/voidcrew/_DEFINES/outpost_prison_experiments.dm index 44cc40a73c0..32cf9e414f1 100644 --- a/voidcrew/_DEFINES/outpost_prison_experiments.dm +++ b/voidcrew/_DEFINES/outpost_prison_experiments.dm @@ -131,74 +131,50 @@ /// How long a rioter who runs has to get away before they riot on #define OUTPOST_PANIC_RETREAT_TIME (15 SECONDS) -// ----- Creatures: health grows with the crew on the level when they appear ----- -/// Most extra players counted -#define OUTPOST_EXPERIMENT_EXTRA_PLAYERS_MAX 3 +// ----- The serum's forms (outpost_prison_experiment_forms.dm) ----- +// The subject's own body changed by tg's genes and species. Their toughness is physiology +// (npc_damage_taken: brute and burn taken as a multiple of a person's), never health, and never +// scaled by who is on the level. -// Hulk -#define OUTPOST_HULK_HEALTH 350 -#define OUTPOST_HULK_HEALTH_PER_PLAYER 100 -#define OUTPOST_HULK_SPEED 1.8 -#define OUTPOST_HULK_EXHAUSTED_SPEED 2.5 -#define OUTPOST_HULK_PUNCH_MIN 18 -#define OUTPOST_HULK_PUNCH_MAX 22 -#define OUTPOST_HULK_PUNCH_COOLDOWN (1.5 SECONDS) -/// Percent chance a punch throws its target OUTPOST_HULK_THROW_RANGE tiles +/// How Kessler finds a creature it comes for (outpost_experiment_down_state()): dead; alive on the +/// floor and held (cuffed or asleep), which pays the subdued rate; or on the floor or held but not both +#define OUTPOST_EXPERIMENT_DOWN_DEAD "dead" +#define OUTPOST_EXPERIMENT_DOWN_SUBDUED "subdued" +#define OUTPOST_EXPERIMENT_DOWN_DOWN "down" +/// Units of chloral hydrate in a Kessler agent's dart (owner ruling OC-3) +#define OUTPOST_KESSLER_DART_DOSE 15 + +// Hulk: tg's hulk gene and gigantism +#define OUTPOST_HULK_DAMAGE_TAKEN 0.6 +/// Percent chance a punch that lands throws someone OUTPOST_HULK_THROW_RANGE tiles; a standing prisoner is knocked down instead #define OUTPOST_HULK_THROW_CHANCE 25 #define OUTPOST_HULK_THROW_RANGE 2 +#define OUTPOST_HULK_KNOCKDOWN (2 SECONDS) /// The charge: wind-up, range, damage, knockdown, cooldown #define OUTPOST_HULK_CHARGE_WINDUP (1.5 SECONDS) #define OUTPOST_HULK_CHARGE_RANGE 6 #define OUTPOST_HULK_CHARGE_DAMAGE 20 #define OUTPOST_HULK_CHARGE_KNOCKDOWN (2 SECONDS) #define OUTPOST_HULK_CHARGE_COOLDOWN (12 SECONDS) -/// Charging into a wall with nobody caught: stunned this long, taking this much more damage meanwhile +/// Charging into a wall with nobody caught: stunned this long #define OUTPOST_HULK_WALL_STUN (2 SECONDS) -#define OUTPOST_HULK_WALL_VULNERABLE 1.25 -/// Tearing through an interior wall, and forcing a door -#define OUTPOST_HULK_TEAR_TIME (3 SECONDS) +/// Forcing a door #define OUTPOST_HULK_DOOR_TIME (4 SECONDS) -/// Worn down to this percent of health, it stops charging and a baton or disabler can put it down -#define OUTPOST_HULK_EXHAUSTED_AT 25 -/// Stamina it can take once exhausted: two baton hits or four disabler shots -#define OUTPOST_HULK_STAMINA 110 -// Nightmare -#define OUTPOST_NIGHTMARE_HEALTH 225 -#define OUTPOST_NIGHTMARE_HEALTH_PER_PLAYER 75 -#define OUTPOST_NIGHTMARE_DARK_SPEED 1.5 -#define OUTPOST_NIGHTMARE_LIGHT_SPEED 2.5 -#define OUTPOST_NIGHTMARE_DAMAGE 20 -#define OUTPOST_NIGHTMARE_ATTACK_COOLDOWN (1.2 SECONDS) +// Nightmare: tg's nightmare species and organs +#define OUTPOST_NIGHTMARE_DAMAGE_TAKEN 0.8 #define OUTPOST_NIGHTMARE_ARMOUR_PENETRATION 20 -/// Percent chance a hit burns out a flashlight the target holds (never flares or glowsticks) -#define OUTPOST_NIGHTMARE_BURNOUT_CHANCE 35 -/// Light level below which it is in the dark (SHADOW_SPECIES_LIGHT_THRESHOLD) -#define OUTPOST_NIGHTMARE_DARK 0.2 -/// Health per second: regained in the dark, lost in the light -#define OUTPOST_NIGHTMARE_REGEN 2 -#define OUTPOST_NIGHTMARE_BURN 2 -/// Percent of shots it sidesteps in the dark -#define OUTPOST_NIGHTMARE_DODGE 60 /// The shadow jaunt: cooldown, the ripple that warns of it, what it does #define OUTPOST_NIGHTMARE_JAUNT_COOLDOWN (12 SECONDS) #define OUTPOST_NIGHTMARE_JAUNT_RIPPLE (1 SECONDS) #define OUTPOST_NIGHTMARE_JAUNT_DAMAGE 25 #define OUTPOST_NIGHTMARE_JAUNT_KNOCKDOWN (1 SECONDS) -/// A handheld flash: burn and stagger -#define OUTPOST_NIGHTMARE_FLASH_DAMAGE 20 -#define OUTPOST_NIGHTMARE_FLASH_STAGGER (2 SECONDS) /// Seconds it spends breaking the lights first, ignoring people who leave it alone #define OUTPOST_NIGHTMARE_OPENING_MIN 30 #define OUTPOST_NIGHTMARE_OPENING_MAX 45 -// Fly person -#define OUTPOST_FLY_HEALTH 60 -/// Its move delay: faster than a running person (1.5) -#define OUTPOST_FLY_SPEED 0.7 -/// Percent of shots it jinks out of the way of while flying freely (not knocked down, held or subdued) -#define OUTPOST_FLY_DODGE 40 -#define OUTPOST_FLY_BITE 5 +// Fly person: tg's fly species and a Slipwire implant +#define OUTPOST_FLY_DAMAGE_TAKEN 1 /// It darts away from anyone this close #define OUTPOST_FLY_FLEE_RANGE 3 /// Its darts: tiles, the pause between random ones, and how long one may take before it gives up on it @@ -209,16 +185,6 @@ #define OUTPOST_FLY_DART_TIMEOUT (1.5 SECONDS) /// How long it keeps away from whoever last hit it #define OUTPOST_FLY_FLIT_TIME (3 SECONDS) -/// Trait source for its flight -#define OUTPOST_FLY_TRAIT "outpost_fly" -/// Seconds between throwing up, and the percent chance it gets someone beside it -#define OUTPOST_FLY_VOMIT_MIN 6 -#define OUTPOST_FLY_VOMIT_MAX 10 -#define OUTPOST_FLY_VOMIT_ON_CHANCE 25 -/// A flyswatter hits it this many times harder, as it does tg's fly people -#define OUTPOST_FLY_SWATTER_MULT 30 -/// Stamina it can take: two baton hits -#define OUTPOST_FLY_STAMINA 100 // ----- Trait sources and timings shared by the experiment files ----- /// Seconds a creature may chase its current target with no progress (not closer, not adjacent) @@ -236,5 +202,10 @@ #define OUTPOST_KESSLER_TEAM_TIME (5 SECONDS) /// How long a Kessler beam takes, in and out #define OUTPOST_KESSLER_BEAM_TIME (3 SECONDS) +/// From a real hit on Kessler staff to Kessler's beam taking them out ("I'm leaving!") +#define OUTPOST_KESSLER_ASSAULT_BEAM_DELAY (2 SECONDS) +/// Seconds before Kessler sends anyone again after its researcher was killed on a visit +#define OUTPOST_KESSLER_KILLED_WAIT_MIN (90 * 60) +#define OUTPOST_KESSLER_KILLED_WAIT_MAX (120 * 60) /// The creatures' sentience type: it matches no potion, lazarus injector or ghost role (the spec's SENTIENCE_NONE) #define OUTPOST_EXPERIMENT_NO_SENTIENCE 0 diff --git a/voidcrew/_DEFINES/outpost_prison_guards.dm b/voidcrew/_DEFINES/outpost_prison_guards.dm index e5018024dee..b73c94b41f4 100644 --- a/voidcrew/_DEFINES/outpost_prison_guards.dm +++ b/voidcrew/_DEFINES/outpost_prison_guards.dm @@ -10,15 +10,16 @@ #define OUTPOST_GUARD_WAGE 2 /// Wages skipped in a row (the treasury could not cover them) before the guards walk off #define OUTPOST_GUARD_UNPAID_LEAVE 2 -#define OUTPOST_GUARD_HEALTH 120 -/// Health at which a guard goes down (godmode, lying) instead of taking more damage -#define OUTPOST_GUARD_DOWN_AT 30 -/// From going down to beaming out +/// From going into crit to the corrections service's medevac of the body #define OUTPOST_GUARD_RECALL_DELAY (10 SECONDS) +/// From a guard's death to the corrections service collecting the body +#define OUTPOST_GUARD_BODY_COLLECT_DELAY (60 SECONDS) +/// A guard whose baton went flat is back with a charged one after this +#define OUTPOST_GUARD_SHIFT_SWAP_TIME (1 MINUTES) +/// A guard with this much brute and burn damage or more patches up from the office first-aid kit when nothing is going on +#define OUTPOST_GUARD_TREAT_ABOVE 20 /// A downed or recalled guard comes back free after this, if still hired and a member is home #define OUTPOST_GUARD_RETURN_TIME (10 MINUTES) -/// Stamina damage per baton strike: prisoners crit at 100, so three strikes -#define OUTPOST_GUARD_BATON_STAMINA 35 #define OUTPOST_GUARD_BATON_COOLDOWN (2 SECONDS) /// Percent chance a guard arriving at an argument ends it with words #define OUTPOST_GUARD_TALKDOWN_CHANCE 60 @@ -56,11 +57,6 @@ #define OUTPOST_GUARD_SPAT_VIEW 7 /// How close to a member in the cell block a guard stays during a riot #define OUTPOST_GUARD_FOLLOW_RANGE 2 -/// Health a guard gets back per minute while not in a riot and not down -#define OUTPOST_GUARD_HEAL_PER_MINUTE 12 -/// Movement: an unhurried walk on routine, a hurry on a response (basic mob speed) -#define OUTPOST_GUARD_SPEED_WALK 2 -#define OUTPOST_GUARD_SPEED_HURRY 1 /// How long a prisoner is left alone after a guard checked on them or they greeted a guard #define OUTPOST_GUARD_CHECK_ON_GAP (5 MINUTES) @@ -97,5 +93,5 @@ /// Trait source for the beam holding a guard still #define OUTPOST_GUARD_BEAM_TRAIT "outpost_guard_beam" -/// Trait source for a guard lying down, out of it -#define OUTPOST_GUARD_DOWN_TRAIT "outpost_guard_down" +/// Trait source for what a guard on their feet refuses (lockers, bags) +#define OUTPOST_GUARD_UPRIGHT_TRAIT "outpost_guard_upright" diff --git a/voidcrew/_DEFINES/outpost_prison_needs.dm b/voidcrew/_DEFINES/outpost_prison_needs.dm index a75776cdf87..fc0e603b607 100644 --- a/voidcrew/_DEFINES/outpost_prison_needs.dm +++ b/voidcrew/_DEFINES/outpost_prison_needs.dm @@ -17,8 +17,6 @@ #define PRISONER_INJURED_BELOW 90 /// Below this health percent an untreated prisoner leaves the odd drip of blood #define PRISONER_BLEED_BELOW 50 -/// How long stamina crit holds after the last stamina hit, long enough to drag one to a cell -#define PRISONER_STAMCRIT_TIME (20 SECONDS) /// Percent chance per minute that a prisoner drops some mess #define OUTPOST_PRISON_MESS_CHANCE 4 /// Pause between spontaneous prisoner lines anywhere in one wing (deciseconds) @@ -37,7 +35,7 @@ #define PRISONER_MOOD_CLEAN_UNIFORM 8 #define PRISONER_MOOD_TREATED 8 -// Where a prisoner is in their stay (/mob/living/basic/outpost_prisoner/var/phase) +// Where a prisoner is in their stay (/mob/living/carbon/human/npc/outpost_prisoner/var/phase) /// Being beamed into their cell #define PRISONER_ARRIVING "arriving" /// In the wing, serving their sentence @@ -45,7 +43,7 @@ /// Being beamed out, released or collected #define PRISONER_LEAVING "leaving" -// What /mob/living/basic/outpost_prisoner/proc/try_reach() found +// What /mob/living/carbon/human/npc/outpost_prisoner/proc/try_reach() found #define PRISONER_REACH_FAILED 0 #define PRISONER_REACH_OK 1 /// Their side of a serving hatch is opening diff --git a/voidcrew/_DEFINES/outpost_prison_trouble.dm b/voidcrew/_DEFINES/outpost_prison_trouble.dm index f8a4942d809..7b102d3be93 100644 --- a/voidcrew/_DEFINES/outpost_prison_trouble.dm +++ b/voidcrew/_DEFINES/outpost_prison_trouble.dm @@ -77,22 +77,6 @@ #define PRISONER_THREAT_COOLDOWN 20 /// Seconds a decided swing waits for them to close in before it is dropped #define PRISONER_SWING_TIMEOUT 6 -/// Brute damage of a punch at staff: blunt, and it never wounds -#define PRISONER_PUNCH_MIN 5 -#define PRISONER_PUNCH_MAX 8 -/// Brute damage of a shiv at staff or anyone else who is not a prisoner (tg's glass shiv is force 8). -/// Each blow is a stab or a slash, so it can leave a puncture or a cut that bleeds. -#define PRISONER_SHIV_MIN 10 -#define PRISONER_SHIV_MAX 15 -/// The shiv's wounding, as tg's glass shiv has it in someone's hand. Four blows in five land on the -/// chest, which resists wounds; the rest on an arm, a leg or now and then the head (tg's -/// attack_zone_randomiser). On a bare body about two blows in five wound. An armour vest takes a -/// third off chest blows and stops nearly every wound there, leaving about one in five. -#define PRISONER_SHIV_WOUND_BONUS 5 -#define PRISONER_SHIV_EXPOSED_WOUND_BONUS 15 -/// Brute damage of a shiv at another prisoner (a stabbing, outpost_prison_incidents.dm), never a killing blow -#define PRISONER_STAB_MIN 7 -#define PRISONER_STAB_MAX 10 // A player's blow on a prisoner on their feet: they hit back, or back off /// Seconds they go after the attacker before letting it drop @@ -139,10 +123,7 @@ #define PRISONER_FIGHT_COOLDOWN 300 /// Seconds between any two fights in the wing #define PRISON_FIGHT_GAP 180 -/// Brute of a blow in a fight between prisoners -#define PRISONER_FIGHT_HIT_MIN 3 -#define PRISONER_FIGHT_HIT_MAX 6 -/// Health percent at which a fighter yields, and seconds they stay down +/// Health percent at which a losing fighter gives up, and seconds they then stay lying down by choice #define PRISONER_FIGHT_YIELD 40 #define PRISONER_FIGHT_YIELD_TIME 20 /// Below this mood two prisoners near each other may argue without blows, at most once per PRISON_SPAT_GAP in the wing @@ -152,11 +133,8 @@ #define PRISON_SPAT_CHANCE 8 // Beaten down -/// At or below this health percent a prisoner collapses -#define PRISONER_BEATEN_BELOW 15 -/// Seconds they lie beaten, unless treated above PRISONER_RECOVER_ABOVE percent first -#define PRISONER_BEATEN_TIME 120 -#define PRISONER_RECOVER_ABOVE 40 +/// How long something that slams a prisoner on their feet to the floor knocks them down (collapse(), a real tg Knockdown) +#define PRISONER_COLLAPSE_KNOCKDOWN (4 SECONDS) // Riots /// Below these moods each personality joins a riot; if nobody does, the unhappiest starts it alone. @@ -223,8 +201,6 @@ #define PRISON_LOOSE_PING_2 60 /// Mood of a recaptured prisoner #define PRISONER_RECAPTURED_MOOD 35 -/// Damage a loose prisoner does to doors and machines per blow (normal airlocks shrug off under 21) -#define PRISONER_LOOSE_OBJ_DAMAGE 25 // Lock-in /// Confined this many seconds at mood PRISONER_WRECK_MOOD or less, a prisoner wrecks the cell; the bolts shear PRISONER_WRECK_TIME seconds later @@ -233,8 +209,7 @@ #define PRISONER_WRECK_TIME 180 // Cuffs (outpost_prison_capture.dm) -/// Seconds cuffing takes: a prisoner who is down, and one on their feet and out of trouble -#define PRISONER_CUFF_TIME_DOWN (2 SECONDS) +/// How long a prisoner holds still for tg's cuffing (its handcuffs take 4 seconds) #define PRISONER_CUFF_TIME_STANDING (4 SECONDS) /// Seconds taking the cuffs off takes #define PRISONER_UNCUFF_TIME (2 SECONDS) @@ -252,7 +227,7 @@ /// Seconds out of a cell, on their feet and uncuffed, before a prisoner who owes lockdown riots again #define PRISON_LOCKDOWN_GRACE 30 -// What trouble a prisoner is in (/mob/living/basic/outpost_prisoner/var/trouble); null is none +// What trouble a prisoner is in (/mob/living/carbon/human/npc/outpost_prisoner/var/trouble); null is none #define PRISONER_TROUBLE_FIGHT "fight" #define PRISONER_TROUBLE_RIOT "riot" /// Rioting after the riot turned into a breakout: going for the exits diff --git a/voidcrew/_HELPERS/hostile_creatures.dm b/voidcrew/_HELPERS/hostile_creatures.dm index 22dc386a234..0561a69e0c2 100644 --- a/voidcrew/_HELPERS/hostile_creatures.dm +++ b/voidcrew/_HELPERS/hostile_creatures.dm @@ -65,17 +65,19 @@ GLOBAL_LIST_INIT(turret_retaliating_subtrees, typecacheof(list( * GLOB.ai_subtrees, so this is a handful of list lookups. */ /proc/is_hostile_creature(mob/living/creature) + // Prison experiment creatures (outpost_prison_experiment_forms.dm, outpost_prison_horror.dm): fair game + // anywhere while up, like the headslug, whose stock AI already reads as hostile. Their custom AI does + // not. First: a body the serum took is still a prisoner's body. + if(is_outpost_experiment_creature(creature)) + return is_outpost_experiment_turret_target(creature) // Outpost prisoners (outpost_prison_*.dm): left alone inside their wing, rioting or not; // once loose outside it they are fair game. Turrets players build use outpost_prison_security.dm instead. - if(istype(creature, /mob/living/basic/outpost_prisoner)) + if(istype(creature, /mob/living/carbon/human/npc/outpost_prisoner)) return is_loose_outpost_prisoner(creature) - // Prison experiment creatures (outpost_prison_creatures.dm): fair game anywhere, like the - // headslug, whose stock AI already reads as hostile. Their custom AI does not. - if(istype(creature, /mob/living/basic/outpost_experiment)) - return is_outpost_experiment_turret_target(creature) - // Bounty criminals, decoys and companions (voidcrew/modules/bounties/bounty_criminal.dm): trader-outpost - // turrets leave them to the hunters, and no turret shoots a criminal already down, stunned or cuffed. - if(istype(creature, /mob/living/basic/bounty_criminal) || istype(creature, /mob/living/basic/bounty_companion)) + // Bounty criminals, decoys and companions (voidcrew/modules/bounties/bounty_criminal.dm), real human bodies: + // trader-outpost turrets leave them to the hunters, and no turret shoots a criminal already in crit, + // stunned or cuffed (tg's states). + if(istype(creature, /mob/living/carbon/human/npc/bounty_criminal) || istype(creature, /mob/living/carbon/human/npc/bounty_companion)) if(bounty_turret_ignores(creature, istype(get_area(creature), /area/voidcrew/trader_outpost))) return FALSE // Bees from an outpost's own apiary never sting anyone (outpost_apiary.dm), but their diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm index 2d4848184b2..82854e88a5b 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm @@ -372,6 +372,10 @@ /obj/effect/landmark/outpost_network_pad_spot, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost/checkpoint) +"bc" = ( +/obj/structure/sink/directional/north, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "bd" = ( /obj/effect/turf_decal/arrows{ dir = 2 @@ -748,6 +752,10 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"dg" = ( +/obj/effect/decal/cleanable/blood/gibs/robot_debris/old, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "dD" = ( /obj/effect/turf_decal/stripes, /obj/effect/turf_decal/siding/thinplating_new/dark{ @@ -1043,6 +1051,13 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"gS" = ( +/obj/item/trash/can{ + pixel_w = -6; + pixel_z = -4 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "gT" = ( /obj/effect/turf_decal/siding/thinplating_new/dark, /obj/effect/decal/cleanable/dirt, @@ -1171,6 +1186,9 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"iQ" = ( +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "iR" = ( /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 @@ -1292,6 +1310,12 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"ku" = ( +/obj/structure/rack, +/obj/structure/sign/poster/contraband/cybersun_six_hundred/directional/north, +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "kv" = ( /obj/effect/turf_decal/siding/thinplating_new/dark/corner{ dir = 1 @@ -1377,6 +1401,14 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"ld" = ( +/obj/structure/table, +/obj/item/cigbutt{ + pixel_w = 5; + pixel_z = 7 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "lw" = ( /obj/structure/rack, /obj/effect/turf_decal/trimline/red/filled/line{ @@ -1402,6 +1434,11 @@ "ly" = ( /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"lz" = ( +/mob/living/basic/outpost_trader/ripperdoc/street_doc, +/obj/machinery/light/small/dim/directional/north, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "lE" = ( /obj/structure/window/reinforced/shuttle/indestructible, /turf/open/indestructible/plating, @@ -1533,6 +1570,10 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"nV" = ( +/obj/machinery/light/small/directional/west, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "oi" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 1 @@ -1631,18 +1672,6 @@ /obj/machinery/newscaster/directional/east, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"pC" = ( -/obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ - dir = 4 - }, -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 5 - }, -/obj/item/kirbyplants/organic/plant22, -/obj/effect/decal/cleanable/cobweb/cobweb2, -/obj/effect/decal/cleanable/dirt, -/turf/open/indestructible, -/area/voidcrew/trader_outpost) "pE" = ( /obj/effect/turf_decal/tile/dark/full, /obj/effect/invisible_wall, @@ -1731,6 +1760,11 @@ /obj/effect/decal/cleanable/dirt/dust, /turf/open/indestructible/dark/textured, /area/voidcrew/trader_outpost) +"qA" = ( +/obj/structure/fluff/shower_drain, +/obj/effect/decal/cleanable/blood/old, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "qN" = ( /obj/structure/closet/crate/secure{ name = "chassis crate"; @@ -1757,6 +1791,12 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"qZ" = ( +/obj/machinery/door/airlock/maintenance{ + name = "Back Bay" + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "rd" = ( /obj/effect/turf_decal/trimline/yellow/filled/line{ dir = 8 @@ -1771,6 +1811,10 @@ /obj/structure/chair/office/light, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"rg" = ( +/obj/structure/closet/crate/bin, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "rj" = ( /obj/structure/table/reinforced/rglass, /obj/structure/desk_bell{ @@ -1813,6 +1857,16 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"sd" = ( +/obj/machinery/porta_turret/outpost, +/obj/effect/turf_decal/box/red/corners{ + dir = 1 + }, +/obj/effect/turf_decal/box/red/corners{ + dir = 8 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "sf" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -1828,6 +1882,14 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"sh" = ( +/obj/structure/closet/crate{ + anchored = 1; + name = "parts crate" + }, +/obj/effect/decal/cleanable/cobweb, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "si" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 4 @@ -1928,6 +1990,14 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"tf" = ( +/obj/structure/table, +/obj/structure/fluff/paper/stack{ + pixel_w = -3; + pixel_z = 3 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "tj" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -1959,6 +2029,11 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"tD" = ( +/obj/machinery/chrome_cradle, +/obj/effect/decal/cleanable/blood/old, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "tH" = ( /obj/effect/turf_decal/siding/thinplating_new/corner{ dir = 4 @@ -2075,6 +2150,10 @@ /obj/machinery/light/floor, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"uM" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "vb" = ( /obj/structure/fluff/paper/stack{ dir = 1; @@ -2190,6 +2269,10 @@ }, /turf/open/indestructible/white/smooth_large, /area/voidcrew/trader_outpost) +"wK" = ( +/obj/machinery/light/cold/directional/east, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "wM" = ( /obj/structure/table, /obj/item/storage/medkit/regular, @@ -2375,6 +2458,11 @@ /obj/machinery/light/directional/north, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"zp" = ( +/obj/structure/rack, +/obj/effect/decal/cleanable/cobweb/cobweb2, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "zw" = ( /obj/structure/chair{ dir = 1 @@ -2629,6 +2717,11 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"CD" = ( +/obj/structure/chair/stool, +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "CQ" = ( /obj/effect/turf_decal/tile/yellow/half/contrasted{ dir = 8 @@ -2765,6 +2858,19 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"EW" = ( +/obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ + dir = 4 + }, +/obj/effect/turf_decal/siding/thinplating_new{ + dir = 5 + }, +/obj/item/kirbyplants/organic/plant22, +/obj/effect/decal/cleanable/cobweb/cobweb2, +/obj/effect/decal/cleanable/dirt, +/obj/structure/sign/poster/contraband/interdyne_gene_clinics/directional/east, +/turf/open/indestructible, +/area/voidcrew/trader_outpost) "EX" = ( /obj/effect/turf_decal/tile/dark/full, /obj/effect/invisible_wall, @@ -2778,6 +2884,14 @@ /obj/item/radio/intercom/directional/north, /turf/open/indestructible/hoteltile, /area/voidcrew/trader_outpost) +"Ff" = ( +/obj/structure/table, +/obj/structure/desk_bell{ + pixel_w = 0; + pixel_z = 4 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Fx" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -2836,6 +2950,10 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Ga" = ( +/obj/machinery/vending/wallmed/directional/east, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Gd" = ( /obj/effect/turf_decal/siding/wideplating_new/light{ dir = 8 @@ -2854,6 +2972,10 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Gk" = ( +/obj/structure/mirror/directional/east, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Gt" = ( /obj/effect/turf_decal/tile/dark/full, /obj/structure/sign/poster/official/get_your_legs{ @@ -2972,6 +3094,10 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Iu" = ( +/obj/item/cigbutt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Iw" = ( /turf/open/indestructible/hoteltile, /area/voidcrew/trader_outpost) @@ -3299,6 +3425,10 @@ /obj/structure/extinguisher_cabinet/directional/west, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"MB" = ( +/obj/effect/decal/cleanable/blood/drip, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "MI" = ( /obj/effect/spawner/random/trash/soap, /obj/machinery/light/small/directional/south, @@ -3314,6 +3444,10 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"MW" = ( +/obj/effect/decal/cleanable/blood/oil, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Na" = ( /obj/structure/table, /obj/effect/spawner/random/food_or_drink/donkpockets, @@ -3668,6 +3802,11 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"Uc" = ( +/obj/effect/decal/cleanable/blood/gibs/robot_debris/limb, +/obj/item/cigbutt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Ud" = ( /obj/effect/turf_decal/bot, /obj/structure/closet/crate, @@ -3924,6 +4063,10 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"ZP" = ( +/obj/structure/table, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "ZV" = ( /obj/effect/decal/cleanable/dirt/dust, /obj/machinery/light/floor, @@ -5368,7 +5511,7 @@ ai fp vS dU -pC +EW hE gQ Vl @@ -5412,7 +5555,7 @@ CS dU dU dU -dU +qZ dU dU dU @@ -5452,13 +5595,13 @@ lE lE dU dU -aV -aV -aV -aV -aV -aV -aV +sh +ZP +uM +nV +uM +rg +dU dU dU lE @@ -5492,15 +5635,15 @@ ao ao ao ao -ao -aF -aV -aV -aV -aV -aV aV dU +ku +tf +Iu +CD +uM +bc +dU dU FC oU @@ -5534,15 +5677,15 @@ ao ao ao ao -ao -ao -ao -aV -aV -aV -aV aV dU +Uc +ZP +gS +iQ +MW +dg +dU cI cY gg @@ -5576,14 +5719,14 @@ ao ao ao ao -ao -ao -ao -ao -ao -aV -aV aV +dU +lz +Ff +uM +MB +tD +qA lE aE Hd @@ -5618,14 +5761,14 @@ ao ao ao ao -ao -ao -ao -ao -ao -ao -ao -aF +aV +dU +zp +ld +Ga +Gk +wK +sd dU tj AY @@ -5660,14 +5803,14 @@ ao ao ao ao -ao -ao -ao -ao -ao -ao -ao -ao +aV +dU +dU +dU +dU +dU +dU +dU dU dU sf @@ -5702,15 +5845,15 @@ ao ao ao ao -ao -ao -ao -ao -ao -ao -ao -ao -ao +aF +aV +aV +aV +aV +aV +aV +aF +aV dU dU dU diff --git a/voidcrew/controllers/subsystem/planet_mobs.dm b/voidcrew/controllers/subsystem/planet_mobs.dm index ba6718aa84a..f772ec5db19 100644 --- a/voidcrew/controllers/subsystem/planet_mobs.dm +++ b/voidcrew/controllers/subsystem/planet_mobs.dm @@ -351,7 +351,7 @@ SUBSYSTEM_DEF(planet_mobs) continue var/datum/npc_spawn_context/context = new(tracker, tracker.zone_band, source_turf = candidate) - spawn_npc_in_zone(mob_type, candidate, context) + spawn_npc_in_zone_or_queue(mob_type, candidate, context) spawned++ total_managed_mobs++ diff --git a/voidcrew/datums/mapgen/PlanetGenerator.dm b/voidcrew/datums/mapgen/PlanetGenerator.dm index 4bfb47df656..9b5c02e7fa1 100644 --- a/voidcrew/datums/mapgen/PlanetGenerator.dm +++ b/voidcrew/datums/mapgen/PlanetGenerator.dm @@ -552,7 +552,7 @@ if(can_spawn) var/datum/npc_spawn_context/context = new(src, zone_band, "auto", source_turf = target_turf) - spawn_npc_in_zone(picked_mob, target_turf, context) + spawn_npc_in_zone_or_queue(picked_mob, target_turf, context) spawned_something = TRUE // The expensive half of a planet build - every iteration runs several range() // scans - and the one that most needs to stop hogging the tick. See diff --git a/voidcrew/datums/mapgen/biomes/_biome.dm b/voidcrew/datums/mapgen/biomes/_biome.dm index 6ef081c4196..80c1637c63d 100644 --- a/voidcrew/datums/mapgen/biomes/_biome.dm +++ b/voidcrew/datums/mapgen/biomes/_biome.dm @@ -126,7 +126,7 @@ if(can_spawn) var/datum/npc_spawn_context/context = new(src, source_turf = new_turf) - spawn_npc_in_zone(picked_mob, new_turf, context) + spawn_npc_in_zone_or_queue(picked_mob, new_turf, context) new_turf.flags_1 |= NO_LAVA_GEN diff --git a/voidcrew/datums/mapgen/biomes/beach_biomes.dm b/voidcrew/datums/mapgen/biomes/beach_biomes.dm index 6b7d8ffa634..ce3af2a53ca 100644 --- a/voidcrew/datums/mapgen/biomes/beach_biomes.dm +++ b/voidcrew/datums/mapgen/biomes/beach_biomes.dm @@ -94,7 +94,7 @@ dangerous_mob_spawn_list = list( /mob/living/basic/carp/beach = 3, /mob/living/basic/carp/mega/beach = 1, - /mob/living/basic/trooper/pirate/melee/beach/boat = 1, + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach = 1, ) flora_spawn_list = list( /obj/structure/flora/rock/beach = 1, @@ -111,12 +111,12 @@ /mob/living/basic/carp/beach = 6, /mob/living/basic/carp/beach/small = 5, /mob/living/basic/carp/mega/beach = 2, - /mob/living/basic/trooper/pirate/melee/beach/boat = 3, - /mob/living/basic/trooper/pirate/ranged/beach/boat = 1 + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach = 3, + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner = 1 ) dangerous_mob_spawn_list = list( /mob/living/basic/carp/mega/beach = 2, - /mob/living/basic/trooper/pirate/ranged/beach/boat = 1, + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner = 1, ) feature_spawn_chance = 0.1 feature_spawn_list = list( diff --git a/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm b/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm index 2a0736c2fba..c2ff36d4115 100644 --- a/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm +++ b/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm @@ -24,14 +24,14 @@ feature_spawn_chance = 3 mob_spawn_chance = 7 mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter = 10, + /mob/living/carbon/human/npc/ruin/hermit/hunter = 10, /mob/living/basic/spider/giant/wasteland = 1, /mob/living/basic/spider/giant/tarantula/wasteland = 1, /mob/living/basic/mining/legion/wasteland = 3 ) // Meaner tier for dangerous-zone planets (see dangerous_mob_spawn_list) dangerous_mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger = 5, + /mob/living/carbon/human/npc/ruin/hermit/gunslinger = 5, /mob/living/basic/mining/legion/wasteland = 3, /mob/living/basic/spider/giant/tarantula/wasteland = 2, /mob/living/basic/spider/giant/wasteland = 2, @@ -92,13 +92,13 @@ flora_spawn_list = list(/obj/structure/flora/rock/wasteland = 30, /obj/effect/decal/cleanable/greenglow = 30, /obj/structure/elite_tumor = 1) mob_spawn_chance = 20 mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter = 17, + /mob/living/carbon/human/npc/ruin/hermit/hunter = 17, /mob/living/basic/hivebot/range = 5, /mob/living/basic/spider/giant/wasteland = 1, /mob/living/basic/spider/giant/tarantula/wasteland = 1 ) dangerous_mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger = 7, + /mob/living/carbon/human/npc/ruin/hermit/gunslinger = 7, /mob/living/basic/hivebot/rapid/wasteland = 3, /mob/living/basic/spider/giant/tarantula/wasteland = 1, ) diff --git a/voidcrew/edits/mobs/ladder_outfits.dm b/voidcrew/edits/mobs/ladder_outfits.dm index c2925461fe3..6a6f83726c6 100644 --- a/voidcrew/edits/mobs/ladder_outfits.dm +++ b/voidcrew/edits/mobs/ladder_outfits.dm @@ -20,6 +20,11 @@ . = ..() ladder_add_survival_knife() +// The combat-ready kit fills its bag already: equip() below hands the knife over instead. +/datum/outfit/job/miner/equipped/combat/pre_equip(mob/living/carbon/human/H, visuals_only = FALSE) + . = ..() + backpack_contents -= /obj/item/knife/combat/survival + /datum/outfit/job/security/pre_equip(mob/living/carbon/human/H, visuals_only = FALSE) . = ..() ladder_add_survival_knife() @@ -59,10 +64,11 @@ // Corpse and ghost-role outfits are also fixed starting placements. // Appearance dummies are left alone: get_dynamic_human_appearance() dresses one through the // spawner to draw an NPC's look (a gunner holding its gun) and deletes it straight after, so -// nothing on it can ever be picked up. +// nothing on it can ever be picked up. Human NPCs (voidcrew/modules/human_npcs/) are left alone +// too: their kit is locked to the body and never becomes loot, and it's what they fight with. /obj/effect/mob_spawn/equip(mob/living/spawned_mob) . = ..() - if(ishuman(spawned_mob) && !istype(spawned_mob, /mob/living/carbon/human/dummy)) + if(ishuman(spawned_mob) && !istype(spawned_mob, /mob/living/carbon/human/dummy) && !ishumannpc(spawned_mob)) remove_ladder_starting_weapons(spawned_mob, list()) /datum/voucher_set/mining/crusher_kit diff --git a/voidcrew/edits/mobs/weapon_drops.dm b/voidcrew/edits/mobs/weapon_drops.dm index 077bbe65666..ecf94506ea9 100644 --- a/voidcrew/edits/mobs/weapon_drops.dm +++ b/voidcrew/edits/mobs/weapon_drops.dm @@ -59,8 +59,7 @@ if(istype(src, /mob/living/basic/trooper/nanotrasen/ranged)) if((zone == ZONE_GREEN && (istype(src, /mob/living/basic/trooper/nanotrasen/ranged/smg) || istype(src, /mob/living/basic/trooper/nanotrasen/ranged/assault))) || (zone != ZONE_RED && istype(src, /mob/living/basic/trooper/nanotrasen/ranged/elite))) pistol_type = /mob/living/basic/trooper/nanotrasen/ranged - if(zone != ZONE_RED && (istype(src, /mob/living/basic/trooper/nanotrasen/ranged/smg/customs) || istype(src, /mob/living/basic/trooper/nanotrasen/ranged/assault/customs))) - pistol_type = /mob/living/basic/trooper/nanotrasen/ranged + // NT customs are human NPCs now: their ladder pistol is in their outfit table (faction_pirates/nt_patrol.dm) else if(istype(src, /mob/living/basic/trooper/syndicate/ranged)) if(zone != ZONE_RED && (istype(src, /mob/living/basic/trooper/syndicate/ranged/smg) || istype(src, /mob/living/basic/trooper/syndicate/ranged/shotgun) || (zone == ZONE_GREEN && istype(src, /mob/living/basic/trooper/syndicate/ranged/space/stormtrooper)))) pistol_type = /mob/living/basic/trooper/syndicate/ranged @@ -76,8 +75,7 @@ r_hand = /obj/item/knife/combat/survival l_hand = null set_light(0) - if(use_dynamic_appearance) - apply_dynamic_human_appearance(src, mob_spawn_path = mob_spawner, r_hand = r_hand) + apply_dynamic_human_appearance(src, mob_spawn_path = mob_spawner, r_hand = r_hand) return TRUE /mob/living/basic/trooper/proc/use_ladder_pistol(mob/living/basic/trooper/profile) @@ -102,8 +100,7 @@ QDEL_NULL(ai_controller) ai_controller = new /datum/ai_controller/basic_controller/trooper/ranged(src) r_hand = initial(profile.r_hand) - if(use_dynamic_appearance) - apply_dynamic_human_appearance(src, mob_spawn_path = mob_spawner, r_hand = r_hand, l_hand = l_hand) + apply_dynamic_human_appearance(src, mob_spawn_path = mob_spawner, r_hand = r_hand, l_hand = l_hand) // Copy both lists before trimming them: crusher kills must use the very same // construction-time roll, and must keep trophies and the mining accelerator. diff --git a/voidcrew/edits/modules/mob/living/basic/trooper/trooper.dm b/voidcrew/edits/modules/mob/living/basic/trooper/trooper.dm deleted file mode 100644 index 595da62d1af..00000000000 --- a/voidcrew/edits/modules/mob/living/basic/trooper/trooper.dm +++ /dev/null @@ -1,5 +0,0 @@ -// Voidcrew extensions to code/modules/mob/living/basic/trooper/trooper.dm. - -/mob/living/basic/trooper - /// VOIDCREW EDIT: subtypes with bespoke static sprites (e.g. boat pirates) set this FALSE to keep their own icon. - var/use_dynamic_appearance = TRUE diff --git a/voidcrew/edits/modules/mob/living/carbon/floored_fall.dm b/voidcrew/edits/modules/mob/living/carbon/floored_fall.dm new file mode 100644 index 00000000000..3e4796ba2b6 --- /dev/null +++ b/voidcrew/edits/modules/mob/living/carbon/floored_fall.dm @@ -0,0 +1,13 @@ +// tg floors a standing body by laying it down and then picking the side it lies on. For a carbon, lying down +// has already picked a side (lying_angle_on_lying_down()), so the side was picked twice, and half the time the +// second pick was the other one. update_transform() applies a turn straight onto the other side with no tween, +// so half of all knockdowns went flat in a single frame instead of falling. Here the first pick is kept. +// Found on the Mantis lunge's landing (voidcrew/modules/mantis_gore/), true of every knockdown. +/mob/living/carbon/on_floored_start() + on_fall() + if(body_position != STANDING_UP) + return + set_body_position(LYING_DOWN) + // Still upright only if lying down picked nothing: buckled upright, or unable to lie down. + if(!lying_angle) + set_lying_angle(pick(LYING_ANGLE_EAST, LYING_ANGLE_WEST)) diff --git a/voidcrew/edits/modules/mob/living/carbon/inventory.dm b/voidcrew/edits/modules/mob/living/carbon/inventory.dm index 680d16e91b5..2948abe3ca6 100644 --- a/voidcrew/edits/modules/mob/living/carbon/inventory.dm +++ b/voidcrew/edits/modules/mob/living/carbon/inventory.dm @@ -9,4 +9,7 @@ /mob/living/carbon/proc/log_internals_cutoff(reason) var/obj/item/tank/closing = external || internal var/atom/tank_loc = closing.loc - log_shuttle("INTERNALS CUTOFF: [key_name(src)] at [AREACOORD(src)] reason=[reason] tank=[closing.type] ([external ? "external" : "internal"]) tank_loc=[tank_loc] ([tank_loc?.type]) loc_is_mob=[tank_loc == src ? "yes" : "NO"] apparatus=[can_breathe_internals() || "NONE"] mask=[wear_mask ? "[wear_mask.type] up=[wear_mask.up] flags=[wear_mask.clothing_flags]" : "NONE"] head=[head ? "[head.type] flags=[head.clothing_flags]" : "NONE"] tube=[can_breathe_tube() ? "yes" : "no"] stat=[stat]") + // The mask and head slots take things that aren't clothing (a cigar, a bucket), which have no up or clothing_flags + var/obj/item/clothing/mask_clothes = istype(wear_mask, /obj/item/clothing) ? wear_mask : null + var/obj/item/clothing/head_clothes = istype(head, /obj/item/clothing) ? head : null + log_shuttle("INTERNALS CUTOFF: [key_name(src)] at [AREACOORD(src)] reason=[reason] tank=[closing.type] ([external ? "external" : "internal"]) tank_loc=[tank_loc] ([tank_loc?.type]) loc_is_mob=[tank_loc == src ? "yes" : "NO"] apparatus=[can_breathe_internals() || "NONE"] mask=[wear_mask ? "[wear_mask.type] up=[mask_clothes?.up] flags=[mask_clothes?.clothing_flags]" : "NONE"] head=[head ? "[head.type] flags=[head_clothes?.clothing_flags]" : "NONE"] tube=[can_breathe_tube() ? "yes" : "no"] stat=[stat]") diff --git a/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm b/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm new file mode 100644 index 00000000000..840f3821b9d --- /dev/null +++ b/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm @@ -0,0 +1,54 @@ +// Voidcrew extension to code/modules/projectiles/projectile.dm: NPCs shoot past their own side. + +/obj/projectile + /// Set before fire() to keep an NPC's shot hitting its own side in the way (the mafia lair's machine). + var/npc_shot_hits_allies = FALSE + /// The NPC whose side this shot passes over. A shot reflected or parried by someone else stops passing over them. + var/datum/weakref/npc_side_shooter + +/** + * A shot fired by an NPC (a mob nobody is playing) passes over anyone on its side, as tg's turrets already + * do, through tg's own ignored_factions check in can_hit_target(). The mob it was aimed at is always hit, + * whichever side it is on. Every NPC fires through here: human NPCs' guns (casing to projectile, shotgun + * pellets included), basic mobs' ranged_attacks and simple mobs' Shoot(). + * + * A player's shots are unchanged. The shared "neutral" faction is left out, so an NPC never shoots + * through bystanders just because nobody gave them a side. + */ +/obj/projectile/fire(fire_angle, atom/direct_target) + if(isnull(ignored_factions) && !npc_shot_hits_allies && ismob(firer)) + var/mob/firer_mob = firer + if(!firer_mob.ckey && length(firer_mob.faction)) + var/list/sides = firer_mob.faction - FACTION_NEUTRAL + if(length(sides)) + ignored_factions = sides + npc_side_shooter = WEAKREF(firer_mob) + return ..() + +// A reflected, parried or deflected shot takes a new firer: it stops sparing the NPC's side. +// An ally held by the other side is a shield, not an ally: the shot can hit them (see npc_shot_shield()). +/obj/projectile/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) + if(npc_side_shooter && firer && firer.weak_reference != npc_side_shooter) + npc_side_shooter = null + ignored_factions = null + if(npc_side_shooter && npc_shot_shield(target)) + var/list/sides = ignored_factions + ignored_factions = null + . = ..() + ignored_factions = sides + return . + return ..() + +/** + * Whether `target` is one of the NPC's side held in an aggressive grab or tighter by a living mob that is + * not on that side. tg's grab states are the only test: a passive pull (dragging a body or a cuffed + * prisoner) is not a shield. tg's own rules still pass over a held body that is dead or lying helpless. + */ +/obj/projectile/proc/npc_shot_shield(atom/target) + if(!isliving(target) || !length(ignored_factions)) + return FALSE + var/mob/living/held = target + var/mob/living/holder = held.pulledby + if(!isliving(holder) || holder.grab_state < GRAB_AGGRESSIVE) + return FALSE + return faction_check(held.faction, ignored_factions) && !faction_check(holder.faction, ignored_factions) diff --git a/voidcrew/edits/modules/surgery/organs/internal/cyberimp/augments_internal.dm b/voidcrew/edits/modules/surgery/organs/internal/cyberimp/augments_internal.dm new file mode 100644 index 00000000000..142e4631512 --- /dev/null +++ b/voidcrew/edits/modules/surgery/organs/internal/cyberimp/augments_internal.dm @@ -0,0 +1,12 @@ +// Voidcrew fix to code/modules/surgery/organs/internal/cyberimp/augments_internal.dm. + +// tg's cyberimp Destroy() deletes the implant's bodypart overlay before the organ comes out, so +// on_bodypart_remove() finds no overlay to take off and the limb keeps a dead one with no implant +// behind it: every later redraw of that limb runtimes. Chrome deleted inside a body hits it, as a human +// NPC's cyberware (voidcrew/modules/cyberware/, voidcrew/modules/human_npcs/) does when its body is +// deleted. The overlay comes off the limb first. +/obj/item/organ/cyberimp/Destroy() + if(bodypart_aug && bodypart_owner) + var/redraw = !QDELETED(bodypart_owner) && !QDELETED(bodypart_owner.owner) + bodypart_owner.remove_bodypart_overlay(bodypart_aug, update = redraw) + return ..() diff --git a/voidcrew/edits/ship_access.dm b/voidcrew/edits/ship_access.dm index fdc8fa05b19..82d596f97a1 100644 --- a/voidcrew/edits/ship_access.dm +++ b/voidcrew/edits/ship_access.dm @@ -161,8 +161,8 @@ GLOBAL_LIST_EMPTY(crew_locked_ships) /obj/structure/overmap/ship/proc/is_npc_ship_crew(mob/living/npc) if(is_ship_crew(npc)) return TRUE - if(istype(npc, /mob/living/basic/ambient_npc/stray)) - var/mob/living/basic/ambient_npc/stray/stray = npc + if(istype(npc, /mob/living/carbon/human/npc/ambient_npc/stray)) + var/mob/living/carbon/human/npc/ambient_npc/stray/stray = npc return !stray.is_wild() && stray.get_ship() == src var/list/friends = npc.ai_controller?.blackboard[BB_FRIENDS_LIST] for(var/mob/friend as anything in friends) diff --git a/voidcrew/icons/obj/favor_uniques.dmi b/voidcrew/icons/obj/favor_uniques.dmi index f8b13309180..1af2b7283e1 100644 Binary files a/voidcrew/icons/obj/favor_uniques.dmi and b/voidcrew/icons/obj/favor_uniques.dmi differ diff --git a/voidcrew/machinery/ship_defense_turret.dm b/voidcrew/machinery/ship_defense_turret.dm index 13c244dcdc6..a0223d00be2 100644 --- a/voidcrew/machinery/ship_defense_turret.dm +++ b/voidcrew/machinery/ship_defense_turret.dm @@ -284,21 +284,25 @@ * which is the whole job. */ /obj/machinery/porta_turret/ship_defense/proc/valid_target(mob/living/creature) - if(creature.stat == DEAD) + // Dead, in crit or cuffed: out of the fight (human_npc_out_of_fight(), owner ruling OC-7), so a + // boarder the crew has taken down or captured is left alone. A basic mob is out only once dead. + if(human_npc_out_of_fight(creature)) return FALSE if(creature.client || creature.mind) // Anything a player is driving, or was driving. return FALSE - if(iscarbon(creature) || issilicon(creature)) // People, monkeys, xenos, borgs, pAIs. + // People, monkeys, xenos, borgs, pAIs. Human NPCs (voidcrew/modules/human_npcs/) are bodies no + // player drives, the human boarders that replace troopers, so they are fair game like them. + if(issilicon(creature) || (iscarbon(creature) && !ishumannpc(creature))) return FALSE if(creature.invisibility > SEE_INVISIBLE_LIVING) return FALSE if(in_faction(creature)) // Bots, pets and other turrets. return FALSE // With wildlife targeting off the turret only watches for boarding parties, which are - // all trooper-type humanoids (pirates and their kin). Lets the crew hunt the local + // all trooper-type humanoids or human NPCs (pirates and their kin). Lets the crew hunt the local // fauna themselves without the turret stealing every kill. // Escaped outpost prisoners count as boarders (outpost_prison_riot.dm). - if(!target_wildlife && !istype(creature, /mob/living/basic/trooper) && !is_loose_outpost_prisoner(creature)) + if(!target_wildlife && !istype(creature, /mob/living/basic/trooper) && !ishumannpc(creature) && !is_loose_outpost_prisoner(creature)) return FALSE if(!is_hostile_creature(creature)) // Livestock, pets and passive fauna get left alone. return FALSE diff --git a/voidcrew/mapping/ship_crew/bogatyr.dm b/voidcrew/mapping/ship_crew/bogatyr.dm index dd5eac8c914..90117f24ae4 100644 --- a/voidcrew/mapping/ship_crew/bogatyr.dm +++ b/voidcrew/mapping/ship_crew/bogatyr.dm @@ -453,7 +453,7 @@ shoes = /obj/item/clothing/shoes/sneakers/random belt = /obj/item/modular_computer/pda/clear id = /obj/item/card/id/advanced/rainbow - suit_store = /obj/item/reagent_containers/cup/soda_cans/random + r_pocket = /obj/item/reagent_containers/cup/soda_cans/cola /datum/outfit/job/workshop_bogatyr_job_15/pre_equip(mob/living/carbon/human/H, visuals_only = FALSE) . = ..() @@ -465,4 +465,4 @@ shoes = /obj/item/clothing/shoes/sneakers/random belt = /obj/item/modular_computer/pda/clear id = /obj/item/card/id/advanced/rainbow - suit_store = /obj/item/reagent_containers/cup/soda_cans/random + r_pocket = /obj/item/reagent_containers/cup/soda_cans/cola diff --git a/voidcrew/mapping/ship_crew/hiryu_class_carrier.dm b/voidcrew/mapping/ship_crew/hiryu_class_carrier.dm index 26529e7bab4..65bfef1f4ab 100644 --- a/voidcrew/mapping/ship_crew/hiryu_class_carrier.dm +++ b/voidcrew/mapping/ship_crew/hiryu_class_carrier.dm @@ -135,6 +135,7 @@ name = "Hiryu-class Carrier — Medical Doctor" uniform = /obj/item/clothing/under/rank/medical/scrubs/purple suit = null + suit_store = null head = /obj/item/clothing/head/beret/medical neck = /obj/item/clothing/neck/scarf/pink @@ -142,6 +143,7 @@ . = ..() uniform = /obj/item/clothing/under/rank/medical/scrubs/purple suit = null + suit_store = null head = /obj/item/clothing/head/beret/medical neck = /obj/item/clothing/neck/scarf/pink @@ -390,6 +392,7 @@ back = null belt = null suit_store = null + box = null /datum/outfit/job/workshop_hiryu_class_carrier_job_27/pre_equip(mob/living/carbon/human/H, visuals_only = FALSE) . = ..() @@ -399,3 +402,4 @@ back = null belt = null suit_store = null + box = null diff --git a/voidcrew/modules/admin/chrome_test_kit.dm b/voidcrew/modules/admin/chrome_test_kit.dm new file mode 100644 index 00000000000..8ae937c8ac7 --- /dev/null +++ b/voidcrew/modules/admin/chrome_test_kit.dm @@ -0,0 +1,257 @@ +/** + * # Chrome test kit + * + * One admin verb for trying the cyberware lines without a shopping trip. It drops a Chrome + * Cradle beside the admin with a stack of trade vouchers and a holochip of credits, then offers + * a short list of ready-made builds (see GLOB.chrome_test_loadouts) that either go straight into + * the admin's own body or into a crate at their feet, plus a chromed human to point Chrome Read + * at. The dummy is a human NPC body (voidcrew/modules/human_npcs/) with no + * AI, so it is the same kind of body as the NPCs players meet: its chrome works, and burns out + * to scrap when it dies or a piece leaves it. + * + * Installs here are special inserts, the same door the unit tests use: no cradle sequence, no + * boot splash, no install context. They still obey the body's rules in two ways. A piece whose + * limb is missing is not forced into a body that cannot hold it, and a piece that would push the + * body past its neural capacity goes into the crate instead of browning the whole build out + * (the Cascade and Redline cores share the OS slot with the Governors, so the heavy builds do + * not fit on a stock body). The chat report lists what went where and the final load. + * + * Admin only (R_ADMIN|R_DEBUG), logged like the other feature-testing verbs. The loadouts are + * plain typepath lists so they are easy to retune. + */ + +/** + * The ready-made builds, in install order. Order matters twice: a piece is skipped when the + * body no longer has headroom, so the ones listed first are the ones that stay; and pairs list + * the right arm first, its left twin after. + */ +GLOBAL_LIST_INIT(chrome_test_loadouts, list( + "Blade dancer" = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left, + /obj/item/organ/cyberimp/cyberware/cascade, + /obj/item/organ/cyberimp/cyberware/slipwire, + ), + "Tank" = list( + /obj/item/organ/cyberimp/cyberware/gorilla_arms, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + /obj/item/organ/cyberimp/cyberware/slabskin, + /obj/item/organ/cyberimp/cyberware/atlas, + /obj/item/organ/cyberimp/cyberware/redline, + ), + "Street kid" = list( + /obj/item/organ/cyberimp/cyberware/cascade/street, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left, + /obj/item/organ/cyberimp/cyberware/ghostskin/street, + /obj/item/organ/cyberimp/cyberware/hopper/street, + /obj/item/organ/eyes/robotic/cyberware/spyglass, + /obj/item/organ/cyberimp/cyberware/clotter_node, + /obj/item/organ/cyberimp/cyberware/medic_palms, + /obj/item/organ/cyberimp/cyberware/subvocal_mic, + /obj/item/organ/cyberimp/cyberware/muffler_ear, + ), +)) + +/// The kit's menu entries that are not loadouts. +#define CHROME_KIT_EVERYTHING "Everything (one of each, in a crate)" +#define CHROME_KIT_DUMMY "Chrome dummy (a chromed human to test on)" +#define CHROME_KIT_BASICS "Cradle, vouchers and credits only" + +/// Vouchers and credits the kit hands out per use. +#define CHROME_KIT_VOUCHERS 30 +#define CHROME_KIT_CREDITS 20000 + +/** + * Every piece of chrome a ripperdoc shelf sells, one of each, as typepaths: the loose organs, + * and the cased pairs as the case (cracking it open spills both halves). Read off the shop's + * own SKU datums so it follows the roster by itself, plus the one piece no shelf sells. + */ +/proc/chrome_kit_everything_types() + var/list/found = list() + for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku/ripperdoc)) + var/item_path = initial(sku_type.item_path) + if(!item_path || found[item_path]) + continue + if(ispath(item_path, /obj/item/organ) || ispath(item_path, /obj/item/cyberware_pair_case) || ispath(item_path, /obj/item/storage/case/cyberware)) + found[item_path] = TRUE + // The Meteor Piledriver is a mission prize, not on any shelf. + found[/obj/item/organ/cyberimp/cyberware/piledriver] = TRUE + return assoc_to_keys(found) + +/// A free turf beside the user for a prop: open floor with nothing solid on it, nearest first. +/proc/chrome_kit_adjacent_turf(atom/user) + var/turf/origin = get_turf(user) + if(!origin) + return null + for(var/direction in GLOB.cardinals) + var/turf/candidate = get_step(origin, direction) + if(!candidate || candidate.density || isspaceturf(candidate)) + continue + var/blocked = FALSE + for(var/atom/movable/occupant as anything in candidate) + if(occupant.density || istype(occupant, /obj/machinery/chrome_cradle)) + blocked = TRUE + break + if(!blocked) + return candidate + return origin + +/** + * Seats a build into a body with special inserts, in order. A piece stays out when the body has + * no limb for it, when it would not fit under the body's neural capacity (the same netted check + * the cradle uses, so a same-slot swap counts) or when the insert is refused. Returns the + * organs left over, ready for a crate; `report` collects a plain line per piece for the chat + * report. + */ +/proc/chrome_kit_install(mob/living/carbon/target, list/ware_types, list/report) + var/list/left_over = list() + var/turf/here = get_turf(target) + for(var/ware_type in ware_types) + var/obj/item/organ/ware = new ware_type(here) + var/reason + if(!target.get_bodypart(deprecise_zone(ware.zone))) + reason = "no limb for it" + else if(!cyberware_insert_check(ware, target, silent = TRUE)) + reason = "no neural headroom" + else if(!ware.Insert(target, special = TRUE)) + reason = "the body refused it" + if(reason) + report += "[ware.name] left out ([reason])" + left_over += ware + return left_over + +/** + * The kit's dummy: a human NPC body with no AI (it never moves, fights or barks), in a grey + * jumpsuit, on no side anyone fights for. Being an NPC body is the point: its chrome burns out at + * death and when a piece leaves it, as on every NPC players meet. + */ +/mob/living/carbon/human/npc/chrome_dummy + ai_controller = null + npc_name = "Chrome dummy" + npc_outfit = /datum/outfit/chrome_dummy + faction = list(FACTION_NEUTRAL) + npc_voice = null + npc_surrender = null + +/datum/outfit/chrome_dummy + name = "Chrome dummy" + uniform = /obj/item/clothing/under/color/grey + +/** + * A chrome dummy at `where` carrying what fits of `ware_types` (chrome_kit_install(); pieces that + * don't fit are deleted, so the dummy keeps only what fits). Returns the dummy. + */ +/proc/chrome_kit_make_dummy(turf/where, list/ware_types, list/report) + var/mob/living/carbon/human/npc/chrome_dummy/dummy = new(where) + var/list/dummy_spare = chrome_kit_install(dummy, ware_types, report) + QDEL_LIST(dummy_spare) + return dummy + +/// A crate of loose chrome at a turf. Items placed straight into the crate, so its slot count +/// never turns the big builds away. +/proc/chrome_kit_crate(turf/where, list/contents_list, crate_name = "chrome crate") + var/obj/structure/closet/crate/crate = new(where) + crate.name = crate_name + for(var/obj/item/spare in contents_list) + spare.forceMove(crate) + return crate + +/// The admin's chrome status line: load against capacity and any piece that is dark. +/proc/chrome_kit_status_line(mob/living/carbon/target) + var/load = get_chrome_load(target) + var/capacity = get_chrome_capacity(target) + var/dark = 0 + for(var/obj/item/organ/ware as anything in get_installed_cyberware(target)) + if(ware.organ_flags & ORGAN_FAILING) + dark++ + return "[length(get_installed_cyberware(target))] pieces, load [load]/[capacity][dark ? ", [dark] dark" : ""]" + +ADMIN_VERB(chrome_test_kit, R_ADMIN|R_DEBUG, "Chrome: Test Kit", "Drop a Chrome Cradle with vouchers and credits beside you, then take a ready-made chrome build (into your body or a crate) or a chromed dummy to test on.", ADMIN_CATEGORY_DEBUG) + var/mob/admin_mob = user.mob + var/turf/origin = get_turf(admin_mob) + if(!origin) + to_chat(user, span_warning("You need a physical location to drop the kit at.")) + return + + var/list/menu = list() + for(var/loadout_name in GLOB.chrome_test_loadouts) + menu += loadout_name + menu += CHROME_KIT_EVERYTHING + menu += CHROME_KIT_DUMMY + menu += CHROME_KIT_BASICS + var/choice = tgui_input_list(user, "Which build?", "Chrome Test Kit", menu) + if(!choice) + return + + // Where it goes, for the two choices that have a body to put it in. + var/into_body = FALSE + if(GLOB.chrome_test_loadouts[choice]) + if(iscarbon(admin_mob)) + var/where = tgui_input_list(user, "Install it where?", "Chrome Test Kit", list("Into my body", "Into a crate at my feet")) + if(!where) + return + into_body = where == "Into my body" + else + to_chat(user, span_notice("You have no body to install into, so the build goes in a crate.")) + + // The dummy takes any one of the builds, or a random one. + var/dummy_build + if(choice == CHROME_KIT_DUMMY) + var/list/builds = list("Random") + for(var/loadout_name in GLOB.chrome_test_loadouts) + builds += loadout_name + dummy_build = tgui_input_list(user, "Which build does the dummy carry?", "Chrome Test Kit", builds) + if(!dummy_build) + return + if(dummy_build == "Random") + dummy_build = pick(GLOB.chrome_test_loadouts) + + // The bench. One cradle per kit spot; asking twice does not stack them. + var/obj/machinery/chrome_cradle/cradle = locate() in view(5, origin) + var/made_cradle = FALSE + if(!cradle) + cradle = new(chrome_kit_adjacent_turf(admin_mob)) + made_cradle = TRUE + new /obj/item/stack/trade_voucher(origin, CHROME_KIT_VOUCHERS) + new /obj/item/holochip(origin, CHROME_KIT_CREDITS) + + var/list/report = list() + var/summary = "[made_cradle ? "a cradle" : "the cradle already here"], [CHROME_KIT_VOUCHERS] vouchers and [CHROME_KIT_CREDITS] cr" + switch(choice) + if(CHROME_KIT_BASICS) + EMPTY_BLOCK_GUARD // the bench, vouchers and credits are the whole kit + if(CHROME_KIT_EVERYTHING) + var/list/everything = list() + for(var/ware_type in chrome_kit_everything_types()) + everything += new ware_type(origin) + chrome_kit_crate(origin, everything, "chrome crate (everything)") + summary += ", a crate with [length(everything)] items" + if(CHROME_KIT_DUMMY) + var/mob/living/carbon/human/npc/chrome_dummy/dummy = chrome_kit_make_dummy(chrome_kit_adjacent_turf(admin_mob), GLOB.chrome_test_loadouts[dummy_build], report) + summary += ", a chrome dummy carrying the [dummy_build] build ([chrome_kit_status_line(dummy)])" + else + var/list/ware_types = GLOB.chrome_test_loadouts[choice] + if(into_body) + var/list/spare = chrome_kit_install(admin_mob, ware_types, report) + summary += ", the [choice] build installed ([chrome_kit_status_line(admin_mob)])" + if(length(spare)) + chrome_kit_crate(origin, spare, "chrome crate ([choice], left over)") + summary += ", [length(spare)] left over in a crate" + else + var/list/boxed = list() + for(var/ware_type in ware_types) + boxed += new ware_type(origin) + chrome_kit_crate(origin, boxed, "chrome crate ([choice])") + summary += ", the [choice] build in a crate" + + to_chat(user, span_notice("Chrome test kit: [summary].")) + for(var/line in report) + to_chat(user, span_notice(" [line].")) + message_admins("[key_name_admin(user)] used the chrome test kit ([choice]) at [ADMIN_VERBOSEJMP(origin)].") + log_admin("[key_name(user)] used the chrome test kit ([choice]): [summary].") + BLACKBOX_LOG_ADMIN_VERB("Chrome Test Kit") + +#undef CHROME_KIT_EVERYTHING +#undef CHROME_KIT_DUMMY +#undef CHROME_KIT_BASICS diff --git a/voidcrew/modules/ambient_npcs/_ambient_types.dm b/voidcrew/modules/ambient_npcs/_ambient_types.dm index 0808f0ed83e..9866da3e5d2 100644 --- a/voidcrew/modules/ambient_npcs/_ambient_types.dm +++ b/voidcrew/modules/ambient_npcs/_ambient_types.dm @@ -27,64 +27,49 @@ GLOBAL_LIST_EMPTY(ambient_npcs) // ========================================================================= /** - * A person who lives in the world: dressed as a random person in an outfit, protected against - * being dragged, boxed, teleported or turned into something else, environment-proof, kept to a - * leash, talking from a dialogue file and going about a routine of activities. + * A person who lives in the world: a real human body (voidcrew/modules/human_npcs/) with one random + * face for life (outpost_npc_looks.dm), dressed in an outfit whose kit is locked to them (owner + * decision D3), kept to a leash, talking from a dialogue file and going about a routine of + * activities. What they hold for an activity (a mop, a lit welder, a drink) is a real item in + * their hand. * - * Anyone can be killed. A body drops a little cash (`death_cash_low` to `death_cash_high`, capped - * by their place) and any `death_loot`, once: never again after a revive. Hurting one at a trader - * outpost is violence there, like hurting a visitor. + * Anyone can be killed, and their body is a person's: crit, bleeding, revival, carrying, bags and + * surgery work on it as on a player. A living, conscious one won't stay grabbed, buckled, cuffed or + * shut in a closet (their AI resists, as a person would), and at a trader outpost they refuse + * teleports. A body drops a little cash (`death_cash_low` to `death_cash_high`, capped by their + * place) and any `death_loot`, once: never again after a revive. Hurting one at a trader outpost is + * violence there, like hurting a visitor. They breathe and feel the cold like anyone: their outfit + * carries what their place needs (the base's site layers add a sealed suit and air where needed). */ -/mob/living/basic/ambient_npc +/mob/living/carbon/human/npc/ambient_npc name = "spacer" desc = "Someone passing through." - icon = 'icons/mob/simple/simple_human.dmi' - icon_state = "" gender = MALE - unique_name = FALSE - combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - // Nobody drags them off, and nobody pulls them out of their room - move_resist = MOVE_FORCE_VERY_STRONG - density = TRUE - // A body stays where it fell (the planet sweep takes it with the fauna) - basic_mob_flags = NONE - maxHealth = 80 - health = 80 - // An unhurried walk - speed = 2.5 - melee_damage_lower = 0 - melee_damage_upper = 0 - // Airless or frozen ground must not kill them in forty seconds (basic mob defaults do) - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - speak_emote = list("says") - response_help_continuous = "talks to" - response_help_simple = "talk to" + faction = list(FACTION_NEUTRAL) ai_controller = /datum/ai_controller/basic_controller/ambient_npc + // The ones that fight back (planet fighters) stop once someone is down + npc_target_stat = HUMAN_NPC_SPARES_DOWNED // ----- Look ----- - /// The outfit whose worn look they wear + /// The outfit they wear, its kit locked to them. Null for one picked by their npc_outfit_table. var/outfit = /datum/outfit/job/assistant /// If set, one of these is picked for `outfit` when they are made var/list/outfit_choices - /// Which person in that outfit (outpost_npc_looks.dm); random when null - var/look_number + /// Their face for life: skin, hair and eyes (outpost_npc_looks.dm). Rolled when they are made, unless given. + var/datum/outpost_npc_look/person /// Pick a random gender when made var/random_gender = TRUE /// Give them a random real name when made; a fixed `name` otherwise var/random_name = TRUE - /// "idle", "weld" and "tool" looks, for NPCs that do work (built when `work_weights` is set) - var/list/work_looks - /// What they hold, drawn in their hand as part of their look: an /obj/item type, or null + /// The item type they hold for show (a mop, a paper bag): a real one is put in their hand. Null for none. var/held_visual - /// "weld", "tool" or "carry": the work look shown now (null for none) + /// "weld", "tool" or "carry": what they hold for the work they do now (null for none) var/work_look - /// Bumped by every look build; an async build applies only if it is still the latest one - var/look_serial = 0 - /// A real item they carry for an activity (a drink), in their contents, never dropped by a hit + /// The real item in their hand for `held_visual` or `work_look`, locked to them like their kit + var/obj/item/held_prop + /// The slot `held_prop` came off when it is their own (a belt pickaxe), where it goes back; NONE for a prop or a bag + var/held_prop_slot = NONE + /// A real item they carry for an activity (a drink): in their hand while they have it, not locked var/obj/item/held_item // ----- Death ----- @@ -164,7 +149,7 @@ GLOBAL_LIST_EMPTY(ambient_npcs) /// Short text for the admin verb var/name = "standing around" /// Who is doing it - var/mob/living/basic/ambient_npc/doer + var/mob/living/carbon/human/npc/ambient_npc/doer /// Weakref to what it happens at, if anything var/datum/weakref/anchor_ref /// Where to go, or null where they stand @@ -199,7 +184,7 @@ GLOBAL_LIST_EMPTY(ambient_npcs) /// Short text for the admin verb var/name = "somewhere" /// The NPCs here, dead or alive, until they are deleted - var/list/mob/living/basic/ambient_npc/npcs = list() + var/list/mob/living/carbon/human/npc/ambient_npc/npcs = list() /// world.time anyone here may say a spontaneous line var/next_line_at = 0 /// The zone band (ZONE_*) it lies in, if known diff --git a/voidcrew/modules/ambient_npcs/ambient_activity.dm b/voidcrew/modules/ambient_npcs/ambient_activity.dm index 4e0244be391..f0844a4b0ad 100644 --- a/voidcrew/modules/ambient_npcs/ambient_activity.dm +++ b/voidcrew/modules/ambient_npcs/ambient_activity.dm @@ -36,7 +36,7 @@ * current activity is finished and replaced. Returns the activity, or null if it could not be set * up here or was outranked; a refused activity is deleted. */ -/mob/living/basic/ambient_npc/proc/start_activity(datum/ambient_activity/new_activity) +/mob/living/carbon/human/npc/ambient_npc/proc/start_activity(datum/ambient_activity/new_activity) if(QDELETED(new_activity)) return null if(fading || stat == DEAD || (activity && activity.priority > new_activity.priority)) @@ -52,7 +52,7 @@ return new_activity /// Stops their activity: it puts everything back and goes -/mob/living/basic/ambient_npc/proc/end_activity() +/mob/living/carbon/human/npc/ambient_npc/proc/end_activity() if(!activity) return var/datum/ambient_activity/old = activity @@ -61,7 +61,7 @@ qdel(old) /// Their activity, or a new one from their routine. Null while they wait after a failure. -/mob/living/basic/ambient_npc/proc/next_activity() +/mob/living/carbon/human/npc/ambient_npc/proc/next_activity() if(activity) return activity if(world.time < activity_retry_at) @@ -75,7 +75,7 @@ * can be set up, then standing around. Override to choose by other rules (time spent, a bar, a * stall). Returns the started activity or null. */ -/mob/living/basic/ambient_npc/proc/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/proc/pick_activity() var/list/weights = routine?.Copy() while(length(weights)) var/activity_type = pick_weight(weights) @@ -85,7 +85,7 @@ return start_activity(new /datum/ambient_activity/idle(src)) /// One step of their activity where they stand: arriving the first time, then its business. Returns AMBIENT_STEP_*. -/mob/living/basic/ambient_npc/proc/activity_step(seconds) +/mob/living/carbon/human/npc/ambient_npc/proc/activity_step(seconds) if(!activity) return AMBIENT_STEP_DONE if(!activity.arrived) @@ -98,7 +98,7 @@ return activity.act(seconds) /// A walk to their activity's spot ended stuck -/mob/living/basic/ambient_npc/proc/travel_failed() +/mob/living/carbon/human/npc/ambient_npc/proc/travel_failed() if(++travel_failures < AMBIENT_TRAVEL_FAILURES_MAX) return travel_failures = 0 @@ -115,7 +115,7 @@ * activity; if nothing could be set up they stand somewhere out of the way, and their routine * starts when someone comes. Override to start them at something in particular (settle_at()). */ -/mob/living/basic/ambient_npc/proc/settle_in() +/mob/living/carbon/human/npc/ambient_npc/proc/settle_in() // Where they were made is only somewhere on the floor: first somewhere they may stand move_to_settle_tile() for(var/attempt in 1 to AMBIENT_SETTLE_TRIES) @@ -131,7 +131,7 @@ * (within `radius` of `near`, if given), then from others, up to `tries` places in all. For * activities that look round where the NPC stands (the work loop). TRUE if settled. */ -/mob/living/basic/ambient_npc/proc/settle_at(activity_type, atom/anchor, tries = AMBIENT_SETTLE_TRIES, atom/near, radius = 6) +/mob/living/carbon/human/npc/ambient_npc/proc/settle_at(activity_type, atom/anchor, tries = AMBIENT_SETTLE_TRIES, atom/near, radius = 6) if(near || !standable(get_turf(src))) move_to_settle_tile(near, radius) for(var/attempt in 1 to tries) @@ -147,7 +147,7 @@ * (arrive()) and sets it part-way in (settle()). Their leash is anchored where they end up. FALSE * when they have no activity or its spot is taken. */ -/mob/living/basic/ambient_npc/proc/settle_here() +/mob/living/carbon/human/npc/ambient_npc/proc/settle_here() if(!activity) return FALSE if(!activity.at_spot()) @@ -168,7 +168,7 @@ return TRUE /// Moves them (with nobody watching) to a free tile of their place's floor they could stand on, within `radius` of `near` if given. FALSE if there is none. -/mob/living/basic/ambient_npc/proc/move_to_settle_tile(atom/near, radius = 6) +/mob/living/carbon/human/npc/ambient_npc/proc/move_to_settle_tile(atom/near, radius = 6) var/turf/tile = place?.settle_turf(src, near, radius) if(!tile) return FALSE @@ -185,7 +185,7 @@ * where their place lets activities send them (an outpost's public floor). `ignore_floor` skips * the place's floor (taking cover). Spots in `avoid` are skipped. */ -/mob/living/basic/ambient_npc/proc/standable(turf/tile, list/avoid, ignore_floor = FALSE) +/mob/living/carbon/human/npc/ambient_npc/proc/standable(turf/tile, list/avoid, ignore_floor = FALSE) if(!isturf(tile) || LAZYACCESS(avoid, tile)) return FALSE if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) @@ -197,7 +197,7 @@ return ignore_floor || !place || place.spot_allowed(tile, src) /// Whether they could stand about on `tile`: standable, and their place's loiter rules. `ignore` is not counted in the crowd (their chat partner). -/mob/living/basic/ambient_npc/proc/loiter_spot_ok(turf/tile, list/avoid, atom/ignore) +/mob/living/carbon/human/npc/ambient_npc/proc/loiter_spot_ok(turf/tile, list/avoid, atom/ignore) return standable(tile, avoid) && (!place || place.loiter_ok(tile, src, ignore)) /** @@ -205,7 +205,7 @@ * null. Right beside it (distance 1), nothing may stand between them (Adjacent(): no wall, glass or * rail), since they use it from there. */ -/mob/living/basic/ambient_npc/proc/free_tile_beside(atom/thing, distance = 1, list/avoid, ignore_floor = FALSE) +/mob/living/carbon/human/npc/ambient_npc/proc/free_tile_beside(atom/thing, distance = 1, list/avoid, ignore_floor = FALSE) var/turf/center = get_turf(thing) if(!center) return null @@ -223,7 +223,7 @@ return best /// A random free tile within `radius` of `center` in the same area, or null -/mob/living/basic/ambient_npc/proc/random_tile_near(turf/center, radius, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/proc/random_tile_near(turf/center, radius, list/avoid) center = get_turf(center) if(!center) return null @@ -239,7 +239,7 @@ * tiles, a couple of points for one with a wall, a table or a dense anchored machine at its side * (somewhere to lean on). Or null. */ -/mob/living/basic/ambient_npc/proc/find_loiter_spot(atom/near, radius = AMBIENT_LOITER_RANGE, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/proc/find_loiter_spot(atom/near, radius = AMBIENT_LOITER_RANGE, list/avoid) var/turf/center = get_turf(near) if(!center) return null @@ -257,7 +257,7 @@ return best /// A table right beside `thing`, or null -/mob/living/basic/ambient_npc/proc/table_beside(atom/thing) +/mob/living/carbon/human/npc/ambient_npc/proc/table_beside(atom/thing) var/turf/center = get_turf(thing) if(!center) return null @@ -271,7 +271,7 @@ * Whether they could sit on `seat`: loose on the floor, nobody on it, where they may go. Stools * (bar stools too) refuse people by default; they are sat on with a forced buckle all the same. */ -/mob/living/basic/ambient_npc/proc/seat_usable(obj/structure/chair/seat, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/proc/seat_usable(obj/structure/chair/seat, list/avoid) if(QDELETED(seat) || !isturf(seat.loc) || istype(seat, /obj/structure/chair/e_chair)) return FALSE if(seat.has_buckled_mobs() && !(src in seat.buckled_mobs)) @@ -283,7 +283,7 @@ return seat.loc == loc || standable(seat.loc, avoid) /// A random one of the three nearest seats within `range` of `near` they could sit on, beside a table if `needs_table`, in sight of `near` -/mob/living/basic/ambient_npc/proc/find_seat(atom/near, range = AMBIENT_ACTIVITY_RANGE, needs_table = FALSE, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/proc/find_seat(atom/near, range = AMBIENT_ACTIVITY_RANGE, needs_table = FALSE, list/avoid) var/turf/center = get_turf(near) || get_turf(src) if(!center) return null @@ -302,7 +302,7 @@ return pick(nearest) /// The nearest table within `range` of `near`, in sight of it, with room to stand at, or null -/mob/living/basic/ambient_npc/proc/find_table(atom/near, range = AMBIENT_ACTIVITY_RANGE, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/proc/find_table(atom/near, range = AMBIENT_ACTIVITY_RANGE, list/avoid) var/turf/center = get_turf(near) || get_turf(src) if(!center) return null @@ -318,12 +318,12 @@ return best /// Another ambient NPC within `range`, in sight, who is free to talk (awake, unplayed, doing nothing that can't wait, standing somewhere not already crowded), or null -/mob/living/basic/ambient_npc/proc/find_chat_partner(range = AMBIENT_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/ambient_npc/proc/find_chat_partner(range = AMBIENT_ACTIVITY_RANGE) var/turf/here = get_turf(src) if(!here) return null var/list/options = list() - for(var/mob/living/basic/ambient_npc/other in range(range, here)) + for(var/mob/living/carbon/human/npc/ambient_npc/other in range(range, here)) if(other == src || other.client || !other.can_act()) continue if(other.activity && !other.activity.accepts_company) @@ -358,7 +358,8 @@ /** * An ambient NPC's brain: passive (no targeting, so trader outpost turrets read it as harmless), - * long paths for crossing a concourse, and nothing at all while idle (nobody near). + * long paths for crossing a concourse, and nothing at all while idle (nobody near). First it gets + * free of anything holding it, the way a person would. */ /datum/ai_controller/basic_controller/ambient_npc blackboard = list() @@ -366,7 +367,39 @@ ai_movement = /datum/ai_movement/jps/ambient idle_behavior = null max_target_distance = AMBIENT_PATH_LENGTH - planning_subtrees = list(/datum/ai_planning_subtree/ambient_routine) + planning_subtrees = list( + /datum/ai_planning_subtree/ambient_escape, + /datum/ai_planning_subtree/ambient_routine, + ) + +// The basic controller's check, except that cuffs don't stop them: a cuffed person works at the cuffs +// (the human NPC base's rule, voidcrew/modules/human_npcs/human_npc_soft_crit.dm) +/datum/ai_controller/basic_controller/ambient_npc/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/** + * Getting free, as a person does with Resist: out of a grab, a buckle they didn't choose (their own + * seat or boat is fine), a closet or a crate, cuffs, or flames (stop, drop and roll). Only awake and + * up; anyone down is just a body. + */ +/datum/ai_planning_subtree/ambient_escape + +/datum/ai_planning_subtree/ambient_escape/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/ambient_npc/npc = controller.pawn + if(!istype(npc) || npc.stat != CONSCIOUS || HAS_TRAIT(npc, TRAIT_INCAPACITATED) || LAZYLEN(npc.do_afters)) + return + if(!ambient_wants_free(npc)) + return + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Whether `npc` is held somewhere it doesn't want to be: grabbed, buckled to something not its own, shut in, cuffed or on fire +/proc/ambient_wants_free(mob/living/carbon/human/npc/ambient_npc/npc) + if(npc.on_fire || npc.pulledby || HAS_TRAIT(npc, TRAIT_RESTRAINED)) + return TRUE + if(npc.buckled && !npc.own_buckle(npc.buckled)) + return TRUE + return !isturf(npc.loc) && !ismob(npc.loc) // Their AI time, for the Ambient NPCs line in the MC tab (SSambient_npcs.note_ai_cost()) /datum/ai_controller/basic_controller/ambient_npc/SelectBehaviors(seconds_per_tick) @@ -387,7 +420,7 @@ /datum/ai_planning_subtree/ambient_routine /datum/ai_planning_subtree/ambient_routine/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/ambient_npc/npc = controller.pawn + var/mob/living/carbon/human/npc/ambient_npc/npc = controller.pawn if(!istype(npc) || controller.ai_status != AI_STATUS_ON || !npc.can_act()) return npc.check_leash() @@ -396,6 +429,10 @@ var/datum/ambient_activity/current = npc.next_activity() if(!current) return + // A spot can be someone they follow, who may be gone by now + if(current.spot && QDELETED(current.spot)) + current.spot_unreachable() + return if(!current.at_spot()) controller.set_blackboard_key(BB_AMBIENT_DESTINATION, current.spot) controller.queue_behavior(current.spot_distance ? /datum/ai_behavior/travel_towards/ambient/adjacent : /datum/ai_behavior/travel_towards/ambient, BB_AMBIENT_DESTINATION) @@ -423,7 +460,7 @@ var/atom/target = controller.blackboard[target_key] var/stuck = !succeeded && controller.consecutive_pathing_attempts > 0 && target && controller.current_movement_target == target . = ..() - var/mob/living/basic/ambient_npc/npc = controller.pawn + var/mob/living/carbon/human/npc/ambient_npc/npc = controller.pawn if(!istype(npc)) return if(succeeded) @@ -441,11 +478,11 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/ambient_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/ambient_npc/npc = controller.pawn + var/mob/living/carbon/human/npc/ambient_npc/npc = controller.pawn return istype(npc) && !!npc.activity /datum/ai_behavior/ambient_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/ambient_npc/npc = controller.pawn + var/mob/living/carbon/human/npc/ambient_npc/npc = controller.pawn var/datum/ambient_activity/current = npc.activity if(!current) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -463,7 +500,7 @@ // THE ACTIVITY BASE // ========================================================================= -/datum/ambient_activity/New(mob/living/basic/ambient_npc/new_doer, atom/anchor) +/datum/ambient_activity/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/anchor) . = ..() doer = new_doer anchor_ref = anchor ? WEAKREF(anchor) : null @@ -771,8 +808,8 @@ set_duration() lines_left = rand(3, 6) // An NPC partner drops what it was doing and answers. `responding` is set before its setup runs, so it never invites back. - if(istype(partner, /mob/living/basic/ambient_npc)) - var/mob/living/basic/ambient_npc/other = partner + if(istype(partner, /mob/living/carbon/human/npc/ambient_npc)) + var/mob/living/carbon/human/npc/ambient_npc/other = partner var/datum/ambient_activity/chat/answer = new(other, doer) answer.responding = TRUE answer.ends_at = ends_at + 10 SECONDS @@ -822,7 +859,7 @@ return AMBIENT_STEP_DONE if(responding) // Only while the one who started it is still talking to them - var/mob/living/basic/ambient_npc/starter = partner + var/mob/living/carbon/human/npc/ambient_npc/starter = partner var/datum/ambient_activity/chat/their_chat = istype(starter) ? starter.activity : null if(!istype(their_chat) || their_chat.partner() != doer) return AMBIENT_STEP_DONE @@ -840,7 +877,7 @@ if(lines_left-- <= 0) return AMBIENT_STEP_DONE next_line = world.time + rand(7 SECONDS, 12 SECONDS) - var/mob/living/basic/ambient_npc/other = istype(partner, /mob/living/basic/ambient_npc) ? partner : null + var/mob/living/carbon/human/npc/ambient_npc/other = istype(partner, /mob/living/carbon/human/npc/ambient_npc) ? partner : null var/list/conversation = opened ? null : doer.pick_conversation() opened = TRUE if(conversation) @@ -871,7 +908,7 @@ if(!length(weights) || !isturf(doer.loc)) return FALSE // Its room is worked out around where they stand now, and it keeps them in it until the job is done - worker = doer.AddComponent(/datum/component/outpost_ambient_worker, weights, TRUE, CALLBACK(doer, TYPE_PROC_REF(/mob/living/basic/ambient_npc, show_work_look))) + worker = doer.AddComponent(/datum/component/outpost_ambient_worker, weights, TRUE, CALLBACK(doer, TYPE_PROC_REF(/mob/living/carbon/human/npc/ambient_npc, show_work_look))) // A job whose spot they keep off (a doorway, a counter's reach) is skipped for the next one found var/list/job for(var/attempt in 1 to AMBIENT_WORK_FIND_TRIES) @@ -955,7 +992,7 @@ /// world.time they give up walking and fade where they are var/give_up_at = 0 -/datum/ambient_activity/leave/New(mob/living/basic/ambient_npc/new_doer, atom/anchor, delay = 0) +/datum/ambient_activity/leave/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/anchor, delay = 0) . = ..() wait_until = world.time + delay @@ -1059,7 +1096,7 @@ /// How long the storm lasts var/duration = 2 MINUTES -/datum/ambient_activity/shelter/New(mob/living/basic/ambient_npc/new_doer, atom/anchor, duration) +/datum/ambient_activity/shelter/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/anchor, duration) . = ..() if(duration) src.duration = duration diff --git a/voidcrew/modules/ambient_npcs/ambient_admin.dm b/voidcrew/modules/ambient_npcs/ambient_admin.dm index c41cf1527bc..bc78c3ca540 100644 --- a/voidcrew/modules/ambient_npcs/ambient_admin.dm +++ b/voidcrew/modules/ambient_npcs/ambient_admin.dm @@ -14,8 +14,8 @@ ADMIN_VERB(ambient_npc_spawn, R_ADMIN|R_DEBUG, "Ambient NPCs: Spawn", "Spawn an to_chat(user, span_warning("You need a physical location to spawn at.")) return var/list/choices = list() - for(var/npc_type in subtypesof(/mob/living/basic/ambient_npc) + /mob/living/basic/ambient_npc) - choices["NPC: [replacetext("[npc_type]", "/mob/living/basic/ambient_npc", "ambient_npc")]"] = npc_type + for(var/npc_type in subtypesof(/mob/living/carbon/human/npc/ambient_npc) + /mob/living/carbon/human/npc/ambient_npc) + choices["NPC: [replacetext("[npc_type]", "/mob/living/carbon/human/npc/ambient_npc", "ambient_npc")]"] = npc_type for(var/datum/ambient_outpost_role/role as anything in SSambient_npcs.get_outpost_roles()) if(role.npc_type) choices["Role: [role.name] ([role.type])"] = role @@ -28,8 +28,8 @@ ADMIN_VERB(ambient_npc_spawn, R_ADMIN|R_DEBUG, "Ambient NPCs: Spawn", "Spawn an var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(here) var/datum/ambient_place/outpost/outpost_place = outpost ? SSambient_npcs.outpost_place(outpost) : null var/result - if(ispath(picked, /mob/living/basic/ambient_npc)) - var/mob/living/basic/ambient_npc/npc = new picked(here) + if(ispath(picked, /mob/living/carbon/human/npc/ambient_npc)) + var/mob/living/carbon/human/npc/ambient_npc/npc = new picked(here) npc.set_place(outpost_place) result = npc else if(istype(picked, /datum/ambient_outpost_role)) @@ -63,7 +63,7 @@ ADMIN_VERB(ambient_npc_list_sites, R_ADMIN|R_DEBUG, "Ambient NPCs: List Sites", for(var/outpost in SSambient_npcs.outposts) var/datum/ambient_place/outpost/place = SSambient_npcs.outposts[outpost] lines += "  [place.describe()]" - for(var/mob/living/basic/ambient_npc/npc as anything in place.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in place.living_npcs()) lines += "    [npc] ([npc.type]): [npc.activity?.name || "nothing"] [ADMIN_JMP(npc)]" lines += "Planets" for(var/key in SSambient_npcs.planets) diff --git a/voidcrew/modules/ambient_npcs/ambient_npc.dm b/voidcrew/modules/ambient_npcs/ambient_npc.dm index b7462a0d103..7de4be10de8 100644 --- a/voidcrew/modules/ambient_npcs/ambient_npc.dm +++ b/voidcrew/modules/ambient_npcs/ambient_npc.dm @@ -1,174 +1,324 @@ /** * # World population: the base NPC * - * Owner: P0 seams (frozen). Packages subtype /mob/living/basic/ambient_npc in their own files and - * override the procs marked "Override" below; they never edit this file. + * Owner: P0 seams. Packages subtype /mob/living/carbon/human/npc/ambient_npc in their own files and + * override the procs marked "Override" below. * - * What every ambient NPC gets (spec 2.2, 2.3, 2.5): - * - a look: a random person in their outfit (outpost_npc_looks.dm), with working looks when they - * do work at objects; - * - protections: no dragging (a body neither), buckling, closets, crates or bags; no teleports, - * polymorph or type change; SENTIENCE_HUMANOID (sentience, transference and lazarus refuse them); - * immune to air, cold, heat and weather damage; a leash they walk back to, or fade from; - * - killable: a body lies down and drops a little cash (and any `death_loot`) once, never again - * after a revive; at a trader outpost, hurting one is violence there (the outpost's strikes, and + * Every ambient NPC is a real human body (voidcrew/modules/human_npcs/, phase E1 of the human NPC + * migration): wounds, bleeding, crit, death, revival, cuffs, stuns, drink and drugs work on them as + * on a player, through the same tg code. What every ambient NPC gets (spec 2.2, 2.3, 2.5): + * - a face for life (outpost_npc_looks.dm) and an outfit, its kit locked to them (owner decision D3); + * what they hold for an activity is a real item in their hand (set_held(), show_work_look()); + * - a person's resistance: while awake and up, their AI breaks grabs, gets out of buckles it didn't + * choose, works at cuffs and climbs out of closets (/datum/ai_planning_subtree/ambient_escape); in + * crit or dead they are an ordinary body that can be dragged, carried, bagged and operated on. At a + * trader outpost a living one refuses teleports; nobody can polymorph them or change their type; + * - a leash they walk back to, or walk out from (they never vanish in front of players); + * - killable: a body drops a little cash (and any `death_loot`) once, never again after a revive. It + * lies until nobody is watching (SShuman_npcs' corpse rules; a trader outpost takes it sooner, as + * carried off). At a trader outpost, hurting one is violence there (the outpost's strikes, and * everyone near ducks), and turrets never shoot them (FACTION_TURRET while they belong to it); * - a passive AI: no targeting subtree, so outpost turrets never read them as wild hostiles; * - dialogue from a JSON file with per-NPC and per-place cooldowns, replies to other NPCs, and an - * answer for a player who talks to them (an empty hand); + * answer for a player who talks to them (an empty hand), which falls through to tg's help (shaking + * awake, patting out flames, helping up) while they are down, on fire or asleep; * - reactions: being attacked, a fight at their outpost, the kingpin's shootout, a storm, the convoy. * + * Documented deviation from a player (AM-17, a performance rule like the base's dormancy): at a trader + * outpost nobody is on, the living hold still. Their AI is off and their Life() does nothing + * (pause_routine()), so nothing about them moves on until someone comes. + * * The routine (what they do from moment to moment) is in ambient_activity.dm. */ -/// What a hauling worker's carried look shows in hand +/// What a hauling worker carries in hand #define AMBIENT_CARRY_LOOK /obj/item/delivery/big -/mob/living/basic/ambient_npc/Initialize(mapload) - if(random_gender) - gender = pick(MALE, FEMALE) +/** + * `order` is the human NPC base's spawn order (ambient_build_npc()). Its look may carry an identity + * (a stray's stray_identity(): list("name", "gender", "outfit", "person")) that makes them someone + * who was here before. + */ +/mob/living/carbon/human/npc/ambient_npc/Initialize(mapload, datum/human_npc_order/order) + var/list/identity = order?.look + if(islist(identity)) + order.look = null + take_identity(identity) + if(person) + gender = person.gender + else + if(random_gender) + gender = pick(MALE, FEMALE) + person = new_outpost_npc_person(gender) if(length(outfit_choices)) outfit = pick(outfit_choices) - if(isnull(look_number)) - look_number = random_outpost_npc_look_number() + else if(isnull(outfit) && ispath(npc_outfit_table, /datum/human_npc_outfit_table)) + // Their kit by band (AM-1): a camp's yellow and red people are better kitted, never given more health + var/datum/human_npc_outfit_table/table = human_npc_singleton(npc_outfit_table) + outfit = table.pick_outfit(src, order ? order.resolve_zone() : (get_zone_type_for_player_turf(get_turf(src)) || ZONE_GREEN)) + if(outfit) + npc_outfit = outfit + npc_name = random_name ? generate_random_name(gender) : name . = ..() - if(random_name) - name = generate_random_name(gender) - real_name = name home = get_turf(src) GLOB.ambient_npcs += src - // A closet sweeps its tile without asking the drag path (#131); bags and boxes ask this trait - add_traits(list(TRAIT_NO_CONTAINMENT, TRAIT_NO_STORAGE_INSERT, TRAIT_WEATHER_IMMUNE), AMBIENT_NPC_TRAIT) - RegisterSignal(src, COMSIG_MOUSEDROP_ONTO, PROC_REF(refuse_drag)) RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) AddElement(/datum/element/relay_attackers) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) + set_pace(running = FALSE) next_line_at = world.time + rand(AMBIENT_SPEECH_COOLDOWN_LOW, AMBIENT_SPEECH_COOLDOWN_HIGH) * speech_pace - // Dressing a dummy can sleep - INVOKE_ASYNC(src, PROC_REF(build_look)) + refresh_hands() -/mob/living/basic/ambient_npc/Destroy() +/mob/living/carbon/human/npc/ambient_npc/Destroy() end_activity() - set_held(null) + QDEL_NULL(held_prop) QDEL_NULL(held_item) seat_ref = null set_place(null) GLOB.ambient_npcs -= src home = null leash_bounds = null + person = null return ..() +/// Becomes the person `identity` describes (stray_identity()): their face, gender, outfit and name. Before Initialize's parent. +/mob/living/carbon/human/npc/ambient_npc/proc/take_identity(list/identity) + var/datum/outpost_npc_look/face = identity["person"] + if(istype(face)) + person = face.copy() + random_gender = FALSE + else if(identity["gender"] == MALE || identity["gender"] == FEMALE) + random_gender = FALSE + gender = identity["gender"] + if(ispath(identity["outfit"], /datum/outfit)) + outfit_choices = null + outfit = identity["outfit"] + if(istext(identity["name"])) + random_name = FALSE + name = identity["name"] + +/// Who they are, to make the same person again later (spawn_npc(identity =)) +/mob/living/carbon/human/npc/ambient_npc/proc/ambient_identity() + return list("name" = real_name, "gender" = gender, "outfit" = outfit, "person" = person?.copy()) + +// Their own face, for life, rather than a new random one +/mob/living/carbon/human/npc/ambient_npc/npc_apply_random_look() + dna.initialize_dna(newblood_type = random_human_blood_type(), create_mutation_blocks = FALSE, randomize_features = TRUE) + person ||= new_outpost_npc_person(gender) + person.apply_to(src) + +// What they are, after what anyone can see of them (their kit and their body) +/mob/living/carbon/human/npc/ambient_npc/examine(mob/user) + . = ..() + if(desc && stat != DEAD) + . += span_notice(desc) + // ========================================================================= -// LOOK +// WHAT THEY HOLD // ========================================================================= /** - * Dresses them as person `look_number` of their gender in `outfit`, holding whatever `work_look` - * or `held_visual` calls for, the way a player holds an item: a dummy built in the same outfit, - * with a real item in hand, cached (outpost_npc_looks.dm). Can sleep. + * Puts what their work (`work_look`) or their activity (`held_visual`) calls for in their hand, as a + * real item: a lit welder with any welding visor down, a wrench, a parcel, a mop, a weapon. A weapon + * or tool they carry on them (a belt, a pocket, a bag) is taken out and put back later; anything else + * is a prop, locked to them like their kit and deleted when they put it away. Their drink + * (`held_item`) stays in its own hand. */ -/mob/living/basic/ambient_npc/proc/build_look() +/mob/living/carbon/human/npc/ambient_npc/proc/refresh_hands() if(QDELETED(src)) return - var/serial = ++look_serial - if(length(work_weights)) - work_looks = get_outpost_worker_looks(outfit, gender, look_number) - var/look - if((work_look == "weld" || work_look == "tool") && work_looks) - look = work_looks[work_look] + var/wanted_type + switch(work_look) + if("weld") + wanted_type = /obj/item/weldingtool + if("tool") + wanted_type = /obj/item/wrench + if("carry") + wanted_type = AMBIENT_CARRY_LOOK + wanted_type ||= held_visual + set_outpost_worker_visors(src, up = work_look != "weld") + if(held_prop && (QDELETED(held_prop) || !is_holding(held_prop))) + held_prop = null + held_prop_slot = NONE + if(held_prop && wanted_type && istype(held_prop, wanted_type)) + light_prop(held_prop) + return + put_away_prop() + if(!wanted_type) + return + // Already in a hand (a spear or a cleaver their outfit put there) + for(var/obj/item/in_hand in held_items) + if(istype(in_hand, wanted_type) && in_hand != held_item) + held_prop = in_hand + light_prop(in_hand) + return + var/obj/item/carried = carried_item_of(wanted_type) + var/carried_slot = carried ? get_slot_by_item(carried) : NONE + if(carried && human_npc_take_in_hand(src, carried)) + held_prop = carried + held_prop_slot = carried_slot else - var/hand = (work_look == "carry") ? AMBIENT_CARRY_LOOK : held_visual - if(hand) - look = get_outpost_held_look(outfit, gender, look_number, hand) - else if(work_looks) - look = work_looks["idle"] - else - look = get_outpost_npc_look(outfit, gender, look_number) - // A later build already landed while this one slept; leave its look alone. - if(QDELETED(src) || serial != look_serial) + var/obj/item/prop = new wanted_type(src) + if(!put_in_hands(prop)) + qdel(prop) + return + npc_lock_item(prop) + ADD_TRAIT(prop, TRAIT_AMBIENT_PROP, AMBIENT_NPC_TRAIT) + held_prop = prop + light_prop(held_prop) + +/// Something of `item_type` they carry on them but don't hold (a belt, a pocket, a bag), or null +/mob/living/carbon/human/npc/ambient_npc/proc/carried_item_of(item_type) + for(var/obj/item/thing as anything in get_all_contents_type(item_type)) + if(is_holding(thing) || thing == held_item || !human_npc_can_draw(src, thing)) + continue + return thing + return null + +/// A welder for welding is lit; any other prop is left as it is +/mob/living/carbon/human/npc/ambient_npc/proc/light_prop(obj/item/prop) + var/obj/item/weldingtool/welder = prop + if(istype(welder) && welder.welding != (work_look == "weld")) + welder.set_welding(work_look == "weld") + welder.update_appearance() + +/// Puts down what they held for show: a prop goes, something of their own goes back where it came from +/mob/living/carbon/human/npc/ambient_npc/proc/put_away_prop() + var/obj/item/prop = held_prop + var/home_slot = held_prop_slot + held_prop = null + held_prop_slot = NONE + if(QDELETED(prop)) + return + if(HAS_TRAIT(prop, TRAIT_AMBIENT_PROP)) + qdel(prop) return - apply_outpost_npc_look(src, look) - // Applying a look wipes every overlay, sparks included: put the work loop's back. - var/datum/component/outpost_ambient_worker/worker = GetComponent(/datum/component/outpost_ambient_worker) - if(worker?.work_overlay) - add_overlay(worker.work_overlay) - -/// Redresses them in `new_outfit` (a new job, a disguise). Can be called from anywhere; the look is built async. -/mob/living/basic/ambient_npc/proc/set_outfit(new_outfit) - outfit = new_outfit - work_looks = null - INVOKE_ASYNC(src, PROC_REF(build_look)) + var/obj/item/weldingtool/welder = prop + if(istype(welder) && welder.welding) + welder.set_welding(FALSE) + if(is_holding(prop) && !(home_slot && equip_to_slot_if_possible(prop, home_slot, disable_warning = TRUE))) + prop.equip_to_best_slot(src) /// Shows them holding what `look` needs ("weld", "tool", "carry"), or their held item / empty hands for null. The worker component calls this. -/mob/living/basic/ambient_npc/proc/show_work_look(look) +/mob/living/carbon/human/npc/ambient_npc/proc/show_work_look(look) + if(work_look == look) + return work_look = look - INVOKE_ASYNC(src, PROC_REF(build_look)) + refresh_hands() -/// Shows `look` (an item type, or an item instance) in their hand as part of their look; null shows empty hands. A change that ends up the same item type is not rebuilt. -/mob/living/basic/ambient_npc/proc/set_held(look) +/** + * Holds `look` (an item type, or an item instance) for show; null shows empty hands. Their own + * drink (`held_item`) goes into their hand as it is. A change that ends up the same item type keeps + * the item they already hold. + */ +/mob/living/carbon/human/npc/ambient_npc/proc/set_held(look) + if(isitem(look) && look == held_item) + var/obj/item/drink = held_item + if(!QDELETED(drink) && !is_holding(drink)) + if(drink.loc == src) + temporarilyRemoveItemFromInventory(drink, force = TRUE) + if(!put_in_hands(drink)) + drink.forceMove(src) + look = null var/new_held = ambient_held_look_type(look) - if(new_held == held_visual) + if(new_held == held_visual && (!new_held || held_prop)) return held_visual = new_held - INVOKE_ASYNC(src, PROC_REF(build_look)) + refresh_hands() + +/// Walking (an unhurried pace about their business) or running (a fight, getting away) +/mob/living/carbon/human/npc/ambient_npc/proc/set_pace(running) + var/wanted = running ? MOVE_INTENT_RUN : MOVE_INTENT_WALK + if(move_intent != wanted) + move_intent = wanted + update_move_intent_slowdown() /// Fades in where they stand, as someone stepping off the lift -/mob/living/basic/ambient_npc/proc/fade_in() +/mob/living/carbon/human/npc/ambient_npc/proc/fade_in() alpha = 0 animate(src, alpha = 255, time = AMBIENT_FADE_TIME) /** - * Gone: they fade and are deleted (walked off, took the lift, slipped away). Nothing starts once - * this has. `instant` deletes them now, for places nobody is watching. + * Gone (walked off, took the lift, slipped away), if they may go now: up and free, and on their way + * out tile or out of every player's sight (npc_may_vanish_now()). They fade over AMBIENT_FADE_TIME and + * are deleted; anything that puts them down, grabs or buckles them meanwhile stops it (no godmode: a + * shot still lands). One who may not go yet (a player is watching them away from the exit) walks out + * instead. `instant` deletes them now, for places nobody is watching. Returns TRUE if they are going. */ -/mob/living/basic/ambient_npc/proc/fade_out(instant = FALSE) +/mob/living/carbon/human/npc/ambient_npc/proc/fade_out(instant = FALSE) if(fading || QDELETED(src)) - return + return fading + if(instant) + fading = TRUE + end_activity() + qdel(src) + return TRUE + if(!npc_may_vanish_now()) + if(stat == CONSCIOUS && !istype(activity, /datum/ambient_activity/leave)) + start_activity(new /datum/ambient_activity/leave(src)) + return FALSE fading = TRUE end_activity() ADD_TRAIT(src, TRAIT_AI_PAUSED, AMBIENT_NPC_TRAIT) - if(instant) - qdel(src) - return - ADD_TRAIT(src, TRAIT_GODMODE, AMBIENT_NPC_TRAIT) animate(src, alpha = 0, time = AMBIENT_FADE_TIME) - QDEL_IN(src, AMBIENT_FADE_TIME) + addtimer(CALLBACK(src, PROC_REF(finish_fade)), AMBIENT_FADE_TIME, TIMER_DELETE_ME) + return TRUE -/// Scales their health by `multiplier` (a planet NPC by band at its spawn site, never on the type) -/mob/living/basic/ambient_npc/proc/scale_health(multiplier) - if(multiplier <= 0 || multiplier == 1) +/// The end of a fade: gone, unless something stopped them on the way +/mob/living/carbon/human/npc/ambient_npc/proc/finish_fade() + if(QDELETED(src)) + return + if(stat == CONSCIOUS && !human_npc_out_of_fight(src) && !pulledby && (!buckled || own_buckle(buckled)) && isturf(loc)) + npc_drop_not_gear() + qdel(src) return - maxHealth = round(maxHealth * multiplier) - health = maxHealth - updatehealth() + // Shot, grabbed or put down on the way: they are still here + fading = FALSE + REMOVE_TRAIT(src, TRAIT_AI_PAUSED, AMBIENT_NPC_TRAIT) + animate(src, alpha = 255, time = 0.2 SECONDS) + +/** + * Whether they may slip away right now (the base's rule for anyone walking out, N8): awake, up and + * free (not in crit, cuffed, stunned, pulled, buckled to anything but their own seat, or carried), + * and on their place's way out or out of every player's sight. + */ +/mob/living/carbon/human/npc/ambient_npc/proc/npc_may_vanish_now() + if(stat != CONSCIOUS || human_npc_out_of_fight(src) || INCAPACITATED_IGNORING(src, NONE)) + return FALSE + if(pulledby || !isturf(loc) || (buckled && !own_buckle(buckled))) + return FALSE + var/turf/exit = place?.exit_turf(src) + if(exit && get_dist(src, exit) <= 1) + return TRUE + return !human_npc_watched(src) // ========================================================================= // PROTECTIONS // ========================================================================= -/// Nobody drag-drops them onto a bed, into a crate, a disposal unit or a vehicle -/mob/living/basic/ambient_npc/proc/refuse_drag(datum/source, atom/over, mob/user) - SIGNAL_HANDLER - return COMPONENT_CANCEL_MOUSEDROP_ONTO - -/// Teleports of any kind, forced ones included, leave them where they are -/mob/living/basic/ambient_npc/proc/refuse_teleport(datum/source, atom/destination, channel) +/// At a trader outpost, a living person's teleports of any kind, forced ones too, leave them where they are +/mob/living/carbon/human/npc/ambient_npc/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER + if(stat == DEAD || !istype(place, /datum/ambient_place/outpost)) + return NONE return TRUE -/mob/living/basic/ambient_npc/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/ambient_npc/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER return STOP_WABBAJACK -/mob/living/basic/ambient_npc/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/ambient_npc/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE +/// Whether being buckled to `thing` is their own doing (the seat they sat on), so their AI leaves it be. Override (a boat). +/mob/living/carbon/human/npc/ambient_npc/proc/own_buckle(atom/thing) + return !isnull(thing) && thing == seat_ref?.resolve() + // Their own steps stay on the leash. Dragged, thrown, buckled or walked by a script, they go where they are taken. -/mob/living/basic/ambient_npc/Move(atom/newloc, direct, glide_size_override) +/mob/living/carbon/human/npc/ambient_npc/Move(atom/newloc, direct, glide_size_override) if(!own_step_allowed(newloc)) return FALSE return ..() @@ -177,7 +327,7 @@ * Whether a step of their own onto `newloc` is allowed: onto their leash always; off it from on it * never; already off it (shoved, thrown), anywhere safe, so they can walk back. */ -/mob/living/basic/ambient_npc/proc/own_step_allowed(atom/newloc) +/mob/living/carbon/human/npc/ambient_npc/proc/own_step_allowed(atom/newloc) if(!isturf(newloc) || !isturf(loc) || pulledby || buckled || throwing || stat == DEAD) return TRUE var/turf/destination = newloc @@ -192,7 +342,7 @@ * on their place's ground (an outpost's concourse, a planet's ground above its dock strip). * Override for a leash of another shape. */ -/mob/living/basic/ambient_npc/proc/leash_ok(turf/tile) +/mob/living/carbon/human/npc/ambient_npc/proc/leash_ok(turf/tile) tile = get_turf(tile) if(!tile) return FALSE @@ -207,7 +357,7 @@ * AMBIENT_LEASH_GIVE_UP, or somewhere they cannot walk back from, they give up (give_up_leash()). * TRUE while they are off it. */ -/mob/living/basic/ambient_npc/proc/check_leash() +/mob/living/carbon/human/npc/ambient_npc/proc/check_leash() var/turf/here = get_turf(src) if(!here || leash_ok(here)) leash_broken_at = 0 @@ -225,7 +375,7 @@ return TRUE /// They cannot get back to their leash: they slip away. Override to put them home instead. -/mob/living/basic/ambient_npc/proc/give_up_leash() +/mob/living/carbon/human/npc/ambient_npc/proc/give_up_leash() fade_out() // ========================================================================= @@ -233,7 +383,7 @@ // ========================================================================= /// Moves them to `new_place` (or none), keeping both places' lists right -/mob/living/basic/ambient_npc/proc/set_place(datum/ambient_place/new_place) +/mob/living/carbon/human/npc/ambient_npc/proc/set_place(datum/ambient_place/new_place) if(place == new_place) return var/datum/ambient_place/old_place = place @@ -241,9 +391,10 @@ old_place?.remove_npc(src) new_place?.add_npc(src) -/// Whether they can do anything: awake, on their feet or in a seat, not fading -/mob/living/basic/ambient_npc/proc/can_act() - return stat == CONSCIOUS && !fading && !HAS_TRAIT(src, TRAIT_INCAPACITATED) +/// Whether they can go about their business: awake, on their feet or in a seat, not cuffed, not fading +/mob/living/carbon/human/npc/ambient_npc/proc/can_act() + return stat == CONSCIOUS && !fading && !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_RESTRAINED) + // ========================================================================= // HOLDING STILL (nobody at their outpost) @@ -251,24 +402,25 @@ /** * Nobody is at their outpost: they stop where they are, mid-whatever. Their AI goes off - * (TRAIT_AI_PAUSED), and resume_routine() later moves everything they wait for on by as long as - * they stood, so nothing they do runs on while nobody watches. Override to put away anything that - * would tick on by itself; call the parent. + * (TRAIT_AI_PAUSED) and their Life() does nothing, and resume_routine() later moves everything they + * wait for on by as long as they stood, so nothing they do runs on while nobody watches. Override to + * put away anything that would tick on by itself; call the parent. */ -/mob/living/basic/ambient_npc/proc/pause_routine() +/mob/living/carbon/human/npc/ambient_npc/proc/pause_routine() if(paused_at) return paused_at = max(world.time, 1) ADD_TRAIT(src, TRAIT_AI_PAUSED, AMBIENT_PAUSED_TRAIT) -// Holding still at an empty outpost: nothing about them moves on (they are proof against air, cold and heat anyway) -/mob/living/basic/ambient_npc/Life(seconds_per_tick = SSMOBS_DT, times_fired) - if(paused_at && !client) - return +// Holding still at an empty outpost: nothing about the living moves on (a documented performance rule, +// AM-17, like the base's dormancy). A body runs its Life() as any other NPC corpse does. +/mob/living/carbon/human/npc/ambient_npc/Life(seconds_per_tick = SSMOBS_DT, times_fired) + if(paused_at && !client && stat != DEAD) + return FALSE return ..() /// Someone is back: they carry on from where they stopped. Override to bring back what pause_routine() put away; call the parent. -/mob/living/basic/ambient_npc/proc/resume_routine() +/mob/living/carbon/human/npc/ambient_npc/proc/resume_routine() if(!paused_at) return var/stood = world.time - paused_at @@ -281,7 +433,7 @@ * They stood still for `delay`: every world.time they wait for (their next line, the end of their * visit, their activity's steps) moves on by as much. Override for your own; call the parent. */ -/mob/living/basic/ambient_npc/proc/shift_times(delay) +/mob/living/carbon/human/npc/ambient_npc/proc/shift_times(delay) next_line_at = ambient_shifted(next_line_at, delay) activity_retry_at = ambient_shifted(activity_retry_at, delay) leash_broken_at = ambient_shifted(leash_broken_at, delay) @@ -291,21 +443,38 @@ // DEATH // ========================================================================= -/mob/living/basic/ambient_npc/death(gibbed) +/mob/living/carbon/human/npc/ambient_npc/death(gibbed) var/was_alive = stat != DEAD + if(was_alive) + // Before the base starts counting: a trader outpost carries its dead off sooner + npc_corpse_lifetime = corpse_lifetime() . = ..() - if(!was_alive) + if(!was_alive || stat != DEAD) return end_activity() - set_held(null) - SEND_SIGNAL(src, COMSIG_AMBIENT_NPC_DIED, place) + if(crouching) + crouching = FALSE + remove_offsets(AMBIENT_NPC_TRAIT) if(!death_counted) death_counted = TRUE + SEND_SIGNAL(src, COMSIG_AMBIENT_NPC_DIED, place) place?.npc_died(src) drop_loot() +/** + * How long their body lies before SShuman_npcs may take it, once nobody is near (its corpse rules, + * N9): a trader outpost's dead go after AMBIENT_OUTPOST_BODY_TIME, as if carried off; a ship's + * deckhands stay with their crew; anyone else as any NPC corpse does. + */ +/mob/living/carbon/human/npc/ambient_npc/proc/corpse_lifetime() + if(istype(place, /datum/ambient_place/outpost)) + return AMBIENT_OUTPOST_BODY_TIME + if(istype(place, /datum/ambient_place/ship)) + return 0 + return HUMAN_NPC_CORPSE_LIFETIME + /// Their cash and death loot, once: a revived NPC killed again drops nothing -/mob/living/basic/ambient_npc/proc/drop_loot() +/mob/living/carbon/human/npc/ambient_npc/proc/drop_loot() if(loot_dropped) return loot_dropped = TRUE @@ -320,17 +489,6 @@ for(var/item_type in death_loot) new item_type(drop_turf) -// A body lies down, and gets up again if they are revived. Types that turn as they lie (rotate_on_lying) turn by themselves. -/mob/living/basic/ambient_npc/look_dead() - . = ..() - if(!rotate_on_lying) - transform = matrix().Turn(90) - -/mob/living/basic/ambient_npc/look_alive() - . = ..() - if(!rotate_on_lying) - transform = matrix() - // ========================================================================= // DIALOGUE // ========================================================================= @@ -364,7 +522,7 @@ * The lines they have for `context`: their own section, then their file's "any" section, then the * core file's "default" section. Override to add lines from elsewhere (a trader's shop). */ -/mob/living/basic/ambient_npc/proc/get_lines(context) +/mob/living/carbon/human/npc/ambient_npc/proc/get_lines(context) var/list/lines = ambient_dialogue_lines(dialogue_file, dialogue_section, context) if(!length(lines)) lines = ambient_dialogue_lines(dialogue_file, "any", context) @@ -375,7 +533,7 @@ return lines /// A random two-person exchange from their section's "conversations": list(opener, reply), or null -/mob/living/basic/ambient_npc/proc/pick_conversation() +/mob/living/carbon/human/npc/ambient_npc/proc/pick_conversation() var/list/entry = ambient_dialogue_section(dialogue_file, dialogue_section) var/list/conversations = entry?["conversations"] if(!length(conversations)) @@ -387,17 +545,17 @@ return list(conversation["opener"], length(replies) ? pick(replies) : null) /// Their first name, as someone would call them -/mob/living/basic/ambient_npc/proc/speech_name() +/mob/living/carbon/human/npc/ambient_npc/proc/speech_name() var/list/parts = splittext(real_name || name, " ") return length(parts) ? parts[1] : name /// Fills a line's placeholders: {name} their first name, {other} who they talk to, {place} where they are -/mob/living/basic/ambient_npc/proc/fill_line(line, atom/other) +/mob/living/carbon/human/npc/ambient_npc/proc/fill_line(line, atom/other) line = replacetext(line, "{name}", speech_name()) if(findtext(line, "{other}")) var/other_name = "friend" - if(istype(other, /mob/living/basic/ambient_npc)) - var/mob/living/basic/ambient_npc/other_npc = other + if(istype(other, /mob/living/carbon/human/npc/ambient_npc)) + var/mob/living/carbon/human/npc/ambient_npc/other_npc = other other_name = other_npc.speech_name() else if(ismob(other)) var/mob/other_mob = other @@ -410,7 +568,7 @@ * Says a line for `context` (to `other`, if given). A spontaneous line waits out their own pause and * the pause shared by their place; `force` skips both (replies, reactions). TRUE if they spoke. */ -/mob/living/basic/ambient_npc/proc/speak_context(context, atom/other, force = FALSE) +/mob/living/carbon/human/npc/ambient_npc/proc/speak_context(context, atom/other, force = FALSE) if(stat != CONSCIOUS || fading) return FALSE if(!force && (world.time < next_line_at || (place && world.time < place.next_line_at))) @@ -425,7 +583,7 @@ return TRUE /// Says `line` now and starts their pauses. Never sleeps. -/mob/living/basic/ambient_npc/proc/say_line(line) +/mob/living/carbon/human/npc/ambient_npc/proc/say_line(line) if(!line || stat != CONSCIOUS) return next_line_at = world.time + rand(AMBIENT_SPEECH_COOLDOWN_LOW, AMBIENT_SPEECH_COOLDOWN_HIGH) * speech_pace @@ -437,10 +595,10 @@ * Answers `speaker` a moment from now: `line` if given, otherwise a line for `context`. Only if * they are still awake and near each other then. */ -/mob/living/basic/ambient_npc/proc/reply_to(atom/movable/speaker, context = AMBIENT_LINE_REPLY, line) +/mob/living/carbon/human/npc/ambient_npc/proc/reply_to(atom/movable/speaker, context = AMBIENT_LINE_REPLY, line) addtimer(CALLBACK(src, PROC_REF(do_reply), WEAKREF(speaker), context, line), rand(AMBIENT_REPLY_DELAY_LOW, AMBIENT_REPLY_DELAY_HIGH), TIMER_DELETE_ME) -/mob/living/basic/ambient_npc/proc/do_reply(datum/weakref/speaker_ref, context, line) +/mob/living/carbon/human/npc/ambient_npc/proc/do_reply(datum/weakref/speaker_ref, context, line) var/atom/movable/speaker = speaker_ref?.resolve() if(QDELETED(speaker) || stat != CONSCIOUS || fading || get_dist(src, speaker) > 3) return @@ -455,15 +613,21 @@ // PLAYERS // ========================================================================= -// An empty hand talks to them; combat mode or a right click is the usual attack or shove -/mob/living/basic/ambient_npc/attack_hand(mob/living/carbon/human/user, list/modifiers) - if(!user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK) && can_act()) +// An empty hand talks to them while they are up and about; down, on fire or asleep it is tg's help +// (helping them up, patting out the flames, shaking them awake, CPR). Combat mode or a right click is +// the usual attack or shove. +/mob/living/carbon/human/npc/ambient_npc/attack_hand(mob/living/carbon/human/user, list/modifiers) + if(!user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK) && can_be_talked_to()) talked_to(user) return TRUE return ..() +/// Whether an empty hand talks to them now, rather than tg's help: up and about, and not on fire +/mob/living/carbon/human/npc/ambient_npc/proc/can_be_talked_to() + return can_act() && !on_fire && body_position == STANDING_UP + /// Whether `user` may have another answer yet, and starts their wait if so -/mob/living/basic/ambient_npc/proc/talk_ready(mob/user) +/mob/living/carbon/human/npc/ambient_npc/proc/talk_ready(mob/user) var/key = REF(user) if(LAZYACCESS(talk_cooldowns, key) > world.time) return FALSE @@ -474,7 +638,7 @@ return TRUE /// `user` talked to them (an empty hand). Override for a menu (a stray's "Come with us"). -/mob/living/basic/ambient_npc/proc/talked_to(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/proc/talked_to(mob/living/user) if(!talk_ready(user)) return if(!buckled) @@ -486,13 +650,13 @@ // ========================================================================= /// Whether a reaction of `kind` may happen now, and starts its cooldown if so -/mob/living/basic/ambient_npc/proc/reaction_ready(kind, cooldown = AMBIENT_REACTION_COOLDOWN) +/mob/living/carbon/human/npc/ambient_npc/proc/reaction_ready(kind, cooldown = AMBIENT_REACTION_COOLDOWN) if(LAZYACCESS(reaction_cooldowns, kind) > world.time) return FALSE LAZYSET(reaction_cooldowns, kind, world.time + cooldown) return TRUE -/mob/living/basic/ambient_npc/proc/on_attacked(datum/source, atom/attacker, attack_flags) +/mob/living/carbon/human/npc/ambient_npc/proc/on_attacked(datum/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) return @@ -504,7 +668,7 @@ * Someone went for them. Override: a planet NPC fights back or runs (PB). The default is a line, * and at a trader outpost they duck out to the lift. Never sleeps. */ -/mob/living/basic/ambient_npc/proc/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/proc/react_attacked(atom/attacker) if(stat != CONSCIOUS || fading || !reaction_ready("attacked")) return speak_context(AMBIENT_LINE_ATTACKED, attacker, force = TRUE) @@ -515,7 +679,7 @@ * A fight broke out near them at their outpost (COMSIG_TRADER_OUTPOST_VIOLENCE): they duck, then * head for the lift and leave. Override to stay (a barkeep). Never sleeps. */ -/mob/living/basic/ambient_npc/proc/react_violence(mob/living/offender) +/mob/living/carbon/human/npc/ambient_npc/proc/react_violence(mob/living/offender) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/leave) || !reaction_ready("violence")) return // Leaving first: starting it finishes what they were doing, which stands them up @@ -528,7 +692,7 @@ speak_context(AMBIENT_LINE_VIOLENCE, offender, force = TRUE) /// The kingpin's crew is fighting: they head for `refuge` and keep their heads down. Never sleeps. -/mob/living/basic/ambient_npc/proc/react_shootout(turf/refuge) +/mob/living/carbon/human/npc/ambient_npc/proc/react_shootout(turf/refuge) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/leave)) return if(prob(40)) @@ -536,7 +700,7 @@ start_activity(new /datum/ambient_activity/take_cover(src, refuge)) /// The shootout is over: back to what they were doing -/mob/living/basic/ambient_npc/proc/shootout_over() +/mob/living/carbon/human/npc/ambient_npc/proc/shootout_over() if(istype(activity, /datum/ambient_activity/take_cover)) end_activity() @@ -544,7 +708,7 @@ * A storm is coming to their planet (COMSIG_WEATHER_TELEGRAPH): a shouted warning, which doubles * as a telegraph for players, then shelter or a crouch until it passes. Never sleeps. */ -/mob/living/basic/ambient_npc/proc/react_storm(datum/weather/storm) +/mob/living/carbon/human/npc/ambient_npc/proc/react_storm(datum/weather/storm) if(stat != CONSCIOUS || fading || !reaction_ready("storm", 2 MINUTES)) return speak_context(AMBIENT_LINE_STORM, null, force = TRUE) @@ -555,7 +719,7 @@ start_activity(new /datum/ambient_activity/shelter(src, istype(site) ? site.shelter : null, duration)) /// The supply convoy arrived at their outpost (COMSIG_TRADER_OUTPOST_CONVOY). Override: the dock workers (PA). -/mob/living/basic/ambient_npc/proc/react_convoy(obj/structure/overmap/trader_outpost/outpost) +/mob/living/carbon/human/npc/ambient_npc/proc/react_convoy(obj/structure/overmap/trader_outpost/outpost) return // ========================================================================= @@ -563,7 +727,7 @@ // ========================================================================= /// Sits on `seat` from its tile -/mob/living/basic/ambient_npc/proc/sit_on(obj/structure/chair/seat) +/mob/living/carbon/human/npc/ambient_npc/proc/sit_on(obj/structure/chair/seat) if(QDELETED(seat)) return FALSE if(buckled == seat) @@ -576,15 +740,15 @@ seat_ref = WEAKREF(seat) return TRUE -/// Down on the floor: sitting, or ducking -/mob/living/basic/ambient_npc/proc/crouch() - if(crouching || buckled) +/// Down on the floor: sitting, or ducking (a pixel shift; their body stays up) +/mob/living/carbon/human/npc/ambient_npc/proc/crouch() + if(crouching || buckled || body_position == LYING_DOWN) return crouching = TRUE add_offsets(AMBIENT_NPC_TRAIT, y_add = -4) /// Up from their seat or the floor. Only a seat they sat on themselves: never out of one someone put them in. -/mob/living/basic/ambient_npc/proc/stand_up() +/mob/living/carbon/human/npc/ambient_npc/proc/stand_up() if(crouching) crouching = FALSE remove_offsets(AMBIENT_NPC_TRAIT) @@ -592,35 +756,41 @@ seat_ref = null if(seat && buckled == seat) seat.unbuckle_mob(src, force = TRUE) + if(resting && stat == CONSCIOUS) + set_resting(FALSE, silent = TRUE) -/// A drink in hand, poured for the occasion: a real glass in their contents -/mob/living/basic/ambient_npc/proc/take_drink(reagent_type = /datum/reagent/consumable/ethanol/beer) +/// A drink in hand, poured for them where they got it: a real glass, held in a hand +/mob/living/carbon/human/npc/ambient_npc/proc/take_drink(reagent_type = /datum/reagent/consumable/ethanol/beer) var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = held_item - if(!istype(glass) || QDELETED(glass)) + if(!istype(glass) || QDELETED(glass) || !(glass in get_all_contents())) glass = new(src) - glass.reagents.add_reagent(reagent_type, 25) + glass.reagents.add_reagent(reagent_type, AMBIENT_DRINK_POUR) held_item = glass set_held(glass) return glass -/// A sip of their drink; an empty glass is filled again. FALSE with no drink in hand. -/mob/living/basic/ambient_npc/proc/sip() +/** + * A gulp of their drink, swallowed as anyone swallows one (the glass's own drinking: the reagents go + * into them, so drink makes them drunk and whatever else is in it does what it does). An empty glass + * is poured again. FALSE with no drink in hand, or when they can't drink (a mask over the mouth). + */ +/mob/living/carbon/human/npc/ambient_npc/proc/sip() var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = held_item - if(!istype(glass) || QDELETED(glass) || glass.loc != src) + if(!istype(glass) || QDELETED(glass) || !is_holding(glass)) return FALSE if(!glass.reagents.total_volume) - glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) - else - glass.reagents.remove_all(5) - playsound(src, 'sound/items/drink.ogg', 20, TRUE, -4) - set_held(glass) + glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, AMBIENT_DRINK_POUR) + return TRUE + if(!glass.canconsume(src, src)) + return FALSE + glass.attack(src, src) return TRUE /** * Sets their drink down on `table` if it is right there, up to AMBIENT_GLASSES_MAX glasses of * theirs and AMBIENT_TABLE_GLASSES_MAX on the table; otherwise it just goes. */ -/mob/living/basic/ambient_npc/proc/put_drink_down(obj/structure/table/table) +/mob/living/carbon/human/npc/ambient_npc/proc/put_drink_down(obj/structure/table/table) var/obj/item/glass = held_item held_item = null set_held(null) @@ -630,9 +800,8 @@ var/on_table = 0 for(var/obj/item/reagent_containers/cup/glass/other in table.loc) on_table++ - if(on_table < AMBIENT_TABLE_GLASSES_MAX) + if(on_table < AMBIENT_TABLE_GLASSES_MAX && transferItemToLoc(glass, table.loc, force = TRUE)) glasses_left++ - glass.forceMove(table.loc) glass.pixel_x = rand(-6, 6) glass.pixel_y = rand(0, 6) return diff --git a/voidcrew/modules/ambient_npcs/ambient_subsystem.dm b/voidcrew/modules/ambient_npcs/ambient_subsystem.dm index 1006d68e601..e99e2d4e547 100644 --- a/voidcrew/modules/ambient_npcs/ambient_subsystem.dm +++ b/voidcrew/modules/ambient_npcs/ambient_subsystem.dm @@ -190,13 +190,11 @@ SUBSYSTEM_DEF(ambient_npcs) if(place.players) if(!place.occupied) wake_outpost(place) - clear_bodies(place) if(world.time >= place.arrivals_at && arrive_next(place, roles)) place.arrivals_at = world.time + AMBIENT_OUTPOST_ARRIVAL_EVERY return 0 if(place.occupied) still_outpost(place) - clear_bodies(place) // A killed person's place has opened again if(place.refill_at && world.time >= place.refill_at) place.refill_at = 0 @@ -206,7 +204,7 @@ SUBSYSTEM_DEF(ambient_npcs) /// A player reached `place`'s concourse: everyone there carries on from where they stopped /datum/controller/subsystem/ambient_npcs/proc/wake_outpost(datum/ambient_place/outpost/place) place.occupied = TRUE - for(var/mob/living/basic/ambient_npc/npc as anything in place.npcs) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in place.npcs) if(!QDELETED(npc)) npc.resume_routine() var/obj/structure/overmap/trader_outpost/outpost = place.outpost() @@ -218,27 +216,13 @@ SUBSYSTEM_DEF(ambient_npcs) place.occupied = FALSE // Anyone who went while players were here is made up in place place.needs_settling = TRUE - for(var/mob/living/basic/ambient_npc/npc as anything in place.npcs) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in place.npcs) if(!QDELETED(npc)) npc.pause_routine() var/obj/structure/overmap/trader_outpost/outpost = place.outpost() if(outpost) SEND_SIGNAL(outpost, COMSIG_AMBIENT_OUTPOST_EMPTIED, place) -/** - * Takes the bodies at `place` away: at once while nobody is on the concourse to see, or - * AMBIENT_OUTPOST_BODY_TIME after the death while players are (they fade, as if carried off). - * Never one an admin is in. - */ -/datum/controller/subsystem/ambient_npcs/proc/clear_bodies(datum/ambient_place/outpost/place) - for(var/mob/living/basic/ambient_npc/npc as anything in place.npcs.Copy()) - if(QDELETED(npc) || npc.stat != DEAD || npc.fading || npc.ckey) - continue - if(!place.occupied) - npc.fade_out(instant = TRUE) - else if(world.time >= npc.timeofdeath + AMBIENT_OUTPOST_BODY_TIME) - npc.fade_out() - /** * The next person off the lift at `place`: one role that is due (short of its count, less anyone * killed lately, and past its gap), weighted, while the outpost is under @@ -307,11 +291,11 @@ SUBSYSTEM_DEF(ambient_npcs) var/turf/refuge = ambient_kingpin_refuge(place.outpost()) if(refuge && !place.shootout_refuge) place.shootout_refuge = refuge - for(var/mob/living/basic/ambient_npc/npc as anything in place.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in place.living_npcs()) npc.react_shootout(refuge) else if(!refuge && place.shootout_refuge) place.shootout_refuge = null - for(var/mob/living/basic/ambient_npc/npc as anything in place.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in place.living_npcs()) npc.shootout_over() /** @@ -529,7 +513,7 @@ SUBSYSTEM_DEF(ambient_npcs) if(!storm_hits(record, storm)) continue for(var/datum/ambient_place/site/site as anything in record.sites) - for(var/mob/living/basic/ambient_npc/npc as anything in site.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in site.living_npcs()) npc.react_storm(storm) /// Whether `storm` falls on `record`'s planet: its weather site when it came from one, its level otherwise @@ -546,49 +530,49 @@ SUBSYSTEM_DEF(ambient_npcs) // ========================================================================= /datum/ambient_place/Destroy() - for(var/mob/living/basic/ambient_npc/npc as anything in npcs.Copy()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in npcs.Copy()) if(npc.place == src) npc.place = null npcs.Cut() return ..() -/// `npc` belongs here now. Use /mob/living/basic/ambient_npc/proc/set_place(), which calls this. -/datum/ambient_place/proc/add_npc(mob/living/basic/ambient_npc/npc) +/// `npc` belongs here now. Use /mob/living/carbon/human/npc/ambient_npc/proc/set_place(), which calls this. +/datum/ambient_place/proc/add_npc(mob/living/carbon/human/npc/ambient_npc/npc) npcs |= npc /// `npc` is gone from here (deleted, or moved elsewhere) -/datum/ambient_place/proc/remove_npc(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/proc/remove_npc(mob/living/carbon/human/npc/ambient_npc/npc) npcs -= npc /// `npc` died here -/datum/ambient_place/proc/npc_died(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/proc/npc_died(mob/living/carbon/human/npc/ambient_npc/npc) return /// `attacker` went for `npc` here (`attack_flags`: ATTACKER_*), the blow already landed. Override. Never sleeps. -/datum/ambient_place/proc/npc_attacked(mob/living/basic/ambient_npc/npc, atom/attacker, attack_flags) +/datum/ambient_place/proc/npc_attacked(mob/living/carbon/human/npc/ambient_npc/npc, atom/attacker, attack_flags) return /// How much of `amount` cash `npc` drops, killed here. Override to cap it. -/datum/ambient_place/proc/cash_for(mob/living/basic/ambient_npc/npc, amount) +/datum/ambient_place/proc/cash_for(mob/living/carbon/human/npc/ambient_npc/npc, amount) return amount /// Its NPCs who are alive and not leaving /datum/ambient_place/proc/living_npcs() . = list() - for(var/mob/living/basic/ambient_npc/npc as anything in npcs) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in npcs) if(!QDELETED(npc) && npc.stat != DEAD && !npc.fading) . += npc /// Whether its NPCs may be on `tile` by their own steps (their leash). Override. -/datum/ambient_place/proc/leash_ok(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/proc/leash_ok(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) return TRUE /// Whether an activity may send `npc` to `tile` (an outpost's public floor). Override. -/datum/ambient_place/proc/spot_allowed(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/proc/spot_allowed(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) return TRUE /// Whether `npc` may stand about on `tile` here (idle, a stroll, a queue). Override. -/datum/ambient_place/proc/loiter_ok(turf/tile, mob/living/basic/ambient_npc/npc, atom/ignore) +/datum/ambient_place/proc/loiter_ok(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc, atom/ignore) return TRUE /** @@ -596,11 +580,11 @@ SUBSYSTEM_DEF(ambient_npcs) * yet), within AMBIENT_CROWD_RADIUS of `tile`; `asker` and `ignore` are never counted. INFINITY if * anyone is on `tile` itself. */ -/datum/ambient_place/proc/crowd_count(turf/tile, mob/living/basic/ambient_npc/asker, atom/ignore) +/datum/ambient_place/proc/crowd_count(turf/tile, mob/living/carbon/human/npc/ambient_npc/asker, atom/ignore) if(!tile) return INFINITY var/nearby = 0 - for(var/mob/living/basic/ambient_npc/npc as anything in living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in living_npcs()) if(npc == asker || npc == ignore) continue var/datum/ambient_activity/current = npc.activity @@ -614,11 +598,11 @@ SUBSYSTEM_DEF(ambient_npcs) return nearby /// Whether `tile` is crowded for `asker`: someone on it, or AMBIENT_CROWD_MAX or more of its NPCs within AMBIENT_CROWD_RADIUS -/datum/ambient_place/proc/crowded(turf/tile, mob/living/basic/ambient_npc/asker, atom/ignore) +/datum/ambient_place/proc/crowded(turf/tile, mob/living/carbon/human/npc/ambient_npc/asker, atom/ignore) return crowd_count(tile, asker, ignore) >= AMBIENT_CROWD_MAX /// Where `npc` leaves from (a lift), or null to fade where they stand. Override. -/datum/ambient_place/proc/exit_turf(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/proc/exit_turf(mob/living/carbon/human/npc/ambient_npc/npc) return null /** @@ -626,18 +610,9 @@ SUBSYSTEM_DEF(ambient_npcs) * stand there if given; within `radius` of `near` if given. Override: places with no floor to settle * on have none. */ -/datum/ambient_place/proc/settle_turf(mob/living/basic/ambient_npc/npc, atom/near, radius = 6) +/datum/ambient_place/proc/settle_turf(mob/living/carbon/human/npc/ambient_npc/npc, atom/near, radius = 6) return null -/// The band's health multiplier for a killable NPC here -/datum/ambient_place/proc/health_multiplier() - switch(band) - if(ZONE_YELLOW) - return AMBIENT_HEALTH_MULT_YELLOW - if(ZONE_RED) - return AMBIENT_HEALTH_MULT_RED - return 1 - /// One line for the admin verb /datum/ambient_place/proc/describe() return "[name]: [length(living_npcs())] out" @@ -662,7 +637,7 @@ SUBSYSTEM_DEF(ambient_npcs) var/obj/structure/overmap/trader_outpost/outpost = outpost() if(outpost) UnregisterSignal(outpost, list(COMSIG_TRADER_OUTPOST_VIOLENCE, COMSIG_TRADER_OUTPOST_CONVOY, COMSIG_QDELETING)) - for(var/mob/living/basic/ambient_npc/npc as anything in npcs.Copy()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in npcs.Copy()) if(!QDELETED(npc)) qdel(npc) public_floor = null @@ -685,7 +660,7 @@ SUBSYSTEM_DEF(ambient_npcs) /// A fight at the outpost: everyone near it ducks and heads off /datum/ambient_place/outpost/proc/on_violence(datum/source, mob/living/offender) SIGNAL_HANDLER - for(var/mob/living/basic/ambient_npc/npc as anything in living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in living_npcs()) if(get_dist(npc, offender) <= AMBIENT_VIOLENCE_RANGE) npc.react_violence(offender) @@ -695,25 +670,25 @@ SUBSYSTEM_DEF(ambient_npcs) if(!occupied) return var/obj/structure/overmap/trader_outpost/outpost = outpost() - for(var/mob/living/basic/ambient_npc/npc as anything in living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in living_npcs()) npc.react_convoy(outpost) // Its turrets never shoot its own people; with nobody on the concourse, whoever joins holds still until someone comes -/datum/ambient_place/outpost/add_npc(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/add_npc(mob/living/carbon/human/npc/ambient_npc/npc) . = ..() npc.faction |= FACTION_TURRET if(!occupied) npc.pause_routine() // Gone from here (moved elsewhere): none of this place's cover any more -/datum/ambient_place/outpost/remove_npc(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/remove_npc(mob/living/carbon/human/npc/ambient_npc/npc) . = ..() if(!QDELETED(npc)) npc.faction -= FACTION_TURRET npc.resume_routine() // Killed here: their place in their role stays empty a good while (AMBIENT_OUTPOST_KILLED_SLOT_TIME) -/datum/ambient_place/outpost/npc_died(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/npc_died(mob/living/carbon/human/npc/ambient_npc/npc) if(!npc.role) return var/opens_at = world.time + AMBIENT_OUTPOST_KILLED_SLOT_TIME @@ -725,14 +700,14 @@ SUBSYSTEM_DEF(ambient_npcs) refill_at = refill_at ? min(refill_at, opens_at) : opens_at // A real blow is violence at the outpost, as against a visitor: its strikes, and everyone near ducks (register_aggression()) -/datum/ambient_place/outpost/npc_attacked(mob/living/basic/ambient_npc/npc, atom/attacker, attack_flags) +/datum/ambient_place/outpost/npc_attacked(mob/living/carbon/human/npc/ambient_npc/npc, atom/attacker, attack_flags) if(!(attack_flags & ATTACKER_DAMAGING_ATTACK) || !isliving(attacker)) return var/obj/structure/overmap/trader_outpost/outpost = outpost() outpost?.register_aggression(attacker) // Its people drop no more than AMBIENT_OUTPOST_CASH_CAP between them in a round -/datum/ambient_place/outpost/cash_for(mob/living/basic/ambient_npc/npc, amount) +/datum/ambient_place/outpost/cash_for(mob/living/carbon/human/npc/ambient_npc/npc, amount) . = clamp(AMBIENT_OUTPOST_CASH_CAP - cash_dropped, 0, amount) cash_dropped += . @@ -763,7 +738,7 @@ SUBSYSTEM_DEF(ambient_npcs) /// How many of its living NPCs came as role `role_type` /datum/ambient_place/outpost/proc/count_role(role_type) . = 0 - for(var/mob/living/basic/ambient_npc/npc as anything in living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in living_npcs()) if(npc.role == role_type) .++ @@ -827,11 +802,11 @@ SUBSYSTEM_DEF(ambient_npcs) return loiter_floor // On the concourse: the lift alcove included, so someone just off the lift is not off their leash -/datum/ambient_place/outpost/leash_ok(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/leash_ok(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) return ambient_in_bounds(tile, concourse_bounds()) // The public floor, never the lift itself (it carries off whoever stands on it) -/datum/ambient_place/outpost/spot_allowed(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/spot_allowed(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) var/obj/structure/overmap/trader_outpost/outpost = outpost() if(!outpost || (tile in outpost.lobby_alcove_turfs)) return FALSE @@ -839,14 +814,14 @@ SUBSYSTEM_DEF(ambient_npcs) return !!floor[tile] // The loiter floor, less anyone standing about within AMBIENT_CROWD_RADIUS. Sites keep the base TRUE. -/datum/ambient_place/outpost/loiter_ok(turf/tile, mob/living/basic/ambient_npc/npc, atom/ignore) +/datum/ambient_place/outpost/loiter_ok(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc, atom/ignore) return !!get_loiter_floor()[tile] && !crowded(tile, npc, ignore) -/datum/ambient_place/outpost/exit_turf(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/exit_turf(mob/living/carbon/human/npc/ambient_npc/npc) return bounty_outpost_exit_turf(npc, outpost()) /// Whether `tile` is free to settle someone on: real ground, nothing on it, not on or right beside the lift (people step off there), and standable for `npc` if given -/datum/ambient_place/outpost/proc/settle_free(turf/tile, obj/structure/overmap/trader_outpost/outpost, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/outpost/proc/settle_free(turf/tile, obj/structure/overmap/trader_outpost/outpost, mob/living/carbon/human/npc/ambient_npc/npc) if(!tile || !ambient_ground_ok(tile) || tile.is_blocked_turf(exclude_mobs = FALSE)) return FALSE for(var/turf/alcove as anything in outpost.lobby_alcove_turfs) @@ -859,7 +834,7 @@ SUBSYSTEM_DEF(ambient_npcs) * least crowded free loiter tile seen if none is uncrowded, and the old public-floor sampling * (tiny test rooms and odd maps) only when the loiter floor itself is empty. */ -/datum/ambient_place/outpost/settle_turf(mob/living/basic/ambient_npc/npc, atom/near, radius = 6) +/datum/ambient_place/outpost/settle_turf(mob/living/carbon/human/npc/ambient_npc/npc, atom/near, radius = 6) var/obj/structure/overmap/trader_outpost/outpost = outpost() var/list/floor = get_public_floor() if(!outpost || !length(floor)) @@ -922,13 +897,13 @@ SUBSYSTEM_DEF(ambient_npcs) data.Cut() return ..() -/datum/ambient_place/site/remove_npc(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/site/remove_npc(mob/living/carbon/human/npc/ambient_npc/npc) . = ..() if(state == AMBIENT_SITE_ACTIVE && !length(living_npcs())) state = AMBIENT_SITE_DORMANT /// One of its NPCs was killed: once they all are, it is spent for the round -/datum/ambient_place/site/npc_died(mob/living/basic/ambient_npc/npc) +/datum/ambient_place/site/npc_died(mob/living/carbon/human/npc/ambient_npc/npc) dead++ if(dead >= npc_total) state = AMBIENT_SITE_SPENT @@ -940,14 +915,19 @@ SUBSYSTEM_DEF(ambient_npcs) return 0 return max(0, npc_total - dead - length(living_npcs())) -/// Makes one of its NPCs, of `npc_type`, at `where`: theirs, leashed here, health scaled by band when killable -/datum/ambient_place/site/proc/spawn_npc(npc_type, turf/where) +/** + * Makes one of its NPCs, of `npc_type`, at `where`: theirs and leashed here, dressed for the site's + * band (the kit their outfit table gives in yellow and red, and whatever the air there needs). + * `identity` (a stray's stray_identity()) makes them someone who was here before. + */ +/datum/ambient_place/site/proc/spawn_npc(npc_type, turf/where, list/identity) where = get_turf(where) || center - if(!where || !ispath(npc_type, /mob/living/basic/ambient_npc)) + if(!where || !ispath(npc_type, /mob/living/carbon/human/npc/ambient_npc)) + return null + var/mob/living/carbon/human/npc/ambient_npc/npc = ambient_build_npc(npc_type, where, band, identity) + if(QDELETED(npc)) return null - var/mob/living/basic/ambient_npc/npc = new npc_type(where) npc.set_place(src) - npc.scale_health(health_multiplier()) state = AMBIENT_SITE_ACTIVE return npc @@ -966,7 +946,7 @@ SUBSYSTEM_DEF(ambient_npcs) return null // On its planet's ground above the dock strip and off any ship; on a field, on the field -/datum/ambient_place/site/leash_ok(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/site/leash_ok(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) if(!tile || istype(get_area(tile), /area/shuttle)) return FALSE if(planet) @@ -1070,7 +1050,7 @@ SUBSYSTEM_DEF(ambient_npcs) return TRUE /// One of `site`'s NPCs was killed. Override. -/datum/ambient_site_kind/proc/on_npc_died(datum/ambient_place/site/site, mob/living/basic/ambient_npc/npc) +/datum/ambient_site_kind/proc/on_npc_died(datum/ambient_place/site/site, mob/living/carbon/human/npc/ambient_npc/npc) return /// The planet was swept: `site`'s NPCs are gone until the next visit (a fire burns down). Override. @@ -1093,7 +1073,7 @@ SUBSYSTEM_DEF(ambient_npcs) /datum/ambient_outpost_role/proc/arrive(datum/ambient_place/outpost/place, turf/where) if(!npc_type) return null - var/mob/living/basic/ambient_npc/npc = new npc_type(where) + var/mob/living/carbon/human/npc/ambient_npc/npc = new npc_type(where) npc.role = type npc.set_place(place) npc.fade_in() @@ -1113,7 +1093,7 @@ SUBSYSTEM_DEF(ambient_npcs) var/turf/start = place.settle_turf() if(!start) return null - var/mob/living/basic/ambient_npc/npc = new npc_type(start) + var/mob/living/carbon/human/npc/ambient_npc/npc = new npc_type(start) npc.role = type npc.set_place(place) npc.settle_in() @@ -1123,6 +1103,23 @@ SUBSYSTEM_DEF(ambient_npcs) // HELPERS // ========================================================================= +/** + * Builds an ambient NPC of `npc_type` at `where` now, through the human NPC base's spawn order + * (voidcrew/modules/human_npcs/human_npc_spawning.dm), dressed for `zone` (ZONE_*, or null for the + * zone of `where`). `identity` (a stray's stray_identity()) rides in the order's look and makes them + * someone who was here before. Built at once rather than queued, since every caller sets them up + * straight after; the callers bound how many come at once (AMBIENT_PLANET_NPCS_MAX for a planet, one + * a fire for an outpost). Returns the NPC, or null. + */ +/proc/ambient_build_npc(npc_type, turf/where, zone, list/identity) + if(!ispath(npc_type, /mob/living/carbon/human/npc/ambient_npc) || isnull(get_turf(where))) + return null + var/datum/human_npc_order/order = new /datum/human_npc_order(npc_type, where) + order.zone = zone + if(islist(identity)) + order.look = identity + return order.fulfil() + /// `deadline` (a world.time) brought forward to somewhere between a quarter and all of what is left of it: someone who has been at it a while /proc/ambient_part_way(deadline) var/left = deadline - world.time diff --git a/voidcrew/modules/ambient_npcs/convict.dm b/voidcrew/modules/ambient_npcs/convict.dm index 79bf0667f63..f7e8296213c 100644 --- a/voidcrew/modules/ambient_npcs/convict.dm +++ b/voidcrew/modules/ambient_npcs/convict.dm @@ -7,24 +7,35 @@ * inside the existing two-offer limit, paid by the existing capture rules; recruiting them * withdraws it unpaid. * - * The convict (spec 5.4) is the bounty module's meek criminal in prison orange, with its own AI: - * camping at a small fire on a planet, or looking round a space ruin; sprinting and hiding from - * anyone who comes at them armed; a holdout when cornered. This file only calls the bounty - * module's procs; it edits nothing there. - * - Sighted: the first time someone of a ship crew comes into view, they put their hands up - * ("Don't shoot!") and a private petty bounty on them is posted to that crew's ship, if the board - * would offer that ship another private bounty now (under BOUNTY_PRIVATE_MAX, and not in the gap - * after its last one ended). Only ever one bounty per convict. The crew cuff them and use - * their pad, and the bounty pays by the usual rules. + * The convict (spec 5.4) is the bounty module's meek criminal, a real human body + * (voidcrew/modules/human_npcs/), in prison orange, with the meek AI: camping at a small fire on a + * planet, or looking round a space ruin; running and hiding from anyone who comes at them armed; a + * holdout pistol when cornered. This file only calls the bounty module's procs; it edits nothing there. + * - Sighted: the first time someone of a ship crew comes into view, they give up the way a person + * does (npc_surrender(): the weapon put away, "Don't shoot!", tg's *surrender, a real 20 second + * Paralyze), and a private petty bounty on them is posted to that crew's ship, if the board would + * offer that ship another private bounty now (under BOUNTY_PRIVATE_MAX, and not in the gap after its + * last one ended). Only ever one bounty per convict. The crew cuff them and use their pad, and the + * bounty pays on their condition there by the usual rules. * - Talked down: an empty hand from a crew member while they are calm, and they ask to come along. - * "Come with us" turns them into a stray following that crew (recruit.dm), wearing the same face, - * and withdraws their bounty unpaid. A convict who is fleeing, hiding, down or cuffed won't talk. + * "Come with us" and the SAME body goes with that crew (defaults 1.9, AM-6): its bounty is withdrawn + * unpaid, it is taken off the board for good (no posting, no leash, no teleport block, no sweep + * protection, off the convict count), and its AI and faction change: a small follower AI of its own + * (below) walks after whoever asked, keeps out of fights, and stays aboard once it has stepped onto + * their ship. Wounds, blood and everything else about the body carry on. A convict who is fleeing, + * hiding, down, stunned or cuffed won't talk. * - Left alone: when a bounty on a free convict ends unclaimed, they stay where they are, off the * board for good. * Where they turn up: a planet site (/datum/ambient_site_kind/convict, yellow and red space) or, * rolled once per space ruin per round by SSambient_strays (stranded_lifeboat.dm), a loaded ruin * a crew has docked at. At most one per AMBIENT_CONVICT_SHIPS_PER active crews, and never more than * AMBIENT_CONVICTS_MAX alive. + * + * A talked-down convict is still a /bounty_criminal type: a mob can't change type and stay the same + * body. What reads the posting (the pad, the board, proof of death, pay) leaves it alone, since it has + * none. What reads the type alone still counts it as a bounty body: other criminals never pick it as a + * target, and the core's transporter, bag-fold and contract-pad checks still refuse it while it is alive + * (see the PHASE D CORE REQUEST below). Turrets leave it be: its follower AI has no target finding. */ /// How far a convict sees a crew member coming @@ -37,8 +48,33 @@ #define CONVICT_TALK_COOLDOWN (4 SECONDS) /// The reason a convict's bounty closes when a crew takes them in #define CONVICT_CLOSE_RECRUITED "recruited" - -/// Weakrefs to every escaped convict's body (weakrefs: the bounty body's own Destroy() is not overridden here) +/// Beyond this many tiles (or off the level, or dead) a follower's leader is out of reach +#define CONVICT_FOLLOW_RANGE 12 +/// A leader out of reach this long is lost: the follower waits where it is +#define CONVICT_LOST_TIME (60 SECONDS) +/// Further than this behind its leader, a follower runs to catch up +#define CONVICT_RUN_DISTANCE 4 +/// How long a follower keeps clear of someone who hit it +#define CONVICT_SHY_TIME (10 SECONDS) +/// How close a fight has to be for a follower to back off from it +#define CONVICT_FIGHT_RANGE 5 +/// Between a follower's idle lines aboard, while somebody is near +#define CONVICT_IDLE_LINE_LOW (90 SECONDS) +#define CONVICT_IDLE_LINE_HIGH (180 SECONDS) +/// Contexts a talked-down convict says from the strays file's deckhand section first +#define CONVICT_DECKHAND_CONTEXTS list("talk", "idle", "follow", "wait") + +/// Blackboard: who a talked-down convict follows (null: it stays where it is) +#define BB_CONVICT_LEADER "convict_leader" +/// Blackboard: world.time its leader went out of reach, or null +#define BB_CONVICT_LOST_SINCE "convict_lost_since" +/// Blackboard: whoever last hit it, and the world.time it stops keeping clear of them +#define BB_CONVICT_HIT_BY "convict_hit_by" +#define BB_CONVICT_HIT_UNTIL "convict_hit_until" +/// Blackboard: what it is backing away from now (run_away_from_target clears it) +#define BB_CONVICT_AVOID "convict_avoid" + +/// Weakrefs to every escaped convict's body still on the board (weakrefs: the bounty body's own Destroy() is not overridden here) GLOBAL_LIST_EMPTY(ambient_convicts) // ========================================================================= @@ -62,7 +98,7 @@ GLOBAL_LIST_EMPTY(ambient_convicts) /proc/ambient_live_convicts() . = 0 for(var/datum/weakref/ref as anything in GLOB.ambient_convicts.Copy()) - var/mob/living/basic/bounty_criminal/meek/convict/convict = ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = ref?.resolve() if(QDELETED(convict)) GLOB.ambient_convicts -= ref continue @@ -78,7 +114,8 @@ GLOBAL_LIST_EMPTY(ambient_convicts) /** * A new convict at `where`, on site `site` (the overmap planet or ruin, or null), for a * `placement_kind` (BOUNTY_PLACEMENT_PLANET or _RUIN) site: `record`'s person, or someone new. They - * camp on a planet and look round a ruin. Returns the convict, or null. + * camp on a planet and look round a ruin. One body, wanted back at once, so it is built now rather + * than queued, in its record's face and name. Returns the convict, or null. */ /proc/ambient_spawn_convict(turf/where, obj/structure/overmap/site, placement_kind = BOUNTY_PLACEMENT_PLANET, datum/bounty_record/record) where = get_turf(where) @@ -87,7 +124,11 @@ GLOBAL_LIST_EMPTY(ambient_convicts) record ||= generate_bounty_record(BOUNTY_TIER_PETTY, BOUNTY_ARCHETYPE_MEEK, placement_kind) if(!record) return null - var/mob/living/basic/bounty_criminal/meek/convict/convict = new(where) + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/meek/convict, where, look = record, name = record.name, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(convict)) + return null convict.body_setup(record, null) if(site) convict.body_set_site(site) @@ -167,7 +208,7 @@ GLOBAL_LIST_EMPTY(ambient_convicts) if(site.data["taken"] || site.state == AMBIENT_SITE_SPENT) return FALSE var/datum/weakref/convict_ref = site.data["convict"] - var/mob/living/basic/bounty_criminal/meek/convict/convict = convict_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = convict_ref?.resolve() if(!QDELETED(convict)) return FALSE if(!has_room()) @@ -190,7 +231,24 @@ GLOBAL_LIST_EMPTY(ambient_convicts) // THE CONVICT // ========================================================================= -/mob/living/basic/bounty_criminal/meek/convict +/** + * Prison orange and the escapee's shiv, with the meek criminal's kit (bounty_styles.dm's holdout): the + * little Makarov in a pocket, its spare magazine and the stimpack they bolt on in a fanny pack. They run + * and hide, and only shoot when cornered. Their own name stays on them: no prisoner number. + */ +/datum/outfit/prisoner/bounty_convict + name = "Escaped convict" + l_pocket = /obj/item/gun/ballistic/automatic/pistol + belt = /obj/item/storage/belt/fannypack + belt_contents = list( + /obj/item/ammo_box/magazine/m9mm = 1, + /obj/item/reagent_containers/hypospray/medipen/stimpack = 1, + ) + +/datum/outfit/prisoner/bounty_convict/post_equip(mob/living/carbon/human/prisoner, visuals_only = FALSE) + return + +/mob/living/carbon/human/npc/bounty_criminal/meek/convict name = "escaped convict" desc = "Someone in prison orange, a long way from any prison." /// Weakref to the planet site (/datum/ambient_place/site) that keeps them, if any @@ -205,16 +263,25 @@ GLOBAL_LIST_EMPTY(ambient_convicts) var/convict_next_line = 0 /// REF of a player -> world.time they may talk to them again var/list/convict_talk_cooldowns - -/mob/living/basic/bounty_criminal/meek/convict/Initialize(mapload) + /// Talked down: off the board, going with a crew (the same body, a follower AI) + var/convict_recruited = FALSE + /// Weakref to the ship whose crew took them in + var/datum/weakref/convict_ship_ref + /// They have stepped aboard that ship, and stay aboard from now on + var/convict_aboard = FALSE + /// world.time of their next idle line aboard + var/convict_next_idle_line = 0 + +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/Initialize(mapload, datum/human_npc_order/order) . = ..() GLOB.ambient_convicts += WEAKREF(src) -// Prison orange, wherever they are -/mob/living/basic/bounty_criminal/meek/convict/bounty_outfit() - return /datum/outfit/prisoner +// Prison orange, wherever they are (the human NPC base adds a sealed suit and air, a mask or warm +// clothes on top where the spot needs them) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/bounty_build_outfit(zone) + return /datum/outfit/prisoner/bounty_convict -/mob/living/basic/bounty_criminal/meek/convict/death(gibbed) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/death(gibbed) var/was_alive = stat != DEAD . = ..() if(!was_alive) @@ -224,7 +291,7 @@ GLOBAL_LIST_EMPTY(ambient_convicts) site.npc_died(src) /// Their planet site knows they are gone for good (a bounty took them over, or a crew took them in) -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_mark_site_taken() +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_mark_site_taken() var/datum/ambient_place/site/site = convict_site_ref?.resolve() if(!site || QDELETED(site)) return @@ -233,38 +300,63 @@ GLOBAL_LIST_EMPTY(ambient_convicts) // ----- their own lines ----- +/** + * Their lines for `context` from the strays file: the convict section, and once a crew has taken them + * in the deckhand section too (first for its everyday contexts, CONVICT_DECKHAND_CONTEXTS). + */ +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_lines(context) + var/list/sections = list("convict") + if(convict_recruited) + if(context in CONVICT_DECKHAND_CONTEXTS) + sections.Insert(1, "deckhand") + else + sections += "deckhand" + for(var/section in sections) + var/list/lines = ambient_dialogue_lines(AMBIENT_STRINGS_STRAYS, section, context) + if(length(lines)) + return lines + return list() + /// Says a line for `context` from the strays file, now and then; `force` skips the wait. TRUE if they spoke. -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_say(context, force = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_say(context, force = FALSE) if(stat != CONSCIOUS || (!force && world.time < convict_next_line)) return FALSE - var/list/lines = ambient_dialogue_lines(AMBIENT_STRINGS_STRAYS, "convict", context) + var/list/lines = convict_lines(context) if(!length(lines)) return FALSE convict_next_line = world.time + CONVICT_LINE_COOLDOWN INVOKE_ASYNC(src, TYPE_PROC_REF(/atom/movable, say), pick(lines)) return TRUE -// Their own words where the strays file has them (at the camp, cornered, cuffed), the bounty module's otherwise -/mob/living/basic/bounty_criminal/meek/convict/bounty_say(context, list/values, force = FALSE) - if(!length(ambient_dialogue_lines(AMBIENT_STRINGS_STRAYS, "convict", context))) - return ..() +// Their own words where the strays file has them (seen and giving up, at the camp, cornered, cuffed), +// the bounty module's otherwise +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/bounty_say(context, list/values, force = FALSE) + // The human NPC base's surrender bark: theirs is "Don't shoot! I'm not armed." + if(context == HUMAN_NPC_BARK_SURRENDER) + context = "sighted" + if(!length(convict_lines(context))) + return ..(context, values, force) return convict_say(context, force) // ----- being seen ----- -// Each look round for hunters is also a look for crews they have not seen yet -/mob/living/basic/bounty_criminal/meek/convict/ai_look_around() - convict_look_for_crews() +// Each look round for hunters is also a look for crews they have not seen yet. Giving up to one, they +// notice nobody else this look: they are lying there with their hands on their head. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/ai_look_around() + if(convict_recruited) + return null + if(convict_look_for_crews() || npc_surrendering) + return null return ..() -/// The first time someone of a ship crew comes into view: hands up, and maybe their bounty -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_look_for_crews() - if(world.time < convict_next_look || hidden || !ai_can_act()) - return +/// The first time someone of a ship crew comes into view: they give up, and maybe their bounty. TRUE if they saw a new crew. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_look_for_crews() + if(convict_recruited || world.time < convict_next_look || hidden || !ai_can_act()) + return FALSE convict_next_look = world.time + CONVICT_LOOK_INTERVAL var/turf/here = get_turf(src) if(!here) - return + return FALSE for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, CONVICT_SIGHT_RANGE)) if(!bounty_ai_is_hunter(person)) continue @@ -276,16 +368,21 @@ GLOBAL_LIST_EMPTY(ambient_convicts) continue LAZYADD(convict_seen_by, key) INVOKE_ASYNC(src, PROC_REF(convict_sighted), person, ship) - return + return TRUE + return FALSE -/// `person` of `ship` saw them -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_sighted(mob/living/person, obj/structure/overmap/ship/ship) - if(QDELETED(src) || stat != CONSCIOUS) +/** + * `person` of `ship` saw them. They give up the way a person does (npc_surrender(): whatever weapon is + * in their hands goes in a pocket, "Don't shoot!", tg's *surrender, a real 20 second Paralyze, the cuff + * window), and that crew may get a private bounty on them. If nobody cuffs them before they get up, the + * meek AI carries on: calm, they can be talked to; at someone armed, they run. + */ +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_sighted(mob/living/person, obj/structure/overmap/ship/ship) + if(QDELETED(src) || stat != CONSCIOUS || convict_recruited) return if(!QDELETED(person)) face_atom(person) - manual_emote("puts [p_their()] hands up.") - convict_say("sighted", force = TRUE) + npc_surrender() convict_post_bounty(ship) /** @@ -293,8 +390,8 @@ GLOBAL_LIST_EMPTY(ambient_convicts) * private offer. board_post() rolls a record of its own, so the posting takes theirs instead, and * the card shows who the crew actually saw. Returns the posting, or null. */ -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_post_bounty(obj/structure/overmap/ship/ship) - if(convict_posted || QDELETED(ship) || !record || posting() || stat == DEAD) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_post_bounty(obj/structure/overmap/ship/ship) + if(convict_posted || convict_recruited || QDELETED(ship) || !record || posting() || stat == DEAD) return null // Inside the board's own rules for private offers: at most BOUNTY_PRIVATE_MAX open, and none during the gap after one ends if(SScriminal_bounties.board_private_count(ship) >= BOUNTY_PRIVATE_MAX || world.time < SScriminal_bounties.board_private_next[WEAKREF(ship)]) @@ -329,15 +426,15 @@ GLOBAL_LIST_EMPTY(ambient_convicts) /** * Their bounty closed. Claimed, the pad has them. Anything else while they are free on their own - * ground (not dead, down, cuffed or aboard a ship): they stay where they are, off the board. + * ground (not dead, in crit, cuffed or aboard a ship): they stay where they are, off the board. */ -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_on_posting_closed(datum/source, reason, obj/structure/overmap/ship/winner) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_on_posting_closed(datum/source, reason, obj/structure/overmap/ship/winner) SIGNAL_HANDLER UnregisterSignal(source, COMSIG_BOUNTY_POSTING_CLOSED) var/datum/criminal_bounty/posting = source if(reason == BOUNTY_CLOSE_CLAIMED || QDELETED(src) || posting.criminal() != src) return - if(stat == DEAD || downed || is_restrained() || get_ship_from_atom(src)) + if(stat == DEAD || is_downed() || is_restrained() || get_ship_from_atom(src)) return posting.board_detach_criminal() posting_ref = null @@ -345,20 +442,28 @@ GLOBAL_LIST_EMPTY(ambient_convicts) // ----- being talked down ----- -/// Whether they would talk: awake, calm, on their feet, not hidden or cuffed -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_can_talk() - if(stat != CONSCIOUS || hidden || downed || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_REMOVED)) +/// Whether they would talk: awake, calm, on their feet, not hidden, stunned or cuffed, and not taken in already +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_can_talk() + if(convict_recruited || stat != CONSCIOUS || hidden || is_downed() || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_REMOVED)) return FALSE return ai_can_act() && ai_mode == BOUNTY_AI_CALM -/mob/living/basic/bounty_criminal/meek/convict/attack_hand(mob/living/carbon/human/user, list/modifiers) - if(istype(user) && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK) && convict_can_talk()) - INVOKE_ASYNC(src, PROC_REF(convict_talk), user) - return TRUE +// An empty hand: the plea while they are wild, follow or stay once a crew has them. Anything else (they +// are down, asleep or on fire) is tg's own help: CPR, shaking them awake, patting them out. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/attack_hand(mob/living/carbon/human/user, list/modifiers) + if(istype(user) && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK)) + if(convict_recruited) + var/obj/structure/overmap/ship/ship = convict_ship() + if(ship && convict_follower_can_talk() && ambient_crew_ship(user) == ship) + INVOKE_ASYNC(src, PROC_REF(convict_crew_menu), user) + return TRUE + else if(convict_can_talk()) + INVOKE_ASYNC(src, PROC_REF(convict_talk), user) + return TRUE return ..() /// A word with `user`, and to a crew member with room aboard, the plea. Sleeps. -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_talk(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_talk(mob/living/user) var/key = REF(user) if(LAZYACCESS(convict_talk_cooldowns, key) > world.time) return @@ -384,68 +489,370 @@ GLOBAL_LIST_EMPTY(ambient_convicts) convict_say("refused", force = TRUE) /** - * They go with `leader` to `ship`: a stray with the same name and face takes their place, following - * `leader` (recruit.dm), and their bounty is withdrawn unpaid. Returns the stray, or null. + * They go with `leader` to `ship`, in the same body (defaults 1.9): their bounty is withdrawn unpaid, + * they come off the board for good, their side changes to the people's (FACTION_NEUTRAL, as a player's) + * and their AI to the follower's, set on `leader`. The ship counts them as one of the strays it took this + * round (recruit.dm's limits). Returns them, or null if they can't go now. */ -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_join(mob/living/leader, obj/structure/overmap/ship/ship) - var/turf/here = get_turf(src) - if(!here || QDELETED(ship) || QDELETED(leader)) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_join(mob/living/leader, obj/structure/overmap/ship/ship) + if(convict_recruited || stat == DEAD || !isturf(loc) || QDELETED(ship) || QDELETED(leader)) return null - var/mob/living/basic/ambient_npc/stray/convict/stray = new(here, list("name" = real_name, "gender" = gender), record) - stray.setDir(dir) - // Their face, carried over at once; build_look() puts the same one back on from the record - stray.icon = icon - stray.icon_state = icon_state - stray.appearance_flags |= KEEP_TOGETHER - stray.copy_overlays(src, cut_old = TRUE) + // Off its posting first, so closing it takes nobody away var/datum/criminal_bounty/posting = posting() if(posting) UnregisterSignal(posting, COMSIG_BOUNTY_POSTING_CLOSED) posting.board_detach_criminal() + posting_ref = null convict_mark_site_taken() + convict_site_ref = null ai_put_out_camp() - // Gone on purpose: no proof of death, nothing to turn in - body_mark_removed() - ADD_TRAIT(src, TRAIT_BOUNTY_REMOVED, BOUNTY_PAD_TRAIT) + ai_end_activity() + // Withdrawn unpaid: the crew who saw them are told, as for any offer that ends posting?.close(CONVICT_CLOSE_RECRUITED) - ambient_offer_recruit(stray) - stray.accept_offer(leader, ship) + convict_leave_board() + convict_recruited = TRUE + convict_ship_ref = WEAKREF(ship) + var/datum/ambient_place/ship/deck = ambient_ship_place(ship) + if(deck) + deck.taken++ + faction = list(FACTION_NEUTRAL) + new /datum/ai_controller/basic_controller/convict_follower(src) + convict_follow(leader) + convict_say("accepted", force = TRUE) log_game("AMBIENT: the escaped convict [real_name] went with the [ship.name], talked down by [key_name(leader)]") - qdel(src) - return stray + return src + +/** + * Takes the bounty body's rules off them, for a convict who is no criminal any more: the sweep + * protection, the teleport and mob-swap block, the body's signal hooks (teleport, polymorph and + * type-change refusals, grudges), any container's teleport block, the leash and the grudge list. Off the + * convict count. Its corpse is an ordinary human NPC's from now on: the corpse sweep may clear it. + */ +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_leave_board() + REMOVE_TRAIT(src, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) + remove_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP), BOUNTY_BODY_TRAIT) + qdel(GetComponent(/datum/component/bounty_body)) + body_set_holder(null) + body_set_site(null) + grudge.Cut() + ai_set_mode(BOUNTY_AI_CALM) + GLOB.ambient_convicts -= WEAKREF(src) + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + +// Taken in, nothing they sit in is held back from a teleport any more +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/body_set_holder(atom/movable/holder) + return ..(convict_recruited ? null : holder) + +// Taken in, they let their own crew cuff them +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/body_on_cuff_attempt(mob/living/user) + if(convict_recruited) + return + return ..() // ========================================================================= -// THE CONVICT WHO JOINED A CREW +// THE CONVICT WHO WENT WITH A CREW // ========================================================================= -/// A convict who went with a crew: the same face, a stray now (recruit.dm) -/mob/living/basic/ambient_npc/stray/convict - name = "escaped convict" - desc = "Someone in prison orange who has decided not to go back." - outfit = /datum/outfit/prisoner - dialogue_section = "convict" - random_name = FALSE - /// Who they are (/datum/bounty_record): their face comes from it - var/datum/bounty_record/bounty_record - -/mob/living/basic/ambient_npc/stray/convict/Initialize(mapload, list/identity, datum/bounty_record/from_record) - bounty_record = from_record - return ..(mapload, identity) - -/mob/living/basic/ambient_npc/stray/convict/Destroy() - bounty_record = null - return ..() +/// The ship whose crew took them in, if it is still there +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_ship() + var/obj/structure/overmap/ship/ship = convict_ship_ref?.resolve() + return QDELETED(ship) ? null : ship + +/// That ship's deckhand place (recruit.dm), if any +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_deck() + var/obj/structure/overmap/ship/ship = convict_ship() + return ship ? ambient_ship_place(ship, create = FALSE) : null + +/// Whether `spot` is aboard the ship that took them in +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_on_ship(turf/spot) + var/datum/ambient_place/ship/deck = convict_deck() + return !!deck?.aboard(get_turf(spot)) + +/// Who they follow, or null while they stay where they are +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_leader() + var/mob/living/leader = ai_controller?.blackboard[BB_CONVICT_LEADER] + return QDELETED(leader) ? null : leader + +/// They follow `leader` from now on. TRUE if they will. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_follow(mob/living/leader) + if(!isliving(leader) || QDELETED(leader) || !istype(ai_controller, /datum/ai_controller/basic_controller/convict_follower)) + return FALSE + ai_controller.set_blackboard_key(BB_CONVICT_LEADER, leader) + ai_controller.clear_blackboard_key(BB_CONVICT_LOST_SINCE) + return TRUE + +/// They stay where they are +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_stay() + ai_controller?.clear_blackboard_key(BB_CONVICT_LEADER) + ai_controller?.clear_blackboard_key(BB_CONVICT_LOST_SINCE) + +/// Their leader has been out of reach too long: they wait where they are for someone of the crew +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_lose_leader() + convict_stay() + convict_say("lost", force = TRUE) -/mob/living/basic/ambient_npc/stray/convict/build_look() - if(QDELETED(src)) +/// Whether `leader` can still be followed: alive, on this level, near enough +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_leader_in_reach(mob/living/leader) + var/turf/there = get_turf(leader) + return !QDELETED(leader) && leader.stat != DEAD && there && isturf(loc) && there.z == z && get_dist(src, there) <= CONVICT_FOLLOW_RANGE + +/// Whether they would answer a crew member: awake and on their feet +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_follower_can_talk() + return stat == CONSCIOUS && !body_is_stunned() + +/// Follow or stay, for `user` of their ship. Sleeps. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_crew_menu(mob/living/user) + var/key = REF(user) + if(LAZYACCESS(convict_talk_cooldowns, key) > world.time) return - if(bounty_record) - apply_bounty_look(src, bounty_record, /datum/outfit/prisoner) + LAZYSET(convict_talk_cooldowns, key, world.time + CONVICT_TALK_COOLDOWN) + if(!buckled) + face_atom(user) + convict_say("talk", force = TRUE) + var/list/choices = list() + if(convict_leader() == user) + choices["Stay here"] = image(icon = 'icons/hud/radial.dmi', icon_state = "guard") + else + choices["Follow me"] = image(icon = 'icons/hud/radial.dmi', icon_state = "move") + var/choice = show_radial_menu(user, src, choices, require_near = TRUE, tooltips = TRUE, autopick_single_option = FALSE) + if(QDELETED(src) || QDELETED(user) || !convict_follower_can_talk() || ambient_crew_ship(user) != convict_ship()) + return + switch(choice) + if("Follow me") + if(convict_follow(user)) + convict_say("follow", force = TRUE) + if("Stay here") + convict_stay() + convict_say("wait", force = TRUE) + +/// The first time they stand aboard their crew's ship: the line, and they live aboard from now on. TRUE if they just stepped aboard. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_board_if_aboard() + if(!convict_recruited || convict_aboard || !convict_on_ship(get_turf(src))) + return FALSE + convict_aboard = TRUE + convict_next_idle_line = world.time + rand(CONVICT_IDLE_LINE_LOW, CONVICT_IDLE_LINE_HIGH) + convict_say("aboard", force = TRUE) + var/obj/structure/overmap/ship/ship = convict_ship() + log_game("AMBIENT: the escaped convict [real_name] came aboard the [ship?.name || "ship"]") + return TRUE + +/// Aboard, now and then a word while somebody is near +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_idle_chatter() + if(!convict_aboard || world.time < convict_next_idle_line) + return + convict_next_idle_line = world.time + rand(CONVICT_IDLE_LINE_LOW, CONVICT_IDLE_LINE_HIGH) + var/turf/here = get_turf(src) + if(here && ambient_any_player_near(here, 5)) + convict_say("idle") + +/// Hit by someone of the crew they follow: they stop following and say so +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_hit_by(mob/living/attacker) + if(QDELETED(src) || stat != CONSCIOUS || !isliving(attacker)) return + var/obj/structure/overmap/ship/ship = convict_ship() + if(!ship || ambient_crew_ship(attacker) != ship || !convict_leader()) + return + convict_stay() + convict_say("attacked", force = TRUE) + +/// Where they may take a step of their own: never into lava, a chasm or the like; aboard, never off the ship; never out into no air without a sealed suit, unless they are out in it already +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_may_step(turf/next) + next = get_turf(next) + if(!next) + return TRUE + if(!next.can_cross_safely(src)) + return FALSE + var/turf/here = get_turf(src) + if(convict_aboard && convict_on_ship(here) && !convict_on_ship(next)) + return FALSE + if(!is_atmos_sealed() && convict_airless(next) && !convict_airless(here)) + return FALSE + return TRUE + +/// Whether `spot` has too little air to stand in unsuited (read only: never materialises a planet turf's air) +/proc/convict_airless(turf/spot) + if(!isopenturf(spot)) + return FALSE + var/datum/gas_mixture/air = spot.return_air_readonly() + return isnull(air) || air.return_pressure() < HAZARD_LOW_PRESSURE + +// ========================================================================= +// THE FOLLOWER'S AI +// ========================================================================= + +/** + * A talked-down convict's AI: a human pawn (human_npc_ai_able_to_run(), so it stops while stunned, + * knocked down or out, and in soft crit only crawls, pleads and uses a medipen). It never picks a fight: + * no target finding at all, so turrets read it as harmless. It backs off from whoever hit it and from + * anything fighting it or its leader, and otherwise walks after its leader (running to catch up), by a + * pathfinder that keeps out of hazards and airless ground and, once it is aboard, on the ship. + */ +/datum/ai_controller/basic_controller/convict_follower + ai_movement = /datum/ai_movement/jps/convict_follower + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/convict_keep_clear, + /datum/ai_planning_subtree/convict_follow, + ) + +/datum/ai_controller/basic_controller/convict_follower/TryPossessPawn(atom/new_pawn) + if(!istype(new_pawn, /mob/living/carbon/human/npc/bounty_criminal/meek/convict)) + return AI_CONTROLLER_INCOMPATIBLE + new_pawn.AddElement(/datum/element/relay_attackers) + RegisterSignal(new_pawn, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_pawn_attacked)) + return ..() + +/datum/ai_controller/basic_controller/convict_follower/UnpossessPawn(destroy) + if(pawn) + UnregisterSignal(pawn, COMSIG_ATOM_WAS_ATTACKED) return ..() +/datum/ai_controller/basic_controller/convict_follower/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/convict_follower/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + +/// Someone hit it: it keeps clear of them for a while, and if it was its own crew, it stops following +/datum/ai_controller/basic_controller/convict_follower/proc/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(source == attacker || !isliving(attacker) || !(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) + return + set_blackboard_key(BB_CONVICT_HIT_BY, attacker) + set_blackboard_key(BB_CONVICT_HIT_UNTIL, world.time + CONVICT_SHY_TIME) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal/meek/convict, convict_hit_by), attacker) + +/// Backs off from a fight near it: whoever hit it lately, or anything set on it or its leader +/datum/ai_planning_subtree/convict_keep_clear + +/datum/ai_planning_subtree/convict_keep_clear/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = controller.pawn + if(!istype(convict) || convict.stat != CONSCIOUS) + return + var/mob/living/threat = convict_nearest_fight(controller) + if(isnull(threat)) + return + controller.set_blackboard_key(BB_CONVICT_AVOID, threat) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_CONVICT_AVOID) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The fight `controller`'s pawn should keep clear of, or null +/proc/convict_nearest_fight(datum/ai_controller/controller) + var/mob/living/pawn = controller.pawn + var/mob/living/hitter = controller.blackboard[BB_CONVICT_HIT_BY] + if(isliving(hitter) && !QDELETED(hitter) && hitter.stat == CONSCIOUS && world.time < controller.blackboard[BB_CONVICT_HIT_UNTIL] && can_see(pawn, hitter, CONVICT_FIGHT_RANGE)) + return hitter + var/mob/living/leader = controller.blackboard[BB_CONVICT_LEADER] + var/mob/living/closest + var/closest_distance = INFINITY + for(var/mob/living/other in oview(CONVICT_FIGHT_RANGE, pawn)) + var/datum/ai_controller/their_ai = other.ai_controller + if(other.stat != CONSCIOUS || other.client || !istype(their_ai)) + continue + var/atom/their_target = their_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(isnull(their_target) || (their_target != pawn && their_target != leader)) + continue + var/distance = get_dist(pawn, other) + if(distance < closest_distance) + closest = other + closest_distance = distance + return closest + +/// Walks after its leader, says its line on stepping aboard, and loses a leader out of reach too long +/datum/ai_planning_subtree/convict_follow + +/datum/ai_planning_subtree/convict_follow/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = controller.pawn + if(!istype(convict) || convict.stat != CONSCIOUS) + return + convict.convict_board_if_aboard() + var/mob/living/leader = controller.blackboard[BB_CONVICT_LEADER] + if(isnull(leader)) + convict.convict_idle_chatter() + return + if(!convict.convict_leader_in_reach(leader)) + var/lost_since = controller.blackboard[BB_CONVICT_LOST_SINCE] + if(!lost_since) + controller.set_blackboard_key(BB_CONVICT_LOST_SINCE, world.time) + else if(world.time - lost_since >= CONVICT_LOST_TIME) + convict.convict_lose_leader() + return + controller.clear_blackboard_key(BB_CONVICT_LOST_SINCE) + var/distance = get_dist(convict, leader) + var/run = distance > CONVICT_RUN_DISTANCE + if((convict.move_intent == MOVE_INTENT_RUN) != run) + convict.toggle_move_intent() + if(distance <= 2) + if(!convict.buckled) + convict.face_atom(leader) + convict.convict_idle_chatter() + return + // Aboard, it stays aboard: it waits for a leader who has gone off the ship + if(convict.convict_aboard && !convict.convict_on_ship(get_turf(leader))) + return + controller.queue_behavior(/datum/ai_behavior/convict_follow, BB_CONVICT_LEADER) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Walks to its leader's side, following them as they move +/datum/ai_behavior/convict_follow + required_distance = 1 + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/convict_follow/setup(datum/ai_controller/controller, leader_key) + . = ..() + var/atom/leader = controller.blackboard[leader_key] + if(QDELETED(leader)) + return FALSE + set_movement_target(controller, leader) + +/datum/ai_behavior/convict_follow/perform(seconds_per_tick, datum/ai_controller/controller, leader_key) + var/mob/living/leader = controller.blackboard[leader_key] + if(QDELETED(leader) || leader.stat == DEAD) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(get_dist(controller.pawn, leader) <= 1) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/// Smart pathing that keeps a follower out of hazards and airless ground, and aboard once it lives there +/datum/ai_movement/jps/convict_follower + +/datum/ai_movement/jps/convict_follower/allowed_to_move(datum/move_loop/has_target/jps/source) + . = ..() + if(!.) + return + var/list/path = source.movement_path + if(!length(path)) + return + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = source.moving + if(istype(convict) && !convict.convict_may_step(path[1])) + return FALSE + +/* + * PHASE D CORE REQUEST: bounty_blocks_transport(), bounty_holds_living_criminal() and + * bounty_blocks_bag_fold() (bounty_turn_in.dm) and bounty_is_gang_body() (bounty_criminal.dm) go by type + * alone, so a talked-down convict (still a /bounty_criminal type, posting_ref null, convict_recruited TRUE) + * can't ride a transporter beam, be sent on a contract pad or be folded into a bluespace body bag while it + * lives, and other criminals never take it for a target. A shared "is this body wanted on anything" check + * (a posting, or not a recruited convict) in those procs would fix it; this file can't reach them. + */ + #undef CONVICT_SIGHT_RANGE #undef CONVICT_LOOK_INTERVAL #undef CONVICT_LINE_COOLDOWN #undef CONVICT_TALK_COOLDOWN #undef CONVICT_CLOSE_RECRUITED +#undef CONVICT_FOLLOW_RANGE +#undef CONVICT_LOST_TIME +#undef CONVICT_RUN_DISTANCE +#undef CONVICT_SHY_TIME +#undef CONVICT_FIGHT_RANGE +#undef CONVICT_IDLE_LINE_LOW +#undef CONVICT_IDLE_LINE_HIGH +#undef CONVICT_DECKHAND_CONTEXTS +#undef BB_CONVICT_LEADER +#undef BB_CONVICT_LOST_SINCE +#undef BB_CONVICT_HIT_BY +#undef BB_CONVICT_HIT_UNTIL +#undef BB_CONVICT_AVOID diff --git a/voidcrew/modules/ambient_npcs/faction_recruiters.dm b/voidcrew/modules/ambient_npcs/faction_recruiters.dm index dfb0c09c7bf..462a22ed463 100644 --- a/voidcrew/modules/ambient_npcs/faction_recruiters.dm +++ b/voidcrew/modules/ambient_npcs/faction_recruiters.dm @@ -5,7 +5,7 @@ * * Owner decision D2: recruiters are flavour only. Pamphlets and talk; nothing is recorded, no pin, * no stored choice, no sign-up. PD builds here (spec 3.5): the two recruiters as - * /mob/living/basic/ambient_npc subtypes with their /datum/ambient_outpost_role subtypes (which + * /mob/living/carbon/human/npc/ambient_npc subtypes with their /datum/ambient_outpost_role subtypes (which * outposts), their pamphlets and posters (capped per outpost), their barbs at Quartermain, and * their lines in strings/faction_recruiters.json (AMBIENT_STRINGS_RECRUITERS). * @@ -42,7 +42,7 @@ * and trades barbs with the rival recruiter when one is nearby. Talking to them gets a pitch and, * once per ten minutes, a pamphlet. Nothing about a player is kept afterwards (D2). */ -/mob/living/basic/ambient_npc/recruiter +/mob/living/carbon/human/npc/ambient_npc/recruiter name = "recruiter" desc = "Someone working the crowd with a clipboard and a smile." dialogue_file = AMBIENT_STRINGS_RECRUITERS @@ -64,12 +64,12 @@ /// REF of a player -> world.time they may have another pamphlet var/list/pamphlet_cooldowns -/mob/living/basic/ambient_npc/recruiter/Destroy() +/mob/living/carbon/human/npc/ambient_npc/recruiter/Destroy() pamphlet_cooldowns = null return ..() /// Already at their post when players come: a few steps from the lift lobby, working the crowd -/mob/living/basic/ambient_npc/recruiter/settle_in() +/mob/living/carbon/human/npc/ambient_npc/recruiter/settle_in() var/datum/ambient_place/outpost/outpost_place = place var/obj/structure/overmap/trader_outpost/outpost = istype(outpost_place) ? outpost_place.outpost() : null var/turf/post = length(outpost?.lobby_alcove_turfs) ? ambient_waiting_spot(src, pick(outpost.lobby_alcove_turfs), 3, 6) : null @@ -81,7 +81,7 @@ return !!start_activity(new /datum/ambient_activity/idle(src)) && settle_here() // An empty hand gets a pitch, and a pamphlet once every ten minutes -/mob/living/basic/ambient_npc/recruiter/talked_to(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/recruiter/talked_to(mob/living/user) if(!talk_ready(user)) return if(!buckled) @@ -91,7 +91,7 @@ addtimer(CALLBACK(src, PROC_REF(offer_pamphlet), WEAKREF(user)), rand(AMBIENT_REPLY_DELAY_LOW, AMBIENT_REPLY_DELAY_HIGH), TIMER_DELETE_ME) /// Whether `user` may have another pamphlet yet, and starts their wait if so -/mob/living/basic/ambient_npc/recruiter/proc/pamphlet_ready(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/recruiter/proc/pamphlet_ready(mob/living/user) var/key = REF(user) if(LAZYACCESS(pamphlet_cooldowns, key) > world.time) return FALSE @@ -101,7 +101,7 @@ pamphlet_cooldowns.Cut(1, 2) return TRUE -/mob/living/basic/ambient_npc/recruiter/proc/offer_pamphlet(datum/weakref/user_ref) +/mob/living/carbon/human/npc/ambient_npc/recruiter/proc/offer_pamphlet(datum/weakref/user_ref) var/mob/living/user = user_ref?.resolve() if(QDELETED(user) || stat != CONSCIOUS || fading || get_dist(src, user) > 3) return @@ -110,7 +110,7 @@ user.put_in_hands(leaflet) /// A random two-line barb from their section's "barbs": opener said by this side, reply in the rival's voice -/mob/living/basic/ambient_npc/recruiter/proc/pick_barb() +/mob/living/carbon/human/npc/ambient_npc/recruiter/proc/pick_barb() var/list/entry = ambient_dialogue_section(dialogue_file, dialogue_section) var/list/barbs = entry?["barbs"] if(!length(barbs)) @@ -122,11 +122,11 @@ return list(barb["opener"], length(replies) ? pick(replies) : null) // The supply convoy arrived: a comment, not a reaction -/mob/living/basic/ambient_npc/recruiter/react_convoy(obj/structure/overmap/trader_outpost/outpost) +/mob/living/carbon/human/npc/ambient_npc/recruiter/react_convoy(obj/structure/overmap/trader_outpost/outpost) speak_context("convoy", outpost, force = TRUE) /// The nearest poster of `types` within `range`, or null -/mob/living/basic/ambient_npc/recruiter/proc/find_poster(list/types, range = AMBIENT_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/ambient_npc/recruiter/proc/find_poster(list/types, range = AMBIENT_ACTIVITY_RANGE) if(!length(types)) return null var/turf/here = get_turf(src) @@ -138,7 +138,7 @@ return null /// How many of this side's posters are already up within `range` -/mob/living/basic/ambient_npc/recruiter/proc/count_own_posters(range = AMBIENT_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/ambient_npc/recruiter/proc/count_own_posters(range = AMBIENT_ACTIVITY_RANGE) . = 0 if(!length(poster_types)) return @@ -150,7 +150,7 @@ .++ /// A bare wall within `range` with room for a poster and a floor tile to stand beside it, or null -/mob/living/basic/ambient_npc/recruiter/proc/find_bare_wall(range = AMBIENT_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/ambient_npc/recruiter/proc/find_bare_wall(range = AMBIENT_ACTIVITY_RANGE) var/turf/here = get_turf(src) if(!here) return null @@ -183,7 +183,7 @@ var/datum/weakref/target_ref /datum/ambient_activity/recruit_poster/setup() - var/mob/living/basic/ambient_npc/recruiter/recruiter = doer + var/mob/living/carbon/human/npc/ambient_npc/recruiter/recruiter = doer if(!istype(recruiter)) return FALSE var/obj/structure/sign/poster/rival = recruiter.find_poster(recruiter.rival_poster_types) @@ -211,7 +211,7 @@ return TRUE /datum/ambient_activity/recruit_poster/arrive() - var/mob/living/basic/ambient_npc/recruiter/recruiter = doer + var/mob/living/carbon/human/npc/ambient_npc/recruiter/recruiter = doer var/atom/target = target_ref?.resolve() if(QDELETED(target)) return @@ -236,10 +236,10 @@ var/datum/weakref/rival_ref /datum/ambient_activity/recruit_barb/setup() - var/mob/living/basic/ambient_npc/recruiter/recruiter = doer + var/mob/living/carbon/human/npc/ambient_npc/recruiter/recruiter = doer if(!istype(recruiter) || !recruiter.rival_type) return FALSE - var/mob/living/basic/ambient_npc/recruiter/rival = find_rival(recruiter) + var/mob/living/carbon/human/npc/ambient_npc/recruiter/rival = find_rival(recruiter) if(!rival) return FALSE rival_ref = WEAKREF(rival) @@ -250,11 +250,11 @@ set_duration() return TRUE -/datum/ambient_activity/recruit_barb/proc/find_rival(mob/living/basic/ambient_npc/recruiter/recruiter) +/datum/ambient_activity/recruit_barb/proc/find_rival(mob/living/carbon/human/npc/ambient_npc/recruiter/recruiter) var/turf/here = get_turf(recruiter) if(!here) return null - for(var/mob/living/basic/ambient_npc/recruiter/other in range(AMBIENT_ACTIVITY_RANGE, here)) + for(var/mob/living/carbon/human/npc/ambient_npc/recruiter/other in range(AMBIENT_ACTIVITY_RANGE, here)) if(other == recruiter || other.place != recruiter.place || !istype(other, recruiter.rival_type)) continue if(other.stat != CONSCIOUS || istype(other.activity, /datum/ambient_activity/leave)) @@ -263,8 +263,8 @@ return null /datum/ambient_activity/recruit_barb/arrive() - var/mob/living/basic/ambient_npc/recruiter/recruiter = doer - var/mob/living/basic/ambient_npc/recruiter/rival = rival_ref?.resolve() + var/mob/living/carbon/human/npc/ambient_npc/recruiter/recruiter = doer + var/mob/living/carbon/human/npc/ambient_npc/recruiter/rival = rival_ref?.resolve() if(QDELETED(rival)) return if(!doer.buckled) @@ -279,13 +279,13 @@ // OUTFITS (plain clothes, nothing worth stripping) // ========================================================================= -/datum/outfit/ambient/recruiter_nanotrasen +/datum/outfit/ambient_recruiter_nanotrasen name = "Nanotrasen recruiter" uniform = /obj/item/clothing/under/rank/civilian/head_of_personnel/suit neck = /obj/item/clothing/neck/tie/blue shoes = /obj/item/clothing/shoes/laceup -/datum/outfit/ambient/recruiter_syndicate +/datum/outfit/ambient_recruiter_syndicate name = "Syndicate recruiter" uniform = /obj/item/clothing/under/rank/civilian/lawyer/black neck = /obj/item/clothing/neck/tie/black/tied @@ -296,10 +296,10 @@ // NANOTRASEN // ========================================================================= -/mob/living/basic/ambient_npc/recruiter/nanotrasen +/mob/living/carbon/human/npc/ambient_npc/recruiter/nanotrasen name = "Nanotrasen recruiter" desc = "A neatly pressed suit and a stack of pamphlets." - outfit = /datum/outfit/ambient/recruiter_nanotrasen + outfit = /datum/outfit/ambient_recruiter_nanotrasen dialogue_section = "nanotrasen" pamphlet_type = /obj/item/paper/pamphlet/nanotrasen poster_types = list( @@ -311,11 +311,11 @@ /obj/structure/sign/poster/contraband/syndicate_recruitment, /obj/structure/sign/poster/contraband/gorlex_recruitment, ) - rival_type = /mob/living/basic/ambient_npc/recruiter/syndicate + rival_type = /mob/living/carbon/human/npc/ambient_npc/recruiter/syndicate /datum/ambient_outpost_role/recruiter/nanotrasen name = "Nanotrasen recruiter" - npc_type = /mob/living/basic/ambient_npc/recruiter/nanotrasen + npc_type = /mob/living/carbon/human/npc/ambient_npc/recruiter/nanotrasen outpost_types = list( /obj/structure/overmap/trader_outpost/general, /obj/structure/overmap/trader_outpost/outfitter, @@ -326,10 +326,10 @@ // SYNDICATE // ========================================================================= -/mob/living/basic/ambient_npc/recruiter/syndicate +/mob/living/carbon/human/npc/ambient_npc/recruiter/syndicate name = "sharp-dressed stranger" desc = "A dark suit, dark glasses, and a way of not quite looking at the cameras." - outfit = /datum/outfit/ambient/recruiter_syndicate + outfit = /datum/outfit/ambient_recruiter_syndicate dialogue_section = "syndicate" pamphlet_type = /obj/item/paper/pamphlet/syndicate poster_types = list( @@ -341,11 +341,11 @@ /obj/structure/sign/poster/official/enlist, /obj/structure/sign/poster/official/nanotrasen_logo, ) - rival_type = /mob/living/basic/ambient_npc/recruiter/nanotrasen + rival_type = /mob/living/carbon/human/npc/ambient_npc/recruiter/nanotrasen /datum/ambient_outpost_role/recruiter/syndicate name = "Syndicate recruiter" - npc_type = /mob/living/basic/ambient_npc/recruiter/syndicate + npc_type = /mob/living/carbon/human/npc/ambient_npc/recruiter/syndicate outpost_types = list( /obj/structure/overmap/trader_outpost/outfitter, /obj/structure/overmap/trader_outpost/black_market, diff --git a/voidcrew/modules/ambient_npcs/outpost_angler.dm b/voidcrew/modules/ambient_npcs/outpost_angler.dm index 15f30fc0608..a2d7060255a 100644 --- a/voidcrew/modules/ambient_npcs/outpost_angler.dm +++ b/voidcrew/modules/ambient_npcs/outpost_angler.dm @@ -38,16 +38,16 @@ gloves = /obj/item/clothing/gloves/fishing shoes = /obj/item/clothing/shoes/galoshes -/// Camo trousers, an old field jacket and a battered fishing hat +/// Camo trousers, an old field jacket and a green cap (the legendary fishing hat only goes on a master angler) /datum/outfit/ambient_angler/camo name = "Pond angler (field jacket)" uniform = /obj/item/clothing/under/pants/camo suit = /obj/item/clothing/suit/jacket/miljacket - head = /obj/item/clothing/head/soft/fishing_hat + head = /obj/item/clothing/head/soft/green gloves = null shoes = /obj/item/clothing/shoes/workboots -/mob/living/basic/ambient_npc/outpost/angler +/mob/living/carbon/human/npc/ambient_npc/outpost/angler desc = "Here for the fish, and the quiet." dialogue_file = AMBIENT_STRINGS_WORKERS dialogue_section = "angler" @@ -75,11 +75,11 @@ /// Weakrefs to the players whose fishing they follow var/list/watched -/mob/living/basic/ambient_npc/outpost/angler/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/Initialize(mapload) . = ..() leave_at = world.time + rand(ANGLER_VISIT_LOW, ANGLER_VISIT_HIGH) -/mob/living/basic/ambient_npc/outpost/angler/Destroy() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/Destroy() for(var/datum/weakref/ref as anything in watched) var/mob/living/player = ref.resolve() if(player) @@ -93,7 +93,7 @@ fishing_spot = null return ..() -/mob/living/basic/ambient_npc/outpost/angler/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/pick_activity() if(world.time >= leave_at) if(prob(40)) speak_context(AMBIENT_LINE_LEAVE) @@ -111,7 +111,7 @@ return start_activity(new /datum/ambient_activity/pond_sit(src)) /// Here a while already: at the pond's edge in their chair with a line out, part-way through their stay -/mob/living/basic/ambient_npc/outpost/angler/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/settle_in() leave_at = ambient_part_way(leave_at) if(find_pond_spot()) // PB's fishing, anchored on the water (planet_fishers.dm) @@ -122,14 +122,14 @@ return TRUE return ..() -/mob/living/basic/ambient_npc/outpost/angler/shift_times(delay) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/shift_times(delay) . = ..() leave_at = ambient_shifted(leave_at, delay) next_watch_at = ambient_shifted(next_watch_at, delay) next_show_at = ambient_shifted(next_show_at, delay) /// Whether `tile` is a place at the pond's edge they could fish from into `pond` -/mob/living/basic/ambient_npc/outpost/angler/proc/fishing_spot_ok(turf/tile, turf/open/water/pond) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/fishing_spot_ok(turf/tile, turf/open/water/pond) if(!tile || !istype(pond) || !isopenturf(tile) || istype(tile, /turf/open/water)) return FALSE if(tile != loc && !standable(tile)) @@ -141,7 +141,7 @@ * tile on the concourse floor. An empty chair already there is theirs to use; otherwise they bring * a folding chair, once a visit. FALSE when there is no pond or no room at it. */ -/mob/living/basic/ambient_npc/outpost/angler/proc/find_pond_spot() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/find_pond_spot() if(water && fishing_spot && fishing_spot_ok(fishing_spot, water)) return TRUE var/datum/ambient_place/outpost/outpost_place = place @@ -166,7 +166,7 @@ return FALSE /// A chair at `stand`: an empty one already there, their own folding chair moved over, or a new folding chair (once a visit) -/mob/living/basic/ambient_npc/outpost/angler/proc/take_chair(turf/stand, turf/pond) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/take_chair(turf/stand, turf/pond) var/obj/structure/chair/seat = locate() in stand if(seat && seat_usable(seat)) chair_ref = WEAKREF(seat) @@ -185,7 +185,7 @@ return folding /// Sits in their chair if they are at it -/mob/living/basic/ambient_npc/outpost/angler/proc/sit_in_chair() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/sit_in_chair() if(buckled || fading) return var/obj/structure/chair/seat = chair_ref?.resolve() @@ -193,11 +193,11 @@ sit_on(seat) /// Faces the water -/mob/living/basic/ambient_npc/outpost/angler/proc/face_water() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/face_water() if(water && !buckled && loc != water) face_atom(water) -/mob/living/basic/ambient_npc/outpost/angler/activity_step(seconds) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/activity_step(seconds) . = ..() if(world.time >= next_watch_at) next_watch_at = world.time + ANGLER_WATCH_EVERY @@ -207,7 +207,7 @@ sit_in_chair() /// Starts following the fishing of players near them, and stops for the ones who went -/mob/living/basic/ambient_npc/outpost/angler/proc/watch_players() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/watch_players() for(var/datum/weakref/ref as anything in watched?.Copy()) var/mob/living/player = ref.resolve() if(QDELETED(player) || get_dist(src, player) > ANGLER_WATCH_RANGE + 3) @@ -215,11 +215,11 @@ UnregisterSignal(player, COMSIG_MOB_COMPLETE_FISHING) LAZYREMOVE(watched, ref) for(var/mob/living/player in view(ANGLER_WATCH_RANGE, src)) - if(player.client && !istype(player, /mob/living/basic/ambient_npc)) + if(player.client && !istype(player, /mob/living/carbon/human/npc/ambient_npc)) watch_player(player) /// Follows `player`'s fishing -/mob/living/basic/ambient_npc/outpost/angler/proc/watch_player(mob/living/player) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/watch_player(mob/living/player) for(var/datum/weakref/ref as anything in watched) if(ref.resolve() == player) return @@ -227,7 +227,7 @@ LAZYADD(watched, WEAKREF(player)) /// A player near them finished reeling in: a word about the fish, or about the one that got away -/mob/living/basic/ambient_npc/outpost/angler/proc/on_player_fished(mob/living/source, datum/fishing_challenge/challenge, win) +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/on_player_fished(mob/living/source, datum/fishing_challenge/challenge, win) SIGNAL_HANDLER if(stat != CONSCIOUS || fading || get_dist(src, source) > ANGLER_WATCH_RANGE + 1 || !reaction_ready("player_fish", 20 SECONDS)) return @@ -237,7 +237,7 @@ * A fish to hold up: a type from the pond's own fish table, weighted as the pond would weigh it. * Only the picture is used; nothing is caught and the pond's stock is never touched. */ -/mob/living/basic/ambient_npc/outpost/angler/proc/pond_fish_picture() +/mob/living/carbon/human/npc/ambient_npc/outpost/angler/proc/pond_fish_picture() var/turf/open/water/pond = water if(!istype(pond) || !pond.fishing_datum) return /obj/item/fish/goldfish @@ -261,7 +261,7 @@ duration_high = 4 MINUTES /datum/ambient_activity/pond_sit/setup() - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer if(!istype(angler) || !angler.fishing_spot) return FALSE go_to(angler.fishing_spot) @@ -270,7 +270,7 @@ return TRUE /datum/ambient_activity/pond_sit/arrive() - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer angler.face_water() angler.sit_in_chair() doer.set_held(/obj/item/fishing_rod) @@ -287,7 +287,7 @@ /// Could not get to their spot: they look for another next time instead of sitting down wherever they are /datum/ambient_activity/pond_sit/spot_unreachable() . = ..() - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer if(istype(angler)) angler.fishing_spot = null ends_at = world.time @@ -308,7 +308,7 @@ var/next_step_at = 0 /datum/ambient_activity/show_catch/setup() - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer if(!istype(angler)) return FALSE var/mob/living/basic/outpost_trader/pike = ambient_outpost_trader_of(doer.place, /datum/outpost_shop/vendor/bait_shop) @@ -328,7 +328,7 @@ return ..() /datum/ambient_activity/show_catch/arrive() - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer var/mob/living/basic/outpost_trader/pike = pike_ref?.resolve() switch(stage) if(0) @@ -347,7 +347,7 @@ next_step_at = world.time + rand(1 SECONDS, 2 SECONDS) /datum/ambient_activity/show_catch/act(seconds) - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer if(world.time < next_step_at) return AMBIENT_STEP_CONTINUE switch(stage) @@ -365,7 +365,7 @@ /// Back into the pond it goes /datum/ambient_activity/show_catch/proc/release() - var/mob/living/basic/ambient_npc/outpost/angler/angler = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/angler/angler = doer playsound(angler.water || doer, 'sound/effects/fish_splash.ogg', 30, TRUE, -3) doer.set_held(null) if(prob(50)) @@ -387,7 +387,7 @@ /// One angler at Halcyon's pond /datum/ambient_outpost_role/angler name = "angler" - npc_type = /mob/living/basic/ambient_npc/outpost/angler + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/angler outpost_types = list(/obj/structure/overmap/trader_outpost/general) max_count = 1 weight = 2 diff --git a/voidcrew/modules/ambient_npcs/outpost_dancer.dm b/voidcrew/modules/ambient_npcs/outpost_dancer.dm index 8a927ec22a0..76e884a9402 100644 --- a/voidcrew/modules/ambient_npcs/outpost_dancer.dm +++ b/voidcrew/modules/ambient_npcs/outpost_dancer.dm @@ -81,7 +81,7 @@ // ========================================================================= /// Dances on the pole for the crowd, and screams and runs if the kingpin's crew starts trouble -/mob/living/basic/ambient_npc/outpost/dancer +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer desc = "Dances on the pole for the crowd." dialogue_file = "outpost_dancer.json" dialogue_section = "dancer" @@ -98,7 +98,7 @@ var/back_to_pole = FALSE /// Dances at the pole, with the odd break at the bar or a nearby seat. Straight back to the pole after trouble. -/mob/living/basic/ambient_npc/outpost/dancer/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/pick_activity() if(back_to_pole) back_to_pole = FALSE if(start_activity(new /datum/ambient_activity/dance_pole(src))) @@ -108,13 +108,13 @@ return pick_anchored(routine, bar_spot, list(/datum/ambient_activity/sit)) /// Found mid-dance, up on the pole -/mob/living/basic/ambient_npc/outpost/dancer/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/settle_in() if(settle_at(/datum/ambient_activity/dance_pole)) return TRUE return ..() /// The Undertow's suplexed rod, if there is one -/mob/living/basic/ambient_npc/outpost/dancer/proc/find_pole() +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/proc/find_pole() return ambient_outpost_find(get_outpost(), /obj/structure/festivus/anchored) /** @@ -123,7 +123,7 @@ * is the dance's own check for that one tile: open ground on her leash, not in `avoid`, and nobody * else on it, standing or lying. */ -/mob/living/basic/ambient_npc/outpost/dancer/proc/pole_free(turf/tile, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/proc/pole_free(turf/tile, list/avoid) if(!isturf(tile) || LAZYACCESS(avoid, tile)) return FALSE if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) @@ -134,7 +134,7 @@ return leash_ok(tile) /// A fight at the outpost: she gets clear of it and keeps her head down until it has been quiet a while, screaming if it is right by the kingpin. She never leaves over it. -/mob/living/basic/ambient_npc/outpost/dancer/react_violence(mob/living/offender) +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/react_violence(mob/living/offender) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/leave) || istype(activity, /datum/ambient_activity/take_cover)) return // Already hiding: it is still going on @@ -150,7 +150,7 @@ speak_context(AMBIENT_LINE_VIOLENCE, offender, force = TRUE) /// Hit herself: she screams and gets clear, and goes back to her pole once it is quiet -/mob/living/basic/ambient_npc/outpost/dancer/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/react_attacked(atom/attacker) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/leave)) return if(still_hiding()) @@ -161,7 +161,7 @@ scream() /// The kingpin's crew is shooting: she screams and runs for cover, and goes back to her pole once it is over (shootout_over(), inherited) -/mob/living/basic/ambient_npc/outpost/dancer/react_shootout(turf/refuge) +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/react_shootout(turf/refuge) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/leave) || istype(activity, /datum/ambient_activity/take_cover)) return if(start_activity(new /datum/ambient_activity/take_cover(src, refuge))) @@ -169,14 +169,14 @@ scream() /// Starts her hiding from `threat`. TRUE if she is. -/mob/living/basic/ambient_npc/outpost/dancer/proc/hide_from(atom/threat) +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/proc/hide_from(atom/threat) if(!start_activity(new /datum/ambient_activity/dancer_hide(src, threat))) return FALSE back_to_pole = TRUE return TRUE /// If she is hiding, more trouble means it is not quiet yet, so she stays hidden longer. TRUE if she is hiding. -/mob/living/basic/ambient_npc/outpost/dancer/proc/still_hiding() +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/proc/still_hiding() var/datum/ambient_activity/dancer_hide/hiding = activity if(!istype(hiding)) return FALSE @@ -184,7 +184,7 @@ return TRUE /// A free tile within DANCER_HIDE_RANGE of her, further from `threat` than she is and as far as she can find; null to stay put -/mob/living/basic/ambient_npc/outpost/dancer/proc/hiding_spot(atom/threat, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/proc/hiding_spot(atom/threat, list/avoid) var/turf/here = get_turf(src) var/turf/danger = get_turf(threat) if(!here || !danger || danger.z != here.z) @@ -203,17 +203,9 @@ best_distance = distance return best -/// A woman's scream. Never sleeps. -/mob/living/basic/ambient_npc/outpost/dancer/proc/scream() - var/static/list/screams = list( - 'sound/mobs/humanoids/human/scream/femalescream_1.ogg', - 'sound/mobs/humanoids/human/scream/femalescream_2.ogg', - 'sound/mobs/humanoids/human/scream/femalescream_3.ogg', - 'sound/mobs/humanoids/human/scream/femalescream_4.ogg', - 'sound/mobs/humanoids/human/scream/femalescream_5.ogg', - ) - manual_emote("screams!") - playsound(src, pick(screams), 50, TRUE) +/// A scream, her own (tg's *scream). Never sleeps. +/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/proc/scream() + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob, emote), "scream") /** * Keeping clear of a fight: she runs a few steps away from it and crouches, until nothing has @@ -229,7 +221,7 @@ var/calm_at = 0 /datum/ambient_activity/dancer_hide/setup() - var/mob/living/basic/ambient_npc/outpost/dancer/dancer = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/dancer = doer if(!istype(dancer)) return FALSE set_duration() @@ -282,7 +274,7 @@ var/side = SOUTH /datum/ambient_activity/dance_pole/setup() - var/mob/living/basic/ambient_npc/outpost/dancer/dancer = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/dancer = doer if(!istype(dancer)) return FALSE var/obj/structure/festivus/anchored/pole = dancer.find_pole() @@ -346,7 +338,7 @@ /datum/ambient_activity/dance_pole/proc/try_climb() if(on_pole) return TRUE - var/mob/living/basic/ambient_npc/outpost/dancer/dancer = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/dancer/dancer = doer if(!pole() || doer.buckled || get_dist(doer, pole_turf) > 1 || !dancer.pole_free(pole_turf)) return FALSE if(doer.loc != pole_turf) @@ -498,7 +490,7 @@ /// One dancer at the Undertow /datum/ambient_outpost_role/dancer name = "dancer" - npc_type = /mob/living/basic/ambient_npc/outpost/dancer + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/dancer outpost_types = list(/obj/structure/overmap/trader_outpost/black_market) max_count = 1 weight = 3 diff --git a/voidcrew/modules/ambient_npcs/outpost_patrons.dm b/voidcrew/modules/ambient_npcs/outpost_patrons.dm index 78f33a7124c..62a2cfe1755 100644 --- a/voidcrew/modules/ambient_npcs/outpost_patrons.dm +++ b/voidcrew/modules/ambient_npcs/outpost_patrons.dm @@ -2,7 +2,7 @@ * # World population: customers and drinkers at the trader outposts (owner items 2 and 3) * * Built here (spec 3.1, 3.2): - * - /mob/living/basic/ambient_npc/outpost: the base for everyone PA brings to a trader outpost + * - /mob/living/carbon/human/npc/ambient_npc/outpost: the base for everyone PA brings to a trader outpost * (customers, drinkers, the staff in outpost_workers.dm, the angler in outpost_angler.dm). They * keep out of the pond, doorways, the lift's mouth, the kingpin's lounge and the bees. * - Customers: to one or two counters, a line for that trader and the trader's answer in the @@ -20,7 +20,7 @@ * mess for the janitor. * Lines are in strings/outpost_patrons.json (AMBIENT_STRINGS_PATRONS). * - * Seams (P0, frozen): /mob/living/basic/ambient_npc (speak_context(), reply_to(), talked_to(), + * Seams (P0, frozen): /mob/living/carbon/human/npc/ambient_npc (speak_context(), reply_to(), talked_to(), * react_violence(), react_shootout(), react_convoy(), pick_activity(), `routine`), the activities * in ambient_activity.dm (idle, wander, sit, drink, chat, work, leave), /datum/ambient_outpost_role * (npc_type, outpost_types, max_count, weight, gap_low/gap_high, wanted(), arrive()), @@ -45,19 +45,27 @@ #define PATRON_SERVE_HIGH (15 SECONDS) /// How far from the bar a drinker looks for a seat #define PATRON_BAR_RANGE 14 -/// Sips of something strong per drunk stage -#define PATRON_SIPS_PER_STAGE 2 -/// The stage at which they pass out +/// How drunk (tg's get_drunk_amount()) a drinker is at each stage: chatty, loud, swaying, and done for +/// (tg's own lines: 6 is drunk not tipsy, 16 slurs, 41 brings dizziness and confusion) +#define PATRON_DRUNK_CHATTY 6 +#define PATRON_DRUNK_LOUD 16 +#define PATRON_DRUNK_SWAYING 31 +#define PATRON_DRUNK_DONE 41 +/// Drunk enough to be sick (tg's own line for it) +#define PATRON_DRUNK_SICK 51 +/// The stage at which they are done for and sleep it off #define PATRON_DRUNK_MAX 4 -/// A drinker sips this often -#define PATRON_SIP_LOW (20 SECONDS) -#define PATRON_SIP_HIGH (40 SECONDS) +/// Gulps a drinker who was here before anyone came has already had, per round of drinks +#define PATRON_GULPS_PER_ROUND 3 +/// A drinker takes a gulp this often +#define PATRON_SIP_LOW (15 SECONDS) +#define PATRON_SIP_HIGH (30 SECONDS) /// Coffee sips at the crew room before something stronger #define PATRON_COFFEE_SIPS 2 /// How long a drinker sleeps it off before going #define PATRON_SLEEP_LOW (2 MINUTES) #define PATRON_SLEEP_HIGH (4 MINUTES) -/// Chance, when a drinker reaches stage 3 or 4, that they are sick on the floor (once) +/// Chance, when a drinker drunk enough to be sick reaches a new stage, that they are (once) #define PATRON_VOMIT_CHANCE 20 /// Least time between two drinks handed to one drinker #define PATRON_HANDED_COOLDOWN (20 SECONDS) @@ -83,7 +91,7 @@ * activities never send them into the pond, into or beside a doorway, into the lift's mouth, into * the kingpin's lounge or near an apiary. */ -/mob/living/basic/ambient_npc/outpost +/mob/living/carbon/human/npc/ambient_npc/outpost desc = "Someone off one of the ships." dialogue_file = AMBIENT_STRINGS_PATRONS dialogue_section = "customer" @@ -97,7 +105,7 @@ * ambient_outpost_tile_avoided() lists: the pond, doorways, the lift's mouth, the kingpin's lounge, * the hives, and a counter's reach unless they have business there and nobody is being served. */ -/mob/living/basic/ambient_npc/outpost/standable(turf/tile, list/avoid, ignore_floor = FALSE) +/mob/living/carbon/human/npc/ambient_npc/outpost/standable(turf/tile, list/avoid, ignore_floor = FALSE) if(!isturf(tile) || LAZYACCESS(avoid, tile)) return FALSE if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) @@ -112,7 +120,7 @@ return FALSE return !ambient_outpost_tile_avoided(tile, place, counter_business) -/mob/living/basic/ambient_npc/outpost/seat_usable(obj/structure/chair/seat, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/seat_usable(obj/structure/chair/seat, list/avoid) . = ..() if(!.) return @@ -127,7 +135,7 @@ * can be set up, then standing about: P0's pick_activity() with an anchor. The types in * `anchored_types` (all of them when null) are made with `anchor`, the rest without. */ -/mob/living/basic/ambient_npc/outpost/proc/pick_anchored(list/weights, atom/anchor, list/anchored_types) +/mob/living/carbon/human/npc/ambient_npc/outpost/proc/pick_anchored(list/weights, atom/anchor, list/anchored_types) var/list/options = weights?.Copy() while(length(options)) var/activity_type = pick_weight(options) @@ -138,12 +146,12 @@ return start_activity(new /datum/ambient_activity/idle(src)) /// The trader outpost they are at, if any -/mob/living/basic/ambient_npc/outpost/proc/get_outpost() +/mob/living/carbon/human/npc/ambient_npc/outpost/proc/get_outpost() var/datum/ambient_place/outpost/outpost_place = place return istype(outpost_place) ? outpost_place.outpost() : null /// A line for `context` from `section` of their file, said now to `other`. TRUE if said. -/mob/living/basic/ambient_npc/outpost/proc/say_from(section, context, atom/other) +/mob/living/carbon/human/npc/ambient_npc/outpost/proc/say_from(section, context, atom/other) var/list/lines = ambient_dialogue_lines(dialogue_file, section, context) if(!length(lines) || stat != CONSCIOUS || fading) return FALSE @@ -264,7 +272,7 @@ if(!trader) return null for(var/mob/living/customer in range(PATRON_COUNTER_RANGE, trader)) - if(customer.stat == DEAD || istype(customer, /mob/living/basic/ambient_npc) || istype(customer, /mob/living/basic/outpost_trader)) + if(customer.stat == DEAD || istype(customer, /mob/living/carbon/human/npc/ambient_npc) || istype(customer, /mob/living/basic/outpost_trader)) continue if(customer.client || ishuman(customer)) return customer @@ -273,7 +281,7 @@ /// How many ambient NPCs stand or sit within reach of `trader`, not counting `exclude` /proc/ambient_counter_crowd(mob/living/basic/outpost_trader/trader, mob/living/exclude) . = 0 - for(var/mob/living/basic/ambient_npc/npc in range(PATRON_COUNTER_RANGE, trader)) + for(var/mob/living/carbon/human/npc/ambient_npc/npc in range(PATRON_COUNTER_RANGE, trader)) if(npc != exclude && npc.stat != DEAD && !npc.fading) .++ @@ -282,12 +290,12 @@ * tiles out with the counter (a table) in between, so never on the trader's own side. A trader with * no counter at all is served from anywhere within reach. */ -/proc/ambient_counter_spots(mob/living/basic/ambient_npc/npc, mob/living/basic/outpost_trader/trader, list/avoid) +/proc/ambient_counter_spots(mob/living/carbon/human/npc/ambient_npc/npc, mob/living/basic/outpost_trader/trader, list/avoid) . = list() var/turf/center = get_turf(trader) if(!center || !npc) return - var/mob/living/basic/ambient_npc/outpost/visitor = npc + var/mob/living/carbon/human/npc/ambient_npc/outpost/visitor = npc if(istype(visitor)) visitor.counter_business = TRUE var/list/open_spots = list() @@ -329,7 +337,7 @@ * With `loiter` (default), only loiter spots count, so a crowded or bad queue gives up the * spot instead of stacking. Null if none. */ -/proc/ambient_waiting_spot(mob/living/basic/ambient_npc/npc, atom/center, low = 3, high = 4, list/avoid, loiter = TRUE) +/proc/ambient_waiting_spot(mob/living/carbon/human/npc/ambient_npc/npc, atom/center, low = 3, high = 4, list/avoid, loiter = TRUE) var/turf/middle = get_turf(center) if(!middle || !npc) return null @@ -512,7 +520,7 @@ /proc/ambient_outpost_report_mess(datum/ambient_place/outpost/place, obj/effect/decal/cleanable/mess) if(!istype(place) || !ambient_is_mess(mess)) return - for(var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor in place.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor in place.living_npcs()) if(janitor.notice_mess(mess)) return @@ -591,7 +599,7 @@ // ========================================================================= /// Someone off one of the berthed ships, doing their shopping -/mob/living/basic/ambient_npc/outpost/customer +/mob/living/carbon/human/npc/ambient_npc/outpost/customer desc = "Someone off one of the ships, doing their shopping." dialogue_section = "customer" outfit_choices = list( @@ -617,16 +625,16 @@ /// Weakrefs to the traders they already bought from var/list/visited -/mob/living/basic/ambient_npc/outpost/customer/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/Initialize(mapload) . = ..() leave_at = world.time + rand(PATRON_VISIT_LOW, PATRON_VISIT_HIGH) stalls_left = rand(1, PATRON_STALLS_MAX) -/mob/living/basic/ambient_npc/outpost/customer/Destroy() +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/Destroy() visited = null return ..() -/mob/living/basic/ambient_npc/outpost/customer/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/pick_activity() if(world.time >= leave_at) if(prob(30)) speak_context(AMBIENT_LINE_LEAVE) @@ -639,26 +647,26 @@ return ..() /// Here a while already: part-way through their visit, at a counter, or done there with a bag in hand -/mob/living/basic/ambient_npc/outpost/customer/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/settle_in() leave_at = ambient_part_way(leave_at) if(prob(40)) stalls_left-- set_held(/obj/item/storage/box/papersack) return ..() -/mob/living/basic/ambient_npc/outpost/customer/shift_times(delay) +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/shift_times(delay) . = ..() leave_at = ambient_shifted(leave_at, delay) /// Whether they already bought from `trader` this visit -/mob/living/basic/ambient_npc/outpost/customer/proc/has_visited(mob/living/basic/outpost_trader/trader) +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/proc/has_visited(mob/living/basic/outpost_trader/trader) for(var/datum/weakref/ref as anything in visited) if(ref.resolve() == trader) return TRUE return FALSE /// The convoy came in: now and then a word about it -/mob/living/basic/ambient_npc/outpost/customer/react_convoy(obj/structure/overmap/trader_outpost/outpost) +/mob/living/carbon/human/npc/ambient_npc/outpost/customer/react_convoy(obj/structure/overmap/trader_outpost/outpost) if(stat == CONSCIOUS && !fading && prob(30) && reaction_ready("convoy", 1 MINUTES)) speak_context("convoy") @@ -699,7 +707,7 @@ /// A trader at their place to visit: one they have not bought from, whose counter is not crowded with NPCs /datum/ambient_activity/shop_visit/proc/pick_trader() - var/mob/living/basic/ambient_npc/outpost/customer/shopper = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/customer/shopper = doer var/list/options = list() for(var/mob/living/basic/outpost_trader/trader as anything in ambient_outpost_traders(doer.place)) if(!ambient_trader_section(trader)) @@ -791,7 +799,7 @@ /// Served: off with a paper bag /datum/ambient_activity/shop_visit/proc/served(mob/living/basic/outpost_trader/trader) doer.set_held(/obj/item/storage/box/papersack) - var/mob/living/basic/ambient_npc/outpost/customer/shopper = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/customer/shopper = doer if(istype(shopper)) LAZYADD(shopper.visited, WEAKREF(trader)) @@ -812,7 +820,7 @@ // ========================================================================= /// Off duty and at the bar, and it shows more with every drink -/mob/living/basic/ambient_npc/outpost/drinker +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker desc = "Someone off one of the ships, off duty." dialogue_section = "drinker" outfit_choices = list( @@ -830,11 +838,9 @@ var/datum/weakref/barkeep_ref /// Their bar's section of AMBIENT_STRINGS_PATRONS (PATRON_BAR_*) var/bar_section - /// 0 sober to PATRON_DRUNK_MAX passed out + /// How far gone they are, 0 sober to PATRON_DRUNK_MAX done for: read from tg's drunkenness (drunk_stage()) var/drunk = 0 - /// Sips of something strong since the last stage - var/strong_sips = 0 - /// Sips of coffee (the crew room starts with coffee) + /// Gulps of coffee (the crew room starts with coffee) var/coffee_sips = 0 /// They ordered at the bar var/ordered = FALSE @@ -842,21 +848,20 @@ var/cut_off = FALSE /// They were sick on the floor (once) var/vomited = FALSE - /// They slept it off: next stop, the lift + /// They slept it off, or have had their fill: next stop, the lift var/slept = FALSE - /// Lying down and snoring - var/asleep = FALSE - /// Their transform before they lay down - var/matrix/standing_transform -/mob/living/basic/ambient_npc/outpost/drinker/Destroy() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/Initialize(mapload, datum/human_npc_order/order) + . = ..() + RegisterSignal(src, COMSIG_CARBON_VOMITED, PROC_REF(on_vomited)) + +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/Destroy() bar_ref = null barkeep_ref = null - standing_transform = null return ..() /// Finds their outpost's bar. FALSE when it has none. -/mob/living/basic/ambient_npc/outpost/drinker/proc/find_bar() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/find_bar() var/list/bar = ambient_outpost_bar(place) if(!length(bar)) return FALSE @@ -864,23 +869,23 @@ return TRUE /// Sets their bar: `where` it is, its `barkeep` (or null), its `section` (PATRON_BAR_*) -/mob/living/basic/ambient_npc/outpost/drinker/proc/set_bar(atom/where, mob/living/basic/outpost_trader/barkeep, section) +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/set_bar(atom/where, mob/living/basic/outpost_trader/barkeep, section) bar_ref = where ? WEAKREF(where) : null barkeep_ref = barkeep ? WEAKREF(barkeep) : null bar_section = section /// Their bar, if it is still there -/mob/living/basic/ambient_npc/outpost/drinker/proc/get_bar() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/get_bar() var/atom/bar = bar_ref?.resolve() return QDELETED(bar) ? null : bar /// Their barkeep, if still at the bar -/mob/living/basic/ambient_npc/outpost/drinker/proc/get_barkeep() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/get_barkeep() var/mob/living/basic/outpost_trader/barkeep = barkeep_ref?.resolve() return QDELETED(barkeep) ? null : barkeep /// What is poured for them at their bar -/mob/living/basic/ambient_npc/outpost/drinker/proc/bar_drink() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/bar_drink() switch(bar_section) if(PATRON_BAR_CREW_ROOM) return /datum/reagent/consumable/coffee @@ -888,7 +893,8 @@ return pick(/datum/reagent/consumable/ethanol/whiskey, /datum/reagent/consumable/ethanol/rum, /datum/reagent/consumable/ethanol/beer) return /datum/reagent/consumable/ethanol/beer -/mob/living/basic/ambient_npc/outpost/drinker/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/pick_activity() + update_drunk_stage() if(slept) return start_activity(new /datum/ambient_activity/leave(src)) if(drunk >= PATRON_DRUNK_MAX) @@ -906,58 +912,57 @@ /** * Here a while already: in a seat near the bar (or at a table) with a glass in hand, having ordered - * long ago, and some of them a stage or two in. With no seat or table to be had, their routine - * starts when someone comes. + * long ago, and some of them a few drinks in: those gulps are swallowed now, while nobody is there + * to see, and the drink works on them as it would on anyone once people come. With no seat or table + * to be had, their routine starts when someone comes. */ -/mob/living/basic/ambient_npc/outpost/drinker/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/settle_in() if(!get_bar() && !find_bar()) return ..() ordered = TRUE if(!start_activity(new /datum/ambient_activity/drink/bar(src, get_bar())) || !settle_here()) end_activity() return ..() - var/stages = pick(0, 1, 1, 2) - if(stages && bar_section == PATRON_BAR_CREW_ROOM) + var/rounds = pick(0, 1, 1, 2) + if(rounds && bar_section == PATRON_BAR_CREW_ROOM) // Off the coffee and onto something stronger already - var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = held_item - if(istype(glass)) - glass.reagents.clear_reagents() - glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) - set_held(glass) - coffee_sips = -INFINITY - if(stages) - get_drunker(stages) - strong_sips = rand(0, PATRON_SIPS_PER_STAGE - 1) + switch_to_strong(quiet = TRUE) + for(var/gulp in 1 to rounds * PATRON_GULPS_PER_ROUND) + if(!sip()) + break return TRUE -// Slurring would wear off by itself while they sit still -/mob/living/basic/ambient_npc/outpost/drinker/pause_routine() - . = ..() - remove_status_effect(/datum/status_effect/speech/slurring/generic) - -/mob/living/basic/ambient_npc/outpost/drinker/resume_routine() - . = ..() - if(drunk >= 3) - set_slurring_if_lower(10 MINUTES) - /// Their lines for being talked to depend on how far gone they are -/mob/living/basic/ambient_npc/outpost/drinker/get_lines(context) +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/get_lines(context) if(context == AMBIENT_LINE_TALK) - var/list/staged = ambient_dialogue_lines(dialogue_file, dialogue_section, asleep ? "talk_asleep" : "talk_[drunk]") + var/list/staged = ambient_dialogue_lines(dialogue_file, dialogue_section, "talk_[drunk]") if(length(staged)) return staged return ..() +/// How far gone they are, from tg's drunkenness: 0 sober, 1 chatty, 2 loud, 3 swaying, PATRON_DRUNK_MAX done for +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/drunk_stage() + var/amount = get_drunk_amount() + if(amount >= PATRON_DRUNK_DONE) + return PATRON_DRUNK_MAX + if(amount >= PATRON_DRUNK_SWAYING) + return 3 + if(amount >= PATRON_DRUNK_LOUD) + return 2 + if(amount >= PATRON_DRUNK_CHATTY) + return 1 + return 0 + /** - * One stage drunker (or `amount`; negative sobers them). Stage 1 is chatty, stage 2 loud, stage 3 - * slurring and swaying, stage 4 done: the barkeep cuts them off and they go and lie down. + * Catches their stage up with how drunk they are. Stage 1 is chatty, stage 2 loud, stage 3 slurring + * and swaying (tg's drunkenness does the slurring), stage 4 done: the barkeep cuts them off and they + * go and sleep it off. A heavy drinker may be sick on the floor, once. */ -/mob/living/basic/ambient_npc/outpost/drinker/proc/get_drunker(amount = 1) - var/old_stage = drunk - drunk = clamp(drunk + amount, 0, PATRON_DRUNK_MAX) - if(drunk == old_stage) +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/update_drunk_stage() + var/stage = drunk_stage() + if(stage == drunk) return - strong_sips = 0 + drunk = stage switch(drunk) if(0) speech_pace = 1 @@ -967,17 +972,13 @@ speech_pace = 0.6 else speech_pace = 0.7 - if(drunk >= 3) - set_slurring_if_lower(10 MINUTES) - else - remove_status_effect(/datum/status_effect/speech/slurring/generic) - if(drunk >= 3 && !vomited && prob(PATRON_VOMIT_CHANCE)) + if(drunk >= 3 && !vomited && get_drunk_amount() >= PATRON_DRUNK_SICK && prob(PATRON_VOMIT_CHANCE)) be_sick() if(drunk >= PATRON_DRUNK_MAX) cut_off() /// The barkeep has seen enough (once) -/mob/living/basic/ambient_npc/outpost/drinker/proc/cut_off() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/cut_off() if(cut_off) return cut_off = TRUE @@ -985,49 +986,59 @@ if(barkeep) ambient_trader_reply(barkeep, src, AMBIENT_STRINGS_PATRONS, bar_section, "cutoff", 10) -/// Sick on the floor beside them, once. The janitor hears about it. -/mob/living/basic/ambient_npc/outpost/drinker/proc/be_sick() +/** + * Sick on the floor, once: tg's own vomiting. Human NPCs have TRAIT_NOHUNGER (the base's performance + * rule), which stops tg's drunk vomiting, so this is the drunk's own heave, at tg's own drunkenness + * for it (PATRON_DRUNK_SICK). + */ +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/be_sick() vomited = TRUE - var/turf/spot = free_tile_beside(src, 1) || get_turf(src) - if(!spot) - return - var/obj/effect/decal/cleanable/vomit/mess = new(spot) - playsound(src, 'sound/effects/splat.ogg', 30, TRUE, -3) - INVOKE_ASYNC(src, TYPE_PROC_REF(/atom, manual_emote), "throws up.") - if(!QDELETED(mess)) - ambient_outpost_report_mess(place, mess) + vomit(VOMIT_CATEGORY_DEFAULT | MOB_VOMIT_FORCE) + +/// They threw up: the janitor hears about the mess +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/on_vomited(datum/source, distance, force) + SIGNAL_HANDLER + vomited = TRUE + // The decal goes down after the signal: report it next tick + addtimer(CALLBACK(src, PROC_REF(report_vomit), get_turf(src), dir), 1, TIMER_DELETE_ME) + +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/report_vomit(turf/where, facing) + for(var/turf/tile in list(where, get_step(where, facing))) + var/obj/effect/decal/cleanable/vomit/mess = locate() in tile + if(mess) + ambient_outpost_report_mess(place, mess) + return /** - * A sip was had from their glass: it counts towards the next stage when it was something strong. - * At the crew room the first sips are coffee, then something stronger. + * A gulp was had from their glass. At the crew room the first gulps are coffee, then something + * stronger; drink works on them by itself, and their stage follows it. */ -/mob/living/basic/ambient_npc/outpost/drinker/proc/on_sip(strong) +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/on_sip(strong) if(!strong) coffee_sips++ if(bar_section == PATRON_BAR_CREW_ROOM && coffee_sips >= PATRON_COFFEE_SIPS) switch_to_strong() - return - if(++strong_sips >= PATRON_SIPS_PER_STAGE) - get_drunker(1) + update_drunk_stage() /// Coffee's done; something stronger in the same glass -/mob/living/basic/ambient_npc/outpost/drinker/proc/switch_to_strong() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/switch_to_strong(quiet = FALSE) var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = held_item if(!istype(glass)) return glass.reagents.clear_reagents() - glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) + glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, AMBIENT_DRINK_POUR) set_held(glass) coffee_sips = -INFINITY - say_from(bar_section, "stronger") + if(!quiet) + say_from(bar_section, "stronger") /// Whether their glass holds something strong right now -/mob/living/basic/ambient_npc/outpost/drinker/proc/glass_is_strong() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/glass_is_strong() var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = held_item return istype(glass) && glass.reagents?.has_reagent(/datum/reagent/consumable/ethanol, check_subtypes = TRUE) /// A word for the stage they are at: the bar's own lines early on, songs at stage 2, a pair talk with another drinker -/mob/living/basic/ambient_npc/outpost/drinker/proc/drunk_chatter() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/drunk_chatter() if(world.time < next_line_at || (place && world.time < place.next_line_at)) return if(drunk >= 1 && prob(35) && pair_talk()) @@ -1039,12 +1050,12 @@ speak_context("stage_[drunk]") /// An exchange with another drinker at their bar within a few tiles. TRUE if one started. -/mob/living/basic/ambient_npc/outpost/drinker/proc/pair_talk() +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/pair_talk() var/list/conversation = pick_conversation() if(length(conversation) < 2) return FALSE - for(var/mob/living/basic/ambient_npc/outpost/drinker/other in range(3, src)) - if(other == src || other.asleep || !other.can_act() || other.bar_section != bar_section) + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/other in range(3, src)) + if(other == src || !other.can_act() || other.bar_section != bar_section) continue if(!istype(other.activity, /datum/ambient_activity/drink/bar)) continue @@ -1053,45 +1064,27 @@ return TRUE return FALSE -/// Down on the sofa (or the floor), out cold -/mob/living/basic/ambient_npc/outpost/drinker/proc/lie_down() - if(asleep) - return - asleep = TRUE - standing_transform = matrix(transform) - var/matrix/lying = matrix(transform) - lying.Turn(90) - animate(src, transform = lying, time = 0.4 SECONDS) - -/// Up again -/mob/living/basic/ambient_npc/outpost/drinker/proc/wake_up() - if(!asleep) - return - asleep = FALSE - // Killed where they slept: a body stays down - if(stat != DEAD) - animate(src, transform = standing_transform || matrix(), time = 0.4 SECONDS) - standing_transform = null - /** - * A drink handed to them: they drink from it. Something strong takes them a stage further, - * anything else sobers them a little; poison they will not touch. The drink stays in the giver's hand. + * A drink handed to them: they take a gulp from it, as anyone does (the glass's own drinking: what + * is in it goes into them). They turn down only what anyone could tell by looking: a glass of + * something that isn't a drink, or a known poison. Poisoning one at a trader outpost is violence + * there, like hitting them. The drink stays in the giver's hand. */ -/mob/living/basic/ambient_npc/outpost/drinker/item_interaction(mob/living/user, obj/item/tool, list/modifiers) +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/item_interaction(mob/living/user, obj/item/tool, list/modifiers) . = ..() if(. || user.combat_mode) return if(!istype(tool, /obj/item/reagent_containers/cup/glass) && !istype(tool, /obj/item/reagent_containers/cup/soda_cans)) return + // Awake and up, they answer it themselves (a gulp, or a refusal); otherwise it is tg's feeding + if(!can_act() || fading) + return accept_drink(user, tool) return ITEM_INTERACT_SUCCESS /// `user` hands them `drink`. TRUE if they drank from it. -/mob/living/basic/ambient_npc/outpost/drinker/proc/accept_drink(mob/living/user, obj/item/reagent_containers/drink) - if(stat != CONSCIOUS || fading) - return FALSE - if(asleep) - INVOKE_ASYNC(src, TYPE_PROC_REF(/atom, manual_emote), "snores on.") +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/proc/accept_drink(mob/living/user, obj/item/reagent_containers/cup/drink) + if(!can_act() || fading) return FALSE if(!reaction_ready("handed", PATRON_HANDED_COOLDOWN)) return FALSE @@ -1100,18 +1093,23 @@ if(!drink.reagents?.total_volume || !drink.is_drainable()) speak_context("handed_empty", user, force = TRUE) return FALSE - if(drink.reagents.has_reagent(/datum/reagent/toxin, check_subtypes = TRUE)) + var/datum/reagent/looks_like = drink.reagents.get_master_reagent() + if(!istype(looks_like, /datum/reagent/consumable) || istype(looks_like, /datum/reagent/toxin)) speak_context("handed_refuse", user, force = TRUE) return FALSE + if(!drink.canconsume(src, src)) + return FALSE var/strong = drink.reagents.has_reagent(/datum/reagent/consumable/ethanol, check_subtypes = TRUE) - drink.reagents.remove_all(20) - playsound(src, 'sound/items/drink.ogg', 30, TRUE, -4) + var/poisoned = drink.reagents.has_reagent(/datum/reagent/toxin, check_subtypes = TRUE) + log_combat(user, src, "handed a drink to", drink, "containing [drink.reagents.get_reagent_log_string()]") + drink.attack(src, src) + if(poisoned) + place?.npc_attacked(src, user, ATTACKER_DAMAGING_ATTACK) if(strong) - get_drunker(1) speak_context(prob(30) ? "handed_gossip" : "handed", user, force = TRUE) else - get_drunker(-1) speak_context("handed_sober", user, force = TRUE) + update_drunk_stage() return TRUE /// Ordering at the bar: up to the barkeep's counter ("the usual"), or a cup from the crew room's coffee machine @@ -1125,7 +1123,7 @@ var/done_at = 0 /datum/ambient_activity/bar_order/setup() - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = doer var/atom/bar = anchor() if(!istype(drinker) || !bar) return FALSE @@ -1151,7 +1149,7 @@ done_at = world.time + rand(4 SECONDS, 7 SECONDS) /datum/ambient_activity/bar_order/act(seconds) - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = doer var/atom/bar = anchor() if(!bar || !istype(drinker)) return AMBIENT_STEP_DONE @@ -1189,7 +1187,7 @@ var/next_sway = 0 /datum/ambient_activity/drink/bar/setup() - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = doer if(!istype(drinker)) return FALSE reagent_type = drinker.bar_drink() @@ -1234,9 +1232,12 @@ next_sip = world.time + rand(8 SECONDS, 15 SECONDS) /datum/ambient_activity/drink/bar/act(seconds) - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = doer - // Done for (a drink handed over can do it between sips) - if(!istype(drinker) || drinker.drunk >= PATRON_DRUNK_MAX) + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = doer + if(!istype(drinker)) + return AMBIENT_STEP_DONE + drinker.update_drunk_stage() + // Done for (a drink handed over can do it between gulps) + if(drinker.drunk >= PATRON_DRUNK_MAX) return AMBIENT_STEP_DONE if(world.time >= next_sip) next_sip = world.time + rand(PATRON_SIP_LOW, PATRON_SIP_HIGH) @@ -1248,20 +1249,23 @@ return AMBIENT_STEP_DONE if(drinker.drunk >= 3 && world.time >= next_sway) next_sway = world.time + rand(30 SECONDS, 60 SECONDS) - drinker.Shake(2, 0, 0.6 SECONDS) drinker.manual_emote(pick("sways.", "hiccups.", "grabs the edge of the table.", "nearly slides off the seat.")) if(world.time >= next_line) next_line = world.time + rand(25 SECONDS, 50 SECONDS) drinker.drunk_chatter() return AMBIENT_STEP_CONTINUE -/// The glass is finished before it goes down, so the barback can take it +/// The glass is finished before it goes down, so the barback can take it. A night that ran its length +/// without them getting done for ends with them heading home. /datum/ambient_activity/drink/bar/finish() + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = doer var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = doer?.held_item if(istype(glass)) glass.reagents?.clear_reagents() if(!QDELETED(doer) && !ambient_outpost_has_barback(doer.place)) table_ref = null + if(istype(drinker) && !QDELETED(drinker) && ends_at && world.time >= ends_at && drinker.drunk < PATRON_DRUNK_MAX) + drinker.slept = TRUE return ..() /datum/ambient_activity/drink/bar/shift_times(delay) @@ -1269,18 +1273,19 @@ next_sway = ambient_shifted(next_sway, delay) /** - * Sleeping it off: over to a sofa if there is one near (the floor if not), down, and snoring for a - * few minutes. Then they wake and go, unless the barback walks them out first. + * Sleeping it off: over to a sofa if there is one near (the floor if not), down, and asleep for a + * few minutes, as anyone falls asleep (tg's Sleeping: they snore by themselves, and can be shaken + * awake). Then they wake and go, unless the barback shakes them awake and walks them out first. */ /datum/ambient_activity/sleep_it_off name = "sleeping it off" duration_low = PATRON_SLEEP_LOW duration_high = PATRON_SLEEP_HIGH var/datum/weakref/sofa_ref - /// world.time of the next snore - var/next_snore = 0 - /// world.time the barback is asked to come + /// world.time the barback comes for them, if one is free (half the time); 0 once asked var/escort_at = 0 + /// They have dropped off + var/slept_yet = FALSE /datum/ambient_activity/sleep_it_off/setup() var/obj/structure/chair/sofa/sofa = find_sofa() @@ -1306,31 +1311,30 @@ return best /datum/ambient_activity/sleep_it_off/arrive() - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = doer var/obj/structure/chair/sofa/sofa = sofa_ref?.resolve() if(sofa) doer.sit_on(sofa) - if(istype(drinker)) - drinker.lie_down() - next_snore = world.time + rand(5 SECONDS, 15 SECONDS) - escort_at = world.time + rand(30 SECONDS, 60 SECONDS) + else + doer.set_resting(TRUE, silent = TRUE) + doer.manual_emote(pick("slumps back, eyes closing.", "settles in for a nap.")) + slept_yet = TRUE + escort_at = prob(50) ? world.time + rand(30 SECONDS, 60 SECONDS) : 0 + doer.Sleeping(max(1 SECONDS, ends_at - world.time)) +// They only act once awake again: the sleep ran out, or someone shook them /datum/ambient_activity/sleep_it_off/act(seconds) - if(world.time >= next_snore) - next_snore = world.time + rand(15 SECONDS, 30 SECONDS) - doer.manual_emote(pick("snores.", "snores loudly.", "mumbles in their sleep.", "snores, and turns over.")) - if(escort_at && world.time >= escort_at) - escort_at = 0 - ambient_outpost_call_escort(doer.place, doer) - return AMBIENT_STEP_CONTINUE + if(doer.IsSleeping()) + return AMBIENT_STEP_CONTINUE + return AMBIENT_STEP_DONE /datum/ambient_activity/sleep_it_off/finish() - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = doer - if(istype(drinker) && !QDELETED(drinker)) - drinker.wake_up() + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = doer + if(istype(drinker) && !QDELETED(drinker) && slept_yet) drinker.slept = TRUE - if(!drinker.fading && prob(50)) - drinker.speak_context("wake", null, force = TRUE) + if(drinker.stat == CONSCIOUS) + drinker.set_resting(FALSE, silent = TRUE) + if(!drinker.fading && prob(50)) + drinker.speak_context("wake", null, force = TRUE) return ..() /datum/ambient_activity/sleep_it_off/spot_unreachable() @@ -1339,14 +1343,25 @@ /datum/ambient_activity/sleep_it_off/shift_times(delay) . = ..() - next_snore = ambient_shifted(next_snore, delay) escort_at = ambient_shifted(escort_at, delay) +/// A drinker at `place` sleeping it off whom the barback should come and walk out now, or null. Once each. +/proc/ambient_outpost_drunk_to_walk_out(datum/ambient_place/outpost/place) + if(!istype(place)) + return null + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drunk in place.npcs) + var/datum/ambient_activity/sleep_it_off/nap = drunk.activity + if(QDELETED(drunk) || drunk.stat == DEAD || drunk.fading || !istype(nap) || !nap.escort_at || world.time < nap.escort_at) + continue + nap.escort_at = 0 + return drunk + return null + /// Someone at `place` could walk `drunk` to the lift: a free barback there, half the time -/proc/ambient_outpost_call_escort(datum/ambient_place/outpost/place, mob/living/basic/ambient_npc/drunk) +/proc/ambient_outpost_call_escort(datum/ambient_place/outpost/place, mob/living/carbon/human/npc/ambient_npc/drunk) if(!istype(place) || QDELETED(drunk) || !prob(50)) return FALSE - for(var/mob/living/basic/ambient_npc/outpost/worker/barback/barback in place.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/barback in place.living_npcs()) if(barback.activity?.priority > AMBIENT_PRIORITY_ROUTINE) continue if(barback.start_activity(new /datum/ambient_activity/escort(barback, drunk))) @@ -1360,7 +1375,7 @@ /// Customers at every trader outpost: two at a time (one at the Undertow), one every minute or so /datum/ambient_outpost_role/customer name = "customer" - npc_type = /mob/living/basic/ambient_npc/outpost/customer + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/customer outpost_types = list( /obj/structure/overmap/trader_outpost/general, /obj/structure/overmap/trader_outpost/outfitter, @@ -1382,7 +1397,7 @@ /// One drinker at each outpost's bar /datum/ambient_outpost_role/drinker name = "drinker" - npc_type = /mob/living/basic/ambient_npc/outpost/drinker + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/drinker outpost_types = list( /obj/structure/overmap/trader_outpost/general, /obj/structure/overmap/trader_outpost/outfitter, @@ -1398,7 +1413,7 @@ /datum/ambient_outpost_role/drinker/arrive(datum/ambient_place/outpost/place, turf/where) . = ..() - var/mob/living/basic/ambient_npc/outpost/drinker/drinker = . + var/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/drinker = . if(istype(drinker)) drinker.find_bar() @@ -1411,7 +1426,12 @@ #undef PATRON_SERVE_LOW #undef PATRON_SERVE_HIGH #undef PATRON_BAR_RANGE -#undef PATRON_SIPS_PER_STAGE +#undef PATRON_DRUNK_CHATTY +#undef PATRON_DRUNK_LOUD +#undef PATRON_DRUNK_SWAYING +#undef PATRON_DRUNK_DONE +#undef PATRON_DRUNK_SICK +#undef PATRON_GULPS_PER_ROUND #undef PATRON_DRUNK_MAX #undef PATRON_SIP_LOW #undef PATRON_SIP_HIGH diff --git a/voidcrew/modules/ambient_npcs/outpost_regulars.dm b/voidcrew/modules/ambient_npcs/outpost_regulars.dm index 5873d7cb129..a61b56273b6 100644 --- a/voidcrew/modules/ambient_npcs/outpost_regulars.dm +++ b/voidcrew/modules/ambient_npcs/outpost_regulars.dm @@ -13,7 +13,7 @@ // ========================================================================= /// Reuses the mechanic outfits and work loop; always mid-job at a rack, pipe or panel -/mob/living/basic/ambient_npc/outpost/worker/mechanic +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/mechanic desc = "Permanently mid-job. Nobody has ever seen the job finished." dialogue_section = "mechanic" outfit_choices = list( @@ -37,7 +37,7 @@ ) /// Found mid-job: at work on something -/mob/living/basic/ambient_npc/outpost/worker/mechanic/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/mechanic/settle_in() if(settle_at(/datum/ambient_activity/work)) return TRUE return ..() @@ -45,7 +45,7 @@ /// One mechanic at Halcyon /datum/ambient_outpost_role/mechanic name = "mechanic" - npc_type = /mob/living/basic/ambient_npc/outpost/worker/mechanic + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/worker/mechanic outpost_types = list(/obj/structure/overmap/trader_outpost/general) max_count = 1 weight = 3 @@ -63,13 +63,13 @@ back = /obj/item/claymore/cutlass /// A drinker flavour: their own lines while sober, the drinker's staged lines once they've had a few -/mob/living/basic/ambient_npc/outpost/drinker/off_duty_pirate +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/off_duty_pirate desc = "Off duty, and about as relaxed as a pirate gets. The cutlass is mostly decorative." outfit_choices = list(/datum/outfit/outpost_off_duty_pirate/ambient) /// Their own lines, tried before the drinker's var/flavour_section = "off_duty_pirate" -/mob/living/basic/ambient_npc/outpost/drinker/off_duty_pirate/get_lines(context) +/mob/living/carbon/human/npc/ambient_npc/outpost/drinker/off_duty_pirate/get_lines(context) if(context == AMBIENT_LINE_IDLE || context == AMBIENT_LINE_ATTACKED || (context == AMBIENT_LINE_TALK && drunk == 0)) var/list/lines = ambient_dialogue_lines(dialogue_file, flavour_section, context) if(length(lines)) @@ -79,7 +79,7 @@ /// One off-duty pirate at the Undertow, inheriting the drinker's wanted() and arrive() /datum/ambient_outpost_role/drinker/off_duty_pirate name = "off-duty pirate" - npc_type = /mob/living/basic/ambient_npc/outpost/drinker/off_duty_pirate + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/drinker/off_duty_pirate outpost_types = list(/obj/structure/overmap/trader_outpost/black_market) max_count = 1 weight = 2 diff --git a/voidcrew/modules/ambient_npcs/outpost_staff_door.dm b/voidcrew/modules/ambient_npcs/outpost_staff_door.dm index 7921f501881..8fd8b6b614f 100644 --- a/voidcrew/modules/ambient_npcs/outpost_staff_door.dm +++ b/voidcrew/modules/ambient_npcs/outpost_staff_door.dm @@ -9,7 +9,7 @@ /// Whether `thing` is one of the ambient NPCs this door keeps out /proc/outpost_staff_door_blocks(atom/thing) - return istype(thing, /mob/living/basic/ambient_npc) + return istype(thing, /mob/living/carbon/human/npc/ambient_npc) /obj/machinery/door/airlock/centcom/outpost_staff/CanAStarPass(to_dir, datum/can_pass_info/pass_info) if(outpost_staff_door_blocks(pass_info.requester_ref?.resolve())) diff --git a/voidcrew/modules/ambient_npcs/outpost_workers.dm b/voidcrew/modules/ambient_npcs/outpost_workers.dm index fec79e7269c..936ffde5a88 100644 --- a/voidcrew/modules/ambient_npcs/outpost_workers.dm +++ b/voidcrew/modules/ambient_npcs/outpost_workers.dm @@ -58,12 +58,12 @@ // ========================================================================= /// Someone who works at the outpost. They duck when a fight starts and then carry on. -/mob/living/basic/ambient_npc/outpost/worker +/mob/living/carbon/human/npc/ambient_npc/outpost/worker desc = "Works here." dialogue_file = AMBIENT_STRINGS_WORKERS dialogue_section = "janitor" -/mob/living/basic/ambient_npc/outpost/worker/react_violence(mob/living/offender) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/react_violence(mob/living/offender) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/leave) || !reaction_ready("violence")) return if(!start_activity(new /datum/ambient_activity/duck(src, offender))) @@ -75,7 +75,7 @@ speak_context(AMBIENT_LINE_VIOLENCE, offender, force = TRUE) /// Staff take a swing with a word and stay at work -/mob/living/basic/ambient_npc/outpost/worker/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/react_attacked(atom/attacker) if(stat != CONSCIOUS || fading || !reaction_ready("attacked")) return speak_context(AMBIENT_LINE_ATTACKED, attacker, force = TRUE) @@ -126,7 +126,7 @@ . += thing /// Where `npc` stands to use `thing`: on its tile if they can stand there (a wall-hung sink), beside it otherwise -/proc/ambient_use_spot(mob/living/basic/ambient_npc/npc, atom/thing, list/avoid) +/proc/ambient_use_spot(mob/living/carbon/human/npc/ambient_npc/npc, atom/thing, list/avoid) var/turf/tile = get_turf(thing) if(!tile) return null @@ -153,7 +153,7 @@ suit = /obj/item/clothing/suit/apron/overalls head = /obj/item/clothing/head/beanie -/mob/living/basic/ambient_npc/outpost/worker/janitor +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor desc = "Keeps the floors clean, more or less." dialogue_section = "janitor" outfit_choices = list( @@ -173,30 +173,30 @@ /// Mess they could not get to: turf -> world.time they may try again var/list/unreachable -/mob/living/basic/ambient_npc/outpost/worker/janitor/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/Initialize(mapload) . = ..() set_held(/obj/item/mop) -/mob/living/basic/ambient_npc/outpost/worker/janitor/Destroy() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/Destroy() mopped = null unreachable = null return ..() /// They could not get to the mess on `tile`: it is left alone for a few minutes -/mob/living/basic/ambient_npc/outpost/worker/janitor/proc/give_up_on(turf/tile) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/proc/give_up_on(turf/tile) if(!tile) return LAZYSET(unreachable, tile, world.time + WORKER_REMOP_TIME) if(length(unreachable) > 12) unreachable.Cut(1, 2) -/mob/living/basic/ambient_npc/outpost/worker/janitor/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/pick_activity() // What was on the floor before anyone looked is the map's, not a mess ambient_outpost_mark_decor(place) return ..() /// Found mid-job: mopping somewhere on the floor -/mob/living/basic/ambient_npc/outpost/worker/janitor/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/settle_in() ambient_outpost_mark_decor(place) if(settle_at(/datum/ambient_activity/work/janitor)) return TRUE @@ -224,7 +224,7 @@ . += junk /// Where to stand to mop `tile`: beside it, or on it -/mob/living/basic/ambient_npc/outpost/worker/janitor/proc/mop_spot(turf/tile, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/proc/mop_spot(turf/tile, list/avoid) if(!tile || !leash_ok(tile) || LAZYACCESS(unreachable, tile) > world.time) return null var/turf/stand = free_tile_beside(tile, 1, avoid) @@ -236,7 +236,7 @@ * The nearest tile with mess on it they could get to, or null. However much mess is about, no more * than WORKER_MESS_SPOT_CHECKS tiles are checked for a place to stand, nearest first. */ -/mob/living/basic/ambient_npc/outpost/worker/janitor/proc/find_mess() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/proc/find_mess() var/turf/here = get_turf(src) if(!here) return null @@ -272,7 +272,7 @@ * Someone made `mess` near them: they come and mop it, unless they are busy with something that * matters more or it is too far. TRUE if they are on their way. */ -/mob/living/basic/ambient_npc/outpost/worker/janitor/proc/notice_mess(obj/effect/decal/cleanable/mess) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/proc/notice_mess(obj/effect/decal/cleanable/mess) if(!can_act() || QDELETED(mess) || get_dist(src, mess) > WORKER_MESS_RANGE * 2) return FALSE if(istype(activity, /datum/ambient_activity/mop_mess) || activity?.priority > AMBIENT_PRIORITY_ROUTINE) @@ -280,7 +280,7 @@ return !!start_activity(new /datum/ambient_activity/mop_mess(src, mess)) /// Cleans `tile`: its fresh mess and wrappers go, nothing else is touched -/mob/living/basic/ambient_npc/outpost/worker/janitor/proc/clean_tile(turf/tile) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/proc/clean_tile(turf/tile) for(var/atom/movable/mess as anything in ambient_outpost_mess_on(tile)) qdel(mess) LAZYSET(mopped, tile, world.time) @@ -288,7 +288,7 @@ mopped.Cut(1, 2) /// Whether they mopped `tile` a little while ago -/mob/living/basic/ambient_npc/outpost/worker/janitor/proc/mopped_recently(turf/tile) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/proc/mopped_recently(turf/tile) return LAZYACCESS(mopped, tile) && world.time - mopped[tile] < WORKER_REMOP_TIME /** @@ -312,7 +312,7 @@ var/wetted = FALSE /datum/ambient_activity/mop_mess/setup() - var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor = doer if(!istype(janitor)) return FALSE var/turf/tile = get_turf(anchor()) @@ -333,7 +333,7 @@ return ..() /datum/ambient_activity/mop_mess/arrive() - var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor = doer if(!mess_tile) return if(mess_tile != get_turf(doer) && !doer.buckled) @@ -347,7 +347,7 @@ /// A wet floor sign on the mess, if they have one left /datum/ambient_activity/mop_mess/proc/put_sign() - var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor = doer if(janitor.signs_left <= 0 || !isopenturf(mess_tile) || mess_tile.is_blocked_turf(exclude_mobs = TRUE)) return janitor.signs_left-- @@ -367,7 +367,7 @@ doer.setDir(turn(get_dir(doer, mess_tile) || doer.dir, pick(90, -90))) return AMBIENT_STEP_CONTINUE mopped = TRUE - var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor = doer janitor.clean_tile(mess_tile) // Wet only under their own sign, and never in or at a doorway var/obj/item/clothing/suit/caution/sign = sign_ref?.resolve() @@ -393,7 +393,7 @@ wetted = FALSE var/turf/open/floor = mess_tile floor.MakeDry(TURF_WET_WATER, TRUE) - var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor = doer if(!QDELETED(sign) && isturf(sign.loc) && get_dist(sign, doer) <= 2) qdel(sign) if(istype(janitor)) @@ -409,7 +409,7 @@ /datum/ambient_activity/mop_mess/spot_unreachable() . = ..() - var/mob/living/basic/ambient_npc/outpost/worker/janitor/janitor = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor/janitor = doer if(istype(janitor)) janitor.give_up_on(mess_tile) mess_tile = null @@ -445,7 +445,7 @@ head = /obj/item/clothing/head/flatcap shoes = /obj/item/clothing/shoes/galoshes -/mob/living/basic/ambient_npc/outpost/worker/gardener +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener desc = "Looks after the outpost's plants." dialogue_section = "gardener" outfit_choices = list( @@ -463,30 +463,30 @@ /// Weakrefs to the trays they watered last var/list/tended -/mob/living/basic/ambient_npc/outpost/worker/gardener/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/Initialize(mapload) . = ..() can_water = rand(1, WORKER_CAN_POURS) set_held(/obj/item/reagent_containers/cup/watering_can) -/mob/living/basic/ambient_npc/outpost/worker/gardener/Destroy() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/Destroy() tended = null return ..() /// Found mid-job: at one of the outpost's trays with the watering can -/mob/living/basic/ambient_npc/outpost/worker/gardener/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/settle_in() if(settle_at(/datum/ambient_activity/tend_plants, null, 1)) return TRUE return ..() /// Whether they watered `tray` lately -/mob/living/basic/ambient_npc/outpost/worker/gardener/proc/tended_lately(obj/machinery/hydroponics/tray) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/proc/tended_lately(obj/machinery/hydroponics/tray) for(var/datum/weakref/ref as anything in tended) if(ref.resolve() == tray) return TRUE return FALSE /// Remembers watering `tray` -/mob/living/basic/ambient_npc/outpost/worker/gardener/proc/remember_tray(obj/machinery/hydroponics/tray) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/proc/remember_tray(obj/machinery/hydroponics/tray) LAZYADD(tended, WEAKREF(tray)) if(length(tended) > WORKER_TENDED_MEMORY) tended.Cut(1, 2) @@ -495,7 +495,7 @@ * The next of the outpost's own trays to water: the driest one they did not just water, and could * stand beside (never near a hive). Trays a player brought or built are left alone. */ -/mob/living/basic/ambient_npc/outpost/worker/gardener/proc/find_tray(list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/proc/find_tray(list/avoid) var/obj/structure/overmap/trader_outpost/outpost = get_outpost() var/obj/machinery/hydroponics/best var/best_score = INFINITY @@ -511,7 +511,7 @@ return best /// The nearest sink they could fill the can at, or null -/mob/living/basic/ambient_npc/outpost/worker/gardener/proc/find_sink(list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/proc/find_sink(list/avoid) var/obj/structure/sink/best var/best_distance = INFINITY for(var/obj/structure/sink/sink as anything in ambient_outpost_find_all(get_outpost(), /obj/structure/sink)) @@ -535,7 +535,7 @@ var/done_at = 0 /datum/ambient_activity/tend_plants/setup() - var/mob/living/basic/ambient_npc/outpost/worker/gardener/gardener = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/gardener = doer if(!istype(gardener)) return FALSE var/atom/target @@ -574,7 +574,7 @@ doer.speak_context("water") /datum/ambient_activity/tend_plants/act(seconds) - var/mob/living/basic/ambient_npc/outpost/worker/gardener/gardener = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/gardener = doer var/atom/target = target_ref?.resolve() if(QDELETED(target) || !istype(gardener)) return AMBIENT_STEP_DONE @@ -599,7 +599,7 @@ */ /datum/ambient_activity/tend_plants/spot_unreachable() . = ..() - var/mob/living/basic/ambient_npc/outpost/worker/gardener/gardener = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener/gardener = doer var/obj/machinery/hydroponics/tray = target_ref?.resolve() target_ref = null if(!istype(gardener)) @@ -630,7 +630,7 @@ uniform = /obj/item/clothing/under/color/grey head = /obj/item/clothing/head/beanie -/mob/living/basic/ambient_npc/outpost/worker/barback +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback desc = "Clears the glasses, wipes the tables and walks out whoever can't walk." dialogue_section = "barback" outfit_choices = list( @@ -645,12 +645,16 @@ /datum/ambient_activity/chat = 1, ) /// Empty glasses on their tray - var/glasses = 0 + var/tray_glasses = 0 -/mob/living/basic/ambient_npc/outpost/worker/barback/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/pick_activity() + // A drinker sleeping it off who has been there long enough: shaken awake and walked out + var/mob/living/carbon/human/npc/ambient_npc/drunk = ambient_outpost_drunk_to_walk_out(place) + if(drunk && start_activity(new /datum/ambient_activity/escort(src, drunk))) + return activity var/list/bar = ambient_outpost_bar(place) var/atom/bar_spot = length(bar) ? bar[1] : null - if(glasses >= WORKER_TRAY_MAX || (glasses > 0 && prob(30))) + if(tray_glasses >= WORKER_TRAY_MAX || (tray_glasses > 0 && prob(30))) var/datum/ambient_activity/wash = start_activity(new /datum/ambient_activity/wash_glasses(src, bar_spot)) if(wash) return wash @@ -658,7 +662,7 @@ return pick_anchored(routine, bar_spot, list(/datum/ambient_activity/collect_glasses, /datum/ambient_activity/wander)) /// Found mid-job: wiping down tables round the bar -/mob/living/basic/ambient_npc/outpost/worker/barback/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/settle_in() var/list/bar = ambient_outpost_bar(place) var/atom/bar_spot = length(bar) ? bar[1] : null if(bar_spot && settle_at(/datum/ambient_activity/work/barback, null, AMBIENT_SETTLE_TRIES, bar_spot)) @@ -666,7 +670,7 @@ return ..() /// An empty drinking glass left on a table or the floor near `center` that they could reach, or null -/mob/living/basic/ambient_npc/outpost/worker/barback/proc/find_glass(atom/center, list/avoid) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/proc/find_glass(atom/center, list/avoid) var/turf/middle = get_turf(center) || get_turf(src) if(!middle) return null @@ -693,8 +697,8 @@ var/pick_at = 0 /datum/ambient_activity/collect_glasses/setup() - var/mob/living/basic/ambient_npc/outpost/worker/barback/barback = doer - if(!istype(barback) || barback.glasses >= WORKER_TRAY_MAX) + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/barback = doer + if(!istype(barback) || barback.tray_glasses >= WORKER_TRAY_MAX) return FALSE var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = barback.find_glass(anchor(), failed_spots) if(!glass) @@ -714,7 +718,7 @@ pick_at = world.time + rand(1 SECONDS, 2 SECONDS) /datum/ambient_activity/collect_glasses/act(seconds) - var/mob/living/basic/ambient_npc/outpost/worker/barback/barback = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/barback = doer var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = glass_ref?.resolve() if(!ambient_glass_is_empty(glass) || !istype(barback)) return AMBIENT_STEP_DONE @@ -723,12 +727,12 @@ var/turf/spot = glass.loc var/on_floor = !(locate(/obj/structure/table) in spot) for(var/obj/item/reagent_containers/cup/glass/drinkingglass/empty in spot.contents.Copy()) - if(barback.glasses >= WORKER_TRAY_MAX) + if(barback.tray_glasses >= WORKER_TRAY_MAX) break if(!ambient_glass_is_empty(empty)) continue qdel(empty) - barback.glasses++ + barback.tray_glasses++ playsound(barback, 'sound/items/handling/drinkglass_pickup.ogg', 30, TRUE, -4) barback.set_held(/obj/item/storage/bag/tray) if(prob(40)) @@ -754,8 +758,8 @@ var/done_at = 0 /datum/ambient_activity/wash_glasses/setup() - var/mob/living/basic/ambient_npc/outpost/worker/barback/barback = doer - if(!istype(barback) || barback.glasses <= 0) + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/barback = doer + if(!istype(barback) || barback.tray_glasses <= 0) return FALSE var/atom/bar = anchor() var/turf/center = get_turf(bar) || get_turf(doer) @@ -777,7 +781,7 @@ target = stand ? barkeep : null if(!target) // Nowhere to take them: they go out the back with the rest - barback.glasses = 0 + barback.tray_glasses = 0 barback.set_held(null) return FALSE target_ref = WEAKREF(target) @@ -795,7 +799,7 @@ done_at = world.time + rand(4 SECONDS, 7 SECONDS) /datum/ambient_activity/wash_glasses/act(seconds) - var/mob/living/basic/ambient_npc/outpost/worker/barback/barback = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/barback = doer var/atom/target = target_ref?.resolve() if(QDELETED(target) || !istype(barback)) return AMBIENT_STEP_DONE @@ -803,7 +807,7 @@ if(prob(50)) playsound(barback, istype(target, /obj/structure/sink) ? 'sound/machines/sink-faucet.ogg' : 'sound/items/handling/drinkglass_drop.ogg', 20, TRUE, -4) return AMBIENT_STEP_CONTINUE - barback.glasses = 0 + barback.tray_glasses = 0 barback.set_held(null) return AMBIENT_STEP_DONE @@ -811,9 +815,9 @@ /datum/ambient_activity/wash_glasses/spot_unreachable() . = ..() target_ref = null - var/mob/living/basic/ambient_npc/outpost/worker/barback/barback = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback/barback = doer if(istype(barback)) - barback.glasses = 0 + barback.tray_glasses = 0 barback.set_held(null) /datum/ambient_activity/wash_glasses/shift_times(delay) @@ -826,8 +830,8 @@ work_weights = list(/datum/outpost_ambient_work/wipe = 1) /** - * Walking a passed-out drinker to the lift: over to them, a word to wake them, and beside them all - * the way while they leave. + * Walking a passed-out drinker to the lift: over to them, a shake to wake them (tg's help: an empty + * hand on someone asleep), a word, and beside them all the way while they leave. */ /datum/ambient_activity/escort name = "walking someone out" @@ -837,29 +841,36 @@ var/woke = FALSE /datum/ambient_activity/escort/setup() - var/mob/living/basic/ambient_npc/drunk = anchor() - if(!istype(drunk) || drunk.stat != CONSCIOUS || drunk.fading) + var/mob/living/carbon/human/npc/ambient_npc/drunk = anchor() + if(!istype(drunk) || drunk.stat == DEAD || drunk.fading || HAS_TRAIT(drunk, TRAIT_CRITICAL_CONDITION)) return FALSE go_to(get_turf(drunk), 1) set_duration() return TRUE -/datum/ambient_activity/escort/arrive() - var/mob/living/basic/ambient_npc/drunk = anchor() - if(!drunk || woke) - return - woke = TRUE - if(!doer.buckled) - doer.face_atom(drunk) - doer.speak_context("escort", drunk, force = TRUE) - drunk.start_activity(new /datum/ambient_activity/leave(drunk)) - /datum/ambient_activity/escort/act(seconds) - var/mob/living/basic/ambient_npc/drunk = anchor() - if(!drunk || drunk.fading || drunk.stat != CONSCIOUS || !istype(drunk.activity, /datum/ambient_activity/leave)) + var/mob/living/carbon/human/npc/ambient_npc/drunk = anchor() + if(!drunk || drunk.fading || drunk.stat == DEAD || HAS_TRAIT(drunk, TRAIT_CRITICAL_CONDITION)) + return AMBIENT_STEP_DONE + if(!woke) + if(get_dist(doer, drunk) > 1) + go_to(get_turf(drunk), 1) + return AMBIENT_STEP_MOVE + if(!doer.buckled) + doer.face_atom(drunk) + if(drunk.stat != CONSCIOUS) + // A shake, as anyone wakes a sleeper (an empty hand) + human_npc_ready_empty_hand(doer) + doer.ai_controller?.ai_interact(drunk, combat_mode = FALSE) + return AMBIENT_STEP_CONTINUE + woke = TRUE + doer.speak_context("escort", drunk, force = TRUE) + drunk.start_activity(new /datum/ambient_activity/leave(drunk)) + return AMBIENT_STEP_CONTINUE + if(drunk.stat != CONSCIOUS || !istype(drunk.activity, /datum/ambient_activity/leave)) return AMBIENT_STEP_DONE // They can manage the last few steps to the lift; the barback stays off it, and on this side of the checkpoint - var/mob/living/basic/ambient_npc/outpost/barback = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/barback = doer var/obj/structure/overmap/trader_outpost/outpost = istype(barback) ? barback.get_outpost() : null if(length(outpost?.checkpoints) && !outpost.is_secured_turf(get_turf(drunk))) return AMBIENT_STEP_DONE @@ -892,7 +903,7 @@ uniform = /obj/item/clothing/under/misc/overalls head = /obj/item/clothing/head/utility/hardhat -/mob/living/basic/ambient_npc/outpost/worker/dock +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/dock desc = "Moves the depot's freight, one crate at a time." dialogue_section = "dock_worker" outfit_choices = list( @@ -907,12 +918,12 @@ /datum/ambient_activity/chat = 1, ) -/mob/living/basic/ambient_npc/outpost/worker/dock/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/dock/pick_activity() // The coffee break is taken by the crew room's machine return pick_anchored(routine, ambient_outpost_find(get_outpost(), /obj/machinery/vending/coffee), list(/datum/ambient_activity/drink/coffee)) /// Found mid-job hauling freight, or else on a coffee break by the crew room's machine -/mob/living/basic/ambient_npc/outpost/worker/dock/settle_in() +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/dock/settle_in() if(settle_at(/datum/ambient_activity/work/dock)) return TRUE var/obj/machinery/vending/coffee/machine = ambient_outpost_find(get_outpost(), /obj/machinery/vending/coffee) @@ -921,7 +932,7 @@ return ..() /// The convoy is in: everything down, and the crates come off the lift -/mob/living/basic/ambient_npc/outpost/worker/dock/react_convoy(obj/structure/overmap/trader_outpost/outpost) +/mob/living/carbon/human/npc/ambient_npc/outpost/worker/dock/react_convoy(obj/structure/overmap/trader_outpost/outpost) if(stat != CONSCIOUS || fading || istype(activity, /datum/ambient_activity/convoy_unload)) return start_activity(new /datum/ambient_activity/convoy_unload(src)) @@ -974,7 +985,7 @@ var/wait_until = 0 /datum/ambient_activity/convoy_unload/setup() - var/mob/living/basic/ambient_npc/outpost/worker/dock/worker = doer + var/mob/living/carbon/human/npc/ambient_npc/outpost/worker/dock/worker = doer var/obj/structure/overmap/trader_outpost/outpost = istype(worker) ? worker.get_outpost() : null if(!outpost) return FALSE @@ -1058,7 +1069,7 @@ /// A janitor at Quartermain /datum/ambient_outpost_role/janitor name = "janitor" - npc_type = /mob/living/basic/ambient_npc/outpost/worker/janitor + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/worker/janitor outpost_types = list(/obj/structure/overmap/trader_outpost/outfitter) max_count = 1 weight = 3 @@ -1068,7 +1079,7 @@ /// A gardener for Halcyon's conservatory and dome /datum/ambient_outpost_role/gardener name = "gardener" - npc_type = /mob/living/basic/ambient_npc/outpost/worker/gardener + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/worker/gardener outpost_types = list(/obj/structure/overmap/trader_outpost/general) max_count = 1 weight = 3 @@ -1078,7 +1089,7 @@ /// A barback at the Dregs /datum/ambient_outpost_role/barback name = "barback" - npc_type = /mob/living/basic/ambient_npc/outpost/worker/barback + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/worker/barback outpost_types = list(/obj/structure/overmap/trader_outpost/black_market) max_count = 1 weight = 3 @@ -1088,7 +1099,7 @@ /// A dock worker at Quartermain /datum/ambient_outpost_role/dock_worker name = "dock worker" - npc_type = /mob/living/basic/ambient_npc/outpost/worker/dock + npc_type = /mob/living/carbon/human/npc/ambient_npc/outpost/worker/dock outpost_types = list(/obj/structure/overmap/trader_outpost/outfitter) max_count = 1 weight = 3 diff --git a/voidcrew/modules/ambient_npcs/planet_fishers.dm b/voidcrew/modules/ambient_npcs/planet_fishers.dm index a0e179457ec..f05eca6a344 100644 --- a/voidcrew/modules/ambient_npcs/planet_fishers.dm +++ b/voidcrew/modules/ambient_npcs/planet_fishers.dm @@ -89,7 +89,7 @@ var/catches = 0 var/misses = 0 -/datum/ambient_activity/fish/New(mob/living/basic/ambient_npc/new_doer, atom/water) +/datum/ambient_activity/fish/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/water) . = ..() if(!water) water = find_water() @@ -251,7 +251,7 @@ . = ..() if(QDELETED(doer)) return - var/mob/living/basic/ambient_npc/planet/planet_npc = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/planet_npc = doer if(istype(planet_npc)) planet_npc.show_idle_held() else @@ -306,11 +306,23 @@ shoes = /obj/item/clothing/shoes/workboots/mining gloves = /obj/item/clothing/gloves/color/black glasses = /obj/item/clothing/glasses/meson + belt = /obj/item/pickaxe -/mob/living/basic/ambient_npc/planet/lava_fisher +/// Red: a goliath hide cloak over the scorched overalls +/datum/outfit/ambient_lava_fisher/cloak + name = "Ambient NPC: lava fisher in a hide cloak" + suit = /obj/item/clothing/suit/hooded/cloak/goliath + +/// Lava fishers by band (AM-1) +/datum/human_npc_outfit_table/ambient_lava_fisher + green = list(/datum/outfit/ambient_lava_fisher = 1) + red = list(/datum/outfit/ambient_lava_fisher/cloak = 1) + +/mob/living/carbon/human/npc/ambient_npc/planet/lava_fisher name = "lava fisher" desc = "A miner in a scorched coat, fishing the lava on a long steel line." - outfit = /datum/outfit/ambient_lava_fisher + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_lava_fisher dialogue_section = "lava_fisher" speech_pace = 1.2 fights_back = TRUE @@ -325,7 +337,7 @@ ) // Once per crew: a fillet off the hot rock -/mob/living/basic/ambient_npc/planet/lava_fisher/talked_to(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/lava_fisher/talked_to(mob/living/user) if(!talk_ready(user)) return if(!buckled) @@ -375,7 +387,7 @@ chair.setDir(get_dir(site.center, lava)) ensure_prop(site, /obj/effect/ambient_camp_prop/lamp, ambient_free_turf_near(site.center, 1)) for(var/i in 1 to missing) - var/mob/living/basic/ambient_npc/planet/lava_fisher/fisher = site.spawn_npc(npc_type, ambient_free_turf_near(site.center, 1) || site.center) + var/mob/living/carbon/human/npc/ambient_npc/planet/lava_fisher/fisher = site.spawn_npc(npc_type, ambient_free_turf_near(site.center, 1) || site.center) if(fisher) fisher.leash_bounds = ambient_square_bounds(site.center, 8) return TRUE @@ -419,9 +431,19 @@ name = "Ambient NPC: boat fisher" uniform = /obj/item/clothing/under/color/blue suit = /obj/item/clothing/suit/jacket/puffer/vest - head = /obj/item/clothing/head/soft/fishing_hat + head = /obj/item/clothing/head/soft/blue // the legendary fishing hat only stays on a master angler shoes = /obj/item/clothing/shoes/workboots +/// Red: a flak vest instead of the puffer +/datum/outfit/ambient_boat_fisher/vest + name = "Ambient NPC: boat fisher in a flak vest" + suit = /obj/item/clothing/suit/armor/vest + +/// Boat fishers by band (AM-1) +/datum/human_npc_outfit_table/ambient_boat_fisher + green = list(/datum/outfit/ambient_boat_fisher = 1) + red = list(/datum/outfit/ambient_boat_fisher/vest = 1) + /** * A hide boat out on the water: scenery with a seat. The fisher sits in it; it floats (nothing in * it is drawn under the water) and rows with them in it. Nobody can pull the fisher out or wreck it. @@ -439,16 +461,17 @@ resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | ACID_PROOF | UNACIDABLE layer = BELOW_MOB_LAYER -// The fisher stays in their boat while they live +// The fisher stays in their boat while awake; one who is down or dead can be pulled out like anyone /obj/structure/ambient_boat/user_unbuckle_mob(mob/living/buckled_mob, mob/user) - if(istype(buckled_mob, /mob/living/basic/ambient_npc) && buckled_mob.stat != DEAD) + if(istype(buckled_mob, /mob/living/carbon/human/npc/ambient_npc) && buckled_mob.stat == CONSCIOUS) return null return ..() -/mob/living/basic/ambient_npc/planet/boat_fisher +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher name = "fisher" desc = "A weathered fisher in a hide boat, line in the water." - outfit = /datum/outfit/ambient_boat_fisher + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_boat_fisher dialogue_section = "boat_fisher" speech_pace = 1.2 watches = TRUE @@ -461,17 +484,21 @@ /// world.time they next row somewhere else var/next_row_at = 0 -/mob/living/basic/ambient_npc/planet/boat_fisher/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/Initialize(mapload) . = ..() next_row_at = world.time + rand(AMBIENT_BOAT_ROW_LOW, AMBIENT_BOAT_ROW_HIGH) +// Their boat is their own seat: they don't try to get out of it +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/own_buckle(atom/thing) + return istype(thing, /obj/structure/ambient_boat) || ..() + /// The boat they sit in, if any -/mob/living/basic/ambient_npc/planet/boat_fisher/proc/boat() +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/proc/boat() var/obj/structure/ambient_boat/boat = buckled return istype(boat) ? boat : null // Every few minutes, a new spot -/mob/living/basic/ambient_npc/planet/boat_fisher/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/pick_activity() if(world.time >= next_row_at && boat()) next_row_at = world.time + rand(AMBIENT_BOAT_ROW_LOW, AMBIENT_BOAT_ROW_HIGH) if(start_activity(new /datum/ambient_activity/row(src))) @@ -479,7 +506,7 @@ return ..() // Stuck in a boat: a shout, and they row away -/mob/living/basic/ambient_npc/planet/boat_fisher/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/react_attacked(atom/attacker) if(stat != CONSCIOUS || fading) return if(reaction_ready("attacked", 4 SECONDS)) @@ -488,7 +515,7 @@ start_activity(new /datum/ambient_activity/row(src, attacker)) // Sharks near the boat get shouted at -/mob/living/basic/ambient_npc/planet/boat_fisher/watch(list/players) +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/watch(list/players) if(!reaction_ready("sharks", 90 SECONDS)) return for(var/mob/living/basic/carp/shark in range(6, src)) @@ -502,7 +529,7 @@ LAZYSET(reaction_cooldowns, "sharks", world.time + 10 SECONDS) // Once per crew: a fish thrown over to them -/mob/living/basic/ambient_npc/planet/boat_fisher/talked_to(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/talked_to(mob/living/user) if(!talk_ready(user)) return if(!first_for_crew(user, "fish_thrown")) @@ -539,7 +566,7 @@ /// Where the boat is going var/turf/destination -/datum/ambient_activity/row/New(mob/living/basic/ambient_npc/new_doer, atom/threat) +/datum/ambient_activity/row/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/threat) . = ..() threat_ref = threat ? WEAKREF(threat) : null @@ -578,11 +605,19 @@ continue if(istype(site) && site.center && get_dist(target, site.center) > 12) continue - if(locate(/mob/living/basic/trooper/pirate) in range(5, target)) + if(ambient_pirate_near(target, 5)) continue return target return null +/// Whether a living pirate is within `distance` of `spot`: any human NPC on the pirate side (a ruin's +/// pirates, a faction's crew or raiders). +/proc/ambient_pirate_near(turf/spot, distance) + for(var/mob/living/carbon/human/npc/npc in range(distance, spot)) + if(npc.stat != DEAD && (FACTION_PIRATE in npc.faction)) + return TRUE + return FALSE + /datum/ambient_activity/row/act(seconds) var/obj/structure/ambient_boat/boat = boat() if(!boat || !destination || get_turf(boat) == destination) @@ -629,7 +664,7 @@ boat = make_prop(site, /obj/structure/ambient_boat, site.center) var/turf/where = get_turf(boat) || site.center for(var/i in 1 to missing) - var/mob/living/basic/ambient_npc/planet/boat_fisher/fisher = site.spawn_npc(npc_type, where) + var/mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher/fisher = site.spawn_npc(npc_type, where) if(fisher && boat && !boat.has_buckled_mobs()) boat.buckle_mob(fisher, force = TRUE) return TRUE diff --git a/voidcrew/modules/ambient_npcs/planet_jungle.dm b/voidcrew/modules/ambient_npcs/planet_jungle.dm index 46ea36847a4..17656288cfd 100644 --- a/voidcrew/modules/ambient_npcs/planet_jungle.dm +++ b/voidcrew/modules/ambient_npcs/planet_jungle.dm @@ -90,6 +90,24 @@ uniform = /obj/item/clothing/under/color/grey/ancient suit = /obj/item/clothing/suit/apron/chef shoes = /obj/item/clothing/shoes/sandal + r_hand = /obj/item/knife/butcher + +/// Yellow: a skull for a helmet +/datum/outfit/ambient_cannibal/skull + name = "Ambient NPC: cannibal in a skull" + head = /obj/item/clothing/head/helmet/skull + +/// Red: the skull, and bone armour for the apron +/datum/outfit/ambient_cannibal/bone + name = "Ambient NPC: cannibal in bone" + head = /obj/item/clothing/head/helmet/skull + suit = /obj/item/clothing/suit/armor/bone + +/// The cannibal by band (AM-1) +/datum/human_npc_outfit_table/ambient_cannibal + green = list(/datum/outfit/ambient_cannibal = 1) + yellow = list(/datum/outfit/ambient_cannibal/skull = 1) + red = list(/datum/outfit/ambient_cannibal/bone = 1) /// Dinner: a dead stranger in rags, already carved /datum/outfit/ambient_victim @@ -101,16 +119,18 @@ outfit = /datum/outfit/ambient_victim brute_damage = 60 -/mob/living/basic/ambient_npc/planet/cannibal +/mob/living/carbon/human/npc/ambient_npc/planet/cannibal name = "cannibal" desc = "A gaunt man in a stained apron. He smells of smoke and cooked meat." random_name = FALSE random_gender = FALSE gender = MALE - outfit = /datum/outfit/ambient_cannibal + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_cannibal dialogue_section = "cannibal" fights_back = TRUE weapon_type = /obj/item/knife/butcher + idle_held = /obj/item/knife/butcher watches = TRUE death_loot = list(/obj/item/knife/butcher, /obj/item/food/meat/steak/plain/human) routine = list( @@ -127,7 +147,7 @@ /// REF of a stranger -> TRUE once they were warned this time in sight var/list/warned = list() -/mob/living/basic/ambient_npc/planet/cannibal/Destroy() +/mob/living/carbon/human/npc/ambient_npc/planet/cannibal/Destroy() seen = null invited = null warned = null @@ -137,7 +157,7 @@ * Strangers in sight: an invitation; a second, colder one and the cleaver after a while; then an * attack on anyone within two tiles or in sight for half a minute. Out of sight resets the clock. */ -/mob/living/basic/ambient_npc/planet/cannibal/watch(list/players) +/mob/living/carbon/human/npc/ambient_npc/planet/cannibal/watch(list/players) if(istype(activity, /datum/ambient_activity/fight)) return players = players || ambient_players_near(src, AMBIENT_CANNIBAL_SIGHT) @@ -223,7 +243,7 @@ site.data["camp_built"] = TRUE build_camp(site) for(var/i in 1 to missing) - var/mob/living/basic/ambient_npc/planet/cannibal/cannibal = site.spawn_npc(npc_type, ambient_free_turf_near(site.center, 2) || site.center) + var/mob/living/carbon/human/npc/ambient_npc/planet/cannibal/cannibal = site.spawn_npc(npc_type, ambient_free_turf_near(site.center, 2) || site.center) if(cannibal) cannibal.leash_bounds = ambient_square_bounds(site.center, 10) // Dressing a body can sleep @@ -272,6 +292,7 @@ name = "Ambient NPC: hunter" uniform = /obj/item/clothing/under/costume/loincloth shoes = /obj/item/clothing/shoes/sandal + r_hand = /obj/item/spear/bamboospear /datum/outfit/ambient_tribal/bone name = "Ambient NPC: hunter in bone" @@ -282,11 +303,27 @@ name = "Ambient NPC: hunter in a hide cloak" suit = /obj/item/clothing/suit/hooded/cloak/goliath -/mob/living/basic/ambient_npc/planet/tribal +/// Red: bone bracers too +/datum/outfit/ambient_tribal/bone/bracers + name = "Ambient NPC: hunter in bone and bracers" + gloves = /obj/item/clothing/gloves/bracer + +/datum/outfit/ambient_tribal/cloak/bracers + name = "Ambient NPC: hunter in a hide cloak and bracers" + gloves = /obj/item/clothing/gloves/bracer + +/// Hunters by band (AM-1): bone and hide further out, never more health +/datum/human_npc_outfit_table/ambient_tribal + green = list(/datum/outfit/ambient_tribal = 2, /datum/outfit/ambient_tribal/bone = 1, /datum/outfit/ambient_tribal/cloak = 1) + yellow = list(/datum/outfit/ambient_tribal/bone = 1, /datum/outfit/ambient_tribal/cloak = 1) + red = list(/datum/outfit/ambient_tribal/bone/bracers = 1, /datum/outfit/ambient_tribal/cloak/bracers = 1) + +/mob/living/carbon/human/npc/ambient_npc/planet/tribal name = "hunter" desc = "A lean hunter in hide and bone, a bamboo spear in hand." random_name = FALSE - outfit_choices = list(/datum/outfit/ambient_tribal, /datum/outfit/ambient_tribal/bone, /datum/outfit/ambient_tribal/cloak) + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_tribal dialogue_section = "tribal" speech_pace = 1.5 fights_back = TRUE @@ -305,66 +342,82 @@ /datum/ambient_activity/chat = 2, /datum/ambient_activity/wander = 1, ) - /// Their spear is in their hand (not stuck in the ground somewhere) - var/has_spear = TRUE - /// Where their thrown spear stuck, if it did + /// The spear they threw, while it lies where it came down var/datum/weakref/thrown_spear_ref /// world.time their drawn-back spear flies, while winding up a throw var/throw_windup_until = 0 -/mob/living/basic/ambient_npc/planet/tribal/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/Initialize(mapload, datum/human_npc_order/order) . = ..() if(prob(25)) death_loot = death_loot + /obj/item/clothing/suit/armor/bone -/mob/living/basic/ambient_npc/planet/tribal/Destroy() +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/Destroy() drop_thrown_spear() return ..() -// The spear left in the ground goes too: the one they carried drops with them -/mob/living/basic/ambient_npc/planet/tribal/death(gibbed) +// The spear they threw goes too: nobody fetches it now +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/death(gibbed) drop_thrown_spear() return ..() -/// Their thrown spear, if it is still stuck somewhere -/mob/living/basic/ambient_npc/planet/tribal/proc/thrown_spear() - var/obj/effect/ambient_thrown_spear/stuck = thrown_spear_ref?.resolve() - return QDELETED(stuck) ? null : stuck +/// Whether they have a spear on them (in hand, or slung) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/has_spear() + return length(get_all_contents_type(/obj/item/spear)) > 0 -/// Forgets and removes the spear they threw -/mob/living/basic/ambient_npc/planet/tribal/proc/drop_thrown_spear() - var/obj/effect/ambient_thrown_spear/stuck = thrown_spear() +/// The spear they threw, while it still lies loose where it came down +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/thrown_spear() + var/obj/item/spear/thrown = thrown_spear_ref?.resolve() + if(QDELETED(thrown) || !isturf(thrown.loc)) + return null + return thrown + +/// Forgets the spear they threw, and takes it away if it still lies loose (a camp's spears are its own) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/drop_thrown_spear() + var/obj/item/spear/thrown = thrown_spear() thrown_spear_ref = null - if(stuck) - qdel(stuck) + if(thrown) + qdel(thrown) -/// A spear back in hand -/mob/living/basic/ambient_npc/planet/tribal/proc/rearm() - drop_thrown_spear() - has_spear = TRUE +/** + * A spear back in hand: the one they threw, picked up off the ground beside them, or, when it is + * gone, a spare off the camp's rack (`from_rack`). The camp's spears stay theirs (locked to them, the + * base's consumables rule: it never becomes anyone else's). + */ +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/rearm(from_rack = FALSE) + var/obj/item/spear/thrown = thrown_spear() + thrown_spear_ref = null + if(thrown && get_dist(src, thrown) <= 1) + put_in_hands(thrown) + else if(from_rack && !has_spear()) + var/obj/item/spear/bamboospear/spare = new(src) + if(!put_in_hands(spare)) + qdel(spare) + else + npc_lock_item(spare) idle_held = /obj/item/spear/bamboospear - arm(/obj/item/spear/bamboospear) set_held(/obj/item/spear/bamboospear) // Without a spear the first thing they do is get it back -/mob/living/basic/ambient_npc/planet/tribal/pick_activity() - if(!has_spear && start_activity(new /datum/ambient_activity/fetch_spear(src))) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/pick_activity() + if(!has_spear() && start_activity(new /datum/ambient_activity/fetch_spear(src))) return activity return ..() -/mob/living/basic/ambient_npc/planet/tribal/throw_reach() - return has_spear ? 6 : 0 +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/throw_reach() + return has_spear() ? 6 : 0 /** - * A thrown spear: from three to six tiles, in plain sight, one throw at a time for the whole camp. - * The spear is drawn back first (a wind-up anyone can see) and flies on their next beat. + * A thrown spear: from three to six tiles, in plain sight, one throw at a time for the whole camp, + * and never with one of their own camp in the way. The spear is drawn back first (a wind-up anyone + * can see) and flies on their next beat. */ -/mob/living/basic/ambient_npc/planet/tribal/try_ranged(mob/living/target) - if(!has_spear) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/try_ranged(mob/living/target) + if(!has_spear()) return FALSE var/distance = get_dist(src, target) if(throw_windup_until) - if(distance > 6 || !bounty_ai_can_see(src, target, 6)) + if(distance > 6 || !bounty_ai_can_see(src, target, 6) || ambient_camp_mate_in_line(src, target)) throw_windup_until = 0 return FALSE face_atom(target) @@ -372,7 +425,7 @@ throw_windup_until = 0 throw_spear(target) return TRUE - if(distance < 3 || distance > 6 || !bounty_ai_can_see(src, target, 6)) + if(distance < 3 || distance > 6 || !bounty_ai_can_see(src, target, 6) || ambient_camp_mate_in_line(src, target)) return FALSE var/list/visit = ambient_site_visit(place) if(visit["next_throw"] > world.time) @@ -383,36 +436,35 @@ visible_message(span_danger("[src] draws back a spear!")) return TRUE -/// Throws their spear at `target`: the spear's own throw, and it sticks where it lands -/mob/living/basic/ambient_npc/planet/tribal/proc/throw_spear(mob/living/target) - var/turf/start = get_turf(src) - if(!start) +/// Throws the spear in their hand at `target`, as anyone throws a held item (tg's throw) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/throw_spear(mob/living/target) + var/obj/item/spear/spear = held_weapon() + if(!istype(spear)) + set_held(/obj/item/spear/bamboospear) + spear = held_weapon() + if(!istype(spear) || !isturf(loc)) + return + if(HAS_TRAIT(spear, TRAIT_WIELDED)) + spear.attack_self(src) // let go with one hand to throw + human_npc_ready_item(src, spear) + if(!throw_item(target)) return - var/obj/projectile/ambient_spear/spear = new(start) - var/datum/bounty_real_weapon/weapon = bounty_real_weapon(/obj/item/spear/bamboospear) - weapon.load_thrown(spear) - spear.firer = src - spear.fired_from = src - spear.aim_projectile(target, src) - if(QDELETED(spear)) + if(spear.loc == src || is_holding(spear)) return - has_spear = FALSE + thrown_spear_ref = WEAKREF(spear) + held_prop = null idle_held = null - arm(null) - set_held(null) - do_attack_animation(target) playsound(src, 'sound/items/weapons/thudswoosh.ogg', 40, TRUE) - spear.fire() /** * The camp's lookout (the first of them awake) watches the ring: a warning for anyone who * crosses it, the whole camp on anyone who stays armed, and someone to walk up to anyone who * comes empty-handed. */ -/mob/living/basic/ambient_npc/planet/tribal/watch(list/players) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/watch(list/players) // Their own spear, if they are standing by it - var/obj/effect/ambient_thrown_spear/stuck = thrown_spear() - if(!has_spear && stuck && get_dist(src, stuck) <= 1 && !istype(activity, /datum/ambient_activity/fight)) + var/obj/item/spear/thrown = thrown_spear() + if(thrown && !has_spear() && get_dist(src, thrown) <= 1 && !istype(activity, /datum/ambient_activity/fight)) manual_emote("pulls a spear out of the ground.") rearm() var/datum/ambient_place/site/site = place @@ -442,7 +494,7 @@ // A warning first, once a minute each if(warned[key] < world.time) warned[key] = world.time + 1 MINUTES - var/mob/living/basic/ambient_npc/planet/tribal/shouter = nearest_awake(player) + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/shouter = nearest_awake(player) if(shouter) shouter.face_atom(player) shouter.speak_context("warn", player, force = TRUE) @@ -457,18 +509,18 @@ entered -= key /// The first of the camp who is awake and not already off somewhere: the one who keeps watch -/mob/living/basic/ambient_npc/planet/tribal/proc/camp_lookout() - for(var/mob/living/basic/ambient_npc/planet/tribal/hunter in place?.npcs) - if(hunter.stat == CONSCIOUS && !hunter.fading && !hunter.sleeping) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/camp_lookout() + for(var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter in place?.npcs) + if(hunter.stat == CONSCIOUS && !hunter.fading && !hunter.IsSleeping()) return hunter return null /// The hunter awake and nearest to `who`, or null -/mob/living/basic/ambient_npc/planet/tribal/proc/nearest_awake(atom/who) - var/mob/living/basic/ambient_npc/planet/tribal/best +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/nearest_awake(atom/who) + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/best var/best_distance = INFINITY - for(var/mob/living/basic/ambient_npc/planet/tribal/hunter in place?.living_npcs()) - if(hunter.sleeping) + for(var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter in place?.living_npcs()) + if(hunter.IsSleeping()) continue var/distance = get_dist(hunter, who) if(distance < best_distance) @@ -477,29 +529,29 @@ return best /// The whole camp goes for `target`, sleepers included -/mob/living/basic/ambient_npc/planet/tribal/proc/camp_attack(mob/living/target) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/camp_attack(mob/living/target) var/shouted = FALSE - for(var/mob/living/basic/ambient_npc/planet/tribal/hunter in place?.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter in place?.living_npcs()) if(istype(hunter.activity, /datum/ambient_activity/fight)) continue - if(!shouted && !hunter.sleeping) + if(!shouted && !hunter.IsSleeping()) shouted = TRUE hunter.speak_context("attack", target, force = TRUE) hunter.engage(target) /// Someone walks up to an empty-handed visitor to trade, unless someone already is -/mob/living/basic/ambient_npc/planet/tribal/proc/offer_trade(mob/living/visitor) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/proc/offer_trade(mob/living/visitor) var/list/visit = ambient_site_visit(place) if(visit["offer_until"] > world.time) return - var/mob/living/basic/ambient_npc/planet/tribal/trader = nearest_awake(visitor) + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/trader = nearest_awake(visitor) if(!trader || istype(trader.activity, /datum/ambient_activity/fight) || trader.activity?.priority > AMBIENT_PRIORITY_ROUTINE) return if(trader.start_activity(new /datum/ambient_activity/tribal_offer(trader, visitor))) visit["offer_until"] = world.time + 90 SECONDS /// A hide or meat for a bone spear, once per crew -/mob/living/basic/ambient_npc/planet/tribal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) +/mob/living/carbon/human/npc/ambient_npc/planet/tribal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) if(user.combat_mode || !can_act() || !(istype(tool, /obj/item/stack/sheet/animalhide) || istype(tool, /obj/item/food/meat/slab))) return ..() face_atom(user) @@ -523,105 +575,49 @@ end_activity() return ITEM_INTERACT_SUCCESS -// ----- the thrown spear ----- - -/// A bamboo spear in flight: the spear's own throw, never at the thrower's own camp; it sticks where it lands -/obj/projectile/ambient_spear - name = "bamboo spear" - icon = 'icons/obj/weapons/spear.dmi' - icon_state = "bamboo_spear0" - damage_type = BRUTE - armor_flag = MELEE - speed = 0.6 - range = 7 - hitsound = 'sound/items/weapons/pierce.ogg' - /// It has stuck somewhere already - var/landed = FALSE - -/obj/projectile/ambient_spear/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - . = ..() - if(!. || !istype(target, /mob/living/basic/ambient_npc)) - return - var/mob/living/basic/ambient_npc/thrower = firer - var/mob/living/basic/ambient_npc/other = target - if(istype(thrower) && other.place == thrower.place) - return FALSE - -/obj/projectile/ambient_spear/on_hit(atom/target, blocked = 0, pierce_hit) - . = ..() - land() - -/obj/projectile/ambient_spear/on_range() - land() - return ..() - -/// Sticks in the ground where it came down, for its thrower to fetch -/obj/projectile/ambient_spear/proc/land() - if(landed) - return - landed = TRUE - var/turf/where = get_turf(src) - var/mob/living/basic/ambient_npc/planet/tribal/thrower = firer - if(!istype(thrower) || QDELETED(thrower) || thrower.stat == DEAD || !where || !ambient_ground_ok(where)) - return - thrower.drop_thrown_spear() - var/obj/effect/ambient_thrown_spear/stuck = new(where) - thrower.thrown_spear_ref = WEAKREF(stuck) - -/// A thrown spear stuck in the ground. Its thrower pulls it out again; nobody else can take it. -/obj/effect/ambient_thrown_spear - name = "bamboo spear" - desc = "A bamboo spear, stuck point-first in the ground." - icon = 'icons/obj/weapons/spear.dmi' - icon_state = "bamboo_spear0" - anchored = TRUE - density = FALSE - layer = OBJ_LAYER - -/obj/effect/ambient_thrown_spear/Initialize(mapload) - . = ..() - transform = matrix().Turn(45) - // Nobody came back for it. Our own callback, so deleting it early takes the timer with it. - addtimer(CALLBACK(src, PROC_REF(rot)), 5 MINUTES, TIMER_DELETE_ME) - -/obj/effect/ambient_thrown_spear/proc/rot() - qdel(src) - // ----- activities ----- -/// Getting their spear back: pulled out of the ground where it stuck, or a spare off the rack +/// Getting their spear back: picked up where it came down, or a spare off the camp's rack /datum/ambient_activity/fetch_spear name = "fetching a spear" /datum/ambient_activity/fetch_spear/setup() - var/mob/living/basic/ambient_npc/planet/tribal/hunter = doer - if(!istype(hunter) || hunter.has_spear) + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter = doer + if(!istype(hunter) || hunter.has_spear()) return FALSE - var/atom/target = hunter.thrown_spear() - if(!target) - var/datum/ambient_place/site/site = hunter.place - target = istype(site) ? site.get_prop(/obj/effect/ambient_camp_prop/spear_rack) : null + var/atom/target = hunter.thrown_spear() || camp_rack() if(!target) - hunter.rearm() return FALSE go_to(get_turf(target), 1) return TRUE +/// Their camp's spear rack, if it has one +/datum/ambient_activity/fetch_spear/proc/camp_rack() + var/datum/ambient_place/site/site = doer.place + return istype(site) ? site.get_prop(/obj/effect/ambient_camp_prop/spear_rack) : null + /datum/ambient_activity/fetch_spear/arrive() - var/mob/living/basic/ambient_npc/planet/tribal/hunter = doer - if(!istype(hunter) || hunter.has_spear) + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter = doer + if(!istype(hunter) || hunter.has_spear()) + return + var/obj/item/spear/thrown = hunter.thrown_spear() + if(thrown && get_dist(hunter, thrown) <= 1) + hunter.manual_emote("pulls a spear out of the ground.") + hunter.rearm() return - hunter.manual_emote(hunter.thrown_spear() ? "pulls a spear out of the ground." : "takes a spear from the rack.") - hunter.rearm() + var/atom/rack = camp_rack() + if(rack && get_dist(hunter, rack) <= 1) + hunter.manual_emote("takes a spear from the rack.") + hunter.rearm(from_rack = TRUE) /datum/ambient_activity/fetch_spear/act(seconds) return AMBIENT_STEP_DONE /datum/ambient_activity/fetch_spear/spot_unreachable() - var/mob/living/basic/ambient_npc/planet/tribal/hunter = doer - // It fell somewhere they cannot get to: a spare from the rack + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter = doer + // It came down somewhere they can't get to: the rack, next time round if(istype(hunter)) - hunter.rearm() + hunter.thrown_spear_ref = null spot = null arrived = TRUE @@ -632,7 +628,7 @@ duration_high = 60 SECONDS var/datum/weakref/visitor_ref -/datum/ambient_activity/tribal_offer/New(mob/living/basic/ambient_npc/new_doer, atom/visitor) +/datum/ambient_activity/tribal_offer/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/visitor) . = ..() visitor_ref = WEAKREF(visitor) @@ -672,7 +668,7 @@ /datum/ambient_activity/tribal_offer/finish() . = ..() - var/mob/living/basic/ambient_npc/planet/tribal/hunter = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter = doer if(istype(hunter) && !QDELETED(hunter)) hunter.show_idle_held() @@ -702,8 +698,8 @@ return FALSE go_to(hide) // A partner, if one is free - for(var/mob/living/basic/ambient_npc/planet/tribal/mate in site.living_npcs()) - if(mate == doer || mate.sleeping || (mate.activity && !mate.activity.accepts_company)) + for(var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/mate in site.living_npcs()) + if(mate == doer || mate.IsSleeping() || (mate.activity && !mate.activity.accepts_company)) continue if(mate.start_activity(new /datum/ambient_activity/follow(mate, doer, 2, 3 MINUTES))) break @@ -742,7 +738,7 @@ /datum/ambient_activity/tribal_hunt/finish() . = ..() - var/mob/living/basic/ambient_npc/planet/tribal/hunter = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter = doer if(istype(hunter) && !QDELETED(hunter)) hunter.show_idle_held() @@ -778,8 +774,8 @@ var/atom/fire = prop() if(!fire) return - for(var/mob/living/basic/ambient_npc/planet/tribal/mate in doer.place?.living_npcs()) - if(mate != doer && !mate.sleeping && get_dist(mate, fire) <= 3 && prob(50)) + for(var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/mate in doer.place?.living_npcs()) + if(mate != doer && !mate.IsSleeping() && get_dist(mate, fire) <= 3 && prob(50)) mate.manual_emote("joins the chant.") // ----- the camp ----- @@ -791,7 +787,7 @@ new_visit(site) build_camp(site) for(var/i in 1 to missing) - var/mob/living/basic/ambient_npc/planet/tribal/hunter = site.spawn_npc(npc_type, ambient_free_turf_near(site.center, 2) || site.center) + var/mob/living/carbon/human/npc/ambient_npc/planet/tribal/hunter = site.spawn_npc(npc_type, ambient_free_turf_near(site.center, 2) || site.center) if(hunter) hunter.leash_bounds = ambient_square_bounds(site.center, AMBIENT_TRIBAL_RING_RADIUS + AMBIENT_TRIBAL_CHASE_PAST_RING) return TRUE diff --git a/voidcrew/modules/ambient_npcs/planet_miner.dm b/voidcrew/modules/ambient_npcs/planet_miner.dm index 6ac8e021e11..524dedf4266 100644 --- a/voidcrew/modules/ambient_npcs/planet_miner.dm +++ b/voidcrew/modules/ambient_npcs/planet_miner.dm @@ -8,7 +8,7 @@ * * - /datum/ambient_activity/mine: dig one ore wall for real. Shared: the lava fisher * (planet_fishers.dm) digs with it too. See its doc comment for the API. - * - /mob/living/basic/ambient_npc/planet/miner: a prospector with a small camp (a folding chair, a + * - /mob/living/carbon/human/npc/ambient_npc/planet/miner: a prospector with a small camp (a folding chair, a * lantern, a crate) who swings at a rock face for half a minute or more, digs it out, scoops the * ore into a satchel, and every few walls sits down at camp with a flask. Hand them food or a * drink and they trade a little ore from the satchel. Attacked, they fight with the pickaxe and run @@ -162,7 +162,7 @@ . = ..() if(QDELETED(doer)) return - var/mob/living/basic/ambient_npc/planet/planet_npc = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/planet_npc = doer if(istype(planet_npc)) planet_npc.show_idle_held() else @@ -201,20 +201,39 @@ shoes = /obj/item/clothing/shoes/workboots/mining gloves = /obj/item/clothing/gloves/color/black head = /obj/item/clothing/head/utility/hardhat/orange + belt = /obj/item/pickaxe /datum/outfit/ambient_miner/winter name = "Ambient NPC: prospector in a coat" suit = /obj/item/clothing/suit/hooded/wintercoat/miner +/// Yellow: rougher ground, an explorer suit +/datum/outfit/ambient_miner/explorer + name = "Ambient NPC: prospector in an explorer suit" + suit = /obj/item/clothing/suit/hooded/explorer + +/// Red: the explorer suit and its mask, and a drill +/datum/outfit/ambient_miner/explorer/red + name = "Ambient NPC: prospector with a drill" + mask = /obj/item/clothing/mask/gas/explorer + belt = /obj/item/pickaxe/drill + /datum/outfit/ambient_miner/void name = "Ambient NPC: asteroid miner" suit = /obj/item/clothing/suit/space/eva head = /obj/item/clothing/head/helmet/space/eva -/mob/living/basic/ambient_npc/planet/miner +/// Miners by band (AM-1): kit, not health, makes the red zone's harder to put down +/datum/human_npc_outfit_table/ambient_miner + green = list(/datum/outfit/ambient_miner = 1, /datum/outfit/ambient_miner/winter = 1) + yellow = list(/datum/outfit/ambient_miner/explorer = 1) + red = list(/datum/outfit/ambient_miner/explorer/red = 1) + +/mob/living/carbon/human/npc/ambient_npc/planet/miner name = "miner" desc = "A prospector in dusty overalls, working the rock by hand." - outfit_choices = list(/datum/outfit/ambient_miner, /datum/outfit/ambient_miner/winter) + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_miner dialogue_section = "miner" speech_pace = 1.2 fights_back = TRUE @@ -231,18 +250,18 @@ /// The satchel the ore goes into; dropped with them var/obj/item/storage/bag/ore/ore_bag -/mob/living/basic/ambient_npc/planet/miner/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/planet/miner/Initialize(mapload) . = ..() ore_bag = new(src) if(prob(50)) death_loot = death_loot + /obj/item/mining_scanner -/mob/living/basic/ambient_npc/planet/miner/Destroy() +/mob/living/carbon/human/npc/ambient_npc/planet/miner/Destroy() QDEL_NULL(ore_bag) return ..() // The satchel with what they dug goes down with them, once -/mob/living/basic/ambient_npc/planet/miner/drop_loot() +/mob/living/carbon/human/npc/ambient_npc/planet/miner/drop_loot() var/first_time = !loot_dropped . = ..() if(!first_time || QDELETED(ore_bag)) @@ -253,7 +272,7 @@ ore_bag = null /// Units of ore in their satchel -/mob/living/basic/ambient_npc/planet/miner/proc/ore_carried() +/mob/living/carbon/human/npc/ambient_npc/planet/miner/proc/ore_carried() . = 0 for(var/obj/item/stack/ore/ore in ore_bag) . += ore.amount @@ -262,14 +281,14 @@ * The barter: a bite to eat or something to drink for a little ore from the satchel. Only what they * dug on this rock, and only a few trades a visit for the whole camp. */ -/mob/living/basic/ambient_npc/planet/miner/item_interaction(mob/living/user, obj/item/tool, list/modifiers) +/mob/living/carbon/human/npc/ambient_npc/planet/miner/item_interaction(mob/living/user, obj/item/tool, list/modifiers) if(user.combat_mode || !can_act() || !ambient_is_refreshment(tool)) return ..() barter(user, tool) return ITEM_INTERACT_SUCCESS /// Trades `refreshment` from `user` for one to three ore. TRUE if the trade was made. -/mob/living/basic/ambient_npc/planet/miner/proc/barter(mob/living/user, obj/item/refreshment) +/mob/living/carbon/human/npc/ambient_npc/planet/miner/proc/barter(mob/living/user, obj/item/refreshment) if(!buckled) face_atom(user) var/datum/ambient_place/site/site = place @@ -306,7 +325,7 @@ return FALSE /// Out on an asteroid, in a suit -/mob/living/basic/ambient_npc/planet/miner/void +/mob/living/carbon/human/npc/ambient_npc/planet/miner/void desc = "A prospector in a patched suit, chipping at the asteroid." outfit_choices = null outfit = /datum/outfit/ambient_miner/void @@ -346,10 +365,10 @@ return FALSE new_visit(site) build_camp(site) - var/miner_type = site.field_ref ? /mob/living/basic/ambient_npc/planet/miner/void : npc_type + var/miner_type = site.field_ref ? /mob/living/carbon/human/npc/ambient_npc/planet/miner/void : npc_type for(var/i in 1 to missing) var/turf/where = ambient_free_turf_near(site.center, 2) || site.center - var/mob/living/basic/ambient_npc/planet/miner/miner = site.spawn_npc(miner_type, where) + var/mob/living/carbon/human/npc/ambient_npc/planet/miner/miner = site.spawn_npc(miner_type, where) if(miner) miner.leash_bounds = ambient_square_bounds(site.center, 12) return TRUE diff --git a/voidcrew/modules/ambient_npcs/planet_peddler.dm b/voidcrew/modules/ambient_npcs/planet_peddler.dm index 11b76dfed0e..9166309eb5d 100644 --- a/voidcrew/modules/ambient_npcs/planet_peddler.dm +++ b/voidcrew/modules/ambient_npcs/planet_peddler.dm @@ -68,11 +68,21 @@ shoes = /obj/item/clothing/shoes/laceup back = /obj/item/storage/backpack/satchel/leather +/// Yellow and red: a flak vest under the poncho's place +/datum/outfit/ambient_peddler/vest + name = "Ambient NPC: peddler in a flak vest" + suit = /obj/item/clothing/suit/armor/vest + +/// The peddler by band (AM-1) +/datum/human_npc_outfit_table/ambient_peddler + green = list(/datum/outfit/ambient_peddler = 1) + yellow = list(/datum/outfit/ambient_peddler/vest = 1) + /** * What a walking trader carries: survival basics a crew forgot, one odd thing, one weapon, credits * only, and about a quarter cheaper than a counter, because crews rarely meet one. The ledger buys a few planetary goods at AMBIENT_PEDDLER_BUYBACK_RATE of an outpost's price, * never for vouchers. One shop per peddler, fresh each visit, but for the weapon, which stays with - * the site (/mob/living/basic/ambient_npc/planet/peddler/proc/load_weapon()). + * the site (/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/load_weapon()). */ /datum/outpost_shop/vendor/peddler outpost_name = "Peddler's pack" @@ -422,12 +432,13 @@ * waypoints; "camps", the waypoints it camps at; "leg", the last one reached). Killable like * anyone; a trader carries more cash than most, and the shop dies with them. */ -/mob/living/basic/ambient_npc/planet/peddler +/mob/living/carbon/human/npc/ambient_npc/planet/peddler name = "peddler" desc = "A walking trader under a wide hat, bags hanging off every strap." death_cash_low = 30 death_cash_high = 80 - outfit = /datum/outfit/ambient_peddler + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_peddler dialogue_section = "peddler" speech_pace = 0.8 ai_controller = /datum/ai_controller/basic_controller/ambient_npc/roaming @@ -444,13 +455,13 @@ var/hold_until = 0 COOLDOWN_DECLARE(trade_line_cooldown) -/mob/living/basic/ambient_npc/planet/peddler/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/Initialize(mapload) . = ..() shop = new(null) shop.trader_name = real_name shop_ui = new(src) -/mob/living/basic/ambient_npc/planet/peddler/Destroy() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/Destroy() save_ledger() QDEL_NULL(shop_ui) QDEL_NULL(shop) @@ -461,7 +472,7 @@ * away and brought back next visit is not a fresh market for the same goods or a fresh pack (load_ledger() * puts them back). */ -/mob/living/basic/ambient_npc/planet/peddler/proc/save_ledger() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/save_ledger() var/datum/ambient_place/site/site = place if(!istype(site) || !shop) return @@ -473,7 +484,7 @@ site.data["weapon_left"] = shop.weapon.stock /// Takes up the ledger and the pack where the last visit's peddler left them -/mob/living/basic/ambient_npc/planet/peddler/proc/load_ledger() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/load_ledger() var/datum/ambient_place/site/site = place if(!istype(site) || !shop) return @@ -486,7 +497,7 @@ buyback.demand = demand[buyback.type] /// The site's one weapon: picked the first time from what its zone carries, then only ever the same one, while it has not sold -/mob/living/basic/ambient_npc/planet/peddler/proc/load_weapon(datum/ambient_place/site/site) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/load_weapon(datum/ambient_place/site/site) if(!site.data["weapon"]) site.data["weapon"] = pick(ambient_peddler_weapons(site.band)) site.data["weapon_left"] = 1 @@ -494,7 +505,7 @@ shop.stock_weapon(site.data["weapon"]) /// A line from the shop's own lines (the shop window calls this, as it does on a trader) -/mob/living/basic/ambient_npc/planet/peddler/proc/speak_line(category) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/speak_line(category) var/line = shop?.get_line(category) if(!line || !COOLDOWN_FINISHED(src, trade_line_cooldown)) return @@ -502,17 +513,17 @@ say_line(line) /// The "no" noise (the shop window calls this, as it does on a trader) -/mob/living/basic/ambient_npc/planet/peddler/proc/play_denial() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/play_denial() playsound(src, 'sound/machines/buzz/buzz-sigh.ogg', 30, TRUE) // A customer: the Trade and Talk radial, and the caravan waits for them -/mob/living/basic/ambient_npc/planet/peddler/talked_to(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/talked_to(mob/living/user) if(!talk_ready(user)) return hold_until = world.time + AMBIENT_PEDDLER_HOLD INVOKE_ASYNC(src, PROC_REF(open_menu), user) -/mob/living/basic/ambient_npc/planet/peddler/proc/open_menu(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/open_menu(mob/living/user) face_atom(user) var/list/options = list( "Trade" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_buy"), @@ -529,15 +540,15 @@ chat_with(user) /// Whether `user` can still do business: near, awake, in sight -/mob/living/basic/ambient_npc/planet/peddler/proc/menu_ok(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/menu_ok(mob/living/user) return istype(user) && !IS_DEAD_OR_INCAP(user) && can_act() && get_dist(src, user) <= 2 && (src in view(2, user)) /// The shop window and every sale ask this of their host, as of a trader: a customer still here, and a pack to sell from -/mob/living/basic/ambient_npc/planet/peddler/proc/check_menu(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/check_menu(mob/living/user) return !shop_closed && menu_ok(user) /// Opens the shop window for `user`, unless the stock went down with the pony -/mob/living/basic/ambient_npc/planet/peddler/proc/open_shop(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/open_shop(mob/living/user) if(shop_closed || !shop) speak_context("pack_lost", user, force = TRUE) return @@ -545,7 +556,7 @@ speak_line(TRADER_LINE_GREETING) /// A word; the first time for a crew, where the nearest ruin on this planet lies -/mob/living/basic/ambient_npc/planet/peddler/proc/chat_with(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/chat_with(mob/living/user) var/turf/ruin = ruin_to_tell() if(ruin && first_for_crew(user, "ruin_tip")) var/list/lines = get_lines("ruin_tip") @@ -556,7 +567,7 @@ speak_context(AMBIENT_LINE_TALK, user, force = TRUE) /// A ruin on their planet, looked for once (a few hundred random tiles) and remembered by the site, or null -/mob/living/basic/ambient_npc/planet/peddler/proc/ruin_to_tell() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/ruin_to_tell() var/datum/ambient_place/site/site = place if(!istype(site) || length(site.planet?.bounds) < 5) return null @@ -573,7 +584,7 @@ return null /// The pony is dead: the stock went with it -/mob/living/basic/ambient_npc/planet/peddler/proc/lose_pack() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/lose_pack() if(shop_closed) return shop_closed = TRUE @@ -581,7 +592,7 @@ speak_context("pack_lost", null, force = TRUE) // A word, the guard deals with it, and the peddler gets out of the way -/mob/living/basic/ambient_npc/planet/peddler/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/react_attacked(atom/attacker) if(stat != CONSCIOUS || fading || !reaction_ready("attacked")) return speak_context(AMBIENT_LINE_ATTACKED, attacker, force = TRUE) @@ -590,7 +601,7 @@ run_from(attacker) // Killed: the shop goes with them. Its stock is a ledger, never items on the body. -/mob/living/basic/ambient_npc/planet/peddler/death(gibbed) +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/death(gibbed) var/was_alive = stat != DEAD . = ..() if(!was_alive) @@ -602,7 +613,7 @@ * The caravan's next move: camp at a camp stop not yet camped at, walk to the next waypoint (jumped * on the way, maybe), or walk off the planet at the end of the route. */ -/mob/living/basic/ambient_npc/planet/peddler/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/pick_activity() var/datum/ambient_place/site/site = place var/list/route = istype(site) ? site.data["route"] : null if(!length(route) || world.time < hold_until) @@ -630,7 +641,7 @@ return start_activity(new /datum/ambient_activity/caravan_walk(src, route[leg + 1], leg + 1)) /// The end of the road: the caravan walks off, and the site is done for the round -/mob/living/basic/ambient_npc/planet/peddler/proc/leave_planet() +/mob/living/carbon/human/npc/ambient_npc/planet/peddler/proc/leave_planet() var/datum/ambient_place/site/site = place speak_context("farewell", null, force = TRUE) if(!istype(site)) @@ -638,75 +649,94 @@ return // Spent before anyone goes, so nobody comes back on the next visit site.state = AMBIENT_SITE_SPENT - for(var/mob/living/basic/ambient_npc/member as anything in site.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/member as anything in site.living_npcs()) member.fade_out() /** - * The pack pony. Killable; a dead pony closes the shop. It follows the peddler, and runs a little - * way when hit. + * The pack pony. An animal, so it stays a basic mob (tg's pony) when the people of the caravan are + * human bodies: it isn't one of the site's NPCs, but the caravan's beast, kept in the site's data + * ("pony") and brought back with it on a later visit. Killable; a dead pony closes the shop. It + * follows the peddler, and runs a little way when hit. */ -/mob/living/basic/ambient_npc/planet/pack_pony +/mob/living/basic/pony/ambient_pack name = "pack pony" desc = "A sturdy pony under a mountain of bags and bundles." - icon = 'icons/mob/simple/animal.dmi' - icon_state = "pony" - icon_living = "pony" - icon_dead = "pony_dead" - greyscale_config = /datum/greyscale_config/pony - random_name = FALSE - random_gender = FALSE - mob_biotypes = MOB_ORGANIC | MOB_BEAST - maxHealth = 60 health = 60 - // A pony carries bags, not a purse - death_cash_low = 0 - death_cash_high = 0 - speak_emote = list("neighs") - ai_controller = /datum/ai_controller/basic_controller/ambient_npc/roaming - routine = list(/datum/ambient_activity/idle = 1) + maxHealth = 60 + gold_core_spawnable = NO_SPAWN + ai_controller = /datum/ai_controller/basic_controller/ambient_pack_pony + /// The caravan's site + var/datum/weakref/site_ref -/mob/living/basic/ambient_npc/planet/pack_pony/Initialize(mapload) +/mob/living/basic/pony/ambient_pack/Initialize(mapload, datum/ambient_place/site/site) . = ..() + // tg's pony already remembers who hit it (ai_retaliate), which its flee subtree reads set_greyscale(colors = list("#8a5a3c", "#3b2a1e")) + if(istype(site)) + site_ref = WEAKREF(site) + site.data["pony"] = WEAKREF(src) -// A pony, not a person: no human look -/mob/living/basic/ambient_npc/planet/pack_pony/build_look() - return - -// It lies as its own sprite does -/mob/living/basic/ambient_npc/planet/pack_pony/look_dead() - . = ..() - transform = matrix() - -// Snorts and whickers instead of words -/mob/living/basic/ambient_npc/planet/pack_pony/speak_context(context, atom/other, force = FALSE) - if(stat != CONSCIOUS || fading) - return FALSE - if(!force && world.time < next_line_at) - return FALSE - next_line_at = world.time + rand(60 SECONDS, 120 SECONDS) - manual_emote(pick("snorts.", "whickers.", "stamps a hoof.", "flicks its ears.")) - playsound(src, 'sound/mobs/non-humanoids/pony/snort.ogg', 30, TRUE, -3) - return TRUE - -/mob/living/basic/ambient_npc/planet/pack_pony/pick_activity() - var/mob/living/basic/ambient_npc/planet/peddler/leader = caravan_leader(src) - if(leader && start_activity(new /datum/ambient_activity/follow(src, leader, 2))) - return activity +/mob/living/basic/pony/ambient_pack/Destroy() + site_ref = null return ..() +// No saddle and no rider: it carries the stock +/mob/living/basic/pony/ambient_pack/tamed(mob/living/tamer, atom/food) + return + // The stock was on its back -/mob/living/basic/ambient_npc/planet/pack_pony/death(gibbed) +/mob/living/basic/pony/ambient_pack/death(gibbed) var/was_alive = stat != DEAD . = ..() if(!was_alive) return - var/datum/ambient_place/site/site = place - if(istype(site)) - site.data["pack_lost"] = TRUE - var/mob/living/basic/ambient_npc/planet/peddler/leader = caravan_leader(src) + var/datum/ambient_place/site/site = site_ref?.resolve() + if(!istype(site)) + return + site.data["pack_lost"] = TRUE + var/mob/living/carbon/human/npc/ambient_npc/planet/peddler/leader = caravan_leader_of(site) leader?.lose_pack() +/// Keeps near the caravan's peddler; runs from whoever hits it +/datum/ai_controller/basic_controller/ambient_pack_pony + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic, + ) + ai_traits = PASSIVE_AI_FLAGS + ai_movement = /datum/ai_movement/basic_avoidance + idle_behavior = null + // Walking with players anywhere on the level, as the caravan does + can_idle = FALSE + planning_subtrees = list( + /datum/ai_planning_subtree/find_nearest_thing_which_attacked_me_to_flee, + /datum/ai_planning_subtree/flee_target, + /datum/ai_planning_subtree/ambient_pony_follow, + ) + +/// Walks after the caravan's peddler when more than two tiles behind +/datum/ai_planning_subtree/ambient_pony_follow + +/datum/ai_planning_subtree/ambient_pony_follow/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/basic/pony/ambient_pack/pony = controller.pawn + if(!istype(pony)) + return + var/mob/living/leader = caravan_leader_of(pony.site_ref?.resolve()) + if(!leader || leader.z != pony.z || get_dist(pony, leader) <= 2) + return + controller.set_blackboard_key(BB_AMBIENT_DESTINATION, leader) + controller.queue_behavior(/datum/ai_behavior/travel_towards/stop_on_arrival, BB_AMBIENT_DESTINATION) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The caravan's pack pony on `site`, if it is still alive +/proc/caravan_pony(datum/ambient_place/site/site) + if(!istype(site)) + return null + var/datum/weakref/pony_ref = site.data["pony"] + var/mob/living/basic/pony/ambient_pack/pony = pony_ref?.resolve() + if(QDELETED(pony) || pony.stat == DEAD) + return null + return pony + /datum/outfit/ambient_caravan_guard name = "Ambient NPC: caravan guard" uniform = /obj/item/clothing/under/pants/camo @@ -714,12 +744,24 @@ head = /obj/item/clothing/head/soft/black shoes = /obj/item/clothing/shoes/jackboots mask = /obj/item/clothing/mask/bandana/red + r_hand = /obj/item/melee/baseball_bat + +/// Red: a helmet for the cap +/datum/outfit/ambient_caravan_guard/helmet + name = "Ambient NPC: caravan guard in a helmet" + head = /obj/item/clothing/head/helmet + +/// The caravan guard by band (AM-1): only out in yellow and red +/datum/human_npc_outfit_table/ambient_caravan_guard + green = list(/datum/outfit/ambient_caravan_guard = 1) + red = list(/datum/outfit/ambient_caravan_guard/helmet = 1) /// The hired guard, in yellow and red: follows the peddler, fights anyone who goes for the caravan and the pack that jumps it -/mob/living/basic/ambient_npc/planet/caravan_guard +/mob/living/carbon/human/npc/ambient_npc/planet/caravan_guard name = "caravan guard" desc = "A hired guard in a flak vest, a bat over one shoulder." - outfit = /datum/outfit/ambient_caravan_guard + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_caravan_guard dialogue_section = "caravan_guard" fights_back = TRUE joins_camp_fights = TRUE @@ -730,14 +772,14 @@ ai_controller = /datum/ai_controller/basic_controller/ambient_npc/roaming routine = list(/datum/ambient_activity/idle = 1) -/mob/living/basic/ambient_npc/planet/caravan_guard/pick_activity() - var/mob/living/basic/ambient_npc/planet/peddler/leader = caravan_leader(src) +/mob/living/carbon/human/npc/ambient_npc/planet/caravan_guard/pick_activity() + var/mob/living/carbon/human/npc/ambient_npc/planet/peddler/leader = caravan_leader(src) if(leader && start_activity(new /datum/ambient_activity/follow(src, leader, 3))) return activity return ..() // The pack that jumped the caravan -/mob/living/basic/ambient_npc/planet/caravan_guard/watch(list/players) +/mob/living/carbon/human/npc/ambient_npc/planet/caravan_guard/watch(list/players) if(istype(activity, /datum/ambient_activity/fight)) return var/list/pack = ambient_site_visit(place)["pack"] @@ -748,8 +790,12 @@ return /// The peddler leading `member`'s caravan, if they are still about -/proc/caravan_leader(mob/living/basic/ambient_npc/member) - for(var/mob/living/basic/ambient_npc/planet/peddler/leader in member?.place?.living_npcs()) +/proc/caravan_leader(mob/living/carbon/human/npc/ambient_npc/member) + return caravan_leader_of(member?.place) + +/// The peddler leading the caravan on `site`, if they are still about +/proc/caravan_leader_of(datum/ambient_place/site) + for(var/mob/living/carbon/human/npc/ambient_npc/planet/peddler/leader in site?.living_npcs()) return leader return null @@ -773,7 +819,7 @@ /// world.time they started waiting for the others, or 0 var/waiting_since = 0 -/datum/ambient_activity/caravan_walk/New(mob/living/basic/ambient_npc/new_doer, turf/goal, leg_index) +/datum/ambient_activity/caravan_walk/New(mob/living/carbon/human/npc/ambient_npc/new_doer, turf/goal, leg_index) . = ..() src.goal = goal src.leg_index = leg_index @@ -834,7 +880,7 @@ if(get_dist(doer, goal) <= 2) reached() return AMBIENT_STEP_DONE - var/mob/living/basic/ambient_npc/planet/peddler/peddler = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/peddler/peddler = doer if(istype(peddler) && world.time < peddler.hold_until) return AMBIENT_STEP_CONTINUE // The others catch up first, for a while @@ -854,7 +900,7 @@ /// Whether the pony or the guard has fallen well behind /datum/ambient_activity/caravan_walk/proc/others_behind() - for(var/mob/living/basic/ambient_npc/member in doer.place?.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/member in doer.place?.living_npcs()) if(member != doer && get_dist(member, doer) > 5) return TRUE return FALSE @@ -926,11 +972,15 @@ var/give_up_at = 0 /// The pack (weakrefs) var/list/pack = list() + /// Spawn orders for raiders not built yet (human pirates come through the human NPC queue) + var/list/datum/human_npc_order/orders = list() + /// Where the pack came from: what is left of it walks back out that way + var/turf/landing /datum/ambient_activity/caravan_ambush/setup() go_to(null) pack_at = world.time + AMBIENT_AMBUSH_WARNING - var/mob/living/basic/ambient_npc/shouter = locate(/mob/living/basic/ambient_npc/planet/caravan_guard) in doer.place?.living_npcs() + var/mob/living/carbon/human/npc/ambient_npc/shouter = locate(/mob/living/carbon/human/npc/ambient_npc/planet/caravan_guard) in doer.place?.living_npcs() shouter = shouter || doer shouter.speak_context("ambush", null, force = TRUE) return TRUE @@ -941,6 +991,8 @@ if(raider) UnregisterSignal(raider, COMSIG_ATOM_WAS_ATTACKED) pack = null + QDEL_LIST(orders) + landing = null return ..() /datum/ambient_activity/caravan_ambush/arrive() @@ -956,9 +1008,14 @@ return AMBIENT_STEP_DONE return AMBIENT_STEP_CONTINUE var/alive = 0 + for(var/datum/human_npc_order/order as anything in orders.Copy()) + if(QDELETED(order) || order.done) + orders -= order + else + alive++ for(var/datum/weakref/ref as anything in pack) var/mob/living/raider = ref?.resolve() - if(!QDELETED(raider) && raider.stat != DEAD) + if(!QDELETED(raider) && !human_npc_out_of_fight(raider)) alive++ if(!alive) doer.speak_context("ambush_over", null, force = TRUE) @@ -967,17 +1024,21 @@ return AMBIENT_STEP_DONE return AMBIENT_STEP_CONTINUE -// Whatever is left of the pack slinks off when it is over +// Whatever is left of the pack heads back out the way it came, and is gone once out of sight /datum/ambient_activity/caravan_ambush/finish() + QDEL_LIST(orders) for(var/datum/weakref/ref as anything in pack) - var/mob/living/raider = ref?.resolve() - if(!QDELETED(raider) && raider.stat != DEAD && !raider.client) - qdel(raider) + var/mob/living/carbon/human/npc/raider = ref?.resolve() + if(!ishumannpc(raider) || QDELETED(raider) || raider.client || human_npc_out_of_fight(raider)) + continue + qdel(raider.GetComponent(/datum/component/leash)) + raider.ai_controller?.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + raider.npc_walk_out(landing || get_ranged_target_turf(raider, get_dir(doer, raider) || pick(GLOB.alldirs), AMBIENT_AMBUSH_DISTANCE)) var/list/visit = ambient_site_visit(doer?.place) visit -= "pack" return ..() -/// How many pirates jump a caravan in `band`: none in green +/// How many jump the caravan in `band` /proc/ambient_ambush_size(band) switch(band) if(ZONE_RED) @@ -986,9 +1047,18 @@ return 2 return 0 +/// Open ground on `site` AMBIENT_AMBUSH_DISTANCE from `here` in some direction, where the pack comes from, or null +/datum/ambient_activity/caravan_ambush/proc/find_landing(datum/ambient_place/site/site, turf/here) + for(var/attempt in 1 to 6) + var/turf/far = get_ranged_target_turf(here, pick(GLOB.alldirs), AMBIENT_AMBUSH_DISTANCE) + if(far && site.leash_ok(far) && ambient_ground_ok(far)) + return far + return null + /** - * Makes the pack out of what the planet's budget has left, ten tiles off, turns it on the caravan - * and keeps it near it. Returns how many came. + * Sends for the pack out of what the planet's budget has left, ten tiles off: real human pirates, + * built a body a tick through the human NPC queue, turned on the caravan and kept near it as they + * come (raider_built()). Returns how many were sent for. */ /datum/ambient_activity/caravan_ambush/proc/spawn_pack() var/datum/ambient_place/site/site = doer.place @@ -1003,42 +1073,30 @@ if(count <= 0) return 0 var/turf/here = get_turf(doer) - var/turf/landing - for(var/attempt in 1 to 6) - var/turf/far = get_ranged_target_turf(here, pick(GLOB.alldirs), AMBIENT_AMBUSH_DISTANCE) - if(far && site.leash_ok(far) && ambient_ground_ok(far)) - landing = far - break + landing = find_landing(site, here) if(!landing) return 0 - var/mob/living/bait = locate(/mob/living/basic/ambient_npc/planet/pack_pony) in site.living_npcs() - bait = bait || locate(/mob/living/basic/ambient_npc/planet/caravan_guard) in site.living_npcs() - var/list/visit = ambient_site_visit(site) - var/list/pack_refs = list() + var/mob/living/bait = caravan_pony(site) || locate(/mob/living/carbon/human/npc/ambient_npc/planet/caravan_guard) in site.living_npcs() + ambient_site_visit(site)["pack"] = list() var/static/list/raiders = list( - /mob/living/basic/trooper/pirate/faction/grey/melee = 2, - /mob/living/basic/trooper/pirate/faction/skeleton/melee = 2, - /mob/living/basic/trooper/pirate/faction/grey/ranged = 1, + /mob/living/carbon/human/npc/faction_pirate/grey/melee = 2, + /mob/living/carbon/human/npc/faction_pirate/skeleton/melee = 2, + /mob/living/carbon/human/npc/faction_pirate/grey/ranged = 1, ) for(var/i in 1 to count) var/turf/where = ambient_free_turf_near(landing, 2) || landing - var/raider_type = i == 3 ? /mob/living/basic/trooper/pirate/faction/grey/ranged : pick_weight(raiders - /mob/living/basic/trooper/pirate/faction/grey/ranged) - var/mob/living/raider = spawn_raider(raider_type, where, bait) - var/datum/weakref/ref = WEAKREF(raider) - pack += ref - pack_refs += ref - visit["pack"] = pack_refs + var/raider_type = i == 3 ? /mob/living/carbon/human/npc/faction_pirate/grey/ranged : pick_weight(raiders - /mob/living/carbon/human/npc/faction_pirate/grey/ranged) + var/datum/npc_spawn_context/context = new(site, site.band, source_turf = here) + orders += spawn_human_npc(raider_type, where, CALLBACK(src, PROC_REF(raider_built), WEAKREF(bait)), context = context) if(tracker) tracker.spawned_count += count SSplanet_mobs.total_managed_mobs += count return count -/// Ready each ambusher against the caravan site before applying its profile. -/datum/ambient_activity/caravan_ambush/proc/spawn_raider(raider_type, turf/where, mob/living/bait) - var/datum/ambient_place/site/site = doer.place - var/datum/npc_spawn_context/context = new(site, site.band, source_turf = get_turf(doer)) - var/mob/living/raider = spawn_npc_in_zone(raider_type, where, context, FALSE) - // Not the planet's own: it comes for the caravan, and is kept near it. +/// A raider of the pack is built: not the planet's own, it comes for the caravan and is kept near it +/datum/ambient_activity/caravan_ambush/proc/raider_built(datum/weakref/bait_ref, mob/living/raider) + if(QDELETED(src) || QDELETED(doer) || QDELETED(raider)) + return var/list/kept = list() for(var/faction_token in raider.faction) if(!findtext("[faction_token]", "planetary_")) @@ -1047,15 +1105,22 @@ raider.AddComponent(/datum/component/leash, doer, AMBIENT_AMBUSH_LEASH) raider.AddElement(/datum/element/relay_attackers) RegisterSignal(raider, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_pack_attacked)) - if(bait) + var/mob/living/bait = bait_ref?.resolve() + if(!QDELETED(bait) && bait.stat != DEAD) raider.ai_controller?.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, bait) - context.finalize() - return raider + var/datum/weakref/ref = WEAKREF(raider) + pack += ref + var/list/visit = ambient_site_visit(doer.place) + var/list/pack_refs = visit["pack"] + if(!islist(pack_refs)) + pack_refs = list() + visit["pack"] = pack_refs + pack_refs += ref /// Someone hit the pack: their crew gets the helpers' price /datum/ambient_activity/caravan_ambush/proc/on_pack_attacked(datum/source, atom/attacker, attack_flags) SIGNAL_HANDLER - var/mob/living/basic/ambient_npc/planet/peddler/peddler = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/peddler/peddler = doer if(!istype(peddler) || !peddler.shop || !ismob(attacker)) return var/obj/structure/overmap/ship/ship = get_crew_ship(attacker) @@ -1068,7 +1133,7 @@ /** * Brings the caravan out: plans its route the first time, then puts whoever is missing (and was - * not killed) where it last got to. + * not killed) where it last got to, and the pony with them while it lives. */ /datum/ambient_site_kind/planet/peddler/realize(datum/ambient_place/site/site) var/missing = site.npcs_missing() @@ -1082,10 +1147,10 @@ if(!ambient_ground_ok(start)) start = ambient_free_turf_near(start, 3) || site.center var/list/killed = site.data["killed"] || list() - var/list/wanted = list(/mob/living/basic/ambient_npc/planet/peddler, /mob/living/basic/ambient_npc/planet/pack_pony) - if(site.npc_total >= 3) - wanted += /mob/living/basic/ambient_npc/planet/caravan_guard - for(var/mob/living/basic/ambient_npc/present as anything in site.living_npcs()) + var/list/wanted = list(/mob/living/carbon/human/npc/ambient_npc/planet/peddler) + if(site.npc_total >= 2) + wanted += /mob/living/carbon/human/npc/ambient_npc/planet/caravan_guard + for(var/mob/living/carbon/human/npc/ambient_npc/present as anything in site.living_npcs()) wanted -= present.type var/spawned = 0 for(var/member_type in wanted) @@ -1094,7 +1159,7 @@ if(spawned >= missing) break var/turf/where = spawned ? (ambient_free_turf_near(start, 2) || start) : start - var/mob/living/basic/ambient_npc/planet/peddler/member = site.spawn_npc(member_type, where) + var/mob/living/carbon/human/npc/ambient_npc/planet/peddler/member = site.spawn_npc(member_type, where) if(!member) continue spawned++ @@ -1102,9 +1167,18 @@ member.load_ledger() if(site.data["pack_lost"]) member.shop_closed = TRUE + if(spawned && !site.data["pack_lost"] && !caravan_pony(site)) + new /mob/living/basic/pony/ambient_pack(ambient_free_turf_near(start, 2) || start, site) return spawned > 0 -/datum/ambient_site_kind/planet/peddler/on_npc_died(datum/ambient_place/site/site, mob/living/basic/ambient_npc/npc) +// The pony goes with the caravan when the planet is swept, and comes back with it +/datum/ambient_site_kind/planet/peddler/on_depopulated(datum/ambient_place/site/site) + var/mob/living/basic/pony/ambient_pack/pony = caravan_pony(site) + if(pony) + qdel(pony) + return ..() + +/datum/ambient_site_kind/planet/peddler/on_npc_died(datum/ambient_place/site/site, mob/living/carbon/human/npc/ambient_npc/npc) var/list/killed = site.data["killed"] if(!islist(killed)) killed = list() diff --git a/voidcrew/modules/ambient_npcs/planet_sites.dm b/voidcrew/modules/ambient_npcs/planet_sites.dm index cf47c7d4bb5..fcb45ceb15d 100644 --- a/voidcrew/modules/ambient_npcs/planet_sites.dm +++ b/voidcrew/modules/ambient_npcs/planet_sites.dm @@ -12,7 +12,7 @@ * - /datum/ambient_site_kind/planet and its subtypes: which camps roll on which planet types and * how often (spec 4.1), and how many people they have. The spot finders the core's does not * cover (a rock face with ore, a lava bank, open water) sit beside each camp's realize(). - * - /mob/living/basic/ambient_npc/planet: the killable planet person every PB NPC builds on. Loot + * - /mob/living/carbon/human/npc/ambient_npc/planet: the killable planet person every PB NPC builds on. Loot * drops once, the body falls over, they fight back (or run) when attacked, a camp fights * together, and a camp that watches for strangers does it in watch(). * - Shared activities: fight, flee, follow, camp_chore and nap. @@ -129,14 +129,14 @@ ) field_chance = 10 spot_room = 1 - npc_type = /mob/living/basic/ambient_npc/planet/miner + npc_type = /mob/living/carbon/human/npc/ambient_npc/planet/miner /// Owner item 7: a cannibal cooking a man over a fire. Jungle worlds. /datum/ambient_site_kind/planet/cannibal name = "cannibal's fire" planet_chances = list(/datum/overmap/planet/jungle = 8) spot_room = 2 - npc_type = /mob/living/basic/ambient_npc/planet/cannibal + npc_type = /mob/living/carbon/human/npc/ambient_npc/planet/cannibal /// Owner item 8: a band of hunters with spears. Jungle worlds; bigger bands deeper out. /datum/ambient_site_kind/planet/tribal @@ -145,7 +145,7 @@ min_npcs = 3 max_npcs = 5 spot_room = 3 - npc_type = /mob/living/basic/ambient_npc/planet/tribal + npc_type = /mob/living/carbon/human/npc/ambient_npc/planet/tribal /datum/ambient_site_kind/planet/tribal/npc_count(band) switch(band) @@ -159,14 +159,14 @@ /datum/ambient_site_kind/planet/lava_fisher name = "lava fisher's bank" planet_chances = list(/datum/overmap/planet/lava = 15) - npc_type = /mob/living/basic/ambient_npc/planet/lava_fisher + npc_type = /mob/living/carbon/human/npc/ambient_npc/planet/lava_fisher /// Owner item 10: a fisher in a boat out on the ocean. Beach worlds. /datum/ambient_site_kind/planet/boat_fisher name = "fishing boat" planet_chances = list(/datum/overmap/planet/beach = 18) spot_on_water = TRUE - npc_type = /mob/living/basic/ambient_npc/planet/boat_fisher + npc_type = /mob/living/carbon/human/npc/ambient_npc/planet/boat_fisher /// Owner item 11: a peddler crossing the planet with a pack pony, and a guard deeper out. /datum/ambient_site_kind/planet/peddler @@ -178,14 +178,15 @@ /datum/overmap/planet/ice = 6, /datum/overmap/planet/wasteland = 6, ) - min_npcs = 2 - max_npcs = 3 + min_npcs = 1 + max_npcs = 2 spot_room = 1 - npc_type = /mob/living/basic/ambient_npc/planet/peddler + npc_type = /mob/living/carbon/human/npc/ambient_npc/planet/peddler -// The peddler and the pony; a guard joins them in yellow and red +// The peddler; a guard joins them in yellow and red. The pony is an animal, not one of the site's people +// (planet_peddler.dm, caravan_pony()). /datum/ambient_site_kind/planet/peddler/npc_count(band) - return (band == ZONE_YELLOW || band == ZONE_RED) ? 3 : 2 + return (band == ZONE_YELLOW || band == ZONE_RED) ? 2 : 1 // ========================================================================= // HELPERS @@ -198,7 +199,7 @@ if(!middle || !islist(SSmobs.clients_by_zlevel) || middle.z > length(SSmobs.clients_by_zlevel)) return for(var/mob/living/player in SSmobs.clients_by_zlevel[middle.z]) - if(player.stat == DEAD || !player.client || istype(player, /mob/living/basic/ambient_npc)) + if(player.stat == DEAD || !player.client || istype(player, /mob/living/carbon/human/npc/ambient_npc)) continue var/turf/where = get_turf(player) if(where && get_dist(where, middle) <= range) @@ -223,6 +224,24 @@ var/area/tile_area = get_area(tile) return !tile_area || istype(tile_area, /area/ruin) || istype(tile_area, /area/shuttle) +/** + * Whether one of `thrower`'s own camp (an ambient NPC of the same place) stands between them and + * `target`, so a throw or a shot would hit them first. Nobody passes through a thrown spear: they + * hold it instead. Bodies lying down are passed over, as a throw passes over them. + */ +/proc/ambient_camp_mate_in_line(mob/living/carbon/human/npc/ambient_npc/thrower, atom/target) + var/turf/start = get_turf(thrower) + var/turf/finish = get_turf(target) + if(!start || !finish || !thrower.place) + return FALSE + for(var/turf/line_turf as anything in get_line(start, finish)) + if(line_turf == start || line_turf == finish) + continue + for(var/mob/living/carbon/human/npc/ambient_npc/other in line_turf) + if(other != thrower && other.place == thrower.place && other.stat != DEAD && other.body_position == STANDING_UP) + return TRUE + return FALSE + /// A square leash of `radius` around `center`, as list(min x, min y, max x, max y, z) /proc/ambient_square_bounds(turf/center, radius) center = get_turf(center) @@ -235,12 +254,12 @@ // ========================================================================= /** - * Someone living on a planet or an asteroid: killable (health scaled by band where they spawn, - * site.spawn_npc()), their loot dropped once, their body falling over. Fighters fight back when - * attacked and their camp joins in; everyone else runs. A camp that watches for strangers does it - * in watch(), about every two seconds while they are awake. + * Someone living on a planet or an asteroid: a real body, dressed by band where they spawn (their + * outfit table's kit in yellow and red, site.spawn_npc()), their loot dropped once. Fighters fight + * back with the real weapon they carry and their camp joins in; everyone else runs. A camp that + * watches for strangers does it in watch(), about every two seconds while they are awake. */ -/mob/living/basic/ambient_npc/planet +/mob/living/carbon/human/npc/ambient_npc/planet name = "drifter" desc = "Someone living rough." dialogue_file = AMBIENT_STRINGS_PLANETS @@ -253,7 +272,7 @@ var/fights_back = FALSE /// Joins in when someone at their camp is attacked var/joins_camp_fights = FALSE - /// The real item they fight with (its numbers, bounty_real_weapon()); null for fists + /// The type of the real weapon they fight with, which their outfit gives them (a belt, a hand); null for fists var/weapon_type /// What they hold when nothing is going on (a spear, a club), or null var/idle_held @@ -263,38 +282,29 @@ var/flee_below = 0 /// Their camp looks out for strangers (watch()) var/watches = FALSE - /// Asleep: they see nothing until woken - var/sleeping = FALSE -/mob/living/basic/ambient_npc/planet/Initialize(mapload) +/mob/living/carbon/human/npc/ambient_npc/planet/Initialize(mapload, datum/human_npc_order/order) . = ..() - arm(weapon_type) show_idle_held() -/// Their blows hit like `item_type` (null: fists), and it is the weapon they show in a fight -/mob/living/basic/ambient_npc/planet/proc/arm(item_type) - weapon_type = item_type - var/datum/bounty_real_weapon/weapon = bounty_real_weapon(item_type) - weapon.apply_melee(src) - -/// Back to holding what they hold when nothing is going on -/mob/living/basic/ambient_npc/planet/proc/show_idle_held() +/// Back to holding what they hold when nothing is going on, putting their weapon away +/mob/living/carbon/human/npc/ambient_npc/planet/proc/show_idle_held() if(stat == DEAD) return set_held(idle_held) -// A body lies down, and gets up again if they are revived -/mob/living/basic/ambient_npc/planet/look_dead() - . = ..() - transform = matrix().Turn(90) - -/mob/living/basic/ambient_npc/planet/look_alive() - . = ..() - transform = matrix() +/// The weapon they fight with, if they have it in hand +/mob/living/carbon/human/npc/ambient_npc/planet/proc/held_weapon() + if(!weapon_type) + return null + for(var/obj/item/held in held_items) + if(istype(held, weapon_type)) + return held + return null -/mob/living/basic/ambient_npc/planet/Life(seconds_per_tick = SSMOBS_DT, times_fired) +/mob/living/carbon/human/npc/ambient_npc/planet/Life(seconds_per_tick = SSMOBS_DT, times_fired) . = ..() - if(!watches || sleeping || stat != CONSCIOUS || fading || !place || HAS_TRAIT(src, TRAIT_AI_PAUSED)) + if(!watches || IsSleeping() || stat != CONSCIOUS || fading || !place || HAS_TRAIT(src, TRAIT_AI_PAUSED)) return watch() @@ -302,23 +312,24 @@ * Looks out for strangers: `players`, or the players near them when null (tests pass their own). * Override. Runs from Life(), so it must not sleep. */ -/mob/living/basic/ambient_npc/planet/proc/watch(list/players) +/mob/living/carbon/human/npc/ambient_npc/planet/proc/watch(list/players) return -/// Wakes them from a nap -/mob/living/basic/ambient_npc/planet/proc/wake_up() - if(!sleeping) +/// Done napping, if they are awake: up off the mat. Sleep itself only ends as anyone's does (tg's +/// Sleeping running out, or someone shaking them awake). +/mob/living/carbon/human/npc/ambient_npc/planet/proc/wake_up() + if(IsSleeping()) return - sleeping = FALSE if(istype(activity, /datum/ambient_activity/nap)) end_activity() -/// Whether they could go after `target`: someone alive and near, never one of their own camp -/mob/living/basic/ambient_npc/planet/proc/can_fight(mob/living/target) - if(!fights_back || QDELETED(target) || !isliving(target) || target.stat == DEAD || target == src) +/// Whether they could go after `target`: someone up and near, never one of their own camp +/mob/living/carbon/human/npc/ambient_npc/planet/proc/can_fight(mob/living/target) + // Someone already down is left there (npc_target_stat): they fight people, they don't finish them + if(!fights_back || QDELETED(target) || !isliving(target) || target.stat > npc_target_stat || target == src) return FALSE - if(istype(target, /mob/living/basic/ambient_npc)) - var/mob/living/basic/ambient_npc/other = target + if(istype(target, /mob/living/carbon/human/npc/ambient_npc)) + var/mob/living/carbon/human/npc/ambient_npc/other = target if(other.place == place) return FALSE var/turf/here = get_turf(src) @@ -326,7 +337,7 @@ return here && there && here.z == there.z && get_dist(here, there) <= PLANET_NPC_CHASE_RANGE /// Goes after `target`, unless already fighting them. Returns the fight, or null. -/mob/living/basic/ambient_npc/planet/proc/engage(mob/living/target) +/mob/living/carbon/human/npc/ambient_npc/planet/proc/engage(mob/living/target) if(!can_fight(target)) return null var/datum/ambient_activity/fight/current = activity @@ -336,36 +347,41 @@ return start_activity(new /datum/ambient_activity/fight(src, target)) /// Everyone at their camp who fights joins them against `target` -/mob/living/basic/ambient_npc/planet/proc/camp_alert(mob/living/target) +/mob/living/carbon/human/npc/ambient_npc/planet/proc/camp_alert(mob/living/target) if(!place) return - for(var/mob/living/basic/ambient_npc/planet/mate in place.living_npcs()) + for(var/mob/living/carbon/human/npc/ambient_npc/planet/mate in place.living_npcs()) if(mate == src || !mate.joins_camp_fights || istype(mate.activity, /datum/ambient_activity/fight)) continue mate.engage(target) -/// One blow at `target`, with their weapon's own numbers and one of its verbs -/mob/living/basic/ambient_npc/planet/proc/strike(mob/living/target) - var/datum/bounty_real_weapon/weapon = bounty_real_weapon(weapon_type) - weapon.pick_verb(src) - melee_attack(target, null, TRUE) +/// One blow at `target`: a click with the weapon in hand (or a fist), through the real attack chain +/mob/living/carbon/human/npc/ambient_npc/planet/proc/strike(mob/living/target) + var/obj/item/weapon = held_weapon() + if(weapon) + human_npc_ready_item(src, weapon) + human_npc_ready_weapon(src, weapon) + else if(!human_npc_ready_empty_hand(src)) + return + zone_selected = human_npc_pick_aim_zone() + ai_controller?.ai_interact(target, combat_mode = TRUE) /// How far they could throw something right now (their spear), or 0 -/mob/living/basic/ambient_npc/planet/proc/throw_reach() +/mob/living/carbon/human/npc/ambient_npc/planet/proc/throw_reach() return 0 /// A try at hitting `target` from where they stand (a thrown spear). TRUE if they did something. -/mob/living/basic/ambient_npc/planet/proc/try_ranged(mob/living/target) +/mob/living/carbon/human/npc/ambient_npc/planet/proc/try_ranged(mob/living/target) return FALSE /// Runs from `threat` -/mob/living/basic/ambient_npc/planet/proc/run_from(atom/threat) +/mob/living/carbon/human/npc/ambient_npc/planet/proc/run_from(atom/threat) if(istype(activity, /datum/ambient_activity/flee)) return activity return start_activity(new /datum/ambient_activity/flee(src, threat)) // Fighters fight and their camp joins in; everyone else says something and gets away -/mob/living/basic/ambient_npc/planet/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/planet/react_attacked(atom/attacker) if(stat != CONSCIOUS || fading) return var/mob/living/foe = isliving(attacker) ? attacker : null @@ -381,7 +397,7 @@ camp_alert(foe) // No sheltering from the weather in the middle of a fight -/mob/living/basic/ambient_npc/planet/react_storm(datum/weather/storm) +/mob/living/carbon/human/npc/ambient_npc/planet/react_storm(datum/weather/storm) if(istype(activity, /datum/ambient_activity/fight) || istype(activity, /datum/ambient_activity/flee)) return return ..() @@ -390,7 +406,7 @@ * Whether `user`'s crew has had `what` from this site yet; marks it if not. TRUE only the first * time for a crew, for as long as the site lasts (the planet's life). */ -/mob/living/basic/ambient_npc/planet/proc/first_for_crew(mob/user, what) +/mob/living/carbon/human/npc/ambient_npc/planet/proc/first_for_crew(mob/user, what) var/datum/ambient_place/site/site = place if(!istype(site) || !user) return FALSE @@ -425,7 +441,7 @@ /// The walk to the target failed too often: they give up var/gave_up = FALSE -/datum/ambient_activity/fight/New(mob/living/basic/ambient_npc/new_doer, atom/target) +/datum/ambient_activity/fight/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/target) . = ..() target_ref = WEAKREF(target) @@ -437,11 +453,12 @@ return target /datum/ambient_activity/fight/setup() - var/mob/living/basic/ambient_npc/planet/fighter = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/fighter = doer var/mob/living/target = target() if(!istype(fighter) || !target || !fighter.can_fight(target)) return FALSE fighter.stand_up() + fighter.set_pace(running = TRUE) fighter.set_held(fighter.weapon_type) next_blow = world.time + PLANET_NPC_FIRST_BLOW_DELAY last_reachable = world.time @@ -450,11 +467,11 @@ /// Heads for the target: near enough to throw for someone who can, right beside them otherwise /datum/ambient_activity/fight/proc/chase(mob/living/target) - var/mob/living/basic/ambient_npc/planet/fighter = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/fighter = doer go_to(target, max(1, fighter.throw_reach())) /datum/ambient_activity/fight/act(seconds) - var/mob/living/basic/ambient_npc/planet/fighter = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/fighter = doer var/mob/living/target = target() if(gave_up || !target || !fighter.can_fight(target)) return AMBIENT_STEP_DONE @@ -486,10 +503,11 @@ arrived = FALSE /datum/ambient_activity/fight/finish() - var/mob/living/basic/ambient_npc/planet/fighter = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/fighter = doer . = ..() if(istype(fighter) && !QDELETED(fighter)) fighter.show_idle_held() + fighter.set_pace(running = FALSE) /// Running from something: well away from it, a breather, and away again if it follows /datum/ambient_activity/flee @@ -500,7 +518,7 @@ /// What they run from var/datum/weakref/threat_ref -/datum/ambient_activity/flee/New(mob/living/basic/ambient_npc/new_doer, atom/threat) +/datum/ambient_activity/flee/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/threat) . = ..() threat_ref = WEAKREF(threat) @@ -510,6 +528,7 @@ set_duration() doer.stand_up() doer.speak_context("flee", threat_ref?.resolve(), force = TRUE) + doer.set_pace(running = TRUE) return TRUE /// Sends them somewhere away from the threat they could get to. FALSE when there is nowhere. @@ -538,6 +557,11 @@ doer.setDir(pick(GLOB.cardinals)) return AMBIENT_STEP_CONTINUE +/datum/ambient_activity/flee/finish() + if(!QDELETED(doer)) + doer.set_pace(running = FALSE) + return ..() + /** * Keeping near someone (a caravan's leader, a hunting partner) until they are gone or it ends. * `keep` is how near; `duration` 0 follows for as long as the leader is there. @@ -548,7 +572,7 @@ /// How near they keep var/keep = 2 -/datum/ambient_activity/follow/New(mob/living/basic/ambient_npc/new_doer, atom/leader, keep = 2, duration = 0) +/datum/ambient_activity/follow/New(mob/living/carbon/human/npc/ambient_npc/new_doer, atom/leader, keep = 2, duration = 0) . = ..() src.keep = keep if(duration) @@ -559,7 +583,7 @@ var/mob/living/leader = anchor() if(!isliving(leader) || leader.stat == DEAD) return null - var/mob/living/basic/ambient_npc/npc_leader = leader + var/mob/living/carbon/human/npc/ambient_npc/npc_leader = leader if(istype(npc_leader) && npc_leader.fading) return null return leader @@ -693,7 +717,7 @@ . = ..() if(QDELETED(doer)) return - var/mob/living/basic/ambient_npc/planet/planet_npc = doer + var/mob/living/carbon/human/npc/ambient_npc/planet/planet_npc = doer if(istype(planet_npc)) planet_npc.show_idle_held() else if(held_look) @@ -737,25 +761,25 @@ best_distance = distance return best +// They lie down and go to sleep, as anyone does (tg's resting and Sleeping): asleep they snore by +// themselves, see nothing, and wake when the sleep runs out or someone shakes them. /datum/ambient_activity/nap/arrive() - var/mob/living/basic/ambient_npc/planet/sleeper = doer doer.set_held(null) doer.manual_emote("lies down.") - animate(doer, transform = matrix().Turn(90), time = 0.5 SECONDS) - if(istype(sleeper)) - sleeper.sleeping = TRUE + doer.set_resting(TRUE, silent = TRUE) + doer.Sleeping(max(1 SECONDS, ends_at - world.time)) +// Awake again before it is over (shaken, or the sleep ran out early): a turn over, and up /datum/ambient_activity/nap/act(seconds) - if(prob(4)) - doer.manual_emote(pick("snores.", "turns over.")) + if(!doer.IsSleeping()) + if(prob(30)) + doer.manual_emote("turns over.") + return AMBIENT_STEP_DONE return AMBIENT_STEP_CONTINUE /datum/ambient_activity/nap/finish() - var/mob/living/basic/ambient_npc/planet/sleeper = doer - if(istype(sleeper) && !QDELETED(sleeper)) - sleeper.sleeping = FALSE - if(sleeper.stat != DEAD) - animate(sleeper, transform = matrix(), time = 0.5 SECONDS) + if(!QDELETED(doer) && doer.stat == CONSCIOUS) + doer.set_resting(FALSE, silent = TRUE) return ..() // ========================================================================= diff --git a/voidcrew/modules/ambient_npcs/recruit.dm b/voidcrew/modules/ambient_npcs/recruit.dm index e4b9a61554d..4f092de56de 100644 --- a/voidcrew/modules/ambient_npcs/recruit.dm +++ b/voidcrew/modules/ambient_npcs/recruit.dm @@ -5,7 +5,7 @@ * * Owner decision D1: recruits are AI deckhands only. No ghost poll, no ghost role, no body swap. * - * A stray (/mob/living/basic/ambient_npc/stray: the stranded survivor, the lifeboat survivor, a + * A stray (/mob/living/carbon/human/npc/ambient_npc/stray: the stranded survivor, the lifeboat survivor, a * convict who was talked down) goes through three states: * - wild: where they were found. An empty hand from a member of a ship crew is a plea and a * choice, "Come with us" or "Not now". They wave down crew members they see. @@ -20,7 +20,9 @@ * AMBIENT_RECRUITS_PER_SHIP_ROUND taken aboard in a round. A stray taken aboard spends the site they * came from, so it never brings them back. * - * Nothing here pays anything, and a deckhand drops nothing when killed. + * A stray rescued from being stranded (`gives_gifts`: the crashed pod and lifeboat survivors) + * hands whoever brought them aboard a random ship part, then every AMBIENT_STRAY_GIFT_LOW to _HIGH + * walks up to someone of the crew with another, up to AMBIENT_STRAY_GIFTS_MAX. Nothing else here pays anything, and a deckhand drops nothing when killed. */ /// Where they were found, open to an offer @@ -57,8 +59,8 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) * Lets a crew member ask `stray` to come with them: opens a stray to offers, and the flow above * runs from their talked_to(). Returns TRUE for a stray, FALSE for anyone else. */ -/proc/ambient_offer_recruit(mob/living/basic/ambient_npc/stray) - var/mob/living/basic/ambient_npc/stray/recruit = stray +/proc/ambient_offer_recruit(mob/living/carbon/human/npc/ambient_npc/stray) + var/mob/living/carbon/human/npc/ambient_npc/stray/recruit = stray if(!istype(recruit) || QDELETED(recruit)) return FALSE recruit.recruit_open = TRUE @@ -135,7 +137,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) /// The stray on their way aboard, if they still are /datum/ambient_place/ship/proc/pending() - var/mob/living/basic/ambient_npc/stray/stray = pending_ref?.resolve() + var/mob/living/carbon/human/npc/ambient_npc/stray/stray = pending_ref?.resolve() if(QDELETED(stray) || stray.stat == DEAD || !stray.is_following()) pending_ref = null return null @@ -160,10 +162,10 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) return istype(outpost) ? outpost : null // Aboard, and nowhere else -/datum/ambient_place/ship/leash_ok(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/ship/leash_ok(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) return aboard(tile) -/datum/ambient_place/ship/spot_allowed(turf/tile, mob/living/basic/ambient_npc/npc) +/datum/ambient_place/ship/spot_allowed(turf/tile, mob/living/carbon/human/npc/ambient_npc/npc) return aboard(tile) /datum/ambient_place/ship/describe() @@ -172,7 +174,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) /// The ship is gone: so are the strays who lived on it /datum/ambient_place/ship/proc/on_ship_deleted(datum/source) SIGNAL_HANDLER - for(var/mob/living/basic/ambient_npc/npc as anything in npcs.Copy()) + for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in npcs.Copy()) if(!QDELETED(npc) && !npc.ckey) npc.fade_out(instant = TRUE) qdel(src) @@ -186,12 +188,11 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) * (not even cash: a crew's own deckhands are no purse). * Subtypes set their look, their dialogue section and what they do while wild. */ -/mob/living/basic/ambient_npc/stray +/mob/living/carbon/human/npc/ambient_npc/stray name = "stranded spacer" desc = "Someone a long way from anywhere." death_cash_low = 0 death_cash_high = 0 - rotate_on_lying = TRUE dialogue_file = AMBIENT_STRINGS_STRAYS dialogue_section = "stranded" routine = list( @@ -216,6 +217,12 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) /// Tries left to walk back aboard, and when the next may start var/rehome_tries = 0 var/rehome_at = 0 + /// Rescued from being stranded: they thank the crew with ship parts + var/gives_gifts = FALSE + /// Gifts handed over since they came aboard, the first one included + var/gifts_given = 0 + /// world.time their next gift is due + var/next_gift_at = 0 /// What they do aboard as a deckhand var/list/deckhand_routine = list( /datum/ambient_activity/idle = 2, @@ -228,27 +235,13 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) /datum/ambient_activity/stray_crew = 1, ) -/** - * `identity` (from stray_identity()) makes them the same person they were on an earlier visit: - * list("name", "gender", "outfit", "look"). - */ -/mob/living/basic/ambient_npc/stray/Initialize(mapload, list/identity) - if(islist(identity)) - if(identity["gender"] == MALE || identity["gender"] == FEMALE) - random_gender = FALSE - gender = identity["gender"] - if(ispath(identity["outfit"], /datum/outfit)) - outfit_choices = null - outfit = identity["outfit"] - if(isnum(identity["look"])) - look_number = identity["look"] +// An identity from stray_identity(), riding in the spawn order (ambient_build_npc()), makes them the +// same person they were on an earlier visit: the base takes it (take_identity()). +/mob/living/carbon/human/npc/ambient_npc/stray/Initialize(mapload, datum/human_npc_order/order) origin_section = dialogue_section - . = ..() - if(islist(identity) && istext(identity["name"])) - name = identity["name"] - real_name = name + return ..() -/mob/living/basic/ambient_npc/stray/Destroy() +/mob/living/carbon/human/npc/ambient_npc/stray/Destroy() release_pending() leader_ref = null ship_ref = null @@ -256,36 +249,36 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) return ..() /// Who they are, to make the same person again later -/mob/living/basic/ambient_npc/stray/proc/stray_identity() - return list("name" = real_name, "gender" = gender, "outfit" = outfit, "look" = look_number) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/stray_identity() + return ambient_identity() -/mob/living/basic/ambient_npc/stray/proc/is_wild() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/is_wild() return stray_state == STRAY_WILD -/mob/living/basic/ambient_npc/stray/proc/is_following() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/is_following() return stray_state == STRAY_FOLLOWING -/mob/living/basic/ambient_npc/stray/proc/is_deckhand() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/is_deckhand() return stray_state == STRAY_DECKHAND /// The ship they joined, if it is still there -/mob/living/basic/ambient_npc/stray/proc/get_ship() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/get_ship() var/obj/structure/overmap/ship/ship = ship_ref?.resolve() return QDELETED(ship) ? null : ship /// Who they follow, if anyone -/mob/living/basic/ambient_npc/stray/proc/get_leader() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/get_leader() var/mob/living/leader = leader_ref?.resolve() return QDELETED(leader) ? null : leader /// Frees their ship's place for someone else, if they were on their way aboard -/mob/living/basic/ambient_npc/stray/proc/release_pending() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/release_pending() var/obj/structure/overmap/ship/ship = get_ship() var/datum/ambient_place/ship/deck = ship && ambient_ship_place(ship, create = FALSE) if(deck && deck.pending_ref?.resolve() == src) deck.pending_ref = null -/mob/living/basic/ambient_npc/stray/Life(seconds_per_tick = SSMOBS_DT, times_fired) +/mob/living/carbon/human/npc/ambient_npc/stray/Life(seconds_per_tick = SSMOBS_DT, times_fired) . = ..() if(stat != CONSCIOUS || fading || QDELETED(src)) return @@ -297,12 +290,16 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) lose_leader() else if(deck.aboard(get_turf(src))) board(ship) + if(STRAY_DECKHAND) + if(gives_gifts && gifts_given <= AMBIENT_STRAY_GIFTS_MAX && world.time >= next_gift_at) + next_gift_at = world.time + AMBIENT_STRAY_GIFT_RETRY + start_activity(new /datum/ambient_activity/stray_crew/gift(src)) if(STRAY_WILD) if(recruit_open && world.time >= next_look) next_look = world.time + STRAY_LOOK_INTERVAL look_for_crew() -/mob/living/basic/ambient_npc/stray/death(gibbed) +/mob/living/carbon/human/npc/ambient_npc/stray/death(gibbed) . = ..() release_pending() leader_ref = null @@ -310,14 +307,14 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) // ----- leash ----- // Following, they go wherever their leader leads that they can cross; aboard, the ship's place keeps them there -/mob/living/basic/ambient_npc/stray/leash_ok(turf/tile) +/mob/living/carbon/human/npc/ambient_npc/stray/leash_ok(turf/tile) if(stray_state == STRAY_FOLLOWING && istype(activity, /datum/ambient_activity/stray_follow)) tile = get_turf(tile) return !!tile?.can_cross_safely(src) return ..() // A deckhand off their ship walks back aboard if they can, a couple of times, before they give up -/mob/living/basic/ambient_npc/stray/give_up_leash() +/mob/living/carbon/human/npc/ambient_npc/stray/give_up_leash() if(stray_state == STRAY_DECKHAND && rehome_tries < STRAY_REHOME_TRIES) if(world.time < rehome_at) return @@ -332,7 +329,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) return ..() // Aboard, where they are now is home -/mob/living/basic/ambient_npc/stray/pick_activity() +/mob/living/carbon/human/npc/ambient_npc/stray/pick_activity() if(stray_state == STRAY_DECKHAND && leash_ok(loc)) home = get_turf(src) rehome_tries = 0 @@ -340,7 +337,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) // ----- talking ----- -/mob/living/basic/ambient_npc/stray/talked_to(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/stray/talked_to(mob/living/user) if(!talk_ready(user)) return if(!buckled) @@ -358,7 +355,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) INVOKE_ASYNC(src, PROC_REF(crew_menu), user) /// "Come with us" or "Not now", for `user` of `ship`. Sleeps. -/mob/living/basic/ambient_npc/stray/proc/offer_menu(mob/living/user, obj/structure/overmap/ship/ship) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/offer_menu(mob/living/user, obj/structure/overmap/ship/ship) var/list/choices = list( "Come with us" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_yes"), "Not now" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_no"), @@ -374,7 +371,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) speak_context("refused", user, force = TRUE) /// Follow or stay, and at a trader outpost, going their own way: for `user` of their ship. Sleeps. -/mob/living/basic/ambient_npc/stray/proc/crew_menu(mob/living/user) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/crew_menu(mob/living/user) var/list/choices = list() if(get_leader() == user && istype(activity, /datum/ambient_activity/stray_follow)) choices["Stay here"] = image(icon = 'icons/hud/radial.dmi', icon_state = "guard") @@ -402,7 +399,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) * They agree to join `ship`, and follow `leader` there. Returns TRUE, or FALSE if they can't now * (not wild, not open to offers, or the ship has no room). */ -/mob/living/basic/ambient_npc/stray/proc/accept_offer(mob/living/leader, obj/structure/overmap/ship/ship) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/accept_offer(mob/living/leader, obj/structure/overmap/ship/ship) if(stray_state != STRAY_WILD || !recruit_open || !can_act() || QDELETED(ship) || QDELETED(leader)) return FALSE var/datum/ambient_place/ship/deck = ambient_ship_place(ship) @@ -411,30 +408,31 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) ship_ref = WEAKREF(ship) deck.pending_ref = WEAKREF(src) stray_state = STRAY_FOLLOWING - // Out through the airlock and across to the ship, whatever lies between - ADD_TRAIT(src, TRAIT_SPACEWALK, STRAY_TRAIT) + // Out through the airlock and across to the ship: their jet harness on, if they wear one + ready_for_space(TRUE) if(!start_follow(leader)) release_pending() ship_ref = null leader_ref = null stray_state = STRAY_WILD - REMOVE_TRAIT(src, TRAIT_SPACEWALK, STRAY_TRAIT) + ready_for_space(FALSE) return FALSE exempt_npc_zone() + note_rescue_zone() on_joined(ship) speak_context("accepted", leader, force = TRUE) log_game("AMBIENT: [src] agreed to join the [ship.name], asked by [key_name(leader)] at [AREACOORD(src)]") return TRUE /// They agreed to join `ship`. Override (the lifeboat stops calling). -/mob/living/basic/ambient_npc/stray/proc/on_joined(obj/structure/overmap/ship/ship) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/on_joined(obj/structure/overmap/ship/ship) return /** * They stepped aboard `ship`: a deckhand now, living there under its place, and the site they came * from is spent. Returns TRUE. */ -/mob/living/basic/ambient_npc/stray/proc/board(obj/structure/overmap/ship/ship) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/board(obj/structure/overmap/ship/ship) var/datum/ambient_place/ship/deck = ambient_ship_place(ship) if(!deck) return FALSE @@ -447,7 +445,9 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) old_site.data["taken"] = TRUE old_site.state = AMBIENT_SITE_SPENT stray_state = STRAY_DECKHAND + ready_for_space(FALSE) ship_ref = WEAKREF(ship) + var/mob/living/leader = get_leader() leader_ref = null end_activity() set_place(deck) @@ -455,21 +455,59 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) leash_bounds = null routine = deckhand_routine.Copy() dialogue_section = "deckhand" - var/list/lines = ambient_dialogue_lines(dialogue_file, origin_section, "aboard") - if(length(lines)) - say_line(fill_line(pick(lines))) log_game("AMBIENT: [src] came aboard the [ship.name] as a deckhand") + note_rescuers(ship) + // Rescued: a thank-you for whoever brought them in, and more now and then + var/context = "aboard" + if(gives_gifts && !gifts_given) + give_gift(leader) + context = "rescued" + var/list/lines = ambient_dialogue_lines(dialogue_file, origin_section, context) + if(length(lines)) + say_line(fill_line(pick(lines), leader)) return TRUE +/// Their jet harness on for the crossing to a ship, or off again, if they wear one (as a person works their own) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/ready_for_space(on) + var/obj/item/tank/jetpack/harness = back + if(!istype(harness) || harness.on == !!on) + return + if(on) + harness.turn_on(src) + else + harness.turn_off(src) + +/** + * Hands `receiver` a random ship part, or sets it down if they are out of reach or their hands are + * full, and schedules the next one. Returns the part. + */ +/mob/living/carbon/human/npc/ambient_npc/stray/proc/give_gift(mob/living/receiver) + var/static/list/part_types = list( + /obj/item/ship_parts/combat, + /obj/item/ship_parts/science, + /obj/item/ship_parts/trade, + /obj/item/ship_parts/misc, + ) + var/part_type = pick(part_types) + var/obj/item/ship_parts/part = new part_type(drop_location()) + gifts_given++ + next_gift_at = world.time + rand(AMBIENT_STRAY_GIFT_LOW, AMBIENT_STRAY_GIFT_HIGH) + if(!QDELETED(receiver) && get_dist(src, receiver) <= 2 && receiver.put_in_hands(part)) + manual_emote("hands [receiver] [part].") + else + manual_emote("sets down [part].") + log_game("AMBIENT: [src] gave [part] ([gifts_given] given) to [receiver ? key_name(receiver) : "nobody"] at [AREACOORD(src)]") + return part + /// They follow `leader` from now on. Returns the activity, or null. -/mob/living/basic/ambient_npc/stray/proc/start_follow(mob/living/leader) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/start_follow(mob/living/leader) if(QDELETED(leader)) return null leader_ref = WEAKREF(leader) return start_activity(new /datum/ambient_activity/stray_follow(src, leader)) /// They stop following whoever it was -/mob/living/basic/ambient_npc/stray/proc/stop_follow() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/stop_follow() leader_ref = null if(istype(activity, /datum/ambient_activity/stray_follow)) end_activity() @@ -478,20 +516,25 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) * Their leader is out of reach. A follower on the way to a ship goes back to being wild (and walks * back to where they were found, or slips away); a deckhand stays where they are. */ -/mob/living/basic/ambient_npc/stray/proc/lose_leader() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/lose_leader() leader_ref = null if(stray_state == STRAY_FOLLOWING) release_pending() ship_ref = null stray_state = STRAY_WILD - REMOVE_TRAIT(src, TRAIT_SPACEWALK, STRAY_TRAIT) + ready_for_space(FALSE) if(istype(activity, /datum/ambient_activity/stray_follow)) end_activity() speak_context("lost", null, force = TRUE) /// Let go at a trader outpost by `user`: a goodbye, and they are off -/mob/living/basic/ambient_npc/stray/proc/dismiss(mob/living/user) - speak_context("dismissed", user, force = TRUE) +/mob/living/carbon/human/npc/ambient_npc/stray/proc/dismiss(mob/living/user) + // Rescued and brought somewhere safe: their crew is owed for it (rescue_reputation.dm) + var/list/lines = claim_rescue(user) ? ambient_dialogue_lines(dialogue_file, origin_section, "delivered") : null + if(length(lines)) + say_line(fill_line(pick(lines), user)) + else + speak_context("dismissed", user, force = TRUE) var/obj/structure/overmap/ship/ship = get_ship() log_game("AMBIENT: [src] left the [ship?.name || "ship"], let go by [key_name(user)]") start_activity(new /datum/ambient_activity/leave(src, null, 3 SECONDS)) @@ -499,7 +542,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) // ----- reactions ----- // Hit by the crew they are following, they stop following -/mob/living/basic/ambient_npc/stray/react_attacked(atom/attacker) +/mob/living/carbon/human/npc/ambient_npc/stray/react_attacked(atom/attacker) var/was_ready = LAZYACCESS(reaction_cooldowns, "attacked") <= world.time . = ..() if(!was_ready || stat != CONSCIOUS || stray_state != STRAY_FOLLOWING) @@ -511,7 +554,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) // ----- waving crew down ----- /// Waves down a crew member they can see and have not greeted yet -/mob/living/basic/ambient_npc/stray/proc/look_for_crew() +/mob/living/carbon/human/npc/ambient_npc/stray/proc/look_for_crew() if(!can_act() || activity?.priority >= AMBIENT_PRIORITY_REACTION) return FALSE var/turf/here = get_turf(src) @@ -568,7 +611,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) return FALSE /datum/ambient_activity/stray_follow/act(seconds) - var/mob/living/basic/ambient_npc/stray/stray = doer + var/mob/living/carbon/human/npc/ambient_npc/stray/stray = doer var/mob/living/leader = anchor() if(!in_reach(leader)) if(!lost_since) @@ -783,7 +826,7 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) var/spoke = FALSE /datum/ambient_activity/stray_crew/setup() - var/mob/living/basic/ambient_npc/stray/stray = doer + var/mob/living/carbon/human/npc/ambient_npc/stray/stray = doer var/obj/structure/overmap/ship/ship = istype(stray) ? stray.get_ship() : null var/turf/here = get_turf(doer) if(!ship || !here) @@ -826,6 +869,26 @@ GLOBAL_LIST_EMPTY(ambient_ship_places) ends_at = min(ends_at, world.time + 6 SECONDS) return AMBIENT_STEP_CONTINUE +/// Walking up to someone of the crew with a ship part for them +/datum/ambient_activity/stray_crew/gift + name = "bringing the crew something" + duration_low = 40 SECONDS + duration_high = 40 SECONDS + +/datum/ambient_activity/stray_crew/gift/act(seconds) + var/mob/living/person = anchor() + var/mob/living/carbon/human/npc/ambient_npc/stray/stray = doer + if(!istype(stray) || !person || person.stat != CONSCIOUS || get_dist(doer, person) > 1) + return AMBIENT_STEP_DONE + if(!doer.buckled) + doer.face_atom(person) + if(!spoke) + spoke = TRUE + stray.give_gift(person) + doer.speak_context("gift", person, force = TRUE) + ends_at = min(ends_at, world.time + 6 SECONDS) + return AMBIENT_STEP_CONTINUE + #undef STRAY_WILD #undef STRAY_FOLLOWING #undef STRAY_DECKHAND diff --git a/voidcrew/modules/ambient_npcs/rescue_reputation.dm b/voidcrew/modules/ambient_npcs/rescue_reputation.dm new file mode 100644 index 00000000000..48ece9cb7b7 --- /dev/null +++ b/voidcrew/modules/ambient_npcs/rescue_reputation.dm @@ -0,0 +1,83 @@ +/** + * # Rescued strays raise the crew's reputation when let go at an outpost + * + * A stray rescued from being stranded (`gives_gifts`: the crashed pod and lifeboat survivors) remembers + * the overmap zone they were found in, and the ship and crew that took them aboard. Let go at a trader + * outpost by that ship's crew ("Time to go" in crew_menu(), which runs dismiss()), the rescuing crew gets + * reputation through claim_zone_work(): green pays as easy work (1), yellow as medium (2), red as hard + * (3), under the same zone ceilings as every other job. + * + * Abuse limits: + * - Once per person: `rescue_paid`, plus a one-off receipt key on the crew record. + * - Only strays who came from a real stranded site (their place was a site when they agreed to come), + * so a stray made outside the site flow (admin spawn, tests) pays nothing. + * - The zone is their site's (where the pod or lifeboat came down), not where they stand, and is fixed once + * they come aboard. + * - Only the crew that took them aboard is paid, and only while the ship they live on is still that + * crew's. Nobody else can let a deckhand go (crew_menu() checks the ship). + * - Living and able to walk off only (can_act()); a body pays nothing. Never while a player controls them. + */ + +/// Hands out one-off receipt keys for rescues +GLOBAL_VAR_INIT(ambient_rescue_serial, 0) + +/mob/living/carbon/human/npc/ambient_npc/stray + /// The overmap zone (ZONE_GREEN/YELLOW/RED) they were found in, or null if they were never rescued + var/rescue_zone + /// The ship that took them aboard + var/datum/weakref/rescue_ship_ref + /// The crew record of that ship when they came aboard + var/datum/weakref/rescue_crew_ref + /// The receipt key their drop-off is claimed under + var/rescue_receipt + /// Their drop-off has paid out (or was spent) + var/rescue_paid = FALSE + +/// The zone of the site they were found at: its centre's overmap tile now (a lifeboat drifts), else the band it was +/// made in. Never where they stand, so dragging them off somewhere worse before asking them along changes nothing. +/mob/living/carbon/human/npc/ambient_npc/stray/proc/rescue_site_zone(datum/ambient_place/site/site) + var/zone = get_zone_type_for_player_turf(site.center) + if(!(zone in list(ZONE_GREEN, ZONE_YELLOW, ZONE_RED))) + zone = site.band + return (zone in list(ZONE_GREEN, ZONE_YELLOW, ZONE_RED)) ? zone : null + +/// They agreed to come along: a rescued stray from a stranded site remembers the zone they were found in +/mob/living/carbon/human/npc/ambient_npc/stray/proc/note_rescue_zone() + var/datum/ambient_place/site/site = place + if(!gives_gifts || rescue_receipt || !istype(site)) + return + rescue_zone = rescue_site_zone(site) + +/// They came aboard `ship`: the ship and its crew are who rescued them +/mob/living/carbon/human/npc/ambient_npc/stray/proc/note_rescuers(obj/structure/overmap/ship/ship) + if(!gives_gifts || !rescue_zone || rescue_receipt || QDELETED(ship)) + return + var/datum/voidcrew_crew_record/crew = ship.get_crew_record() + if(QDELETED(crew)) + return + rescue_ship_ref = WEAKREF(ship) + rescue_crew_ref = WEAKREF(crew) + rescue_receipt = "rescue:[++GLOB.ambient_rescue_serial]" + +/** + * Let go by `user` at the trader outpost their ship is docked at: the crew that rescued them gets + * reputation for the zone they were found in. Returns the reputation added (0 for none). Never yields. + */ +/mob/living/carbon/human/npc/ambient_npc/stray/proc/claim_rescue(mob/living/user) + if(rescue_paid || !rescue_receipt || !rescue_zone || ckey || stat != CONSCIOUS || !is_deckhand()) + return 0 + var/obj/structure/overmap/ship/ship = get_ship() + if(!ship || rescue_ship_ref?.resolve() != ship) + return 0 + var/datum/ambient_place/ship/deck = place + if(!istype(deck) || !deck.docked_outpost()) + return 0 + var/datum/voidcrew_crew_record/crew = rescue_crew_ref?.resolve() + if(QDELETED(crew) || ship.get_crew_record() != crew) + return 0 + rescue_paid = TRUE + var/permission_before = crew.get_zone_permission() + var/gain = crew.claim_zone_work(rescue_zone, rescue_zone, rescue_receipt) + record_zone_work_rows(ship, type, rescue_zone, zone_work_reward(rescue_zone), gain, permission_before, crew.get_zone_permission(), user, list("rescue" = real_name)) + log_game("AMBIENT: [src], rescued in zone [rescue_zone], was let go at an outpost by [key_name(user)] of the [ship.name]: +[gain] reputation") + return gain diff --git a/voidcrew/modules/ambient_npcs/stranded.dm b/voidcrew/modules/ambient_npcs/stranded.dm index bb0efe1d8a7..c8b908ee16d 100644 --- a/voidcrew/modules/ambient_npcs/stranded.dm +++ b/voidcrew/modules/ambient_npcs/stranded.dm @@ -31,7 +31,7 @@ ) chance = 5 spot_room = 2 - npc_type = /mob/living/basic/ambient_npc/stray/stranded + npc_type = /mob/living/carbon/human/npc/ambient_npc/stray/stranded // Oceanic planets see a few more pods come down (spec 4.1) /datum/ambient_site_kind/stranded/chance_on(datum/ambient_planet/record) @@ -44,10 +44,9 @@ return FALSE var/atom/fire = build_camp(site) var/turf/where = (fire && ambient_free_turf_near(get_turf(fire), 1)) || ambient_free_turf_near(site.center, 2) || site.center - var/mob/living/basic/ambient_npc/stray/stranded/npc = new npc_type(where, site.data["identity"]) - npc.set_place(site) - npc.scale_health(site.health_multiplier()) - site.state = AMBIENT_SITE_ACTIVE + var/mob/living/carbon/human/npc/ambient_npc/stray/stranded/npc = site.spawn_npc(npc_type, where, site.data["identity"]) + if(!npc) + return FALSE site.data["identity"] = npc.stray_identity() ambient_offer_recruit(npc) return TRUE @@ -158,15 +157,13 @@ // THE STRANDED SURVIVOR // ========================================================================= -/mob/living/basic/ambient_npc/stray/stranded +/mob/living/carbon/human/npc/ambient_npc/stray/stranded name = "stranded spacer" desc = "Sunburnt, grubby and very glad to see a ship." - outfit_choices = list( - /datum/outfit/ambient_stranded, - /datum/outfit/ambient_stranded/hauler, - /datum/outfit/ambient_stranded/engineer, - ) + outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/ambient_stranded dialogue_section = "stranded" + gives_gifts = TRUE routine = list( /datum/ambient_activity/stray_fire = 3, /datum/ambient_activity/stray_flare = 1, @@ -194,6 +191,20 @@ uniform = /obj/item/clothing/under/rank/engineering/engineer suit = null +/// Further out, an armour vest pulled off the wreck +/datum/outfit/ambient_stranded/vest + name = "Stranded Spacer in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ambient_stranded/hauler/vest + name = "Stranded Hauler in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/// The stranded by band (AM-1) +/datum/human_npc_outfit_table/ambient_stranded + green = list(/datum/outfit/ambient_stranded = 1, /datum/outfit/ambient_stranded/hauler = 1, /datum/outfit/ambient_stranded/engineer = 1) + red = list(/datum/outfit/ambient_stranded/vest = 1, /datum/outfit/ambient_stranded/hauler/vest = 1) + // ========================================================================= // WHAT THEY DO // ========================================================================= diff --git a/voidcrew/modules/ambient_npcs/stranded_lifeboat.dm b/voidcrew/modules/ambient_npcs/stranded_lifeboat.dm index 24bf0a15020..35909af1c6d 100644 --- a/voidcrew/modules/ambient_npcs/stranded_lifeboat.dm +++ b/voidcrew/modules/ambient_npcs/stranded_lifeboat.dm @@ -105,7 +105,7 @@ quiet_timer = null clear_fleet_waypoint() QDEL_NULL(radio) - var/mob/living/basic/ambient_npc/stray/lifeboat/survivor = survivor_ref?.resolve() + var/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat/survivor = survivor_ref?.resolve() if(survivor) survivor.lifeboat_ref = null survivor_ref = null @@ -143,7 +143,7 @@ /// Its survivor on their landmark, unless they are already out /obj/structure/overmap/space_ruin/ambient_lifeboat/proc/place_survivor() - var/mob/living/basic/ambient_npc/stray/lifeboat/survivor = survivor_ref?.resolve() + var/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat/survivor = survivor_ref?.resolve() if(!QDELETED(survivor) && survivor.stat != DEAD) return survivor var/turf/spot = survivor_spot() @@ -167,7 +167,9 @@ /// Its survivor at `spot`: the same person as before, leashed to the lifeboat until a crew takes them /obj/structure/overmap/space_ruin/ambient_lifeboat/proc/spawn_survivor(turf/spot) - var/mob/living/basic/ambient_npc/stray/lifeboat/survivor = new(spot, survivor_identity) + var/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat/survivor = ambient_build_npc(/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat, spot, null, survivor_identity) + if(QDELETED(survivor)) + return null survivor_identity = survivor.stray_identity() survivor.lifeboat_ref = WEAKREF(src) survivor.leash_bounds = interior_bounds() @@ -247,11 +249,12 @@ // THE SURVIVOR // ========================================================================= -/mob/living/basic/ambient_npc/stray/lifeboat +/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat name = "lifeboat survivor" desc = "Pale, thin and still in an emergency softsuit." outfit = /datum/outfit/ambient_lifeboat dialogue_section = "lifeboat" + gives_gifts = TRUE routine = list( /datum/ambient_activity/stray_rest = 2, /datum/ambient_activity/lifeboat_canister = 1, @@ -263,17 +266,12 @@ /// The lifeboat they were found on var/datum/weakref/lifeboat_ref -/mob/living/basic/ambient_npc/stray/lifeboat/Initialize(mapload, list/identity) - . = ..() - // Their softsuit: across open space to a ship, and about a ship's hull - ADD_TRAIT(src, TRAIT_SPACEWALK, AMBIENT_NPC_TRAIT) - -/mob/living/basic/ambient_npc/stray/lifeboat/Destroy() +/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat/Destroy() lifeboat_ref = null return ..() // Killed where they were found, they are not there again on the next load -/mob/living/basic/ambient_npc/stray/lifeboat/death(gibbed) +/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat/death(gibbed) var/was_alive = stat != DEAD . = ..() if(!was_alive || !is_wild()) @@ -282,16 +280,20 @@ if(!QDELETED(lifeboat)) lifeboat.survivor_died() -/mob/living/basic/ambient_npc/stray/lifeboat/on_joined(obj/structure/overmap/ship/ship) +/mob/living/carbon/human/npc/ambient_npc/stray/lifeboat/on_joined(obj/structure/overmap/ship/ship) var/obj/structure/overmap/space_ruin/ambient_lifeboat/lifeboat = lifeboat_ref?.resolve() if(!QDELETED(lifeboat)) lifeboat.survivor_joined() -/// A freighter hand in the softsuit from the lifeboat's locker +/// A freighter hand in the softsuit from the lifeboat's locker, with its helmet and a jet harness of air: enough to cross to a ship /datum/outfit/ambient_lifeboat name = "Lifeboat Survivor" uniform = /obj/item/clothing/under/color/grey suit = /obj/item/clothing/suit/space/fragile + head = /obj/item/clothing/head/helmet/space/fragile + mask = /obj/item/clothing/mask/breath + back = /obj/item/tank/jetpack/oxygen/harness + internals_slot = ITEM_SLOT_BACK shoes = /obj/item/clothing/shoes/sneakers/black /// Tapping the gauge on the air canister diff --git a/voidcrew/modules/ambient_npcs/strings/strays.json b/voidcrew/modules/ambient_npcs/strings/strays.json index 04f410e5048..fc60875d3e8 100644 --- a/voidcrew/modules/ambient_npcs/strings/strays.json +++ b/voidcrew/modules/ambient_npcs/strings/strays.json @@ -31,6 +31,14 @@ "Real walls. I forgot what walls looked like.", "It smells like people in here. I missed that." ], + "rescued": [ + "Real walls. I forgot what walls looked like. Here, {other}, I pulled this out of the pod. It's yours.", + "Thank you. I mean it. Take this, it's the only thing off the pod worth keeping." + ], + "delivered": [ + "Solid ground. I'll tell them who pulled me out of that pod.", + "Thanks, {other}. They'll hear your crew's name from me." + ], "fire": [ "Don't you go out on me.", "Last of the dry stuff." @@ -81,6 +89,14 @@ "Gravity. Real air. Thank you.", "I'm never getting in one of those things again." ], + "rescued": [ + "Gravity. Real air. Here, {other}, take this. It came off the lifeboat.", + "I owe you. Start with this." + ], + "delivered": [ + "A real dock. Thank you, {other}.", + "Everyone here's going to hear who picked up that lifeboat." + ], "canister": [ "Still a third. Still a third.", "Come on, hold." @@ -167,6 +183,11 @@ "{other}, where are we off to next?", "You look tired, {other}." ], + "gift": [ + "{other}. Found this behind a panel. Figured you'd want it.", + "Here, {other}. I fixed it up in my spare time.", + "For you, {other}. Still paying off that rescue." + ], "wash": [ "Hot water. Unbelievable." ], diff --git a/voidcrew/modules/antag_ruins/ascension_mech.dm b/voidcrew/modules/antag_ruins/ascension_mech.dm index b8fab656eb6..52619b45dba 100644 --- a/voidcrew/modules/antag_ruins/ascension_mech.dm +++ b/voidcrew/modules/antag_ruins/ascension_mech.dm @@ -2037,7 +2037,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/safeties name = "Cut the Safeties" - desc = "Strip the target's safety interlocks. Exactly what an emag would do to it, which depends entirely on what it is." + desc = "Strip the target's safety interlocks." radial_icon = 'icons/obj/card.dmi' radial_icon_state = "emag" cooldown = 25 SECONDS @@ -2070,7 +2070,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /datum/machine_quickhack/bolts name = "Bolts" - desc = "Throw the door's bolts, or pull them if they are already down. Works on the AI wire, so a cut door will not answer." + desc = "Throw the door's bolts, or pull them if they are already down." radial_icon_state = "lockdown" cooldown = 8 SECONDS @@ -2105,8 +2105,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/electrify name = "Electrify" - desc = "Run the door's frame live for 30 seconds. It draws on your own current rather than the area's, so a \ - nearly-empty APC still lands the shock. Touching it without insulated gloves is 20 burn and a stun." + desc = "Run the door's frame live for 30 seconds. Touching it without insulated gloves is 20 burn and a stun." radial_icon = 'icons/obj/machines/wallmounts.dmi' radial_icon_state = "apc-spark" cooldown = 20 SECONDS @@ -2171,7 +2170,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /datum/machine_quickhack/kill_power name = "Kill the Power" - desc = "Flip an APC's breaker. Lights, equipment and air handling across that whole area go dead until it is flipped back." + desc = "Flip an APC's breaker. Lights, equipment and air handling across that whole area go dead." radial_icon = 'icons/obj/machines/wallmounts.dmi' radial_icon_state = "apc0" cooldown = 30 SECONDS @@ -2199,7 +2198,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/burst_lights name = "Burst the Lights" - desc = "Push an APC's lighting circuit until every bulb in its area breaks. They stay broken." + desc = "Push an APC's lighting circuit until every bulb in its area breaks." radial_icon_state = "blackout" cooldown = 30 SECONDS @@ -2239,7 +2238,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/feedback_surge name = "Feedback Surge" - desc = "Dump everything a person's gear is holding into the person. Empties every power cell they are carrying and shocks them for up to 60 burn, scaled to how much charge there was." + desc = "Empty every power cell a person is carrying through the person, for up to 60 burn." radial_icon = 'icons/obj/weapons/baton.dmi' radial_icon_state = "stunbaton_active" cooldown = 60 SECONDS @@ -2277,7 +2276,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/cutout name = "Cutout" - desc = "Quietly empty every power cell a person is carrying and switch their radios off for 45 seconds. No shock and no stun; their gear just stops." + desc = "Quietly empty every power cell a person is carrying and switch their radios off for 45 seconds." radial_icon = 'icons/obj/weapons/baton.dmi' radial_icon_state = "stunbaton_nocell" cooldown = 35 SECONDS @@ -2312,7 +2311,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /datum/machine_quickhack/silence name = "Silence" - desc = "Jam every radio within nine tiles of what you clicked for 45 seconds, and switch the ones already in range off outright for as long." + desc = "Jam every radio within nine tiles of what you clicked for 45 seconds." radial_icon = 'icons/obj/devices/syndie_gadget.dmi' radial_icon_state = "jammer" cooldown = 40 SECONDS @@ -2608,10 +2607,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) */ /datum/machine_masshack/overload name = "Overload the Room" - desc = "Feed every machine around you power at once, each one at full overload strength. Five seconds of the whole \ - room buzzing, then up to twelve real explosions land a fifth of a second apart. It takes the walls with it and \ - it will breach a hull if one is in the pattern. Lights just burst. Nothing that goes up comes back, and you are \ - standing in the middle of it, leave." + desc = "Overload every machine around you at once. Five seconds of buzzing, then up to twelve explosions. You are standing in the middle of it." radial_icon_state = "overload_machine" cooldown = 3 MINUTES @@ -2652,9 +2648,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) */ /datum/machine_masshack/arc_flash name = "Arc Flash" - desc = "Every powered machine around you starts earthing itself through whoever is standing nearest to it. Five \ - volleys a second and a half apart, 14 burn each, and it comes out of the wall rather than through their hands, \ - so insulated gloves do nothing. It hits everyone in the room except you, friend or not." + desc = "Every powered machine around you earths itself through whoever is standing nearest. Five volleys, 14 burn each, on everyone but you." radial_icon = 'icons/mob/actions/actions_spells.dmi' radial_icon_state = "lightning" cooldown = 2 MINUTES @@ -2767,9 +2761,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) */ /datum/machine_masshack/wake name = "Wake the Machines" - desc = "Stand every free-standing machine in the room up to fight for you. Up to eight of them, twice as tough as an \ - animated machine and hitting twice as hard, for two minutes before they fall apart. They know you and nobody \ - else, everyone else in the room is a stranger to them. The machines do not come back." + desc = "Stand up to eight free-standing machines in the room up to fight for you for two minutes." radial_icon_state = "override_machine" cooldown = 3 MINUTES @@ -2797,7 +2789,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) for(var/mob/living/second as anything in woken) if(first != second) first.befriend(second) - to_chat(user, span_boldnotice("[length(woken)] of them get up. They know your face and each other's, and nobody else's.")) + to_chat(user, span_boldnotice("[length(woken)] of them get up.")) return TRUE /** diff --git a/voidcrew/modules/bounties/bounty_activities.dm b/voidcrew/modules/bounties/bounty_activities.dm index 894fd6f1026..48da60a175a 100644 --- a/voidcrew/modules/bounties/bounty_activities.dm +++ b/voidcrew/modules/bounties/bounty_activities.dm @@ -4,13 +4,14 @@ * Owner: P3 AI. Do not edit this file outside P3. * * What it owns: the idle activities (spec section 3), each with real objects: - * - bar: a stool or chair beside a table or counter, a drink in hand, sipped now and then; + * - bar: a stool or chair beside a table or counter (sat on: a real buckle), a real glass in hand, + * drunk from now and then (a real gulp: the drink goes into them, and they stop once they are tipsy); * - chat: turns at talking with a companion (or whoever is at hand); * - trade: at a trader's counter or a vending machine, haggling out loud; * - explore: three to five things on the site to walk up to and look over; * - loot: lockers and crates (zone loot first) opened, gone through and shut again; * - camp: a small camp made once (a fire, or a fixed lamp where a fire won't burn; a mattress; a - * crate), sat at, with errands; + * crate), sat at on the ground (tg's resting), with errands; * - blend: the routine a trader-outpost fugitive and every one of its decoys share (AR-C6/C7): the * same steps, weights, speed and lines, so nothing in how they act gives the fugitive away. * An activity scans for what it uses once, when it starts (or a blend step starts), never per tick. @@ -18,14 +19,14 @@ * they could not walk to is not picked again while the activity lasts, and at a trader outpost the * blend routine only picks spots on the public floor (L5). * It runs while they are calm; a flight or a fight puts it away, and it is set up again after. - * The decoy's AI (/mob/living/basic/bounty_criminal/decoy: blend, and getting out of the way when + * The decoy's AI (/mob/living/carbon/human/npc/bounty_criminal/decoy: blend, and getting out of the way when * hit) is here too; what a decoy does when accused is P6's. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P3 uses has an * `ai_` prefix. */ -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal /// What they are doing now (/datum/bounty_activity), if anything var/datum/bounty_activity/ai_activity /// The kind start_activity() set (BOUNTY_ACTIVITY_*), kept so they go back to it once calm @@ -34,17 +35,17 @@ var/datum/weakref/ai_activity_anchor /// world.time before which an activity that could not be set up is not tried again var/ai_activity_retry_at = 0 - /// The drink in their hand, held in their contents + /// The glass in their hand at the bar var/obj/item/reagent_containers/cup/glass/drinkingglass/ai_drink /// Weakref to the seat they sat down on, so only that one is ever left var/datum/weakref/ai_seat_ref - /// Sitting on the floor + /// Sitting on the floor: lying down by their own choice (tg's resting), which they get up from themselves var/ai_crouching = FALSE /// Weakrefs to the camp they made: the fire or lamp, the mattress, the crate var/list/ai_camp /// The mattress and crate were made; only the fire is ever made again var/ai_camp_made = FALSE - /// Glasses left on tables at the bar; past BOUNTY_BAR_GLASSES_MAX the glass just goes (M4) + /// Glasses left on tables at the bar; past BOUNTY_BAR_GLASSES_MAX a finished glass just goes (M4) var/ai_glasses_left = 0 /// Spots they could not walk to (turf = TRUE), not picked again until the activity changes (L5) var/list/ai_failed_spots @@ -57,7 +58,7 @@ * null to find one nearby. BOUNTY_ACTIVITY_BLEND also makes them a blended patron. Returns the * activity, or null if nothing could be set up (they stand around and try again later). */ -/mob/living/basic/bounty_criminal/proc/start_activity(kind, atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/proc/start_activity(kind, atom/anchor) ai_suspend_activity() ai_activity_kind = kind ai_activity_anchor = anchor ? WEAKREF(anchor) : null @@ -73,7 +74,7 @@ return ai_resume_activity() /// Their activity, set up again if it was put away. Falls back to standing around when it can't be set up. -/mob/living/basic/bounty_criminal/proc/ai_resume_activity() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_resume_activity() if(ai_activity) return ai_activity if(!ai_activity_kind || world.time < ai_activity_retry_at) @@ -92,7 +93,7 @@ return activity /// The activity datum for `kind` -/mob/living/basic/bounty_criminal/proc/ai_activity_type(kind) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_activity_type(kind) switch(kind) if(BOUNTY_ACTIVITY_BAR) return /datum/bounty_activity/bar @@ -111,13 +112,13 @@ return /datum/bounty_activity/loiter /// A meek criminal never loots: it looks around instead -/mob/living/basic/bounty_criminal/meek/ai_activity_type(kind) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_activity_type(kind) if(kind == BOUNTY_ACTIVITY_LOOT) return /datum/bounty_activity/explore return ..() /// Puts their activity away (drink down, up from the seat), keeping its kind to go back to -/mob/living/basic/bounty_criminal/proc/ai_suspend_activity() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_suspend_activity() if(!ai_activity) return var/datum/bounty_activity/old = ai_activity @@ -126,13 +127,13 @@ qdel(old) /// Stops their activity for good -/mob/living/basic/bounty_criminal/proc/ai_end_activity() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_end_activity() ai_activity_kind = null ai_activity_anchor = null ai_suspend_activity() /// One step of their activity, where they stand: arriving the first time, then its business. Returns BOUNTY_STEP_*. -/mob/living/basic/bounty_criminal/proc/ai_activity_step(seconds) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_activity_step(seconds) if(!ai_activity) return BOUNTY_STEP_DONE if(!ai_activity.arrived) @@ -142,8 +143,8 @@ // ===== BLENDING IN ===== -/// A patron now: moves like every other, can't be pulled, and shots aimed at someone else pass through them -/mob/living/basic/bounty_criminal/proc/ai_start_blend() +/// A patron now: walks like every other patron, and does what every other patron does (the blend routine) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_blend() blended = TRUE ai_set_pace(BOUNTY_CALM_SPEED) @@ -152,7 +153,7 @@ * (the component's stop_blending(): pulls, containers and the turrets' faction back to normal), so * `blended` and the act never disagree (C1). */ -/mob/living/basic/bounty_criminal/proc/ai_end_blend() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_end_blend() var/datum/component/bounty_outpost_blend/act = GetComponent(/datum/component/bounty_outpost_blend) act?.stop_blending() blended = FALSE @@ -161,8 +162,8 @@ // ===== SEATS, DRINKS, SPOTS ===== -/// Sits on `seat` from its tile -/mob/living/basic/bounty_criminal/proc/ai_sit_on(obj/structure/chair/seat) +/// Sits on `seat` from its tile: buckled to it, as anyone sitting down is +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sit_on(obj/structure/chair/seat) if(QDELETED(seat)) return FALSE if(buckled == seat) @@ -175,68 +176,98 @@ ai_seat_ref = WEAKREF(seat) return TRUE -/// Sits down on the floor -/mob/living/basic/bounty_criminal/proc/ai_crouch() - if(ai_crouching || buckled) +/// Sits down on the floor: tg's resting, as a player lies down +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_crouch() + if(ai_crouching || buckled || resting) return ai_crouching = TRUE - add_offsets(BOUNTY_AI_TRAIT, y_add = -4) + set_resting(TRUE, silent = TRUE) -/// Up from their seat or the floor. Only a seat they sat on themselves: never out of a bed or a chair someone put them in. -/mob/living/basic/bounty_criminal/proc/ai_stand_up() +/// Up from their seat or the floor. Only a seat they sat on themselves, and only the floor they lay down on themselves: never out of a bed or a chair someone put them in. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_stand_up() if(ai_crouching) ai_crouching = FALSE - remove_offsets(BOUNTY_AI_TRAIT) + if(resting) + set_resting(FALSE, silent = TRUE) var/obj/structure/chair/seat = ai_seat_ref?.resolve() ai_seat_ref = null if(seat && buckled == seat) seat.unbuckle_mob(src, force = TRUE) -/// A drink in hand, poured for the occasion -/mob/living/basic/bounty_criminal/proc/ai_take_drink() - if(!QDELETED(ai_drink)) +/// A glass of something in hand, poured for them at the bar: a real drinking glass in a free hand. Null with no hand free. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_take_drink() + if(!QDELETED(ai_drink) && is_holding(ai_drink)) return ai_drink - var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = new(src) - glass.reagents.add_reagent(pick(/datum/reagent/consumable/ethanol/beer, /datum/reagent/consumable/ethanol/whiskey, /datum/reagent/consumable/ethanol/rum), 25) - ai_drink = glass - ai_hand = bounty_ai_update_held(src, ai_hand, glass) - return glass + ai_drink = bounty_ai_pour_drink(src) + return ai_drink -/// A sip; an empty glass gets filled again after a while -/mob/living/basic/bounty_criminal/proc/ai_sip() - if(QDELETED(ai_drink)) - return FALSE - if(!ai_drink.reagents.total_volume) - ai_drink.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) - visible_message(span_notice("[src] gets another drink."), vision_distance = 5) - else - ai_drink.reagents.remove_all(5) - visible_message(span_notice("[src] takes a sip from [p_their()] glass."), vision_distance = 5) - playsound(src, 'sound/items/drink.ogg', 20, TRUE, -4) - ai_hand = bounty_ai_update_held(src, ai_hand, ai_drink) - return TRUE +/// A gulp from their glass, a real one (the drink goes into them); an empty glass gets filled again after a while. Tipsy, they only nurse it. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sip() + return bounty_ai_sip(src, ai_drink) /** * Sets the drink down on `table` if it is right there (on their side of any glass), up to * BOUNTY_BAR_GLASSES_MAX glasses (M4); otherwise, or with no table, it goes. */ -/mob/living/basic/bounty_criminal/proc/ai_put_drink_down(obj/structure/table/table) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_put_drink_down(obj/structure/table/table) var/obj/item/glass = ai_drink ai_drink = null - ai_hand = bounty_ai_update_held(src, ai_hand, null) if(QDELETED(glass)) return - if(stat == CONSCIOUS && ai_glasses_left < BOUNTY_BAR_GLASSES_MAX && !QDELETED(table) && isturf(table.loc) && isturf(loc) && Adjacent(table)) + if(stat == CONSCIOUS && ai_glasses_left < BOUNTY_BAR_GLASSES_MAX && bounty_ai_set_glass_down(src, glass, table)) ai_glasses_left++ - glass.forceMove(table.loc) - glass.pixel_x = rand(-6, 6) - glass.pixel_y = rand(0, 6) - visible_message(span_notice("[src] puts [glass] down on [table]."), vision_distance = 5) return qdel(glass) +/// The drinks a bar patron is poured. The first glass is any of these; another is a beer. +GLOBAL_LIST_INIT(bounty_bar_drinks, list( + /datum/reagent/consumable/ethanol/beer, + /datum/reagent/consumable/ethanol/whiskey, + /datum/reagent/consumable/ethanol/rum, +)) + +/// A fresh glass of something poured into a free hand of `drinker` (a criminal or a companion). Returns it, or null with no hand free. +/proc/bounty_ai_pour_drink(mob/living/carbon/human/drinker) + if(QDELETED(drinker) || !length(drinker.get_empty_held_indexes())) + return null + var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = new(drinker.drop_location()) + glass.reagents.add_reagent(pick(GLOB.bounty_bar_drinks), 25) + if(!drinker.put_in_hands(glass)) + qdel(glass) + return null + return glass + +/** + * A gulp from `glass`: the glass's own drink proc on `drinker` themselves, as a player drinking does, so + * the drink goes into them (and its alcohol is theirs to deal with). Tipsy (BOUNTY_BAR_SOBER_LIMIT), + * they only nurse it. An empty glass gets another beer. + */ +/proc/bounty_ai_sip(mob/living/carbon/human/drinker, obj/item/reagent_containers/cup/glass/glass) + if(QDELETED(glass) || !drinker.is_holding(glass) || drinker.stat != CONSCIOUS) + return FALSE + if(!glass.reagents.total_volume) + glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) + drinker.visible_message(span_notice("[drinker] gets another drink."), vision_distance = 5) + return TRUE + if(drinker.get_drunk_amount() >= BOUNTY_BAR_SOBER_LIMIT) + return FALSE + drinker.visible_message(span_notice("[drinker] takes a sip from [drinker.p_their()] glass."), vision_distance = 5) + glass.attack(drinker, drinker) + return TRUE + +/// Sets `glass` down on `table` from right beside it (on their side of any window), as anyone puts a glass down. Returns TRUE if it is on the table. +/proc/bounty_ai_set_glass_down(mob/living/carbon/human/drinker, obj/item/glass, obj/structure/table/table) + if(QDELETED(table) || !isturf(table.loc) || !isturf(drinker.loc) || !drinker.Adjacent(table) || !drinker.is_holding(glass)) + return FALSE + if(!drinker.transferItemToLoc(glass, table.loc)) + return FALSE + glass.pixel_x = rand(-6, 6) + glass.pixel_y = rand(0, 6) + drinker.visible_message(span_notice("[drinker] puts [glass] down on [table]."), vision_distance = 5) + return TRUE + /// A floor tile they can stand on: not space, lava or a chasm, free, on their site -/mob/living/basic/bounty_criminal/proc/ai_standable(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_standable(turf/tile) if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) return FALSE if(tile != loc && tile.is_blocked_turf(source_atom = src)) @@ -244,13 +275,13 @@ return leash_ok(tile) /// Whether an activity may send them to `tile`: not a spot they failed to reach, and on the floor it is kept to (L5) -/mob/living/basic/bounty_criminal/proc/ai_spot_allowed(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_spot_allowed(turf/tile) if(LAZYACCESS(ai_failed_spots, tile)) return FALSE return !ai_allowed_floor || ai_allowed_floor[tile] /// Remembers `tile` as somewhere they could not walk to, for as long as the activity lasts (a few at most) -/mob/living/basic/bounty_criminal/proc/ai_spot_failed(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_spot_failed(turf/tile) if(!isturf(tile)) return LAZYSET(ai_failed_spots, tile, TRUE) @@ -262,7 +293,7 @@ * Right beside it (distance 1), nothing may stand between the tile and the thing (Adjacent(): no * wall, glass or rail), since they will use it from there. */ -/mob/living/basic/bounty_criminal/proc/ai_free_tile_beside(atom/thing, distance = 1) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_free_tile_beside(atom/thing, distance = 1) var/turf/center = get_turf(thing) if(!center) return null @@ -280,7 +311,7 @@ return best /// A random free tile within `radius` of `center`, in the same area, or null -/mob/living/basic/bounty_criminal/proc/ai_random_tile_near(turf/center, radius) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_random_tile_near(turf/center, radius) if(!center) return null var/area/here = get_area(src) @@ -291,7 +322,7 @@ return null /// A table beside `thing`, or null -/mob/living/basic/bounty_criminal/proc/ai_table_beside(atom/thing) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_table_beside(atom/thing) var/turf/center = get_turf(thing) if(!center) return null @@ -305,7 +336,7 @@ * Whether they could sit on `seat`: loose on the floor, nobody on it, on their site. Stools (bar * stools too) have can_buckle off for people; they are sat on with a forced buckle all the same. */ -/mob/living/basic/bounty_criminal/proc/ai_seat_usable(obj/structure/chair/seat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_seat_usable(obj/structure/chair/seat) if(QDELETED(seat) || !isturf(seat.loc) || istype(seat, /obj/structure/chair/e_chair)) return FALSE if(seat.has_buckled_mobs() && !(src in seat.buckled_mobs)) @@ -316,7 +347,7 @@ return leash_ok(seat.loc) && (seat.loc == loc || ai_spot_allowed(seat.loc)) /// The nearest seat within `range` of `near` they could sit on, beside a table if `needs_table` -/mob/living/basic/bounty_criminal/proc/ai_find_seat(atom/near, range = BOUNTY_ACTIVITY_RANGE, needs_table = TRUE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_seat(atom/near, range = BOUNTY_ACTIVITY_RANGE, needs_table = TRUE) var/turf/center = get_turf(near) || get_turf(src) if(!center) return null @@ -332,7 +363,7 @@ return best /// The nearest table within `range` of `near` with room to stand at, or null -/mob/living/basic/bounty_criminal/proc/ai_find_table(atom/near, range = BOUNTY_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_table(atom/near, range = BOUNTY_ACTIVITY_RANGE) var/turf/center = get_turf(near) || get_turf(src) if(!center) return null @@ -348,7 +379,7 @@ return best /// The nearest trader or vending machine within `range`: somewhere to look at goods and haggle -/mob/living/basic/bounty_criminal/proc/ai_find_stall(range = BOUNTY_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_stall(range = BOUNTY_ACTIVITY_RANGE) var/turf/center = get_turf(src) if(!center) return null @@ -370,22 +401,22 @@ * Someone to talk to within `range`: their own companions first; otherwise someone at hand who is * awake and not played (a patron, an outpost regular, another blended patron, one of their own faction). */ -/mob/living/basic/bounty_criminal/proc/ai_find_chat_partner(range = BOUNTY_ACTIVITY_RANGE, patrons_only = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_chat_partner(range = BOUNTY_ACTIVITY_RANGE, patrons_only = FALSE) var/turf/here = get_turf(src) if(!here) return null if(!patrons_only) - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in ai_companions()) if(buddy.stat == CONSCIOUS && get_dist(src, buddy) <= range) return buddy var/list/options = list() - for(var/mob/living/basic/other in range(range, here)) - if(other == src || other.stat != CONSCIOUS || other.client) + for(var/mob/living/other in range(range, here)) + if(other == src || other.stat != CONSCIOUS || other.client || other.mind) continue - if(istype(other, /mob/living/basic/ambient_npc)) + if(istype(other, /mob/living/carbon/human/npc/ambient_npc)) options += other - else if(istype(other, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/patron = other + else if(istype(other, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/patron = other if(patron.blended || !patrons_only) options += other else if(!patrons_only && (other.mob_biotypes & MOB_HUMANOID) && faction_check_atom(other)) @@ -412,7 +443,7 @@ return is_type_in_typecache(thing, interesting) && isturf(thing.loc) && !thing.invisibility /// Three to five things to look at around `anchor`, spread out, or spots on open ground when there is little; as weakrefs -/mob/living/basic/bounty_criminal/proc/ai_find_points_of_interest(atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_points_of_interest(atom/anchor) var/turf/center = get_turf(anchor) || get_turf(src) . = list() if(!center) @@ -444,7 +475,7 @@ . += WEAKREF(point) /// Whether they could go through `box`: loose, not locked or welded, not outpost property, not a body bag, on their site -/mob/living/basic/bounty_criminal/proc/ai_can_rummage(obj/structure/closet/box) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_can_rummage(obj/structure/closet/box) if(QDELETED(box) || !isturf(box.loc) || box.locked || box.welded) return FALSE if(istype(box, /obj/structure/closet/body_bag) || box.wall_mounted || (box.resistance_flags & INDESTRUCTIBLE)) @@ -452,7 +483,7 @@ return leash_ok(box.loc) /// Up to four containers to go through around `anchor`, zone loot caches first; as weakrefs -/mob/living/basic/bounty_criminal/proc/ai_find_containers(atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_containers(atom/anchor) var/turf/center = get_turf(anchor) || get_turf(src) . = list() if(!center) @@ -475,7 +506,7 @@ break /// Shuts `box` after going through it, from right beside it, unless someone is standing in it -/mob/living/basic/bounty_criminal/proc/ai_close_container(obj/structure/closet/box) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_close_container(obj/structure/closet/box) if(QDELETED(box) || !box.opened || !isturf(loc) || !Adjacent(box)) return if(locate(/mob/living) in box.loc) @@ -489,7 +520,7 @@ * again, and one that is gone is replaced. The fire is solid, so no walk, flight or fight takes * anyone through the flames (L4). Returns the fire or lamp, or null. */ -/mob/living/basic/bounty_criminal/proc/ai_make_camp(turf/center) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_make_camp(turf/center) var/atom/fire = ai_camp_fire() if(fire) var/obj/structure/bonfire/bonfire = fire @@ -528,7 +559,7 @@ return fire /// Their camp's fire or lamp, if it is still there where they put it (one carried off is gone, L4) -/mob/living/basic/bounty_criminal/proc/ai_camp_fire() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_camp_fire() var/atom/movable/fire = ai_camp_piece(/obj/structure/bonfire) || ai_camp_piece(/obj/structure/bounty_camp_lamp) return (fire && isturf(fire.loc)) ? fire : null @@ -550,13 +581,13 @@ light_color = "#ffcc66" /// They are gone: the camp stays as it is, but the fire burns down -/mob/living/basic/bounty_criminal/proc/ai_put_out_camp() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_put_out_camp() var/obj/structure/bonfire/bonfire = ai_camp_piece(/obj/structure/bonfire) if(bonfire?.burning) bonfire.extinguish() /// A piece of their camp of `piece_type`, if it is still there -/mob/living/basic/bounty_criminal/proc/ai_camp_piece(piece_type) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_camp_piece(piece_type) for(var/datum/weakref/ref as anything in ai_camp) var/atom/piece = ref?.resolve() if(!QDELETED(piece) && istype(piece, piece_type)) @@ -564,7 +595,7 @@ return null /// The patron they are talking to answers them, a moment after they spoke -/mob/living/basic/bounty_criminal/proc/ai_get_reply(datum/weakref/partner_ref) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_get_reply(datum/weakref/partner_ref) var/mob/living/partner = partner_ref?.resolve() if(partner && stat == CONSCIOUS && get_dist(src, partner) <= 2) bounty_ai_reply(partner, src) @@ -573,10 +604,10 @@ /proc/bounty_ai_reply(mob/living/partner, mob/living/speaker) if(QDELETED(partner) || partner.stat != CONSCIOUS) return - if(istype(partner, /mob/living/basic/bounty_companion)) + if(istype(partner, /mob/living/carbon/human/npc/bounty_companion)) partner.face_atom(speaker) INVOKE_ASYNC(partner, TYPE_PROC_REF(/mob/living, bounty_say), "companion_reply") - else if(istype(partner, /mob/living/basic/bounty_criminal)) + else if(istype(partner, /mob/living/carbon/human/npc/bounty_criminal)) partner.face_atom(speaker) INVOKE_ASYNC(partner, TYPE_PROC_REF(/mob/living, bounty_say), "idle_chat") @@ -586,7 +617,7 @@ /datum/ai_planning_subtree/bounty_activity /datum/ai_planning_subtree/bounty_activity/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal) || criminal.ai_mode != BOUNTY_AI_CALM || criminal.hidden || !criminal.ai_can_act()) return var/datum/bounty_activity/activity = criminal.ai_activity || criminal.ai_resume_activity() @@ -606,11 +637,11 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/bounty_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn return istype(criminal) && !!criminal.ai_activity /datum/ai_behavior/bounty_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/datum/bounty_activity/activity = criminal.ai_activity if(!activity || criminal.ai_mode != BOUNTY_AI_CALM) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -635,7 +666,7 @@ /datum/bounty_activity /// Short text for the admin panel var/name = "standing around" - var/mob/living/basic/bounty_criminal/doer + var/mob/living/carbon/human/npc/bounty_criminal/doer var/datum/weakref/anchor_ref /// Where they are headed, or null where they are var/turf/spot @@ -646,7 +677,7 @@ /// world.time of the next line var/next_line = 0 -/datum/bounty_activity/New(mob/living/basic/bounty_criminal/new_doer, atom/anchor) +/datum/bounty_activity/New(mob/living/carbon/human/npc/bounty_criminal/new_doer, atom/anchor) . = ..() doer = new_doer anchor_ref = anchor ? WEAKREF(anchor) : null @@ -1276,7 +1307,7 @@ if(prob(step_kind == "chat" ? 70 : 30)) doer.ai_bark("idle_chat") if(step_kind == "chat" && isliving(target)) - addtimer(CALLBACK(doer, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_get_reply), WEAKREF(target)), rand(3, 6) SECONDS, TIMER_DELETE_ME) + addtimer(CALLBACK(doer, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_get_reply), WEAKREF(target)), rand(3, 6) SECONDS, TIMER_DELETE_ME) return BOUNTY_STEP_CONTINUE /// Ends the step: the glass goes with them (never left on the bar, M4), up from the seat @@ -1298,7 +1329,7 @@ * as turf = TRUE), shared for BOUNTY_OUTPOST_FLOOR_CACHE between everyone there; null anywhere else, * or when the outpost has none (a broken load). */ -/proc/bounty_ai_outpost_floor(mob/living/basic/bounty_criminal/patron) +/proc/bounty_ai_outpost_floor(mob/living/carbon/human/npc/bounty_criminal/patron) var/datum/component/bounty_outpost_blend/act = patron?.GetComponent(/datum/component/bounty_outpost_blend) var/obj/structure/overmap/trader_outpost/outpost = act?.outpost_ref?.resolve() if(!istype(outpost)) @@ -1336,22 +1367,25 @@ * this controller is only the default for a decoy made some other way. Every criminal tree carries * bounty_flee_and_calm, so a decoy that runs on any of them runs and then calms down (M3). */ -/mob/living/basic/bounty_criminal/decoy +/mob/living/carbon/human/npc/bounty_criminal/decoy ai_controller = /datum/ai_controller/basic_controller/bounty/criminal/decoy /datum/ai_controller/basic_controller/bounty/criminal/decoy planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_flee_and_calm, /datum/ai_planning_subtree/bounty_activity, ) /// Always a patron -/mob/living/basic/bounty_criminal/decoy/ai_expose() +/mob/living/carbon/human/npc/bounty_criminal/decoy/ai_expose() return /// Made to react (P6, or a decoy no longer blending in): they back away, then go back to their evening -/mob/living/basic/bounty_criminal/decoy/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/decoy/ai_react(mob/living/threat, bark) ai_flee_from(threat) /** @@ -1363,7 +1397,7 @@ /datum/ai_planning_subtree/bounty_flee_and_calm /datum/ai_planning_subtree/bounty_flee_and_calm/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal) || criminal.ai_mode != BOUNTY_AI_FLEEING) return var/seen = criminal.ai_sees_threat(BOUNTY_FLEE_DISTANCE) diff --git a/voidcrew/modules/bounties/bounty_admin.dm b/voidcrew/modules/bounties/bounty_admin.dm index 721a17dfc7e..d7285e7c793 100644 --- a/voidcrew/modules/bounties/bounty_admin.dm +++ b/voidcrew/modules/bounties/bounty_admin.dm @@ -12,8 +12,7 @@ * * It changes other packages' state only through their procs: * - P1: generate_bounty_record(), make_decoy_record(); - * - P2: spawn_bounty_criminal(), body_apply_cuffs(), body_remove_cuffs(), body_downed_line(), - * body_stand_up(), capture_state(); + * - P2: spawn_bounty_criminal(), capture_state(); * - P3: start_activity(); * - P5: post_criminal_bounty(), close(), relist(), hunt() (forced), print_warrant(), * SScriminal_bounties.board_fast_forward() and @@ -21,8 +20,10 @@ * - P6: the decoy's outpost_become() and the outpost's display name, description and outfit; * - P7: bounty_pool_add(), bounty_pool_remove(), bounty_pool_clear(), the prison's * bounty_admit_record(). - * The one direct write is a criminal's worst_state back to free (force_free()): no package has a - * proc that makes a worst state better, and the admin's "Free" exists to try a full-pay turn-in again. + * A criminal is a real human body (voidcrew/modules/human_npcs/), so the forced capture states are + * tg's own, put on through tg's procs (CR-21): real brute on the chest to just past its crit line, a + * real Paralyze, real handcuffs in the cuff slot, death(), and for "Free" tg's revive(HEAL_ALL) with the + * cuffs taken off and every stun ended. No stand-in states; the pad reads them as it reads anyone's. * * Everything goes through /datum/bounty_admin_panel. The menu procs (menu_*) ask the questions; the * action procs (admin_*) take plain arguments, check the admin again, act and log, so a unit test can @@ -43,6 +44,10 @@ #define BOUNTY_ADMIN_TICK_SECONDS 10 /// How long a forced stun lasts #define BOUNTY_ADMIN_STUN_TIME (30 SECONDS) +/// Most hits "force downed" lands to get a tough body past its crit line (its toughness cuts each one) +#define BOUNTY_ADMIN_DOWN_HITS 20 +/// The brute of "force downed"'s first hit, which learns how much of a hit the body takes before the rest are sized +#define BOUNTY_ADMIN_DOWN_PROBE 10 /// Most questions one opening of the panel asks before it closes itself: a guard, never reached by hand #define BOUNTY_ADMIN_MAX_STEPS 200 /// Who the pool's test records say brought them in @@ -52,6 +57,7 @@ // The main menu, in the order it shows #define BOUNTY_ADMIN_MENU_SPAWN "Spawn a criminal at my feet" +#define BOUNTY_ADMIN_MENU_SPAWN_CHROME "Spawn a criminal with chosen chrome" #define BOUNTY_ADMIN_MENU_DECOY "Spawn a decoy next to a criminal" #define BOUNTY_ADMIN_MENU_POST "Post a bounty now" #define BOUNTY_ADMIN_MENU_GIVE "Give a bounty to a player" @@ -127,6 +133,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a /datum/bounty_admin_panel/proc/run_menu(mob/user) var/static/list/menu = list( BOUNTY_ADMIN_MENU_SPAWN, + BOUNTY_ADMIN_MENU_SPAWN_CHROME, BOUNTY_ADMIN_MENU_DECOY, BOUNTY_ADMIN_MENU_POST, BOUNTY_ADMIN_MENU_GIVE, @@ -145,6 +152,8 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a switch(choice) if(BOUNTY_ADMIN_MENU_SPAWN) menu_spawn(user) + if(BOUNTY_ADMIN_MENU_SPAWN_CHROME) + menu_spawn(user, TRUE) if(BOUNTY_ADMIN_MENU_DECOY) menu_decoy(user) if(BOUNTY_ADMIN_MENU_POST) @@ -169,8 +178,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/about = length(live_criminals()) return "[open] open bount[open == 1 ? "y" : "ies"], [about] criminal\s about, [length(GLOB.bounty_prisoner_pool)] record\s in the prisoner pool." -/datum/bounty_admin_panel/proc/menu_spawn(mob/user) +/// The spawn menu. With `pick_chrome` it also asks which chrome they carry (C0); otherwise it is rolled as usual. +/datum/bounty_admin_panel/proc/menu_spawn(mob/user, pick_chrome = FALSE) var/list/archetypes = archetype_choices() + if(pick_chrome) + // The meek carry no chrome + archetypes -= "Meek" var/archetype_choice = ask_list(user, "Which archetype?", "Spawn a criminal", archetypes) if(!archetype_choice || !authorized(user)) return @@ -191,11 +204,18 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a if(!style_choice || !authorized(user)) return variant = styles[style_choice] + var/chrome = BOUNTY_ADMIN_CHROME_ROLL + if(pick_chrome) + var/list/chromes = chrome_choices() + var/chrome_choice = ask_list(user, "Which chrome?", "Spawn a criminal", chromes) + if(!chrome_choice || !authorized(user)) + return + chrome = chromes[chrome_choice] var/list/activities = list("None" = null) + activity_choices() var/activity_choice = ask_list(user, "Doing what?", "Spawn a criminal", activities) if(!activity_choice || !authorized(user)) return - admin_spawn_criminal(user, tier, archetype, variant, activities[activity_choice], get_turf(user)) + admin_spawn_criminal(user, tier, archetype, variant, activities[activity_choice], get_turf(user), chrome) /datum/bounty_admin_panel/proc/menu_decoy(mob/user) var/datum/weakref/wanted_ref = WEAKREF(pick_criminal(user, "Spawn a decoy next to whom?")) @@ -205,7 +225,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/share_choice = ask_list(user, "The decoy shares how much of their look?", "Spawn a decoy", shares) if(!share_choice || !authorized(user)) return - var/mob/living/basic/bounty_criminal/wanted = wanted_ref.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/wanted = wanted_ref.resolve() if(QDELETED(wanted)) refuse(user, "That criminal is gone.") return @@ -353,12 +373,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a "Stunned" = BOUNTY_STATE_STUNNED, "Restrained (cuffs on)" = BOUNTY_STATE_RESTRAINED, "Dead" = BOUNTY_STATE_DEAD, - "Free (up, healed, uncuffed, worst state cleared)" = BOUNTY_STATE_FREE, + "Free (alive, healed, uncuffed)" = BOUNTY_STATE_FREE, ) var/state_choice = ask_list(user, "Which state?", "Force a capture state", states) if(!state_choice || !authorized(user)) return - var/mob/living/basic/bounty_criminal/criminal = criminal_ref.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal_ref.resolve() if(QDELETED(criminal)) refuse(user, "That criminal is gone.") return @@ -454,7 +474,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a /// The live criminal the admin picks (never a decoy), or null /datum/bounty_admin_panel/proc/pick_criminal(mob/user, message) var/list/choices = list() - for(var/mob/living/basic/bounty_criminal/criminal as anything in live_criminals()) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal as anything in live_criminals()) choices[unique_label(choices, criminal_label(criminal))] = WEAKREF(criminal) if(!length(choices)) return refuse(user, "No bounty criminals are about. Spawn one first.") @@ -462,7 +482,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a if(!choice || !authorized(user)) return null var/datum/weakref/criminal_ref = choices[choice] - var/mob/living/basic/bounty_criminal/criminal = criminal_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal_ref?.resolve() if(QDELETED(criminal)) return refuse(user, "That criminal is gone.") return criminal @@ -500,10 +520,10 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a /// Every live bounty criminal in the world, decoys only if asked for /datum/bounty_admin_panel/proc/live_criminals(include_decoys = FALSE) var/list/found = list() - for(var/mob/living/basic/bounty_criminal/criminal in GLOB.mob_living_list) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal in GLOB.mob_living_list) if(QDELETED(criminal)) continue - if(!include_decoys && istype(criminal, /mob/living/basic/bounty_criminal/decoy)) + if(!include_decoys && istype(criminal, /mob/living/carbon/human/npc/bounty_criminal/decoy)) continue found += criminal return found @@ -537,8 +557,47 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a "Demolitionist" = BOUNTY_KIT_DEMOLITIONIST, "Ghost" = BOUNTY_KIT_GHOST, "Heavy" = BOUNTY_KIT_HEAVY, + "Cyberpsycho" = BOUNTY_KIT_CYBERPSYCHO, ) +/** + * The chrome a spawned criminal can be given (C0): rolled as usual for the style or kit picked, none, + * any one piece whatever their style or kit, or every piece that fits in one body. Values are a list of + * BOUNTY_CHROME_* keys, BOUNTY_ADMIN_CHROME_ROLL or BOUNTY_ADMIN_CHROME_ALL. + */ +/datum/bounty_admin_panel/proc/chrome_choices() + var/list/choices = list("Roll as usual" = BOUNTY_ADMIN_CHROME_ROLL, "None" = list()) + for(var/key in GLOB.bounty_chrome_pieces) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + if(piece.weight > 0) + choices[piece.name] = list(key) + choices["All of it"] = BOUNTY_ADMIN_CHROME_ALL + return choices + +/** + * Gives `record` the chrome `chrome` asks for (a chrome_choices() value), for a criminal spawned by an + * admin: rolled as usual for the style or kit it ended up with (never the one P1 rolled first), every + * piece that fits, or exactly the pieces listed. What doesn't fit with its kit's own chrome is left + * off, and a style its chrome can't fight with is changed for one it can. + */ +/datum/bounty_admin_panel/proc/admin_set_chrome(datum/bounty_record/record, chrome) + var/list/kit_organs = record.archetype == BOUNTY_ARCHETYPE_BOSS ? bounty_chrome_kit_organs(record.kit) : list() + if(record.archetype != BOUNTY_ARCHETYPE_NORMAL && record.archetype != BOUNTY_ARCHETYPE_BOSS) + record.chrome = null + return + if(chrome == BOUNTY_ADMIN_CHROME_ALL) + chrome = admin_chrome_all(record, kit_organs) + if(!islist(chrome)) + record.chrome = bounty_chrome_roll(record.tier, record.archetype, record.style, record.kit, kit_organs) + return + var/list/fitted = bounty_chrome_fit_keys(chrome, kit_organs) + record.chrome = length(fitted) ? fitted : null + bounty_chrome_fit_style(record) + +/// "All of it": every piece that fits on top of its kit's chrome. The cyberpsycho kit (C6) takes this choice over. +/datum/bounty_admin_panel/proc/admin_chrome_all(datum/bounty_record/record, list/kit_organs) + return bounty_chrome_all_that_fits(kit_organs) + /// A normal criminal's styles; the meek always carry the holdout, so the menu doesn't offer it /datum/bounty_admin_panel/proc/style_choices() return list( @@ -546,6 +605,8 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a "Knife" = BOUNTY_STYLE_KNIFE, "Pistol" = BOUNTY_STYLE_PISTOL, "Shotgun" = BOUNTY_STYLE_SHOTGUN, + "SMG" = BOUNTY_STYLE_SMG, + "Revolver" = BOUNTY_STYLE_REVOLVER, "Club" = BOUNTY_STYLE_CLUB, "Bottle thrower" = BOUNTY_STYLE_BOTTLE, ) @@ -682,7 +743,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a // ===== DESCRIPTIONS ===== -/datum/bounty_admin_panel/proc/criminal_name(mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/criminal_name(mob/living/carbon/human/npc/bounty_criminal/criminal) if(!criminal) return "nobody" return criminal.record?.name || criminal.real_name || criminal.name @@ -692,9 +753,10 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a if(!record) return "no record" var/variant = record.kit || record.style - return "[bounty_tier_name(record.tier)] [record.archetype || "criminal"][variant ? " ([variant])" : ""]" + var/chrome = length(record.chrome) ? ", chrome: [jointext(record.chrome, ", ")]" : "" + return "[bounty_tier_name(record.tier)] [record.archetype || "criminal"][variant ? " ([variant])" : ""][chrome]" -/datum/bounty_admin_panel/proc/criminal_label(mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/criminal_label(mob/living/carbon/human/npc/bounty_criminal/criminal) var/area/place = get_area(criminal) return "[criminal_name(criminal)]: [record_kind(criminal.record)], [criminal.capture_state()], in [place?.name || "nowhere"]" @@ -739,7 +801,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/datum/bounty_record/record = posting.record var/obj/structure/overmap/site = posting.site() var/obj/structure/overmap/ship/private_ship = posting.offered_to() - var/mob/living/basic/bounty_criminal/criminal = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = posting.criminal() var/location = criminal ? "[ADMIN_VERBOSEJMP(criminal)], [criminal.capture_state()]" : absent_reason(posting) return list( "tier" = bounty_tier_name(record?.tier), @@ -794,10 +856,10 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a /** * Spawns a criminal of `tier` and `archetype` at `where`, with no posting: a new record from P1 with - * the kit (a boss) or style (anyone else) set to `variant` when given, then P2's spawn, then P3's - * `activity` when given. Returns the criminal, or null. + * the kit (a boss) or style (anyone else) set to `variant` when given, its chrome as `chrome` asks + * (admin_set_chrome(), C0), then P2's spawn, then P3's `activity` when given. Returns the criminal, or null. */ -/datum/bounty_admin_panel/proc/admin_spawn_criminal(mob/user, tier, archetype, variant, activity, turf/where) +/datum/bounty_admin_panel/proc/admin_spawn_criminal(mob/user, tier, archetype, variant, activity, turf/where, chrome = BOUNTY_ADMIN_CHROME_ROLL) error = null if(!authorized(user)) return null @@ -829,9 +891,17 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a record.kit = null if(variant) record.style = variant - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(record, where, null) + var/list/asked_chrome + if(islist(chrome)) + var/list/chrome_keys = chrome + asked_chrome = chrome_keys.Copy() + admin_set_chrome(record, chrome) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(record, where, null) if(QDELETED(criminal)) return refuse(user, "The criminal did not spawn.") + var/list/left_off = asked_chrome ? asked_chrome - (record.chrome || list()) : null + if(length(left_off)) + tell(user, span_warning("[jointext(left_off, ", ")] didn't fit in them with the rest.")) if(activity) criminal.start_activity(activity, null) log_action(user, "spawn [criminal_name(criminal)], [record_kind(record)][activity ? ", [activity]" : ""], at [AREACOORD(where)]") @@ -845,11 +915,11 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a * the alert and it walks out when the posting ends; anywhere else it stands alone, with the black * market's look. A decoy is never linked to a posting through posting_ref (P6). Returns the decoy. */ -/datum/bounty_admin_panel/proc/admin_spawn_decoy(mob/user, mob/living/basic/bounty_criminal/wanted, shared = 1) +/datum/bounty_admin_panel/proc/admin_spawn_decoy(mob/user, mob/living/carbon/human/npc/bounty_criminal/wanted, shared = 1) error = null if(!authorized(user)) return null - if(QDELETED(wanted) || istype(wanted, /mob/living/basic/bounty_criminal/decoy)) + if(QDELETED(wanted) || istype(wanted, /mob/living/carbon/human/npc/bounty_criminal/decoy)) return refuse(user, "Pick a wanted criminal, not a decoy.") if(!wanted.record) return refuse(user, "[criminal_name(wanted)] has no record to copy.") @@ -868,8 +938,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/datum/criminal_bounty/posting = wanted.posting() if(posting && (posting.placement_kind != BOUNTY_PLACEMENT_TRADER_OUTPOST || posting.outpost_site?.resolve() != outpost)) posting = null - var/datum/npc_spawn_context/context = wanted.bounty_profile_context() - var/mob/living/basic/bounty_criminal/decoy/decoy = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/decoy, decoy_spot(center), context, FALSE) + // One body, wanted back at once: built now rather than queued, in the decoy record's face + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/decoy, decoy_spot(center), look = decoy_record, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(decoy)) + return refuse(user, "The decoy did not spawn.") decoy.outpost_become(wanted, decoy_record, posting, outpost, bounty_outpost_display_name(outpost), bounty_outpost_display_desc(outpost), bounty_outpost_outfit(outpost)) if(posting) posting.decoys += WEAKREF(decoy) @@ -1012,7 +1086,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return "not spawned yet" /// Moves the admin to `criminal`. Returns TRUE if they moved. -/datum/bounty_admin_panel/proc/admin_jump(mob/user, mob/living/basic/bounty_criminal/criminal, datum/criminal_bounty/posting) +/datum/bounty_admin_panel/proc/admin_jump(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal, datum/criminal_bounty/posting) error = null if(!authorized(user)) return FALSE @@ -1024,7 +1098,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return TRUE /// Brings `criminal` to the admin's tile, out of any locker or crate. Returns TRUE if they came. -/datum/bounty_admin_panel/proc/admin_bring(mob/user, mob/living/basic/bounty_criminal/criminal, datum/criminal_bounty/posting) +/datum/bounty_admin_panel/proc/admin_bring(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal, datum/criminal_bounty/posting) error = null if(!authorized(user)) return FALSE @@ -1043,11 +1117,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a // ===== CAPTURE STATES ===== /** - * Puts `criminal` in capture state `state` (BOUNTY_STATE_*) through P2's own procs: damage to its - * downed line, a stun, real handcuffs put on by nobody (so nobody lands on its grudge list), death, - * or taking all of that off again. Returns TRUE if capture_state() now reads `state`. + * Puts `criminal` in capture state `state` (BOUNTY_STATE_*) the way tg does it to anyone (CR-21): real + * brute to just past its crit line, a real Paralyze, real handcuffs put on by nobody (so nobody lands on + * its grudge list), death(), or for free a revive with all of that taken off again. Returns TRUE if it + * was done; what capture_state() reads then is reported to the admin. */ -/datum/bounty_admin_panel/proc/admin_force_state(mob/user, mob/living/basic/bounty_criminal/criminal, state) +/datum/bounty_admin_panel/proc/admin_force_state(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal, state) error = null if(!authorized(user)) return FALSE @@ -1070,7 +1145,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a if(!done) return FALSE var/now = criminal.capture_state() - log_action(user, "force [criminal_name(criminal)] to [state] (capture state now [now], worst [criminal.worst_state])") + log_action(user, "force [criminal_name(criminal)] to [state] (capture state now [now])") if(now != state) // Stunned or cuffed while down still reads as downed, as the pad would pay it tell(user, span_notice("[criminal_name(criminal)] is [state], but reads as [now]: [now] comes first.")) @@ -1078,21 +1153,42 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a tell(user, span_notice("[criminal_name(criminal)] is now [now].")) return TRUE -/// Damage straight to P2's downed line, past the mercy rule and armour; P2 downs it from there -/datum/bounty_admin_panel/proc/force_downed(mob/user, mob/living/basic/bounty_criminal/criminal) +/** + * Real brute on its chest until it is just in crit: health a point under its crit line (tg's + * crit_threshold, HEALTH_THRESHOLD_CRIT; the hard-crit line for a body that has no soft crit). No + * armour roll and no wound. How much of a hit it takes (its toughness, a species' weak or tough + * limbs) is learned from a small first hit, and each hit after is sized by what the last one took + * off, so a body that takes more than a person's doesn't overshoot into hard crit. + */ +/datum/bounty_admin_panel/proc/force_downed(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) return refuse(user, "[criminal_name(criminal)] is dead.") if(criminal.is_downed()) return refuse(user, "[criminal_name(criminal)] is already down.") - var/excess = criminal.health - criminal.body_downed_line() - if(excess > 0) - criminal.adjust_health(excess, TRUE, TRUE) + var/line = (HAS_TRAIT(criminal, TRAIT_NOSOFTCRIT) ? criminal.hardcrit_threshold : criminal.crit_threshold) - 1 + var/taken = 1 + for(var/hit in 1 to BOUNTY_ADMIN_DOWN_HITS) + if(criminal.is_downed() || criminal.stat == DEAD) + break + var/excess = criminal.health - line + if(excess <= 0) + break + var/amount = excess / taken + if(hit == 1) + amount = min(amount, BOUNTY_ADMIN_DOWN_PROBE) + var/before = criminal.health + if(!criminal.apply_damage(amount, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND)) + break + var/dropped = before - criminal.health + if(dropped <= 0) + break + taken = dropped / amount if(!criminal.is_downed()) - return refuse(user, "[criminal_name(criminal)] did not go down ([criminal.health] health, line [criminal.body_downed_line()]).") + return refuse(user, "[criminal_name(criminal)] did not go down ([criminal.health] health).") return TRUE -/// A long paralysis, past stun immunity -/datum/bounty_admin_panel/proc/force_stunned(mob/user, mob/living/basic/bounty_criminal/criminal) +/// A long tg paralysis, past stun immunity +/datum/bounty_admin_panel/proc/force_stunned(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) return refuse(user, "[criminal_name(criminal)] is dead.") criminal.Paralyze(BOUNTY_ADMIN_STUN_TIME, TRUE) @@ -1100,20 +1196,27 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return refuse(user, "[criminal_name(criminal)] shrugged the stun off.") return TRUE -/// A new pair of real handcuffs, put on at once through P2's body_apply_cuffs() by nobody: held still, its AI paused, and no one on its grudge list -/datum/bounty_admin_panel/proc/force_restrained(mob/user, mob/living/basic/bounty_criminal/criminal) +/** + * A new pair of real handcuffs in its cuff slot, put on the way tg's apply_cuffs() does it, by nobody: + * no cuffing do_after for it to answer, and no one on its grudge list. It can work at them as at + * anyone's. + */ +/datum/bounty_admin_panel/proc/force_restrained(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) - return refuse(user, "[criminal_name(criminal)] is dead: the cuffs would only fall off.") + return refuse(user, "[criminal_name(criminal)] is dead.") if(criminal.is_restrained()) - return refuse(user, "[criminal_name(criminal)] is already cuffed.") + return refuse(user, "[criminal_name(criminal)] is already restrained.") + if(!criminal.canBeHandcuffed()) + return refuse(user, "[criminal_name(criminal)] has no wrists to cuff.") var/obj/item/restraints/handcuffs/cuffs = new(criminal.drop_location()) - if(!criminal.body_apply_cuffs(cuffs, null)) + criminal.equip_to_slot(cuffs, ITEM_SLOT_HANDCUFFED) + if(criminal.handcuffed != cuffs) if(!QDELETED(cuffs) && cuffs.loc != criminal) qdel(cuffs) return refuse(user, "The cuffs did not go on [criminal_name(criminal)].") return TRUE -/datum/bounty_admin_panel/proc/force_dead(mob/user, mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/force_dead(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) return refuse(user, "[criminal_name(criminal)] is already dead.") criminal.death() @@ -1122,27 +1225,20 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return TRUE /** - * Takes every capture state off: the cuffs come off through P2's body_remove_cuffs() (they drop at - * its feet), the surrender ends, stuns end, all damage heals, a downed one gets back up through P2's - * body_stand_up() (a mini-boss rallies, as it would), and the worst state goes back to free, so a - * full-pay turn-in can be tried again. The dead stay dead: a criminal can't be revived. + * Takes every capture state off, through tg (CR-21): revive(HEAL_ALL) heals every hurt (damage, wounds, + * organs, blood, stamina, stuns) and brings a dead one back if its body can live; the cuffs come off + * through tg's uncuff() (they drop at its feet), and any stun left ends, a surrender with it. So a + * full-pay turn-in can be tried again, or a dead criminal brought back for another go. */ -/datum/bounty_admin_panel/proc/force_free(mob/user, mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/force_free(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) + criminal.revive(HEAL_ALL) if(criminal.stat == DEAD) - return refuse(user, "[criminal_name(criminal)] is dead, and the dead stay dead.") - criminal.body_remove_cuffs(null) - REMOVE_TRAIT(criminal, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - criminal.SetParalyzed(0, TRUE) - criminal.SetKnockdown(0, TRUE) - criminal.SetStun(0, TRUE) - criminal.SetImmobilized(0, TRUE) + return refuse(user, "[criminal_name(criminal)] could not be brought back.") + if(criminal.handcuffed || criminal.legcuffed) + criminal.uncuff() + criminal.SetAllImmobility(0) + criminal.SetSleeping(0) criminal.remove_status_effect(/datum/status_effect/incapacitating/stamcrit) - criminal.fully_heal(HEAL_DAMAGE) - if(criminal.is_downed()) - criminal.body_cancel_recovery() - criminal.body_stand_up() - // No proc makes a worst state better: this is the panel's one direct write (see the file's header) - criminal.worst_state = BOUNTY_STATE_FREE if(criminal.capture_state() != BOUNTY_STATE_FREE) return refuse(user, "[criminal_name(criminal)] still reads as [criminal.capture_state()].") return TRUE @@ -1164,7 +1260,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return refuse(user, "That prison is gone.") if(!prison.free_slots()) return refuse(user, "[prison_name(prison)] has no free cell.") - var/mob/living/basic/outpost_prisoner/prisoner = prison.bounty_admit_record(record) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prison.bounty_admit_record(record) if(QDELETED(prisoner)) return refuse(user, "[prison_name(prison)] took nobody: no cell there has room for an arrival.") log_action(user, "admit bounty record [record.name] ([record_kind(record)]) into cell [prisoner.cell?.number] of [prison_name(prison)]") @@ -1335,10 +1431,13 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a #undef BOUNTY_ADMIN_MAX_TICKS #undef BOUNTY_ADMIN_TICK_SECONDS #undef BOUNTY_ADMIN_STUN_TIME +#undef BOUNTY_ADMIN_DOWN_HITS +#undef BOUNTY_ADMIN_DOWN_PROBE #undef BOUNTY_ADMIN_MAX_STEPS #undef BOUNTY_ADMIN_TEST_CAPTOR #undef BOUNTY_ADMIN_RELIST_REASON #undef BOUNTY_ADMIN_MENU_SPAWN +#undef BOUNTY_ADMIN_MENU_SPAWN_CHROME #undef BOUNTY_ADMIN_MENU_DECOY #undef BOUNTY_ADMIN_MENU_POST #undef BOUNTY_ADMIN_MENU_GIVE diff --git a/voidcrew/modules/bounties/bounty_ai.dm b/voidcrew/modules/bounties/bounty_ai.dm index 7efeb9db2b1..a6f4a76f3b5 100644 --- a/voidcrew/modules/bounties/bounty_ai.dm +++ b/voidcrew/modules/bounties/bounty_ai.dm @@ -1,22 +1,30 @@ /** * # Bounty hunting: criminal AI, shared parts * - * Owner: P3 AI. Owns this file, bounty_ai_meek.dm, bounty_ai_normal.dm, bounty_activities.dm, - * bounty_companions.dm, voidcrew/_DEFINES/bounty_ai.dm and the tests in + * Owner: P3 AI. Owns this file, bounty_ai_meek.dm, bounty_ai_normal.dm, bounty_styles.dm, + * bounty_activities.dm, bounty_companions.dm, voidcrew/_DEFINES/bounty_ai.dm and the tests in * voidcrew/modules/unit_tests/voidcrew_bounty_ai.dm. Do not edit this file outside P3. * - * What it owns: the criminal AI's own system (decision 11: never the prison AI). This file holds - * what meek, normal, decoys and companions share: - * - the controller base, which hooks the mob's signals from the controller (a separate datum, so - * nothing here collides with the body's own registrations in bounty_criminal.dm); - * - who they may fight (/datum/targeting_strategy/basic/bounty: the body's may_attack(), never - * their own gang, never a downed hunter), and movement that stays on their site (leash_ok()); - * - the planning gate: nothing is planned while dead, downed, restrained or surrendered, and a - * hidden criminal only keeps still; - * - noticing hunters (a spatial grid lookup of player mobs, once a second, never view()); - * - being attacked, confronted, downed, getting up, cuffed and uncuffed; - * - what they hold in their hand and over their head (drawn in vis_contents, so a look rebuilt - * over their overlays never wipes it), and their pace. + * Criminals and their companions are real human bodies (voidcrew/modules/human_npcs/, phase D of the + * human NPC migration). Their crit, stamina, stuns, cuffs, wounds and death are tg's; this AI only + * chooses what the body does, and the body does it the way a player would: it walks or runs, draws + * a weapon it carries, clicks with it (controller.ai_interact()), throws, shoves, sits on a chair, + * lies down, climbs into a locker, injects a pen it carries. + * + * This file holds what meek, normal, decoys and companions share: + * - the controller base (/datum/ai_controller/basic_controller/bounty), which runs on a human pawn + * (contract 2.4: the human NPC run check, soft crit, the core's bounty_escape_captivity) and hooks + * the pawn's attack signals from the controller (a separate datum, so nothing here collides with the + * body's own registrations in bounty_criminal.dm); + * - who they may fight (/datum/targeting_strategy/basic/bounty: the body's may_attack(), never their + * own gang, never someone who is down), and movement that stays on their site + * (/datum/ai_movement/jps/bounty: the body's own leash rule, body_move_allowed()). There is no body + * Move() override: the leash is the AI's, so anyone can still drag, carry or shove them anywhere; + * - the planning gate: nothing is planned while they are dead, in crit, restrained, stunned or giving + * up, and a hidden criminal only keeps still; + * - noticing hunters (a spatial grid lookup of player mobs, twice a second, never view()); + * - being attacked, confronted, downed, getting up, cuffed and uncuffed, giving up; + * - their pace: a walk or a run (ai_set_pace()), as a person moves. * P4 overrides on_confronted() for bosses in its own files, on the /boss subtype. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P3 uses has an @@ -25,7 +33,7 @@ // ===== STATE ON THE CRIMINAL ===== -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal ai_controller = /datum/ai_controller/basic_controller/bounty/criminal /// BOUNTY_AI_CALM, _AWARE, _FLEEING, _FIGHTING or _RETREATING var/ai_mode = BOUNTY_AI_CALM @@ -37,12 +45,8 @@ var/ai_next_notice = 0 /// How far off each person was at the last look around, by REF(), to spot someone running at them var/list/ai_seen_distances - /// The speed they move at now; see ai_set_pace() + /// The pace they asked for last (ai_set_pace()): calm or slower is a walk, anything quicker a run var/ai_pace - /// What they hold in their hand, drawn in their vis_contents - var/obj/effect/abstract/bounty_held/ai_hand - /// What shows over their head (hands up), drawn in their vis_contents - var/obj/effect/abstract/bounty_held/ai_over_head /// A walk that keeps failing: how many times, so they give up on it var/ai_travel_failures = 0 @@ -50,10 +54,10 @@ /// The criminal a mob fights for: the criminal itself, or a companion's criminal. Null for anyone else. /proc/bounty_ai_side_of(atom/movable/thing) - if(istype(thing, /mob/living/basic/bounty_criminal)) + if(istype(thing, /mob/living/carbon/human/npc/bounty_criminal)) return thing - if(istype(thing, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = thing + if(istype(thing, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = thing return companion.ai_leader() return null @@ -61,7 +65,7 @@ /proc/bounty_ai_same_gang(atom/movable/first, atom/movable/second) if(first == second) return TRUE - var/mob/living/basic/bounty_criminal/side = bounty_ai_side_of(first) + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(first) return !isnull(side) && side == bounty_ai_side_of(second) /// Whether `person` counts as a hunter: someone played, awake, and not a criminal or one of their friends @@ -70,7 +74,7 @@ return FALSE if(!person.mind && !person.client) return FALSE - if(istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(istype(person, /mob/living/carbon/human/npc/bounty_criminal) || istype(person, /mob/living/carbon/human/npc/bounty_companion)) return FALSE return TRUE @@ -83,10 +87,10 @@ /proc/bounty_ai_may_hit(atom/movable/fighter, atom/target) if(QDELETED(target) || bounty_ai_same_gang(fighter, target)) return FALSE - if(istype(fighter, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = fighter + if(istype(fighter, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = fighter return criminal.may_attack(target) - var/mob/living/basic/bounty_criminal/leader = bounty_ai_side_of(fighter) + var/mob/living/carbon/human/npc/bounty_criminal/leader = bounty_ai_side_of(fighter) return !leader || leader.may_attack_scope(target) /// Tiles between `first` and `second` by the turfs they stand on, so a pilot counts as where their mech is (H4); INFINITY across z-levels @@ -150,65 +154,10 @@ continue .++ -// ===== HELD THINGS ===== - -/** - * Something drawn in a mob's hand or over its head. It lives in the mob's vis_contents rather than - * its overlays: the look builder replaces the overlays wholesale, and would wipe it. - */ -/obj/effect/abstract/bounty_held - name = "" - mouse_opacity = MOUSE_OPACITY_TRANSPARENT - appearance_flags = KEEP_APART | RESET_COLOR | RESET_TRANSFORM | PIXEL_SCALE - vis_flags = VIS_INHERIT_PLANE - layer = ABOVE_MOB_LAYER - /// Over the head rather than in the hand - var/over_head = FALSE - /// Drawn with the item's own in-hand sprite, which turns with the holder, rather than its item icon - var/inhand = FALSE - -/** - * Draws `look` in `owner`'s hand (or over their head), reusing `holder`: `look` is an atom, whose - * appearance is copied, or an atom typepath, whose icon is used. An item with an in-hand sprite is - * drawn with that instead, the way a player holds it, so it points the way they face. A null `look` - * takes it away. Returns the holder to keep, or null. - */ -/proc/bounty_ai_update_held(mob/living/owner, obj/effect/abstract/bounty_held/holder, look, over_head = FALSE) - if(isnull(look) || QDELETED(owner)) - if(holder) - owner?.vis_contents -= holder - qdel(holder) - return null - if(!holder) - holder = new(null) - owner.vis_contents += holder - var/list/inhand_look = over_head ? null : npc_inhand_look(look) - if(inhand_look) - holder.appearance = mutable_appearance(inhand_look[1], inhand_look[2]) - else if(ispath(look, /atom)) - var/atom/look_type = look - holder.appearance = mutable_appearance(initial(look_type.icon), initial(look_type.icon_state)) - else if(isatom(look)) - var/atom/look_atom = look - holder.appearance = look_atom.appearance - holder.name = "" - holder.over_head = over_head - holder.inhand = !!inhand_look - holder.mouse_opacity = MOUSE_OPACITY_TRANSPARENT - holder.appearance_flags = KEEP_APART | RESET_COLOR | RESET_TRANSFORM | PIXEL_SCALE - holder.vis_flags = VIS_INHERIT_PLANE - holder.layer = ABOVE_MOB_LAYER - holder.alpha = 255 - holder.dir = SOUTH - holder.pixel_x = 0 - holder.pixel_y = 0 - bounty_ai_place_held(owner, holder) - return holder - /** * The right-hand sprite of `look` (an item or item typepath) as list(file, state), or null when it - * has none: what a player holding it shows. Drawn on an NPC's body it turns with them, as a player's - * does. Used by bounty NPCs and the prison's prisoners and guards. + * has none: what a player holding it shows. The prison's prisoners and guards draw it on their bodies + * (outpost_prison_prisoner.dm); bounty NPCs now hold the real item. */ /proc/npc_inhand_look(look) var/hand_file @@ -225,45 +174,90 @@ return null return list(hand_file, state) -/// Puts a held thing in the hand on the side they face (`facing`, or the way they face now), or over their head -/proc/bounty_ai_place_held(mob/living/owner, obj/effect/abstract/bounty_held/holder, facing) - if(!holder || !owner) - return - // An in-hand sprite is drawn for a whole body: full size, no offset, facing their way - if(holder.inhand) - holder.transform = matrix() - holder.pixel_w = 0 - holder.pixel_z = 0 - holder.dir = facing || owner.dir - return - holder.transform = matrix().Scale(0.6) - if(holder.over_head) - holder.pixel_w = 0 - holder.pixel_z = 22 +/** + * Sets the pace of `body` (a criminal or a companion): calm or slower walks, anything quicker runs, + * through tg's own move intent. A person can't go faster than a run. + */ +/proc/bounty_ai_set_pace(mob/living/carbon/human/body, new_speed) + if(QDELETED(body)) return - var/facing_dir = facing || owner.dir - // The same hand whichever way they face: to the right facing south or east, the left facing north or west - holder.pixel_w = ((facing_dir & WEST) || facing_dir == NORTH) ? -7 : 7 - holder.pixel_z = -4 - -/// What a fighter holds, by whichever of the two types it is -/proc/bounty_ai_hold(mob/living/basic/fighter, look) - if(istype(fighter, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = fighter - criminal.ai_hand = bounty_ai_update_held(criminal, criminal.ai_hand, look) - else if(istype(fighter, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = fighter - companion.ai_hand = bounty_ai_update_held(companion, companion.ai_hand, look) + var/want_run = !isnull(new_speed) && new_speed < BOUNTY_CALM_SPEED + if(want_run != (body.move_intent == MOVE_INTENT_RUN)) + body.toggle_move_intent() + +/** + * A step `mover` (a criminal or a companion) takes by hand, outside the AI's pathing: never off its + * side's leash (the criminal's own body_move_allowed(); a companion its criminal's leash_ok()), into + * lava or a chasm. Returns TRUE if it moved. Every direct step in the bounty AI goes through here. + * + * A `paced` step (a fighter's step aside or back) comes no sooner than a person could take it: the + * body's own move delay after its last step, by hand or on the AI's walk (bounty_ai_step_ready()). Any + * step taken here holds the AI's walk back by that delay too, so the two never add up to a double step. + */ +/proc/bounty_ai_step(mob/living/mover, turf/destination, paced = FALSE) + if(QDELETED(mover) || !isturf(mover.loc) || !isturf(destination) || get_dist(mover, destination) != 1) + return FALSE + if(paced && !bounty_ai_step_ready(mover)) + return FALSE + if(islava(destination) || ischasm(destination) || destination.is_blocked_turf(source_atom = mover)) + return FALSE + if(!bounty_ai_step_allowed(mover, destination)) + return FALSE + var/turf/was = mover.loc + var/direction = get_dir(mover, destination) + mover.Move(destination, direction) + if(mover.loc == was) + return FALSE + var/delay = bounty_ai_note_step(mover, direction) + mover.set_glide_size(DELAY_TO_GLIDE_SIZE(delay)) + // The AI's own walk waits as long before its next step + var/datum/move_loop/walk = GLOB.move_manager.processing_on(mover, SSai_movement) + if(walk && walk.timer < world.time + delay) + walk.pause_for(delay) + return TRUE + +/// Whether `mover` may take a step by hand now: its own move delay has passed since its last step (BB_BOUNTY_NEXT_STEP_AT) +/proc/bounty_ai_step_ready(mob/living/mover) + var/datum/ai_controller/controller = mover?.ai_controller + if(QDELETED(controller)) + return TRUE + return world.time >= controller.blackboard[BB_BOUNTY_NEXT_STEP_AT] + +/** + * `mover` just stepped in `direction`: its next step by hand waits out the body's own move delay + * (cached_multiplicative_slowdown, what a player's step costs them), longer after a diagonal one as a + * player's is. Returns that delay. + */ +/proc/bounty_ai_note_step(mob/living/mover, direction) + var/delay = max(mover.cached_multiplicative_slowdown, world.tick_lag) + if(ISDIAGONALDIR(direction)) + delay *= sqrt(2) + var/datum/ai_controller/controller = mover.ai_controller + if(!QDELETED(controller)) + controller.set_blackboard_key(BB_BOUNTY_NEXT_STEP_AT, world.time + delay) + return delay + +/// Whether `mover`'s own step onto `destination` keeps to its leash (see bounty_ai_step()) +/proc/bounty_ai_step_allowed(mob/living/mover, turf/destination) + if(istype(mover, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = mover + return criminal.body_move_allowed(destination) + var/mob/living/carbon/human/npc/bounty_criminal/leader = bounty_ai_side_of(mover) + if(!leader || leader.leash_ok(destination)) + return TRUE + // Already off its criminal's ground (dragged, carried, woken elsewhere): it may walk about, but never onto danger + return !leader.leash_ok(get_turf(mover)) && destination.can_cross_safely(mover) // ===== THE CRIMINAL'S SIDE OF IT ===== /** - * `user` confronted them: showed the warrant, or accused them. A meek criminal bolts, a normal one - * fights; a boss starts its fight (P4 overrides this on /boss); P6 decides what a decoy does. This - * and P6's exposure (which ends its act, then calls this) are the only ways out of blending in: - * anything else that happens to a blended patron shows nothing (C1). + * `user` confronted them: showed the warrant, accused them, reached for their wrists (the core's cuff + * reaction calls this). A meek criminal bolts, a normal one fights; a boss starts its fight (P4 + * overrides this on /boss); P6 decides what a decoy does. This and P6's exposure (which ends its act, + * then calls this) are the only ways out of blending in: anything else that happens to a blended + * patron shows nothing (C1). */ -/mob/living/basic/bounty_criminal/proc/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/proc/on_confronted(mob/user) if(stat != CONSCIOUS || !isliving(user) || QDELETED(user)) return ai_add_grudge(user) @@ -278,58 +272,58 @@ * What they do about `threat`: a meek criminal runs, a normal one fights, a decoy gets out of the * way. `bark` is the dialogue context to use instead of the usual one, or null. */ -/mob/living/basic/bounty_criminal/proc/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_react(mob/living/threat, bark) return /// Something about `hunter` made them look twice (armed, cuffs, a warrant, running at them) -/mob/living/basic/bounty_criminal/proc/ai_notice(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_notice(mob/living/hunter) return /// How far they notice hunters -/mob/living/basic/bounty_criminal/proc/ai_notice_range() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_notice_range() return BOUNTY_NORMAL_NOTICE_RANGE /// Their fighting style's key, BOUNTY_STYLE_*, or null for a mob that never fights -/mob/living/basic/bounty_criminal/proc/ai_wanted_style() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_wanted_style() return null /// Whether they carry their style's heavier weapon: every tier above Petty. A 9mm is a 9mm; the tier only picks the gun. -/mob/living/basic/bounty_criminal/proc/ai_heavy_weapon() - return record?.tier != BOUNTY_TIER_PETTY +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_heavy_weapon() + return !isnull(record?.tier) && record.tier != BOUNTY_TIER_PETTY -/// Puts their style on them, once the record says which it is. Cheap when nothing changed. -/mob/living/basic/bounty_criminal/proc/ai_update_style() +/// Puts their style on their AI, once the record says which it is. Cheap when nothing changed. The weapon itself is what they carry (bounty_build_outfit()). +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_update_style() var/key = ai_wanted_style() if(!key || QDELETED(ai_controller) || ai_controller.blackboard[BB_BOUNTY_STYLE] == key) return - bounty_ai_apply_style(src, key, ai_heavy_weapon()) + bounty_ai_apply_style(src, key) /** - * Whether they can do anything at all: alive, on their feet, free and not giving up. Knocked down, - * stunned or in stamina crit (P2's body_is_stunned()) is the cuff window: no blows, no shots, no - * climbing into hiding (H2). + * Whether they can do anything at all: alive, out of crit, on their feet, free and not giving up. + * Knocked down, stunned, in stamina crit or lying there with their hands on their head (tg's + * *surrender) is the cuff window: no blows, no shots, no climbing into hiding (H2). All read from tg. */ -/mob/living/basic/bounty_criminal/proc/ai_can_act() - if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_BOUNTY_HELD) || HAS_TRAIT(src, TRAIT_FLOORED) || is_downed() || is_restrained()) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_can_act() + if(stat != CONSCIOUS || npc_surrendering || HAS_TRAIT(src, TRAIT_FLOORED) || is_downed() || is_restrained()) return FALSE return !body_is_stunned() /// Says a line for `context`, never from inside a signal handler's stack. `force` skips the cooldown, for a moment that must be heard. -/mob/living/basic/bounty_criminal/proc/ai_bark(context, force = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_bark(context, force = FALSE) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), context, null, force) /// Puts `who` on the list of people they may fight, by P2's rules (the cap, never another criminal or a companion) -/mob/living/basic/bounty_criminal/proc/ai_add_grudge(mob/living/who) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_add_grudge(mob/living/who) if(!isliving(who) || bounty_ai_same_gang(src, who)) return body_add_grudge(who) /// Whether `who` is on their grudge list -/mob/living/basic/bounty_criminal/proc/ai_has_grudge(mob/living/who) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_has_grudge(mob/living/who) return body_has_grudge(who) /// The nearest person on their grudge list they can see (a pilot through their mech), or null -/mob/living/basic/bounty_criminal/proc/ai_nearest_grudge(range = BOUNTY_FIGHT_VISION) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_nearest_grudge(range = BOUNTY_FIGHT_VISION) var/mob/living/best var/best_distance = INFINITY for(var/datum/weakref/ref as anything in grudge) @@ -345,16 +339,16 @@ return best /// Who they are wary of, running from or fighting, if they still exist -/mob/living/basic/bounty_criminal/proc/ai_threat() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_threat() var/mob/living/threat = ai_threat_ref?.resolve() return QDELETED(threat) ? null : threat -/mob/living/basic/bounty_criminal/proc/ai_set_threat(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_set_threat(mob/living/threat) ai_threat_ref = threat ? WEAKREF(threat) : null ai_threat_seen_at = world.time /// Whether they can see their threat now (a pilot through their mech); seeing it restarts the clock on losing it -/mob/living/basic/bounty_criminal/proc/ai_sees_threat(range = BOUNTY_FIGHT_VISION) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sees_threat(range = BOUNTY_FIGHT_VISION) var/mob/living/threat = ai_threat() var/turf/threat_turf = get_turf(threat) if(!threat || threat_turf?.z != z || !bounty_ai_can_see(src, threat, range)) @@ -362,21 +356,20 @@ ai_threat_seen_at = world.time return TRUE -/mob/living/basic/bounty_criminal/proc/ai_set_mode(new_mode) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_set_mode(new_mode) ai_mode = new_mode -/// Moves at `new_speed` from now on. Compared with the speed they have, not a cache: the body sets speed too (L3). -/mob/living/basic/bounty_criminal/proc/ai_set_pace(new_speed) +/// Walks (calm, BOUNTY_CALM_SPEED or slower) or runs (anything quicker) from now on: tg's move intent, nothing faster. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_set_pace(new_speed) ai_pace = new_speed - if(speed != new_speed) - set_varspeed(new_speed) + bounty_ai_set_pace(src, new_speed) /** * Takes them out of blending in: P6's act ends with it, and they never go back to the routine (a * P6 exposure has already cleared `blended` by the time on_confronted() runs). Not for decoys, who * are always patrons (bounty_activities.dm). */ -/mob/living/basic/bounty_criminal/proc/ai_expose() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_expose() ai_end_blend() /** @@ -385,7 +378,7 @@ * pathfinder's own step check), and never leaves their site. Built once per flight, never per tick * (H1). get_path_to() can't be used here: it sleeps, and planning must not. */ -/mob/living/basic/bounty_criminal/proc/ai_reachable_turfs(radius) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_reachable_turfs(radius) . = list() var/turf/start = get_turf(src) if(!start) @@ -407,10 +400,10 @@ queue += next /// Gets out of the way of `threat` without looking for somewhere to hide (a decoy that stopped blending in) -/mob/living/basic/bounty_criminal/proc/ai_flee_from(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_flee_from(mob/living/threat) if(!isliving(threat) || !ai_flee_event(threat, null, seek_hiding = FALSE, quiet = TRUE)) return FALSE - ai_set_pace(BOUNTY_NORMAL_SPEED_MELEE) + ai_set_pace(BOUNTY_RUN_SPEED) return TRUE /** @@ -420,7 +413,7 @@ * blended in (a fugitive at a trader outpost only reacts to a warrant or a blow), fleeing or * fighting. Returns the nearest one found, or null. */ -/mob/living/basic/bounty_criminal/proc/ai_look_around() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_look_around() if(blended || ai_mode == BOUNTY_AI_FLEEING || ai_mode == BOUNTY_AI_FIGHTING) return null if(world.time < ai_next_notice) @@ -459,7 +452,7 @@ * - Friendly fire from their own side of the site with nobody playing it (the pirates they are * aboard with, the site's fauna) is let go (M1). */ -/mob/living/basic/bounty_criminal/proc/ai_attacked_by(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_attacked_by(mob/living/attacker) if(stat == DEAD || blended || !isliving(attacker) || bounty_ai_same_gang(src, attacker)) return if(bounty_ai_friendly_fire(src, attacker)) @@ -476,88 +469,95 @@ ai_react(attacker, null) /// Attacked while already running or fighting -/mob/living/basic/bounty_criminal/proc/ai_attacked_again(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_attacked_again(mob/living/attacker) return /// Someone touched them without attacking: a hider is found by it -/mob/living/basic/bounty_criminal/proc/ai_touched_by(mob/living/toucher) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_touched_by(mob/living/toucher) if(hidden && ai_hide_mode != BOUNTY_HIDE_CONTAINER) ai_reveal(toucher, "touched") /// Someone walked into them: a hider is found by it -/mob/living/basic/bounty_criminal/proc/ai_bumped_by(mob/living/bumper) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_bumped_by(mob/living/bumper) if(hidden && ai_hide_mode != BOUNTY_HIDE_CONTAINER && bounty_ai_is_hunter(bumper)) ai_reveal(bumper, "bumped") -/// They are about to take damage. Inside a signal handler: must not sleep. -/mob/living/basic/bounty_criminal/proc/ai_took_damage(damage, damagetype) +/// They took a hit of `damage` `damagetype`. Inside a signal handler: must not sleep. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_took_damage(damage, damagetype) return -/// Down at the downed line (P2's COMSIG_BOUNTY_CRIMINAL_DOWNED): they drop everything, a shot or a wind-up included. The body says the line (body_go_down()). -/mob/living/basic/bounty_criminal/proc/ai_on_downed() +/// Into crit (the core's COMSIG_BOUNTY_CRIMINAL_DOWNED): whatever they were doing stops, a shot or a wind-up included. The body says the line. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_downed() ai_let_go() if(!QDELETED(ai_controller)) ai_controller.CancelActions() ai_companions_lose_heart() /** - * Back up after the recovery time (P2's COMSIG_BOUNTY_CRIMINAL_RECOVERED): they run or fight again. + * Out of crit or back up from stamina crit (COMSIG_BOUNTY_CRIMINAL_RECOVERED): they run or fight again. * Blended in, they only go back to the blend routine, like any patron (M2). */ -/mob/living/basic/bounty_criminal/proc/ai_on_recovered() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_recovered() if(blended || !ai_can_act()) return ai_react(ai_threat() || ai_nearest_grudge(), "recover") -/// Cuffed (P2's COMSIG_BOUNTY_CRIMINAL_RESTRAINED). The body says the line (body_apply_cuffs()). -/mob/living/basic/bounty_criminal/proc/ai_on_restrained(mob/living/user) +/// Cuffed (COMSIG_BOUNTY_CRIMINAL_RESTRAINED). The body says the line. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_restrained(mob/living/user) ai_let_go() if(!QDELETED(ai_controller)) ai_controller.CancelActions() ai_add_grudge(user) ai_companions_lose_heart() -/// Cuffs off (P2's COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED): if they slipped them, they make a break for it -/mob/living/basic/bounty_criminal/proc/ai_on_unrestrained(mob/living/user) +/// Cuffs off (COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED): if they got out of them themselves, they make a break for it +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_unrestrained(mob/living/user) if(user) return - REMOVE_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) if(!blended && ai_can_act()) ai_react(ai_nearest_grudge(), null) +/// They gave up (tg's *surrender, run by the human NPC base): hands on their head, the fight dropped, their friends lose heart +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_surrendered() + visible_message(span_notice("[src] raises [p_their()] hands and gives up.")) + ai_stop_aiming() + ai_suspend_activity() + if(!QDELETED(ai_controller)) + ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) + ai_controller.ai_movement?.stop_moving_towards(ai_controller) + ai_companions_lose_heart() + /// Dead: they let go of everything, and a disguise comes off, so the body looks like them -/mob/living/basic/bounty_criminal/proc/ai_on_death() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_death() ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) /** * Drops everything they are doing: out of hiding (looks restored), no aim, the activity put away - * (drink down, standing up), nothing in their hand, not sprinting. Safe to call at any time, + * (drink down, up from a seat or the floor). What they hold stays in their hands: their gear is locked + * to them (D3), and a person who is down or cuffed can't holster anything. Safe to call at any time, * including while they are being deleted. */ -/mob/living/basic/bounty_criminal/proc/ai_let_go() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_let_go() if(hidden) ai_unhide() ai_stop_aiming() ai_suspend_activity() // Off the floor from a retreat's rest too, not only an activity's (L9) ai_stand_up() - ai_hand = bounty_ai_update_held(src, ai_hand, null) - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) if(!QDELETED(ai_controller)) ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) ai_controller.clear_blackboard_key(BB_BASIC_MOB_FLEE_TARGET) ai_controller.clear_blackboard_key(BB_BOUNTY_HIDE_SPOT) ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) -/// Nothing is after them any more: back to what they were doing, at an easy pace -/mob/living/basic/bounty_criminal/proc/ai_calm_down() +/// Nothing is after them any more: weapon away, back to what they were doing, at a walk +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_calm_down() ai_set_mode(BOUNTY_AI_CALM) ai_set_threat(null) ai_stop_aiming() - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - ai_hand = bounty_ai_update_held(src, ai_hand, null) + if(ai_can_act() && !HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + bounty_ai_holster(src) ai_set_pace(BOUNTY_CALM_SPEED) if(!QDELETED(ai_controller)) ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) @@ -566,7 +566,7 @@ ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) /// A walk the AI set up failed; after a few, they give up on it -/mob/living/basic/bounty_criminal/proc/ai_travel_failed() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_travel_failed() if(++ai_travel_failures < BOUNTY_TRAVEL_GIVE_UP) return ai_travel_failures = 0 @@ -579,39 +579,44 @@ ai_activity?.spot_unreachable() /// Their companions, as mobs that still exist -/mob/living/basic/bounty_criminal/proc/ai_companions() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_companions() . = list() for(var/datum/weakref/ref as anything in companions) - var/mob/living/basic/bounty_companion/buddy = ref?.resolve() + var/mob/living/carbon/human/npc/bounty_companion/buddy = ref?.resolve() if(!QDELETED(buddy)) . += buddy /// Tells every companion to go for `target`: the trooper reinforcement pattern, without a view() scan -/mob/living/basic/bounty_criminal/proc/ai_rally_companions(mob/living/target) - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_rally_companions(mob/living/target) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in ai_companions()) buddy.ai_join_fight(target) /// Their criminal is down, cuffed or gave up: each companion may run for it -/mob/living/basic/bounty_criminal/proc/ai_companions_lose_heart() - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_companions_lose_heart() + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in ai_companions()) buddy.ai_lose_heart() /// Whether they may fire at `target` now. A meek criminal only fires its cornered volley. -/mob/living/basic/bounty_criminal/proc/ai_may_shoot(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_may_shoot(atom/target) return TRUE // ===== TARGETING AND MOVEMENT ===== /** * Who a criminal or a companion may fight: tg's rules (in sight, not in godmode), plus - * bounty_ai_may_hit() (the body's rules on who is fair game, never their own gang), and never a - * downed hunter (BB_TARGET_MINIMUM_STAT is CONSCIOUS, spec C4). A pilot is fought through their - * mech (H4): tg's own mech branch tests the pilot with no sight range and never passes, so a mech - * is judged here, as P4 does for bosses. + * bounty_ai_may_hit() (the body's rules on who is fair game, never their own gang), and never someone + * who is down (BB_TARGET_MINIMUM_STAT is CONSCIOUS: the human NPC base's HUMAN_NPC_SPARES_DOWNED, spec + * C4). A check made without a range uses the sight range (BB_AGGRO_RANGE), as the human NPC base's + * targeting does, and never picks someone in another packed encounter. This is the only gate on whom + * a blow or a shot is aimed at: the body then clicks the target like a player. A pilot is fought + * through their mech (H4): tg's own mech branch tests the pilot with no sight range and never + * passes, so a mech is judged here, as P4 does for bosses. */ /datum/targeting_strategy/basic/bounty /datum/targeting_strategy/basic/bounty/can_attack(mob/living/living_mob, atom/the_target, vision_range) + if(isnull(vision_range)) + vision_range = living_mob.ai_controller?.blackboard[BB_AGGRO_RANGE] || BOUNTY_FIGHT_VISION if(ismecha(the_target)) return mech_attackable(living_mob, the_target, vision_range) . = ..() @@ -620,6 +625,8 @@ var/mob/living/person = the_target if(isliving(person) && ismecha(person.loc)) return FALSE + if(map_region_excludes_turf(map_region_for_turf(get_turf(living_mob)), get_turf(the_target))) + return FALSE return bounty_ai_may_hit(living_mob, the_target) /// A mech in sight with someone awake aboard who is fair game @@ -634,9 +641,11 @@ return FALSE /** - * Smart pathing that stays on their site (AR-E1): a step off the leash (P2's leash_ok(): the site's - * bounds, never a ship, hangar or player outpost) is refused. Paths are bounded by - * AI_MAX_PATH_LENGTH like any jps mover. + * Smart pathing that stays on their site (AR-E1, CR-19): a step the body's leash refuses is never + * taken (body_move_allowed(): onto the site always, off it never, and an off-site body only back + * toward it and never into a hazard or another ship). This is the leash; the body has no Move() + * override, so drags, carries and shoves are tg's. Paths are bounded by AI_MAX_PATH_LENGTH like any + * jps mover. */ /datum/ai_movement/jps/bounty @@ -647,10 +656,17 @@ var/list/path = source.movement_path if(!length(path)) return - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(source.moving) - if(leash_holder && !leash_holder.leash_ok(path[1])) + var/mob/living/mover = source.moving + if(isliving(mover) && !bounty_ai_step_allowed(mover, path[1])) return FALSE +// Each step of the walk counts as the body's last step, so a step by hand (bounty_ai_step()) waits a step's time after it +/datum/ai_movement/jps/bounty/post_move(datum/move_loop/source, succeeded) + . = ..() + var/mob/living/mover = source.moving + if(succeeded == MOVELOOP_SUCCESS && isliving(mover)) + bounty_ai_note_step(mover, mover.last_move) + /// Now and then a step in some direction, while nothing else is planned: never off the leash, never while hiding, cuffed or down /datum/idle_behavior/bounty_wander /// Chance a second of a step @@ -658,39 +674,36 @@ /datum/idle_behavior/bounty_wander/perform_idle_behavior(seconds_per_tick, datum/ai_controller/controller) . = ..() - var/mob/living/basic/wanderer = controller.pawn - if(!istype(wanderer) || !isturf(wanderer.loc) || wanderer.buckled || wanderer.pulledby || LAZYLEN(wanderer.do_afters)) + var/mob/living/carbon/human/wanderer = controller.pawn + if(!istype(wanderer) || wanderer.stat != CONSCIOUS || !isturf(wanderer.loc) || wanderer.buckled || wanderer.pulledby || wanderer.resting || LAZYLEN(wanderer.do_afters)) return if(!(wanderer.mobility_flags & MOBILITY_MOVE) || !SPT_PROB(walk_chance, seconds_per_tick)) return - if(istype(wanderer, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = wanderer + if(istype(wanderer, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = wanderer if(criminal.hidden || !criminal.ai_can_act() || criminal.ai_activity) return - else if(istype(wanderer, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = wanderer - if(companion.ai_gave_up) + else if(istype(wanderer, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = wanderer + if(!companion.ai_can_fight()) return - var/mob/living/basic/bounty_criminal/side = bounty_ai_side_of(wanderer) - var/move_dir = pick(GLOB.alldirs) - var/turf/destination = get_step(wanderer, move_dir) + var/turf/destination = get_step(wanderer, pick(GLOB.alldirs)) if(!destination?.can_cross_safely(wanderer)) return - if(side && !side.leash_ok(destination)) - return - wanderer.Move(destination, move_dir) + bounty_ai_step(wanderer, destination) // ===== PLANNING ===== /** - * First in every criminal's tree. Nothing is planned while they are dead, downed, restrained or - * surrendered; a hidden criminal only keeps still (and checks whether it has been found); one - * shut in something they can't get out of waits. + * First of a criminal's own subtrees, after the human NPC base's soft crit and the core's + * bounty_escape_captivity. Nothing is planned while they can't act (in crit, cuffed, stunned, giving + * up); a hidden criminal only keeps still (and checks whether it has been found); one shut in + * something they can't get out of waits. */ /datum/ai_planning_subtree/bounty_gate /datum/ai_planning_subtree/bounty_gate/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal)) return if(!criminal.ai_can_act()) @@ -706,7 +719,7 @@ /datum/ai_planning_subtree/bounty_notice /datum/ai_planning_subtree/bounty_notice/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/mob/living/hunter = criminal.ai_look_around() if(hunter) criminal.ai_notice(hunter) @@ -716,11 +729,11 @@ action_cooldown = 1 SECONDS /datum/ai_behavior/bounty_stay_hidden/setup(datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn return istype(criminal) && criminal.hidden /datum/ai_behavior/bounty_stay_hidden/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!criminal.hidden) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED criminal.ai_hidden_tick() @@ -732,9 +745,9 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/bounty_watch/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/mob/living/threat = istype(criminal) ? criminal.ai_threat() : null - if(threat && bounty_ai_dist(criminal, threat) <= BOUNTY_FIGHT_VISION) + if(threat && bounty_ai_dist(criminal, threat) <= BOUNTY_FIGHT_VISION && !criminal.buckled) criminal.face_atom(bounty_ai_target_for(threat)) return AI_BEHAVIOR_DELAY @@ -760,7 +773,7 @@ var/atom/target = controller.blackboard[target_key] var/stuck = !succeeded && controller.consecutive_pathing_attempts > 0 && target && controller.current_movement_target == target . = ..() - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal)) return if(succeeded) @@ -775,74 +788,69 @@ // ===== CONTROLLERS ===== /** - * What every bounty fighter's controller shares, criminals and companions alike: who they may - * fight, smart pathing on the leash, and the signal hooks for styles and for being attacked. The - * hooks are registered by the controller, never by the mob on itself, so they never collide with - * the body's own registrations (a second RegisterSignal from the same datum replaces the first). - * - * Attacks are read from the attack signals themselves (items, hands, mobs, shots that landed, - * thrown things) rather than relay_attackers, whose projectile hook fires even for a shot that - * passes through a blended patron. + * What every bounty fighter's controller shares, criminals and companions alike, on a human pawn + * (contract 2.4): the human NPC base's run check (cuffs and soft crit don't switch the AI off, so it can + * work at its cuffs and crawl), who they may fight, smart pathing on the leash, and the signal hooks for + * being attacked. The hooks are registered by the controller, never by the mob on itself, so they + * never collide with the body's own registrations (a second RegisterSignal from the same datum + * replaces the first). * * Who they fight comes from their grudge list and retaliation only, filtered by the body's * may_attack() and their own gang, so faction is ignored in targeting: a criminal shares * FACTION_NEUTRAL with players until the placement sets otherwise, and would never hit back. + * + * Every planning list starts with the human NPC base's soft-crit subtree, then the core's + * bounty_escape_captivity (it works at its cuffs and breaks grabs, but never climbs out of a locker it + * hid in or off a chair it sat on). */ /datum/ai_controller/basic_controller/bounty blackboard = list( BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/bounty, - BB_TARGET_MINIMUM_STAT = CONSCIOUS, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + BB_AGGRO_RANGE = BOUNTY_FIGHT_VISION, BB_BASIC_MOB_FLEE_DISTANCE = BOUNTY_FLEE_DISTANCE, BB_ALWAYS_IGNORE_FACTION = TRUE, ) ai_movement = /datum/ai_movement/jps/bounty idle_behavior = /datum/idle_behavior/bounty_wander - /// Whether the mob carries a ranged_attacks component, for a ranged style or the holdout. A tree with a ranged subtree needs it at init. - var/armed = TRUE /datum/ai_controller/basic_controller/bounty/TryPossessPawn(atom/new_pawn) - if(!isbasicmob(new_pawn)) + if(!ishumannpc(new_pawn)) return AI_CONTROLLER_INCOMPATIBLE - var/mob/living/basic/fighter = new_pawn - if(armed) - fighter.AddComponent(/datum/component/ranged_attacks, projectile_type = /obj/projectile/bullet/bounty) - RegisterSignal(fighter, COMSIG_HOSTILE_PRE_ATTACKINGTARGET, PROC_REF(on_pre_melee)) - RegisterSignal(fighter, COMSIG_HOSTILE_POST_ATTACKINGTARGET, PROC_REF(on_post_melee)) - RegisterSignal(fighter, COMSIG_BASICMOB_PRE_ATTACK_RANGED, PROC_REF(on_pre_ranged)) - RegisterSignal(fighter, COMSIG_BASICMOB_POST_ATTACK_RANGED, PROC_REF(on_post_ranged)) - RegisterSignal(fighter, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_fire)) - RegisterSignal(fighter, COMSIG_ATOM_DIR_CHANGE, PROC_REF(on_turned)) + var/mob/living/carbon/human/npc/fighter = new_pawn RegisterSignal(fighter, COMSIG_ATOM_AFTER_ATTACKEDBY, PROC_REF(on_attacked_by_item)) RegisterSignal(fighter, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_touched)) - // A basic mob's blow reaches attack_animal() too, so this one signal covers both kinds of mob - RegisterSignal(fighter, COMSIG_ATOM_ATTACK_ANIMAL, PROC_REF(on_attacked_by_mob)) + RegisterSignals(fighter, list(COMSIG_ATOM_ATTACK_ANIMAL, COMSIG_ATOM_ATTACK_BASIC_MOB), PROC_REF(on_attacked_by_mob)) RegisterSignal(fighter, COMSIG_ATOM_ATTACK_MECH, PROC_REF(on_attacked_by_mech)) RegisterSignal(fighter, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_shot)) RegisterSignal(fighter, COMSIG_ATOM_HITBY, PROC_REF(on_hit_by_thrown)) - RegisterSignal(fighter, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_damage)) + RegisterSignal(fighter, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damage)) RegisterSignal(fighter, SIGNAL_ADDTRAIT(TRAIT_FLOORED), PROC_REF(on_floored)) return ..() /datum/ai_controller/basic_controller/bounty/UnpossessPawn(destroy) if(pawn) UnregisterSignal(pawn, list( - COMSIG_HOSTILE_PRE_ATTACKINGTARGET, - COMSIG_HOSTILE_POST_ATTACKINGTARGET, - COMSIG_BASICMOB_PRE_ATTACK_RANGED, - COMSIG_BASICMOB_POST_ATTACK_RANGED, - COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, - COMSIG_ATOM_DIR_CHANGE, COMSIG_ATOM_AFTER_ATTACKEDBY, COMSIG_ATOM_ATTACK_HAND, COMSIG_ATOM_ATTACK_ANIMAL, + COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_MECH, COMSIG_ATOM_BULLET_ACT, COMSIG_ATOM_HITBY, - COMSIG_MOB_APPLY_DAMAGE, + COMSIG_MOB_AFTER_APPLY_DAMAGE, SIGNAL_ADDTRAIT(TRAIT_FLOORED), )) return ..() +// The basic controller's own check, except that cuffs and soft crit don't stop us (human NPC base, contract 2.4) +/datum/ai_controller/basic_controller/bounty/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/bounty/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + /// Whether the pawn may land a blow or a shot on `target` (a companion fights on when its criminal is down, H3) /datum/ai_controller/basic_controller/bounty/proc/may_strike(atom/target) return bounty_ai_may_hit(pawn, target) @@ -859,50 +867,10 @@ /datum/ai_controller/basic_controller/bounty/proc/pawn_touched(mob/living/toucher) return -/// The pawn is about to take `damage` of `damagetype`. Called inside the signal handler: must not sleep. +/// The pawn just took `damage` of `damagetype` (its health already shows it). Inside the signal handler: must not sleep. /datum/ai_controller/basic_controller/bounty/proc/pawn_damaged(damage, damagetype) return -/// Moves what the pawn holds to the hand on the side it is turning to (`facing`) -/datum/ai_controller/basic_controller/bounty/proc/place_held(facing) - return - -/datum/ai_controller/basic_controller/bounty/proc/on_pre_melee(mob/living/basic/source, atom/target, proximity, list/modifiers) - SIGNAL_HANDLER - if(!may_strike(target)) - return COMPONENT_HOSTILE_NO_ATTACK - // Each swing picks one of the weapon's own verbs, as a player's does - bounty_ai_real_weapon(source)?.pick_verb(source) - return NONE - -/datum/ai_controller/basic_controller/bounty/proc/on_post_melee(mob/living/basic/source, atom/target, result) - SIGNAL_HANDLER - if(!result || !isliving(target)) - return - var/datum/bounty_style/style = bounty_ai_style(source) - if(style) - INVOKE_ASYNC(style, TYPE_PROC_REF(/datum/bounty_style, on_melee_hit), source, target) - -/datum/ai_controller/basic_controller/bounty/proc/on_pre_ranged(mob/living/basic/source, atom/target, list/modifiers) - SIGNAL_HANDLER - return may_shoot(target) ? NONE : COMPONENT_CANCEL_RANGED_ATTACK - -/datum/ai_controller/basic_controller/bounty/proc/on_post_ranged(mob/living/basic/source, atom/target, list/modifiers) - SIGNAL_HANDLER - var/datum/bounty_style/style = bounty_ai_style(source) - style?.after_shot(source) - -/datum/ai_controller/basic_controller/bounty/proc/on_fire(mob/living/basic/source, obj/projectile/shot, atom/fired_from, atom/original) - SIGNAL_HANDLER - var/datum/bounty_style/style = bounty_ai_style(source) - style?.prepare_projectile(source, shot) - -/// Sent before the turn lands, so the new direction is passed on rather than read off the mob -/datum/ai_controller/basic_controller/bounty/proc/on_turned(atom/source, old_dir, new_dir) - SIGNAL_HANDLER - if(old_dir != new_dir) - place_held(new_dir) - /datum/ai_controller/basic_controller/bounty/proc/on_attacked_by_item(atom/source, obj/item/weapon, mob/living/attacker, list/modifiers) SIGNAL_HANDLER if(weapon?.force && isliving(attacker)) @@ -919,12 +887,8 @@ /datum/ai_controller/basic_controller/bounty/proc/on_attacked_by_mob(atom/source, mob/living/attacker) SIGNAL_HANDLER - if(!isliving(attacker)) + if(!isliving(attacker) || attacker.melee_damage_upper <= 0) return - if(isbasicmob(attacker)) - var/mob/living/basic/basic_attacker = attacker - if(basic_attacker.melee_damage_upper <= 0) - return INVOKE_ASYNC(src, PROC_REF(pawn_attacked), attacker) /// A mech's blow is its pilot's (H4) @@ -934,7 +898,7 @@ if(attacker) INVOKE_ASYNC(src, PROC_REF(pawn_attacked), attacker) -/// Only a shot that actually hit: one that passed through a blended patron never gets here. A mech's gunfire is its pilot's (H4). +/// A shot that hit them. A mech's gunfire is its pilot's (H4). /datum/ai_controller/basic_controller/bounty/proc/on_shot(atom/source, obj/projectile/shot, def_zone, piercing_hit, blocked) SIGNAL_HANDLER if(!shot.is_hostile_projectile()) @@ -961,8 +925,12 @@ if(isliving(thrower)) INVOKE_ASYNC(src, PROC_REF(pawn_attacked), thrower) +/// Any damage landed: the hit is noted (a wind-up it lands in is fumbled, bounty_styles.dm) /datum/ai_controller/basic_controller/bounty/proc/on_damage(atom/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) SIGNAL_HANDLER + if(damage <= 0) + return + set_blackboard_key(BB_BOUNTY_HIT_AT, world.time) pawn_damaged(damage, damagetype) /** @@ -971,23 +939,25 @@ * bounty_activities.dm). can_idle stays TRUE, as for every basic mob. */ /datum/ai_controller/basic_controller/bounty/criminal - armed = FALSE planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_activity, ) /datum/ai_controller/basic_controller/bounty/criminal/TryPossessPawn(atom/new_pawn) - if(!istype(new_pawn, /mob/living/basic/bounty_criminal)) + if(!istype(new_pawn, /mob/living/carbon/human/npc/bounty_criminal)) return AI_CONTROLLER_INCOMPATIBLE - var/mob/living/basic/bounty_criminal/criminal = new_pawn - RegisterSignal(criminal, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_prehit)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = new_pawn RegisterSignal(criminal, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(on_pull_check)) RegisterSignal(criminal, COMSIG_ATOM_BUMPED, PROC_REF(on_bumped)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_DOWNED, PROC_REF(on_downed)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_RECOVERED, PROC_REF(on_recovered)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, PROC_REF(on_restrained)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, PROC_REF(on_unrestrained)) + RegisterSignal(criminal, COMSIG_HUMAN_NPC_SURRENDERED, PROC_REF(on_surrendered)) criminal.ai_set_pace(BOUNTY_CALM_SPEED) // Spec section 3: they don't fight the hostiles native to their site. The placement adds the // site's own factions (pirates) on top. @@ -998,100 +968,85 @@ return ..() /datum/ai_controller/basic_controller/bounty/criminal/UnpossessPawn(destroy) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) UnregisterSignal(criminal, list( - COMSIG_PROJECTILE_PREHIT, COMSIG_ATOM_CAN_BE_PULLED, COMSIG_ATOM_BUMPED, COMSIG_BOUNTY_CRIMINAL_DOWNED, COMSIG_BOUNTY_CRIMINAL_RECOVERED, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, + COMSIG_HUMAN_NPC_SURRENDERED, )) - // Anything they hold goes, and a disguise comes off: this runs before the mob's contents are deleted. + // A disguise comes off, a hiding place is left: this runs before the mob's contents are deleted. criminal.ai_let_go() - criminal.ai_over_head = bounty_ai_update_held(criminal, criminal.ai_over_head, null) // Their camp stays as it is, but the fire burns down criminal.ai_put_out_camp() return ..() /datum/ai_controller/basic_controller/bounty/criminal/on_stat_changed(mob/living/source, new_stat) . = ..() - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal) && criminal.stat == DEAD) criminal.ai_on_death() /// No blow from someone giving up, cuffed, down, hidden or passing as a patron /datum/ai_controller/basic_controller/bounty/criminal/may_strike(atom/target) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(!istype(criminal) || !criminal.ai_can_act() || criminal.hidden || criminal.blended) return FALSE return ..() /datum/ai_controller/basic_controller/bounty/criminal/may_shoot(atom/target) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(!istype(criminal) || !criminal.ai_may_shoot(target)) return FALSE return ..() /datum/ai_controller/basic_controller/bounty/criminal/pawn_attacked(mob/living/attacker) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) criminal.ai_attacked_by(attacker) /datum/ai_controller/basic_controller/bounty/criminal/pawn_touched(mob/living/toucher) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) criminal.ai_touched_by(toucher) /datum/ai_controller/basic_controller/bounty/criminal/pawn_damaged(damage, damagetype) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) criminal.ai_took_damage(damage, damagetype) -/datum/ai_controller/basic_controller/bounty/criminal/place_held(facing) - var/mob/living/basic/bounty_criminal/criminal = pawn - if(istype(criminal)) - bounty_ai_place_held(criminal, criminal.ai_hand, facing) - -/// A shot passes through a blended patron (the fugitive or a decoy) it was not aimed at (AR-C7) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_prehit(mob/living/basic/bounty_criminal/source, obj/projectile/shot) +/// Pulling at a hider finds them (CR-8: the pull itself is tg's and goes ahead) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_pull_check(mob/living/carbon/human/npc/bounty_criminal/source, mob/living/user) SIGNAL_HANDLER - if(source.blended && shot.original != source) - return PROJECTILE_INTERRUPT_HIT_PHASE + if(source.hidden && isliving(user)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_reveal), user, "pulled") return NONE -/** - * Pulling at a hider finds them (P2 sends the signal before its own refusal, L1). Who may pull a - * blended patron is P2's rule (body_can_be_dragged(): a downed one can be dragged, AR-C7) and P6's - * act, never decided here. - */ -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_pull_check(mob/living/basic/bounty_criminal/source, mob/living/user) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_bumped(mob/living/carbon/human/npc/bounty_criminal/source, atom/movable/bumper) SIGNAL_HANDLER - if(source.hidden) - if(isliving(user)) - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_reveal), user, "pulled") - return COMSIG_ATOM_CANT_PULL - return NONE + if(source.hidden && isliving(bumper)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_bumped_by), bumper) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_bumped(mob/living/basic/bounty_criminal/source, atom/movable/bumper) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_downed(mob/living/carbon/human/npc/bounty_criminal/source) SIGNAL_HANDLER - if(source.hidden && isliving(bumper)) - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_bumped_by), bumper) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_downed)) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_downed(mob/living/basic/bounty_criminal/source) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_recovered(mob/living/carbon/human/npc/bounty_criminal/source) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_downed)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_recovered)) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_recovered(mob/living/basic/bounty_criminal/source) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_restrained(mob/living/carbon/human/npc/bounty_criminal/source, mob/living/user) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_recovered)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_restrained), user) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_restrained(mob/living/basic/bounty_criminal/source, mob/living/user) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_unrestrained(mob/living/carbon/human/npc/bounty_criminal/source, mob/living/user) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_restrained), user) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_unrestrained), user) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_unrestrained(mob/living/basic/bounty_criminal/source, mob/living/user) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_surrendered(mob/living/carbon/human/npc/bounty_criminal/source) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_unrestrained), user) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_surrendered)) diff --git a/voidcrew/modules/bounties/bounty_ai_human.dm b/voidcrew/modules/bounties/bounty_ai_human.dm new file mode 100644 index 00000000000..753d930050c --- /dev/null +++ b/voidcrew/modules/bounties/bounty_ai_human.dm @@ -0,0 +1,210 @@ +/** + * # Bounty hunting: AI pieces for human bodies + * + * Owner: P2 body. Shared by every bounty AI controller (criminals, companions, decoys, mini-bosses, + * the kingpin's crew, the mafia club), whose pawns are real humans (voidcrew/modules/human_npcs/). + * + * - Breaking free (bounty_escape_captivity): tg's escape_captivity, without the cases that would undo + * what a criminal chose to do: it never resists out of a chair it sat down on or a locker it hid in. + * It works at its cuffs (tg's breakout times), breaks grabs by anyone it may fight, and squirms off a + * carrier's shoulders, the way a player does. + * - Self-treatment (N6, bounty_treat_self): hurt, with nothing it can see attacking it, it uses what it + * carries on itself the way a player does: a medipen through the human NPC base's + * human_npc_treat_self ("use in hand"), a bruise pack, ointment, suture or mesh clicked on itself on its + * worst limb (tg's own self-treatment delay). No heal comes from anywhere else. + * - Injectors (bounty_ai_use_injector()): a meek criminal's + * stimpack as it bolts: a real pen, used on itself. + */ + +// ===== BREAKING FREE ===== + +/datum/ai_planning_subtree/bounty_escape_captivity + /// Blackboard key of the targeting strategy that says whose grab it fights + var/targeting_strategy_key = BB_TARGETING_STRATEGY + +/datum/ai_planning_subtree/bounty_escape_captivity/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/pawn = controller.pawn + if(!iscarbon(pawn) || pawn.stat != CONSCIOUS) + return + if(HAS_TRAIT(pawn, TRAIT_RESTRAINED)) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + var/mob/living/carbon/human/npc/bounty_criminal/criminal = istype(pawn, /mob/living/carbon/human/npc/bounty_criminal) ? pawn : null + // Shut in something it didn't choose to hide in: it gets out the way a person does. + if(!isturf(pawn.loc) && !ismob(pawn.loc) && !criminal?.hidden) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + var/mob/living/puller = pawn.pulledby + if(puller && puller.grab_state > GRAB_PASSIVE && bounty_ai_fights_holder(pawn, puller, strategy)) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + // Over someone's shoulders, or on a buckle it didn't sit down on, it gets off. + if(ismob(pawn.buckled)) + if(bounty_ai_fights_holder(pawn, pawn.buckled, strategy)) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + else if(pawn.buckled?.buckle_requires_restraints) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Whether `pawn` fights its way out of `holder`'s hold: someone it may fight, or any player while it is wanted +/proc/bounty_ai_fights_holder(mob/living/pawn, mob/living/holder, datum/targeting_strategy/strategy) + if(!isliving(holder) || holder == pawn || pawn.faction_check_atom(holder)) + return FALSE + if(strategy?.can_attack(pawn, holder)) + return TRUE + return !isnull(holder.mind) || !isnull(holder.client) + +// ===== SELF-TREATMENT (N6) ===== + +/** + * Hurt and unthreatened, it treats itself with what it carries. Put it after the fight subtrees: it + * only plans when nothing before it did, and never with a living hostile in sight that has hurt it. + */ +/datum/ai_planning_subtree/bounty_treat_self + +/datum/ai_planning_subtree/bounty_treat_self/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/pawn = controller.pawn + if(!iscarbon(pawn) || pawn.stat != CONSCIOUS || HAS_TRAIT(pawn, TRAIT_HANDS_BLOCKED) || LAZYLEN(pawn.do_afters)) + return + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_TREAT]) + return + if(pawn.health > pawn.maxHealth * BOUNTY_TREAT_SELF_BELOW) + return + if(controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + if(bounty_ai_queue_treat_self(controller)) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Queues the best treatment `controller`'s pawn carries for itself: a medipen with something in it, else + * a medical stack that suits its worst hurt. Returns TRUE if it queued one. For a retreat that wants to + * patch up where it sits. + */ +/proc/bounty_ai_queue_treat_self(datum/ai_controller/controller) + var/mob/living/carbon/pawn = controller.pawn + if(!iscarbon(pawn) || world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_TREAT]) + return FALSE + var/obj/item/reagent_containers/hypospray/medipen/pen = bounty_ai_find_medipen(pawn) + if(pen) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, pen) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + var/obj/item/stack/medical/stack = bounty_ai_find_medical_stack(pawn) + if(stack) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, stack) + controller.queue_behavior(/datum/ai_behavior/bounty_apply_medical, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + // Nothing to use: it doesn't look again for a while. + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_TREAT, world.time + BOUNTY_TREAT_SELF_COOLDOWN) + return FALSE + +/// A medipen it carries with something healing in it (not a stimulant: those are for a fight), that it can reach +/proc/bounty_ai_find_medipen(mob/living/carbon/pawn) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in pawn.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(pen.used_up || !pen.reagents?.total_volume || bounty_ai_is_stimulant(pen)) + continue + if(pawn.is_holding(pen) || human_npc_can_draw(pawn, pen)) + return pen + return null + +/// Whether `pen` is a stimulant (a fight drug), not a treatment +/proc/bounty_ai_is_stimulant(obj/item/reagent_containers/hypospray/medipen/pen) + return istype(pen, /obj/item/reagent_containers/hypospray/medipen/stimpack) || istype(pen, /obj/item/reagent_containers/hypospray/medipen/stimulants) || istype(pen, /obj/item/reagent_containers/hypospray/medipen/methamphetamine) + +/// A medical stack it carries and can reach that treats the kind of hurt it has most of +/proc/bounty_ai_find_medical_stack(mob/living/carbon/pawn) + var/burnt = pawn.getFireLoss() > pawn.getBruteLoss() + var/obj/item/stack/medical/fallback + for(var/obj/item/stack/medical/stack in pawn.get_all_contents_type(/obj/item/stack/medical)) + if(stack.amount <= 0 || !(pawn.is_holding(stack) || human_npc_can_draw(pawn, stack))) + continue + if((burnt && stack.heal_burn > 0) || (!burnt && stack.heal_brute > 0)) + return stack + fallback ||= stack + return fallback + +/// The limb of `pawn` with the most brute and burn on it, or the chest +/proc/bounty_ai_worst_limb(mob/living/carbon/pawn) + var/obj/item/bodypart/worst + var/worst_damage = 0 + for(var/obj/item/bodypart/limb as anything in pawn.bodyparts) + var/damage = limb.brute_dam + limb.burn_dam + if(damage > worst_damage) + worst = limb + worst_damage = damage + return worst?.body_zone || BODY_ZONE_CHEST + +/** + * Applies a medical stack to itself the way a player does: takes it in hand (holstering its gun if both + * hands are full), aims at its worst limb and clicks itself with it. tg's own self-treatment do_after + * runs; the AI waits through it. + */ +/datum/ai_behavior/bounty_apply_medical + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/bounty_apply_medical/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/pawn = controller.pawn + var/obj/item/stack/medical/stack = controller.blackboard[item_key] + if(!istype(stack) || QDELETED(stack) || human_npc_holding(stack) != pawn || pawn.stat != CONSCIOUS) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(LAZYLEN(pawn.do_afters)) + return AI_BEHAVIOR_DELAY + if(pawn.health > pawn.maxHealth * BOUNTY_TREAT_SELF_BELOW && !pawn.is_holding(stack)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!pawn.is_holding(stack)) + if(!length(pawn.get_empty_held_indexes())) + var/obj/item/gun/gun = human_npc_held_gun(pawn) + var/obj/item/stowed = gun || human_npc_held_melee_weapon(pawn) + if(isnull(stowed) || !stowed.equip_to_best_slot(pawn)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(pawn, stack)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + // One application per go; it puts the rest away after. + if(controller.blackboard[BB_BOUNTY_TREAT_APPLIED]) + if(!stack.equip_to_best_slot(pawn)) + pawn.dropItemToGround(stack) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!human_npc_ready_item(pawn, stack) || pawn.next_move > world.time) + return AI_BEHAVIOR_DELAY + pawn.zone_selected = bounty_ai_worst_limb(pawn) + controller.set_blackboard_key(BB_BOUNTY_TREAT_APPLIED, TRUE) + controller.ai_interact(target = pawn, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_apply_medical/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + controller.clear_blackboard_key(BB_BOUNTY_TREAT_APPLIED) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_TREAT, world.time + BOUNTY_TREAT_SELF_COOLDOWN) + +// ===== INJECTORS ===== + +/** + * Has `controller`'s pawn inject `pen` (a medipen or autoinjector it carries) into itself, with a + * visible emote first: a boss's combat stimulant, a meek criminal's stimpack as it bolts. A real pen and + * a real dose; what it does is the reagent's. Returns TRUE if the injection is queued. + */ +/proc/bounty_ai_use_injector(datum/ai_controller/controller, obj/item/reagent_containers/hypospray/medipen/pen) + var/mob/living/carbon/pawn = controller?.pawn + if(!iscarbon(pawn) || !istype(pen) || QDELETED(pen) || pen.used_up || !pen.reagents?.total_volume) + return FALSE + if(human_npc_holding(pen) != pawn || !(pawn.is_holding(pen) || human_npc_can_draw(pawn, pen))) + return FALSE + pawn.visible_message(span_warning("[pawn] pulls out [pen].")) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, pen) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + +/// The first unused pen of `pen_type` that `pawn` carries and can reach, or null +/proc/bounty_ai_find_injector(mob/living/carbon/pawn, pen_type = /obj/item/reagent_containers/hypospray/medipen) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in pawn.get_all_contents_type(pen_type)) + if(pen.used_up || !pen.reagents?.total_volume) + continue + if(pawn.is_holding(pen) || human_npc_can_draw(pawn, pen)) + return pen + return null diff --git a/voidcrew/modules/bounties/bounty_ai_meek.dm b/voidcrew/modules/bounties/bounty_ai_meek.dm index b27ae351772..798ace46139 100644 --- a/voidcrew/modules/bounties/bounty_ai_meek.dm +++ b/voidcrew/modules/bounties/bounty_ai_meek.dm @@ -3,26 +3,32 @@ * * Owner: P3 AI. Do not edit this file outside P3. * - * What it owns: the meek archetype (spec section 3, C6): - * - sprinting faster than a runner, getting winded, and getting the sprint back by resting or - * hiding (TRAIT_BOUNTY_SPRINTING while running flat out, for the body's dodge); + * What it owns: the meek archetype (spec section 3, C6, defaults CR-14): + * - running at a person's run, nothing faster. When they bolt they jab the stimpack they carry (a real + * pen, ephedrine and coffee) if they still have it; worn out (tg's own stamina slowdown) they say so. + * Their dodge is a real Slipwire implant (npc_cyberware), the shared dodge arbiter's, which an EMP + * stops; it burns out when they die; * - noticing hunters, and choosing the best hiding place ONCE per flight within - * BOUNTY_MEEK_HIDE_RADIUS, on their site: a locker or crate (in, and shut), a potted plant (they - * take it and pass as it), a dark tile (they stand still, faded); - * - being found (the container opened; bumped, touched, pulled, hit or examined up close; a light - * coming near), which shoves the finder back a tile and sends them running again; + * BOUNTY_MEEK_HIDE_RADIUS, on their site: a locker or crate (in, and shut: a real move into it), a + * potted plant (picked up and held in both hands: tg's own disguise, the plant's tactical + * component, which makes whoever holds it look and read like the plant), a dark tile (they stand + * still, faded); + * - being found (the container opened; bumped, touched, pulled, hit or examined up close; the plant + * knocked out of their hands; a light coming near), which shoves the finder (a real shove) and sends + * them running again; * - a restless tell while hidden a long time, so a search can end; - * - cornered: a shout, a short aim with the holdout (the telegraph), a quick volley, then - * running again. A stamina hit during the aim makes them fumble it; each shot checks they can - * still shoot. - * It never loots and never attacks first. + * - cornered: a shout, the holdout out of their pocket and aimed (the telegraph), then a quick volley + * of real trigger pulls, then the gun goes back in the pocket and they run again. A hit during the + * aim makes them fumble it; each pull checks they can still shoot. + * It never loots and never attacks first. It wears plain clothes for its zone, its tier's armour, the + * holdout and its spare magazine, and the stimpack. * * The shared vars (`hidden` and the rest) are declared in bounty_types.dm and nowhere else; a var * only P3 uses has an `ai_` prefix. The hiding procs sit on the base type so the shared hooks * (attacked, touched, pulled, dead, deleted) can undo them for anyone. */ -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal /// How they are hidden while `hidden`: BOUNTY_HIDE_CONTAINER, _PLANT or _DARK var/ai_hide_mode /// Weakref to what they hide in or as: the closet, the plant or the turf @@ -37,14 +43,12 @@ var/ai_next_restless = 0 /// world.time they next check whether it is quiet enough to come out var/ai_next_quiet_check = 0 - /// Their own look, kept while they pass as a plant or stand faded in the dark - var/list/ai_disguise_saved - /// Deciseconds of sprint left - var/ai_sprint_left = BOUNTY_MEEK_SPRINT_TIME - /// Out of breath: slow until the sprint is all back + /// Their alpha before they stood faded in the dark + var/ai_saved_alpha + /// Out of breath (tg's stamina slowdown), and said so var/ai_winded = FALSE - /// world.time the sprint was last worked out - var/ai_sprint_checked_at = 0 + /// A fresh flight: they jab their stimpack, if they still have one + var/ai_bolting = FALSE /// world.time this cornering began, or 0 var/ai_cornered_since = 0 /// world.time running away last failed, or 0 @@ -53,29 +57,33 @@ var/ai_holdout_ready_at = 0 /// Aiming the holdout: world.time the shots go off, or 0 var/ai_aim_until = 0 + /// world.time the aim began + var/ai_aim_from = 0 /// Their stamina loss when the aim began: any more at the shot and they fumble it var/ai_aim_stamina = 0 /// Weakref to who they aim at var/datum/weakref/ai_aim_ref - /// Shots of the volley still to fire, and when the next goes + /// Trigger pulls of the volley still to come, and when the next goes var/ai_volley_left = 0 var/ai_next_volley_shot = 0 /// Weakref to who the volley is at var/datum/weakref/ai_volley_ref - /// Set only while a volley shot is being fired, so no other shot gets out - var/ai_firing = FALSE #ifdef UNIT_TESTS /// Unit tests only: turfs that read as dark. The test room is fullbright, so nothing is dark there otherwise. GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) #endif -/mob/living/basic/bounty_criminal/meek +/mob/living/carbon/human/npc/bounty_criminal/meek ai_controller = /datum/ai_controller/basic_controller/bounty/criminal/meek + // OC-6 ruling: their dodge is real chrome, the shared arbiter's (voidcrew/modules/cyberware/ware_pro_combat.dm) + npc_cyberware = list(/obj/item/organ/cyberimp/cyberware/slipwire) /datum/ai_controller/basic_controller/bounty/criminal/meek - armed = TRUE planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_meek_volley, /datum/ai_planning_subtree/bounty_notice, @@ -85,27 +93,31 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /datum/ai_planning_subtree/bounty_activity, ) -/mob/living/basic/bounty_criminal/meek/ai_notice_range() +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_notice_range() return BOUNTY_MEEK_NOTICE_RANGE -/mob/living/basic/bounty_criminal/meek/ai_wanted_style() +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_wanted_style() return BOUNTY_STYLE_HOLDOUT /// The holdout is the same little gun whatever the tier -/mob/living/basic/bounty_criminal/meek/ai_heavy_weapon() +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_heavy_weapon() return FALSE -/mob/living/basic/bounty_criminal/meek/ai_sprints() +/// Plain clothes for their zone, their tier's armour, the holdout and its spare, and the stimpack they bolt on +/mob/living/carbon/human/npc/bounty_criminal/meek/bounty_build_outfit(zone) + return bounty_fighter_outfit(BOUNTY_ARCHETYPE_MEEK, zone, record?.tier, BOUNTY_STYLE_HOLDOUT, FALSE, /obj/item/reagent_containers/hypospray/medipen/stimpack) + +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_sprints() return TRUE -/mob/living/basic/bounty_criminal/meek/ai_notice(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_notice(mob/living/hunter) ai_flee_event(hunter, "noticed") -/mob/living/basic/bounty_criminal/meek/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_react(mob/living/threat, bark) ai_flee_event(threat, bark) /// Hit while running: they run from whoever is closer -/mob/living/basic/bounty_criminal/meek/ai_attacked_again(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_attacked_again(mob/living/attacker) var/mob/living/threat = ai_threat() if(!threat || bounty_ai_dist(src, attacker) < bounty_ai_dist(src, threat)) ai_set_threat(attacker) @@ -113,16 +125,16 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_bark("hurt") /// Anything that hurts a hider finds them -/mob/living/basic/bounty_criminal/meek/ai_took_damage(damage, damagetype) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_took_damage(damage, damagetype) if(hidden && damage > 0) INVOKE_ASYNC(src, PROC_REF(ai_reveal), null, "hurt") /// Only the cornered volley gets a shot out: they never attack first -/mob/living/basic/bounty_criminal/meek/ai_may_shoot(atom/target) - return ai_firing +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_may_shoot(atom/target) + return ai_aim_until || ai_volley_left -/// Passing as a plant, they look and read like the plant; up close, looking at them gives them away -/mob/living/basic/bounty_criminal/meek/examine(mob/user) +/// Behind the plant, they read as the plant; up close, looking at them gives them away +/mob/living/carbon/human/npc/bounty_criminal/meek/examine(mob/user) if(!hidden || ai_hide_mode == BOUNTY_HIDE_CONTAINER) return ..() if(user && get_dist(user, src) <= 1 && bounty_ai_is_hunter(user)) @@ -135,8 +147,8 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) // ===== RUNNING ===== -/// Whether they sprint and tire (only the meek) -/mob/living/basic/bounty_criminal/proc/ai_sprints() +/// Whether they run and hide (only the meek) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sprints() return FALSE /** @@ -144,7 +156,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * the best hiding place in reach, once for this whole flight. `bark` is the line to shout, or * null for the usual one on a fresh flight (none if `quiet`). */ -/mob/living/basic/bounty_criminal/proc/ai_flee_event(mob/living/threat, bark, seek_hiding = TRUE, quiet = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_flee_event(mob/living/threat, bark, seek_hiding = TRUE, quiet = FALSE) if(!ai_can_act() || hidden) return FALSE var/fresh = ai_mode != BOUNTY_AI_FLEEING @@ -173,70 +185,43 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) else if(fresh && !quiet) ai_bark("flee") if(ai_sprints()) - ai_update_sprint() + ai_set_pace(BOUNTY_RUN_SPEED) + if(fresh && seek_hiding) + ai_bolting = TRUE return TRUE -/// Their sprint speed: a lizard's is a little quicker -/mob/living/basic/bounty_criminal/proc/ai_sprint_speed() - if(ispath(record?.species, /datum/species/lizard)) - return BOUNTY_MEEK_SPRINT_SPEED - BOUNTY_MEEK_LIZARD_SPRINT_BONUS - return BOUNTY_MEEK_SPRINT_SPEED - -/** - * Works out their sprint since the last time: running in sight of the hunter uses it up, and then - * they are winded (slow) until it is all back. It comes back fastest hidden, slowly while out of - * sight but still on the run, and at a rest in BOUNTY_MEEK_REST_TIME. Sets their pace and - * TRAIT_BOUNTY_SPRINTING while running flat out (never winded, cornered or shooting). - */ -/mob/living/basic/bounty_criminal/proc/ai_update_sprint() - var/now = world.time - var/elapsed = ai_sprint_checked_at ? min(now - ai_sprint_checked_at, 5 SECONDS) : 0 - ai_sprint_checked_at = now - var/running = ai_mode == BOUNTY_AI_FLEEING && !hidden && !ai_aim_until && !ai_volley_left && !ai_cornered_since && (now - ai_threat_seen_at) <= 1 SECONDS - if(running) - if(!ai_winded) - ai_sprint_left -= elapsed - else if(hidden) - ai_sprint_left += elapsed * BOUNTY_MEEK_RECOVER_HIDDEN - else if(ai_mode == BOUNTY_AI_FLEEING) - ai_sprint_left += elapsed * BOUNTY_MEEK_RECOVER_WALKING - else - ai_sprint_left += elapsed * (BOUNTY_MEEK_SPRINT_TIME / BOUNTY_MEEK_REST_TIME) - ai_sprint_left = clamp(ai_sprint_left, 0, BOUNTY_MEEK_SPRINT_TIME) - if(!ai_winded && ai_sprint_left <= 0) - ai_winded = TRUE +/// Out of breath: tg's own stamina slowdown has them. They say so once, and again only after getting their breath back. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_check_winded() + var/winded = getStaminaLoss() >= BOUNTY_MEEK_WINDED_STAMINA + if(winded == ai_winded) + return + ai_winded = winded + if(winded && stat == CONSCIOUS) visible_message(span_notice("[src] is out of breath.")) ai_bark("winded") - else if(ai_winded && ai_sprint_left >= BOUNTY_MEEK_SPRINT_TIME) - ai_winded = FALSE - if(running && !ai_winded) - ADD_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - else - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - if(ai_mode == BOUNTY_AI_FLEEING) - ai_set_pace(ai_winded ? BOUNTY_MEEK_WINDED_SPEED : ai_sprint_speed()) /** - * The run: to the hiding place chosen for this flight if there is one and it is still good, else - * away from the hunter while they can see them, else still and catching their breath. Cornered, - * the holdout comes out. Out of sight long enough, they calm down. + * The run: their stimpack first on a fresh flight, then to the hiding place chosen for this flight if + * there is one and it is still good, else away from the hunter while they can see them, else still and + * catching their breath. Cornered, the holdout comes out. Out of sight long enough, they calm down. */ /datum/ai_planning_subtree/bounty_meek_flee /datum/ai_planning_subtree/bounty_meek_flee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn - // A decoy given this tree (P6 gives decoys their fugitive's controller) never sprints, hides or - // draws: bounty_flee_and_calm, next in the tree, runs it (L6). + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn + // A decoy given this tree (P6 gives decoys their fugitive's controller) never hides or draws: + // bounty_flee_and_calm, next in the tree, runs it (L6). if(!criminal.ai_sprints()) return var/seen = criminal.ai_mode == BOUNTY_AI_FLEEING && criminal.ai_sees_threat(BOUNTY_MEEK_HIDE_RADIUS) - criminal.ai_update_sprint() + criminal.ai_check_winded() if(criminal.ai_mode != BOUNTY_AI_FLEEING) return var/mob/living/threat = criminal.ai_threat() if(!threat || world.time - criminal.ai_threat_seen_at > BOUNTY_MEEK_CALM_AFTER) criminal.ai_calm_down() return + criminal.ai_plan_stimpack(controller) if(seen && criminal.ai_check_cornered(threat)) controller.queue_behavior(/datum/ai_behavior/bounty_holdout_volley) return SUBTREE_RETURN_FINISH_PLANNING @@ -263,6 +248,21 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) controller.queue_behavior(/datum/ai_behavior/bounty_watch) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Bolting: the stimpack they carry, jabbed in as they run (the core's bounty_ai_use_injector(), the human + * NPC base's "use in hand"), once per pen. Kept in the plan until the pen is spent. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_plan_stimpack(datum/ai_controller/controller) + var/obj/item/reagent_containers/hypospray/medipen/pen = controller.blackboard[BB_HUMAN_NPC_TREAT_ITEM] + if(istype(pen, /obj/item/reagent_containers/hypospray/medipen/stimpack) && !pen.used_up) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + if(!ai_bolting) + return FALSE + ai_bolting = FALSE + pen = bounty_ai_find_injector(src, /obj/item/reagent_containers/hypospray/medipen/stimpack) + return pen && bounty_ai_use_injector(controller, pen) + /** * tg's run away, staying on the site: a leg never goes off the leash, into lava or into a chasm * (D-A9). When no leg works, the criminal is cornered (ai_run_failed_at). @@ -273,21 +273,21 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /datum/ai_behavior/run_away_from_target/bounty/setup(datum/ai_controller/controller, target_key, hiding_location_key) . = ..() if(!.) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(istype(criminal)) criminal.ai_run_failed_at = world.time /datum/ai_behavior/run_away_from_target/bounty/perform(seconds_per_tick, datum/ai_controller/controller, target_key, hiding_location_key) . = ..() if(. & AI_BEHAVIOR_FAILED) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(istype(criminal)) criminal.ai_run_failed_at = world.time /datum/ai_behavior/run_away_from_target/bounty/get_furthest_turf(atom/source, angle, atom/target) var/turf/return_turf var/list/airlocks = SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/door/airlock) - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(source) + var/mob/living/carbon/human/npc/bounty_criminal/leash_holder = bounty_ai_side_of(source) for(var/i in 1 to run_distance) var/turf/test_destination = get_ranged_target_turf_direct(source, target, range = i, offset = angle) if(test_destination.is_blocked_turf(source_atom = source, ignore_atoms = airlocks)) @@ -308,7 +308,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) // ===== CORNERED ===== /// How many ways out lead away from `threat`: free, on the leash, and further from them -/mob/living/basic/bounty_criminal/proc/ai_ways_out(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_ways_out(mob/living/threat) . = 0 var/turf/here = get_turf(src) if(!here || !threat) @@ -323,10 +323,9 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /** * Whether they are cornered by `threat`, and have been for BOUNTY_MEEK_CORNERED_TIME: the hunter * close, with running away failing or fewer than two ways out. Once it has lasted, and the holdout - * is ready, the aim starts. Returns TRUE when it did. Being held no longer counts: a free criminal - * pulls away from any hand (P2), so a grab only happens while it is stunned and can't act (H2). + * is ready, the aim starts. Returns TRUE when it did. */ -/mob/living/basic/bounty_criminal/proc/ai_check_cornered(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_check_cornered(mob/living/threat) if(!ai_sprints() || world.time < ai_holdout_ready_at || !threat || !ai_can_act()) ai_cornered_since = 0 return FALSE @@ -347,73 +346,100 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return FALSE return ai_start_aim(threat) -/// The aim: the holdout in hand, a mark where it points, a shout. The shots start BOUNTY_MEEK_PISTOL_WINDUP later. Never from the floor (H2). -/mob/living/basic/bounty_criminal/proc/ai_start_aim(mob/living/target) - if(!ai_sprints() || ai_aim_until || ai_volley_left || !ai_can_act() || !isturf(loc) || !isliving(target)) +/// The holdout they carry (in hand, a pocket or the fanny pack), or null +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_holdout() + var/datum/bounty_style/style = GLOB.bounty_styles[BOUNTY_STYLE_HOLDOUT] + for(var/obj/item/gun/ballistic/gun as anything in get_all_contents_type(style.weapon_type)) + if(is_holding(gun) || human_npc_can_draw(src, gun)) + return gun + return null + +/** + * The aim: the holdout out of their pocket and into their hand, a mark where it points, a shout. The + * shots start BOUNTY_MEEK_PISTOL_WINDUP later. Never from the floor (H2), never with an empty gun. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_aim(mob/living/target) + if(!ai_sprints() || ai_aim_until || ai_volley_left || !ai_can_act() || !isturf(loc) || !isliving(target) || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return FALSE ai_update_style() + var/obj/item/gun/ballistic/gun = ai_holdout() + if(!gun || !(gun.can_shoot() || human_npc_gun_needs_rack(gun))) + return FALSE + if(!is_holding(gun)) + if(!length(get_empty_held_indexes())) + bounty_ai_free_other_hand(src, null) + if(!human_npc_take_in_hand(src, gun)) + return FALSE + if(human_npc_gun_needs_rack(gun)) + human_npc_ready_item(src, gun) + gun.attack_self(src) ai_aim_ref = WEAKREF(target) ai_aim_until = world.time + BOUNTY_MEEK_PISTOL_WINDUP - ai_aim_stamina = staminaloss + ai_aim_from = world.time + ai_aim_stamina = getStaminaLoss() ai_cornered_since = 0 - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) face_atom(bounty_ai_target_for(target)) - ai_hand = bounty_ai_update_held(src, ai_hand, bounty_ai_held_look(src) || /obj/item/gun/ballistic/automatic/pistol) new /obj/effect/temp_visual/telegraphing/bounty_aim(get_turf(target)) - visible_message(span_danger("[src] pulls out a small pistol and aims it at [target]!")) + visible_message(span_danger("[src] pulls out [gun] and aims it at [target]!")) to_chat(target, span_userdanger("[src] is aiming a pistol at you!")) playsound(src, 'sound/items/weapons/gun/pistol/rack_small.ogg', 50, TRUE) - // Forced: the draw is a one-second warning, and a meek criminal has just said "cornered" or "hurt" + // Forced: the draw is a half-second warning, and a meek criminal has just said "cornered" or "hurt" ai_bark("draw_gun", TRUE) return TRUE /** - * The aim is up: the volley starts, BOUNTY_MEEK_PISTOL_SHOTS shots (never more than the holdout's - * magazine) BOUNTY_MEEK_PISTOL_GAP apart, the first one now. A stamina hit or a stun during the aim - * makes them fumble it instead. Returns TRUE if the first shot went. + * The aim is up: the volley starts, BOUNTY_MEEK_PISTOL_SHOTS trigger pulls (never more than the gun + * holds) BOUNTY_MEEK_PISTOL_GAP apart, the first one now. A hit, more stamina loss or a stun during the + * aim makes them fumble it instead. Returns TRUE if the first shot went. */ -/mob/living/basic/bounty_criminal/proc/ai_fire_holdout() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_fire_holdout() var/mob/living/target = ai_aim_ref?.resolve() - var/stunned = !ai_can_act() || staminaloss > ai_aim_stamina + 1 || HAS_TRAIT(src, TRAIT_INCAPACITATED) + var/hit_at = ai_controller?.blackboard[BB_BOUNTY_HIT_AT] + var/spoiled = !ai_can_act() || getStaminaLoss() > ai_aim_stamina + 1 || HAS_TRAIT(src, TRAIT_INCAPACITATED) || (hit_at && hit_at >= ai_aim_from) ai_aim_until = 0 ai_aim_ref = null ai_holdout_ready_at = world.time + BOUNTY_MEEK_PISTOL_COOLDOWN ai_run_failed_at = 0 - if(stunned) + if(spoiled) ai_fumble() return FALSE if(!ai_volley_target_ok(target)) ai_put_gun_away() return FALSE - var/datum/bounty_real_weapon/holdout = bounty_ai_real_weapon(src) - ai_volley_left = holdout?.magazine ? min(BOUNTY_MEEK_PISTOL_SHOTS, holdout.magazine) : BOUNTY_MEEK_PISTOL_SHOTS + var/obj/item/gun/ballistic/gun = ai_holdout() + var/rounds = istype(gun) ? gun.get_ammo(TRUE) : 0 + ai_volley_left = clamp(rounds, 0, BOUNTY_MEEK_PISTOL_SHOTS) ai_volley_ref = WEAKREF(target) return ai_volley_shot() /// Whether the volley may go on at `target`: awake (downed hunters are left alone, C4), close and in sight -/mob/living/basic/bounty_criminal/proc/ai_volley_target_ok(mob/living/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_volley_target_ok(mob/living/target) return !QDELETED(target) && target.stat == CONSCIOUS && bounty_ai_can_see(src, target, BOUNTY_MEEK_PISTOL_RANGE) /** - * One shot of the volley, if they can still shoot (on their feet, not hidden) and the target is - * still there. Returns TRUE if a shot left the gun; a shot the gun refused ends the volley. + * One trigger pull of the volley, the player's way (a click with the gun in hand), if they can still + * shoot (on their feet, not hidden, the gun in hand and loaded) and the target is still there and + * nobody they may not hit stands in the way. Returns TRUE if a round left the gun; a pull that didn't + * fire ends the volley. */ -/mob/living/basic/bounty_criminal/proc/ai_volley_shot() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_volley_shot() var/mob/living/target = ai_volley_ref?.resolve() - if(!ai_volley_left || !ai_can_act() || hidden || !isturf(loc) || !ai_volley_target_ok(target)) + if(!ai_volley_left || !ai_can_act() || hidden || !isturf(loc) || !ai_volley_target_ok(target) || QDELETED(ai_controller)) ai_end_volley() return FALSE - var/datum/component/ranged_attacks/gun = GetComponent(/datum/component/ranged_attacks) - if(!gun) + var/obj/item/gun/ballistic/gun = ai_holdout() + if(!istype(gun) || !is_holding(gun) || !gun.can_shoot() || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) ai_end_volley() return FALSE - face_atom(bounty_ai_target_for(target)) - var/cooldown_before = gun.fire_cooldown - ai_firing = TRUE - RangedAttack(bounty_ai_target_for(target)) - ai_firing = FALSE - // The gun starts its cooldown only when it fires: a refused shot leaves it as it was - if(gun.fire_cooldown == cooldown_before || gun.fire_cooldown <= world.time) + var/atom/aim_at = bounty_ai_target_for(target) + face_atom(aim_at) + if(bounty_ai_line_blocked(src, aim_at) || !human_npc_ready_item(src, gun)) + ai_end_volley() + return FALSE + var/rounds_before = gun.get_ammo(TRUE) + zone_selected = human_npc_pick_aim_zone() + ai_controller.ai_interact(target = aim_at, combat_mode = TRUE) + if(QDELETED(gun) || gun.get_ammo(TRUE) >= rounds_before) ai_end_volley() return FALSE ai_volley_left-- @@ -422,38 +448,41 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_end_volley() return TRUE -/// The volley is over (or cut short): the gun goes away a moment later -/mob/living/basic/bounty_criminal/proc/ai_end_volley() +/// The volley is over (or cut short): the gun goes back in the pocket a moment later +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_end_volley() ai_volley_left = 0 ai_volley_ref = null addtimer(CALLBACK(src, PROC_REF(ai_put_gun_away)), 0.5 SECONDS, TIMER_DELETE_ME) -/// A stamina hit or a stun during the aim: the volley never happens and they run -/mob/living/basic/bounty_criminal/proc/ai_fumble() +/// A hit, more stamina loss or a stun during the aim: the volley never happens and they run +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_fumble() ai_aim_until = 0 ai_aim_ref = null ai_volley_left = 0 ai_volley_ref = null ai_holdout_ready_at = max(ai_holdout_ready_at, world.time + BOUNTY_MEEK_PISTOL_COOLDOWN) - ai_hand = bounty_ai_update_held(src, ai_hand, null) - if(stat != DEAD) - visible_message(span_notice("[src] fumbles the pistol!")) + if(stat == DEAD) + return + visible_message(span_notice("[src] fumbles the pistol!")) + ai_put_gun_away() -/// Puts the holdout away after the volley -/mob/living/basic/bounty_criminal/proc/ai_put_gun_away() - if(ai_aim_until || ai_volley_left || ai_mode == BOUNTY_AI_FIGHTING) +/// Puts the holdout back in a pocket after the volley, when they can +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_put_gun_away() + if(ai_aim_until || ai_volley_left || ai_mode == BOUNTY_AI_FIGHTING || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return - ai_hand = bounty_ai_update_held(src, ai_hand, null) + var/obj/item/gun/gun = ai_holdout() + if(gun && is_holding(gun)) + gun.equip_to_best_slot(src) /// Drops the aim and the volley without a word: they calmed down, hid, were cuffed, died -/mob/living/basic/bounty_criminal/proc/ai_stop_aiming() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_stop_aiming() if(!ai_aim_until && !ai_volley_left) return ai_aim_until = 0 ai_aim_ref = null ai_volley_left = 0 ai_volley_ref = null - ai_hand = bounty_ai_update_held(src, ai_hand, null) + ai_put_gun_away() /// The mark where a cornered criminal aims, for the length of the aim /obj/effect/temp_visual/telegraphing/bounty_aim @@ -461,25 +490,24 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /// The volley: first in a meek criminal's tree while an aim or a volley is on /datum/ai_planning_subtree/bounty_meek_volley - operational_datums = list(/datum/component/ranged_attacks) /datum/ai_planning_subtree/bounty_meek_volley/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!criminal.ai_aim_until && !criminal.ai_volley_left) return controller.queue_behavior(/datum/ai_behavior/bounty_holdout_volley) return SUBTREE_RETURN_FINISH_PLANNING -/// Holds the aim, then fires the volley shot by shot. Cancelled (a stun stops the AI), the aim is fumbled or the volley cut short. +/// Holds the aim, then fires the volley pull by pull. Cancelled (a stun stops the AI), the aim is fumbled or the volley cut short. /datum/ai_behavior/bounty_holdout_volley - action_cooldown = 0.2 SECONDS + action_cooldown = 0.1 SECONDS /datum/ai_behavior/bounty_holdout_volley/setup(datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn return istype(criminal) && (criminal.ai_aim_until || criminal.ai_volley_left) /datum/ai_behavior/bounty_holdout_volley/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_aim_until) if(world.time < criminal.ai_aim_until) var/mob/living/target = criminal.ai_aim_ref?.resolve() @@ -499,7 +527,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) . = ..() if(succeeded) return - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal)) return if(criminal.ai_aim_until) @@ -517,7 +545,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * them and out of the hunter's sight is better. Never where they were just found, never off their * site. Null if nothing will do. */ -/mob/living/basic/bounty_criminal/proc/ai_pick_hide_spot(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_pick_hide_spot(mob/living/threat) var/turf/center = get_turf(src) if(!center) return null @@ -553,7 +581,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) best = thing best_score = score best_stand = stand - var/dark_bonus = BOUNTY_HIDE_SCORE_DARK + (ispath(record?.species, /datum/species/moth) ? BOUNTY_HIDE_SCORE_MOTH_DARK : 0) + var/dark_bonus = BOUNTY_HIDE_SCORE_DARK + (ismoth(src) ? BOUNTY_HIDE_SCORE_MOTH_DARK : 0) for(var/turf/tile as anything in ai_dark_candidates(reachable)) if(tile == last) continue @@ -569,7 +597,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return best /// How good `spot` is to hide from `threat`, or null if it is no good at all -/mob/living/basic/bounty_criminal/proc/ai_score_hide_spot(atom/spot, mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_score_hide_spot(atom/spot, mob/living/threat) var/turf/spot_turf = get_turf(spot) if(!spot_turf || spot_turf.z != z) return null @@ -587,7 +615,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) . += BOUNTY_HIDE_SCORE_UNSEEN /// Up to `samples` dark tiles they could stand in, sampled once from `reachable` (turf = TRUE, the floor they can walk to) -/mob/living/basic/bounty_criminal/proc/ai_dark_candidates(list/reachable, samples = BOUNTY_MEEK_DARK_SAMPLES) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_dark_candidates(list/reachable, samples = BOUNTY_MEEK_DARK_SAMPLES) . = list() var/list/pool = list() for(var/turf/tile as anything in reachable) @@ -602,7 +630,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * Whether `tile` is dark enough to hide in. A turf with no lighting object (planet ground, lit by * its area's ambient light) reads its area's base light instead, which tg's lumcount skips (L2). */ -/mob/living/basic/bounty_criminal/proc/ai_tile_is_dark(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_tile_is_dark(turf/tile) if(!isturf(tile)) return FALSE #ifdef UNIT_TESTS @@ -614,7 +642,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount < BOUNTY_MEEK_DARK_LUMCOUNT return tile.get_lumcount() < BOUNTY_MEEK_DARK_LUMCOUNT -/mob/living/basic/bounty_criminal/proc/ai_hide_spot_usable(atom/spot) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_spot_usable(atom/spot) if(QDELETED(spot)) return FALSE if(istype(spot, /obj/structure/closet)) @@ -629,7 +657,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * A locker or crate they fit in: on the floor, unlocked, not welded, not full, not outpost property, * not a body bag, and nobody standing in it (shutting it would take them along). */ -/mob/living/basic/bounty_criminal/proc/ai_container_usable(obj/structure/closet/container) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_container_usable(obj/structure/closet/container) if(QDELETED(container) || !isturf(container.loc) || container.locked || container.welded) return FALSE if(istype(container, /obj/structure/closet/body_bag) || container.wall_mounted || (container.resistance_flags & INDESTRUCTIBLE)) @@ -647,7 +675,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return leash_ok(get_turf(container)) /// A potted plant on the floor, loose, with nobody standing on it -/mob/living/basic/bounty_criminal/proc/ai_plant_usable(obj/item/kirbyplants/plant) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_plant_usable(obj/item/kirbyplants/plant) if(QDELETED(plant) || !isturf(plant.loc) || plant.anchored) return FALSE var/turf/plant_turf = plant.loc @@ -659,15 +687,15 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return leash_ok(plant_turf) /// A dark floor tile they can stand on: not space, lava or a chasm, free, on their site -/mob/living/basic/bounty_criminal/proc/ai_dark_usable(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_dark_usable(turf/tile) if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) return FALSE if(tile != loc && tile.is_blocked_turf(source_atom = src)) return FALSE return leash_ok(tile) && ai_tile_is_dark(tile) -/// Whether they can hide in or as `spot` from where they stand: on the dark tile, or right beside the closet or plant with nothing between (H1) -/mob/living/basic/bounty_criminal/proc/ai_at_hide_spot(atom/spot) +/// Whether they can hide in or as `spot` from where they stand: on the dark tile or the plant's, or right beside the closet or plant with nothing between (H1) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_at_hide_spot(atom/spot) if(!isturf(loc)) return FALSE if(isturf(spot)) @@ -677,7 +705,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return Adjacent(spot) /// Where they walk to to hide at `spot`: beside a closet on their side of it (the tile picked with it), onto a plant or a dark tile. Null if there is nowhere. -/mob/living/basic/bounty_criminal/proc/ai_hide_approach(atom/spot) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_approach(atom/spot) if(istype(spot, /obj/structure/closet)) if(ai_hide_stand && (ai_hide_stand == loc || ai_standable(ai_hide_stand)) && ai_hide_stand.Adjacent(spot)) return ai_hide_stand @@ -688,7 +716,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * The tile to stand on to climb into `container`: beside it, free, with no wall, window or rail * between (so Adjacent() holds from it), and in `reachable` when that is given. Nearest to them. */ -/mob/living/basic/bounty_criminal/proc/ai_container_approach(obj/structure/closet/container, list/reachable) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_container_approach(obj/structure/closet/container, list/reachable) var/turf/container_turf = get_turf(container) if(!container_turf) return null @@ -710,7 +738,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) action_cooldown = 0.5 SECONDS /datum/ai_behavior/bounty_hide/perform(seconds_per_tick, datum/ai_controller/controller, spot_key) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/atom/spot = controller.blackboard[spot_key] if(QDELETED(spot) || !criminal.ai_hide_in(spot)) controller.clear_blackboard_key(spot_key) @@ -718,17 +746,15 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED /** - * Hides in or as `spot`, from beside it: a closet (in, and shut), a potted plant (theirs now, and - * so is its look) or a dark turf (still and faded). Returns TRUE if they are hidden. + * Hides in or as `spot`, from beside it: a closet (in, and shut), a potted plant (picked up and held + * up in front of them) or a dark turf (still and faded). Returns TRUE if they are hidden. */ -/mob/living/basic/bounty_criminal/proc/ai_hide_in(atom/spot) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_in(atom/spot) // Never from the floor: a knockdown is a cuff window (H2) if(hidden || !ai_can_act() || QDELETED(spot) || !isturf(loc)) return FALSE ai_suspend_activity() ai_stop_aiming() - ai_hand = bounty_ai_update_held(src, ai_hand, null) - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) var/done = FALSE if(istype(spot, /obj/structure/closet)) done = ai_hide_in_container(spot) @@ -743,7 +769,6 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_next_restless = world.time + BOUNTY_MEEK_RESTLESS_AFTER ai_next_quiet_check = world.time + BOUNTY_MEEK_COME_OUT_AFTER ai_cornered_since = 0 - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) if(!QDELETED(ai_controller)) ai_controller.clear_blackboard_key(BB_BOUNTY_HIDE_SPOT) ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) @@ -752,11 +777,12 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return TRUE /** - * In a locker or a crate: opens it if shut, gets in, and shuts it from inside. A crate won't take - * a standing person the ordinary way, so it is straight insertion for both, then close(). Only - * from right beside it, with no wall or window between (H1). + * In a locker or a crate: opens it if shut, gets in (a real move into it), and pulls it shut from + * inside. A crate won't take a standing person the ordinary way, so it is straight in for both, then + * close(). Only from right beside it, with no wall or window between (H1). Getting out is the + * container opening, as anyone gets out. */ -/mob/living/basic/bounty_criminal/proc/ai_hide_in_container(obj/structure/closet/container) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_in_container(obj/structure/closet/container) if(!ai_container_usable(container) || !Adjacent(container)) return FALSE ai_stand_up() @@ -777,10 +803,12 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return TRUE /** - * As a potted plant: they step onto its tile (a real step, from right beside it: H1), take it, keep - * their own look aside, and wear the plant's: name, description, icon, overlays and layer. + * Behind a potted plant: they step onto its tile (a real step, from right beside it: H1), put away what + * is in their hands, and pick it up in both hands. The plant's own tactical component (tg's disguise: + * whoever holds it up looks like the plant to everyone and goes by its name) does the rest; nothing of + * their own look is touched. The plant knocked out of their hands, or put down, gives them away. */ -/mob/living/basic/bounty_criminal/proc/ai_hide_as_plant(obj/item/kirbyplants/plant) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_as_plant(obj/item/kirbyplants/plant) if(!ai_plant_usable(plant)) return FALSE var/turf/plant_turf = plant.loc @@ -788,42 +816,28 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) if(!Adjacent(plant)) return FALSE ai_stand_up() - Move(plant_turf, get_dir(src, plant_turf)) - if(loc != plant_turf) + if(!bounty_ai_step(src, plant_turf) || loc != plant_turf) return FALSE ai_stand_up() - ai_disguise_saved = list( - "plant_turf" = plant_turf, - "name" = name, - "desc" = desc, - "icon" = icon, - "icon_state" = icon_state, - "overlays" = overlays.Copy(), - "underlays" = underlays.Copy(), - "layer" = layer, - "alpha" = alpha, - ) - plant.forceMove(src) - if(plant.loc != src) - ai_disguise_saved = null - return FALSE - name = plant.name - desc = plant.desc - icon = plant.icon - icon_state = plant.icon_state - overlays = plant.overlays.Copy() - underlays = null - layer = plant.layer + bounty_ai_holster(src) + if(length(get_empty_held_indexes()) < 2) + bounty_ai_free_other_hand(src, null) + if(length(get_empty_held_indexes()) < 2 || !put_in_hands(plant)) + return FALSE + if(!is_holding(plant)) + return FALSE + RegisterSignal(plant, COMSIG_ITEM_DROPPED, PROC_REF(ai_on_plant_dropped)) + RegisterSignal(plant, COMSIG_QDELETING, PROC_REF(ai_on_plant_dropped)) ai_hide_mode = BOUNTY_HIDE_PLANT ai_hide_ref = WEAKREF(plant) return TRUE /// In the dark: on the tile, still, faded -/mob/living/basic/bounty_criminal/proc/ai_hide_in_dark(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_in_dark(turf/tile) if(loc != tile || !ai_dark_usable(tile)) return FALSE ai_stand_up() - ai_disguise_saved = list("alpha" = alpha) + ai_saved_alpha = alpha alpha = BOUNTY_MEEK_DARK_ALPHA setDir(pick(GLOB.cardinals)) ai_hide_mode = BOUNTY_HIDE_DARK @@ -831,11 +845,10 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return TRUE /** - * Stops hiding: out of the closet if it opens, the plant put back where it stood and their own look - * back, the fade gone. Welded or locked in, they stay in (and hidden, so opening it later finds - * them) and it returns FALSE. + * Stops hiding: out of the closet if it opens, the plant put down at their feet, the fade gone. Welded + * or locked in, they stay in (and hidden, so opening it later finds them) and it returns FALSE. */ -/mob/living/basic/bounty_criminal/proc/ai_unhide() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_unhide() var/atom/spot = ai_hide_ref?.resolve() switch(ai_hide_mode) if(BOUNTY_HIDE_CONTAINER) @@ -850,54 +863,40 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) RegisterSignal(container, COMSIG_QDELETING, PROC_REF(ai_on_container_gone)) return FALSE if(BOUNTY_HIDE_PLANT) - // Back where it stood (L8): the outpost's decor stays where the outpost put it - var/turf/plant_turf = ai_disguise_saved?["plant_turf"] - ai_restore_look() var/obj/item/kirbyplants/plant = spot - if(istype(plant) && plant.loc == src) - var/atom/put_down = isturf(plant_turf) ? plant_turf : drop_location() - if(put_down) - plant.forceMove(put_down) - else - qdel(plant) + if(istype(plant)) + UnregisterSignal(plant, list(COMSIG_ITEM_DROPPED, COMSIG_QDELETING)) + if(is_holding(plant)) + dropItemToGround(plant) if(BOUNTY_HIDE_DARK) - ai_restore_look() + if(!isnull(ai_saved_alpha)) + alpha = ai_saved_alpha + ai_saved_alpha = null hidden = FALSE ai_hide_mode = null ai_hide_ref = null - ai_sprint_checked_at = world.time return TRUE -/// Their own look back after passing as a plant or standing faded -/mob/living/basic/bounty_criminal/proc/ai_restore_look() - var/list/saved = ai_disguise_saved - ai_disguise_saved = null - if(!saved) - return - if("icon" in saved) - name = saved["name"] - desc = saved["desc"] - icon = saved["icon"] - icon_state = saved["icon_state"] - overlays = saved["overlays"] - underlays = saved["underlays"] - layer = saved["layer"] - alpha = saved["alpha"] - -/mob/living/basic/bounty_criminal/proc/ai_on_container_opened(obj/structure/closet/source, mob/living/user, force) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_container_opened(obj/structure/closet/source, mob/living/user, force) SIGNAL_HANDLER INVOKE_ASYNC(src, PROC_REF(ai_reveal), isliving(user) ? user : null, "opened") -/mob/living/basic/bounty_criminal/proc/ai_on_container_gone(obj/structure/closet/source) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_container_gone(obj/structure/closet/source) SIGNAL_HANDLER INVOKE_ASYNC(src, PROC_REF(ai_reveal), null, "opened") +/// The plant left their hands (knocked away, shoved out of them, broken): they are found +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_plant_dropped(obj/item/kirbyplants/source) + SIGNAL_HANDLER + UnregisterSignal(source, list(COMSIG_ITEM_DROPPED, COMSIG_QDELETING)) + INVOKE_ASYNC(src, PROC_REF(ai_reveal), null, "dropped") + /** - * Found: out of hiding, a word, the finder shoved back a tile if they are right there (M4), and - * off again, to a new hiding place. `finder` may be null (a light came, the closet broke). Welded - * or locked in, they can't get out and stay hidden. Returns TRUE if they came out. + * Found: out of hiding, a word, the finder shoved if they are right there (M4, a real shove), and off + * again, to a new hiding place. `finder` may be null (a light came, the closet broke). Welded or locked + * in, they can't get out and stay hidden. Returns TRUE if they came out. */ -/mob/living/basic/bounty_criminal/proc/ai_reveal(mob/living/finder, reason) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_reveal(mob/living/finder, reason) if(!hidden) return FALSE var/mode = ai_hide_mode @@ -925,30 +924,22 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_flee_event(isliving(finder) ? finder : ai_threat(), "found") return TRUE -/// Shoves `finder` back a tile, without a stun, to get a head start; never onto lava, into a chasm or out into space (M5) -/mob/living/basic/bounty_criminal/proc/ai_shove_away(mob/living/finder) - bounty_ai_shove_back(src, finder) - visible_message(span_warning("[src] shoves [finder] back and bolts!")) - playsound(finder, 'sound/items/weapons/shove.ogg', 50, TRUE) - -/// Pushes `victim` a tile away from `shover`, if the tile behind them is safe to stand on (M5). Returns TRUE if they moved. -/proc/bounty_ai_shove_back(atom/shover, mob/living/victim) - var/shove_dir = get_dir(shover, victim) - var/turf/behind = shove_dir && get_step(victim, shove_dir) - if(!behind || behind.is_blocked_turf(source_atom = victim)) +/// Shoves `finder`, a real shove with an empty hand (tg's own, which may knock them into a wall), to get a head start +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_shove_away(mob/living/finder) + if(QDELETED(ai_controller) || !ai_can_act() || !human_npc_ready_empty_hand(src)) return FALSE - if(isspaceturf(behind) || isopenspaceturf(behind) || isgroundlessturf(behind) || islava(behind) || ischasm(behind) || !behind.can_cross_safely(victim)) - return FALSE - return victim.Move(behind, shove_dir) + face_atom(finder) + return ai_controller.ai_interact(target = finder, combat_mode = TRUE, modifiers = list(RIGHT_CLICK = TRUE)) /** * About once a second while hidden: whether they have been found out (let out of the closet, a - * light on them), the restless tell, and coming out when it has been quiet for long enough. + * light on them, the plant gone from their hands), the restless tell, and coming out when it has been + * quiet for long enough. */ -/mob/living/basic/bounty_criminal/proc/ai_hidden_tick() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hidden_tick() if(!hidden) return - ai_update_sprint() + ai_check_winded() var/atom/spot = ai_hide_ref?.resolve() switch(ai_hide_mode) if(BOUNTY_HIDE_CONTAINER) @@ -956,6 +947,10 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) if(!istype(container) || loc != container || container.opened) ai_reveal(null, "opened") return + if(BOUNTY_HIDE_PLANT) + if(!spot || !is_holding(spot)) + ai_reveal(null, "dropped") + return if(BOUNTY_HIDE_DARK) if(!ai_tile_is_dark(get_turf(src))) ai_reveal(null, "light") @@ -972,7 +967,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * The restless tell (M2): after a while hidden, a noise and a shake of the hiding place every * BOUNTY_MEEK_RESTLESS_GAP, heard within BOUNTY_MEEK_RESTLESS_RANGE, and now and then a mutter. */ -/mob/living/basic/bounty_criminal/proc/ai_restless_tell() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_restless_tell() var/atom/shaken = src var/message switch(ai_hide_mode) @@ -990,7 +985,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_bark("hide") /// Quiet for long enough: they come out and carry on with what they were doing -/mob/living/basic/bounty_criminal/proc/ai_come_out() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_come_out() var/mode = ai_hide_mode var/atom/spot = ai_hide_ref?.resolve() if(!ai_unhide() || !isturf(loc)) diff --git a/voidcrew/modules/bounties/bounty_ai_normal.dm b/voidcrew/modules/bounties/bounty_ai_normal.dm index 70aa025207e..5f8822c3ca5 100644 --- a/voidcrew/modules/bounties/bounty_ai_normal.dm +++ b/voidcrew/modules/bounties/bounty_ai_normal.dm @@ -7,49 +7,68 @@ * - noticing someone armed (aware: up from what they were doing, watching them), and fighting when * attacked, confronted, or when someone with a weapon drawn comes within * BOUNTY_NORMAL_THREAT_RANGE while they are aware; - * - fighting by style, with its companions called in; - * - surrendering (TRAIT_BOUNTY_SURRENDERED, source BOUNTY_TRAIT, which P2's capture_state() reads): - * rolled on the hit that crosses BOUNTY_SURRENDER_BELOW and on later hits, with two hunters near; - * - breaking off and retreating when badly hurt with nobody near. They never regenerate. - * It also owns the fighting styles every bounty fighter uses (criminals, companions, the meek's - * holdout): /datum/bounty_style and their projectiles. Every blow and shot is the real weapon's - * (bounty_weapons.dm). + * - fighting by style with the real weapon they carry (bounty_styles.dm), their companions called in; + * - giving up (CR-10): an AI choice followed by tg's *surrender, through the human NPC base + * (/datum/human_npc_surrender/bounty below: hurt, with hunters around them, less likely for each + * companion still standing). tg's 20 second paralysis is the cuff window; nobody cuffs them in it and + * they decide again: give up again, run, or fight on; + * - breaking off and retreating when badly hurt with nobody near: they sit down there and patch + * themselves up with what they carry (N6), and nothing else heals them; + * - what they wear: plain clothes for their zone, their tier's armour, their style's weapon and spares, + * and a bruise pack; a stimpack when Wanted, if a pocket is free (a mini-boss carries the combat stimpack); + * - combat sense (bounty_combat_sense.dm): the stimpack when they tire, cover or a charge under stun fire. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P3 uses has an * `ai_` prefix. */ -/mob/living/basic/bounty_criminal - /// Whether the roll on the hit that crosses the surrender line has been made - var/ai_surrender_rolled = FALSE - /// world.time the next surrender roll on a later hit may be made - var/ai_next_surrender_roll = 0 - -/mob/living/basic/bounty_criminal/normal +/mob/living/carbon/human/npc/bounty_criminal/normal ai_controller = /datum/ai_controller/basic_controller/bounty/criminal/normal + npc_surrender = /datum/human_npc_surrender/bounty /datum/ai_controller/basic_controller/bounty/criminal/normal - armed = TRUE planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/human_npc_surrender, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_notice, /datum/ai_planning_subtree/bounty_fight_target, - /datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty, - /datum/ai_planning_subtree/basic_melee_attack_subtree/bounty, + // Its chrome: a tell, then the implant's own action (C0, bounty_chrome.dm) + /datum/ai_planning_subtree/bounty_chrome, + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/bounty_arm, + /datum/ai_planning_subtree/bounty_ranged, + /datum/ai_planning_subtree/bounty_melee, /datum/ai_planning_subtree/bounty_retreat, + /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_aware, // For a decoy on this tree (P6 gives decoys their fugitive's controller): run, then calm down (M3) /datum/ai_planning_subtree/bounty_flee_and_calm, /datum/ai_planning_subtree/bounty_activity, ) -/mob/living/basic/bounty_criminal/normal/ai_wanted_style() +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_wanted_style() var/key = record?.style if(key && key != BOUNTY_STYLE_HOLDOUT && GLOB.bounty_styles[key]) return key return BOUNTY_STYLE_BRAWLER -/mob/living/basic/bounty_criminal/normal/ai_notice(mob/living/hunter) +/** + * Plain clothes for their zone, their tier's armour, their style's weapon and spares, a bruise pack, and + * when Wanted a stimpack for when they tire in a fight (combat sense, bounty_combat_sense.dm). Every Most + * Wanted is a mini-boss, which carries its own pens, combat stimpack included (boss_supplies()). The + * stimpack goes in a pocket, if the style left one free: a pistol's spare magazine or a knife fills the + * second pocket, and a holster or bandolier takes nothing else. + */ +/mob/living/carbon/human/npc/bounty_criminal/normal/bounty_build_outfit(zone) + var/datum/outfit/outfit = bounty_fighter_outfit(BOUNTY_ARCHETYPE_NORMAL, zone, record?.tier, ai_wanted_style(), ai_heavy_weapon(), /obj/item/stack/medical/bruise_pack) + if(record?.tier == BOUNTY_TIER_WANTED && outfit && (!outfit.r_pocket || !outfit.l_pocket)) + bounty_outfit_carry(outfit, /obj/item/reagent_containers/hypospray/medipen/stimpack) + return outfit + +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_notice(mob/living/hunter) var/mob/living/watched = ai_threat() if(ai_mode == BOUNTY_AI_CALM || ai_mode == BOUNTY_AI_RETREATING) ai_become_aware(hunter) @@ -58,7 +77,7 @@ if(ai_mode == BOUNTY_AI_AWARE && bounty_ai_dist(src, hunter) <= BOUNTY_NORMAL_THREAT_RANGE && bounty_ai_is_armed(hunter, weapons_only = TRUE)) ai_start_fight(hunter) -/mob/living/basic/bounty_criminal/normal/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_react(mob/living/threat, bark) if(!ai_can_act()) return threat = threat || ai_nearest_grudge() @@ -69,7 +88,7 @@ ai_start_fight(threat, bark) /// Hit in a fight by someone new: they turn on whoever is closer (a pilot through their mech, H4) -/mob/living/basic/bounty_criminal/normal/ai_attacked_again(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_attacked_again(mob/living/attacker) if(ai_mode == BOUNTY_AI_FIGHTING) ai_rally_companions(attacker) var/mob/living/threat = ai_threat() @@ -81,13 +100,10 @@ return ai_start_fight(attacker) -/mob/living/basic/bounty_criminal/normal/ai_took_damage(damage, damagetype) - ai_check_surrender(damage, damagetype) - // ===== AWARE ===== /// Someone armed caught their eye: they stop what they are doing and keep an eye on them -/mob/living/basic/bounty_criminal/proc/ai_become_aware(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_become_aware(mob/living/hunter) if(!ai_can_act()) return ai_suspend_activity() @@ -101,7 +117,7 @@ /datum/ai_planning_subtree/bounty_aware /datum/ai_planning_subtree/bounty_aware/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_mode != BOUNTY_AI_AWARE) return criminal.ai_sees_threat(BOUNTY_FIGHT_VISION) @@ -113,8 +129,12 @@ // ===== FIGHTING ===== -/// Starts (or keeps) fighting `target`: up, weapon out, companions called in, a line for it. A pilot is fought through their mech (H4). -/mob/living/basic/bounty_criminal/proc/ai_start_fight(mob/living/target, bark) +/** + * Starts (or keeps) fighting `target`: up, at a run, companions called in, a line for it. The weapon + * comes out of its holster, pocket or bag through the arm subtree (bounty_styles.dm). A pilot is fought + * through their mech (H4). + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_fight(mob/living/target, bark) if(!ai_can_act() || !isliving(target) || QDELETED(ai_controller)) return FALSE ai_update_style() @@ -124,9 +144,7 @@ ai_set_mode(BOUNTY_AI_FIGHTING) ai_set_threat(target) ai_add_grudge(target) - var/datum/bounty_style/style = bounty_ai_style(src) - ai_set_pace(style?.speed || BOUNTY_NORMAL_SPEED_MELEE) - ai_hand = bounty_ai_update_held(src, ai_hand, bounty_ai_held_look(src)) + ai_set_pace(BOUNTY_RUN_SPEED) if(fresh) ai_bark(bark || "fight_start") ai_rally_companions(target) @@ -137,7 +155,7 @@ * The nearest valid target on their grudge list, keeping the current one while it is still good. * Someone in a mech is fought through the mech (H4): tg's melee can't reach a pilot inside. */ -/mob/living/basic/bounty_criminal/proc/ai_pick_fight_target() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_pick_fight_target() if(QDELETED(ai_controller)) return null var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(ai_controller.blackboard[BB_TARGETING_STRATEGY]) @@ -160,7 +178,7 @@ return best /// The fight is over: nobody on their grudge list seen for a while -/mob/living/basic/bounty_criminal/proc/ai_fight_over() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_fight_over() if(ai_should_retreat()) ai_start_retreat(ai_threat()) return @@ -173,7 +191,7 @@ /datum/ai_planning_subtree/bounty_fight_target /datum/ai_planning_subtree/bounty_fight_target/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_mode != BOUNTY_AI_FIGHTING) return var/atom/target = criminal.ai_pick_fight_target() @@ -190,201 +208,78 @@ controller.queue_behavior(/datum/ai_behavior/bounty_watch) return SUBTREE_RETURN_FINISH_PLANNING -/// Whether a pawn is in a fight it can take part in: a criminal fighting, or a companion that has not broken -/proc/bounty_ai_fighting(mob/living/basic/fighter) - if(istype(fighter, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = fighter - return criminal.ai_mode == BOUNTY_AI_FIGHTING && criminal.ai_can_act() - if(istype(fighter, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = fighter - return companion.ai_can_fight() - return FALSE - -/// tg's melee subtree, for melee styles only, and only in a fight -/datum/ai_planning_subtree/basic_melee_attack_subtree/bounty - -/datum/ai_planning_subtree/basic_melee_attack_subtree/bounty/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/datum/bounty_style/style = GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] - if(!style || style.ranged || !bounty_ai_fighting(controller.pawn)) - return - return ..() - -/// tg's ranged subtree, for ranged styles only, and only in a fight: each style keeps its own distance -/datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty - ranged_attack_behavior = /datum/ai_behavior/basic_ranged_attack/bounty - -/datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/datum/bounty_style/style = GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] - if(!style?.ranged || !bounty_ai_fighting(controller.pawn)) - return - if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) - return - controller.queue_behavior(style.ranged_behavior, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) - return SUBTREE_RETURN_FINISH_PLANNING +// ===== SURRENDER (CR-10) ===== /** - * tg's ranged attack, with the style doing the shooting: its wind-up telegraph, its magazine and - * reload, through the mob's ranged_attacks component. The pistol keeps BOUNTY_PISTOL_RANGE off and - * backs off a step when someone gets inside its style's min_range. Planning carries on meanwhile, - * so a new target, a retreat or a call for help is picked up mid-fight. + * When a normal criminal or a companion gives up (defaults CR-10, 1.3): at or below 40% of its health, + * with two hunters within seven tiles that it can see; 60% on its first roll of a fight, less 20 for + * each companion still standing with it, then 25% every 5 seconds. Through the human NPC base's + * human_npc_surrender subtree, which stows the weapon, says the line and runs tg's *surrender. */ -/datum/ai_behavior/basic_ranged_attack/bounty - // Often enough that the pistol fires at its own pace, and its wind-up is never stretched; each tick walks the line of fire (L10) - action_cooldown = 0.1 SECONDS - behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION - required_distance = BOUNTY_PISTOL_RANGE - chase_range = BOUNTY_FIGHT_VISION - avoid_friendly_fire = TRUE - -/datum/ai_behavior/basic_ranged_attack/bounty/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) - var/mob/living/basic/fighter = controller.pawn - var/atom/target = controller.blackboard[target_key] - var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) - if(!strategy.can_attack(fighter, target, chase_range)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - var/datum/bounty_style/style = bounty_ai_style(fighter) - if(!style?.ranged) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - if(!can_see(fighter, target, required_distance)) - return AI_BEHAVIOR_INSTANT - if(style.min_range && get_dist(fighter, target) < style.min_range && !controller.blackboard[BB_BOUNTY_WINDUP_UNTIL]) - back_off(controller, fighter, target) - if(avoid_friendly_fire && check_friendly_in_path(fighter, target, strategy)) - adjust_position(fighter, target) - return AI_BEHAVIOR_DELAY - return style.ranged_tick(fighter, target) - -/// tg's sidestep out of a friend's line of fire, never off the leash, into lava or into a chasm (L11) -/datum/ai_behavior/basic_ranged_attack/bounty/adjust_position(mob/living/living_pawn, atom/target) - var/turf/our_turf = get_turf(living_pawn) - if(!our_turf) +/datum/human_npc_surrender/bounty + chance = BOUNTY_SURRENDER_CHANCE + retry_chance = BOUNTY_SURRENDER_RETRY + ally_penalty = BOUNTY_SURRENDER_PER_COMPANION + health_share = BOUNTY_SURRENDER_BELOW + hostiles_needed = BOUNTY_SURRENDER_HUNTERS + range = BOUNTY_SURRENDER_HUNTER_RANGE + retry_delay = BOUNTY_SURRENDER_RETRY_GAP + +/// The hunters near it that can see it: a spatial grid lookup of player mobs, as the criminal AI looks around +/datum/human_npc_surrender/bounty/hostiles_near(mob/living/carbon/human/npc/npc) + . = list() + var/turf/here = get_turf(npc) + if(!here) return - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(living_pawn) - var/list/possible_turfs = list() - for(var/direction in GLOB.alldirs) - var/turf/target_turf = get_step(our_turf, direction) - if(isnull(target_turf) || target_turf.is_blocked_turf() || get_dist(target_turf, target) > get_dist(living_pawn, target)) - continue - if(islava(target_turf) || ischasm(target_turf) || !target_turf.can_cross_safely(living_pawn)) + for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, range)) + if(!bounty_ai_is_hunter(person) || get_dist(here, person) > range || !can_see(person, here, range)) continue - if(leash_holder && !leash_holder.leash_ok(target_turf)) - continue - possible_turfs += target_turf - if(!length(possible_turfs)) - return - var/turf/picked_turf = get_closest_atom(/turf, possible_turfs, target) - living_pawn.Move(picked_turf, get_dir(living_pawn, picked_turf)) - -/// A wind-up cut short never carries over: the next shot telegraphs again -/datum/ai_behavior/basic_ranged_attack/bounty/finish_action(datum/ai_controller/controller, succeeded, target_key, targeting_strategy_key, hiding_location_key) - . = ..() - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) + . += person -/// A step back from `target`, now and then, never off the leash -/datum/ai_behavior/basic_ranged_attack/bounty/proc/back_off(datum/ai_controller/controller, mob/living/basic/fighter, atom/target) - if(world.time < controller.blackboard[BB_BOUNTY_BACKSTEP_AT] || !isturf(fighter.loc)) +/// The companions still standing with it (a companion counts its criminal's other companions) +/datum/human_npc_surrender/bounty/allies_near(mob/living/carbon/human/npc/npc) + . = list() + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(npc) + if(!side) return - controller.set_blackboard_key(BB_BOUNTY_BACKSTEP_AT, world.time + BOUNTY_BACKSTEP_GAP) - var/away_dir = get_dir(target, fighter) - var/turf/away = get_step(fighter, away_dir) - if(!away_dir || !away || away.is_blocked_turf(source_atom = fighter) || islava(away) || ischasm(away)) - return - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(fighter) - if(leash_holder && !leash_holder.leash_ok(away)) - return - fighter.Move(away, away_dir) - fighter.face_atom(target) - -/// The shotgun closes in to BOUNTY_SHOTGUN_RANGE -/datum/ai_behavior/basic_ranged_attack/bounty/close - required_distance = BOUNTY_SHOTGUN_RANGE - -/// The bottle thrower stays BOUNTY_BOTTLE_RANGE off -/datum/ai_behavior/basic_ranged_attack/bounty/throw - required_distance = BOUNTY_BOTTLE_RANGE - -// ===== SURRENDER ===== - -/** - * Rolls to give up as a hit is about to land (combat.md N2): BOUNTY_SURRENDER_CHANCE on the hit that - * crosses BOUNTY_SURRENDER_BELOW, less BOUNTY_SURRENDER_PER_COMPANION for each companion still up, - * then BOUNTY_SURRENDER_RETRY on later hits at most every BOUNTY_SURRENDER_RETRY_GAP. Only with - * BOUNTY_SURRENDER_HUNTERS hunters near. Rolled before the damage lands, so the body's mercy rule - * can see TRAIT_BOUNTY_SURRENDERED on that same hit. Must not sleep: it runs in a signal handler. - */ -/mob/living/basic/bounty_criminal/proc/ai_check_surrender(damage, damagetype) - if(damage <= 0 || blended || !(damagetype in list(BRUTE, BURN)) || !ai_can_act()) - return FALSE - var/line = maxHealth * BOUNTY_SURRENDER_BELOW - var/coeff = damage_coeff?[damagetype] - var/after = health - damage * (isnull(coeff) ? 1 : coeff) - var/chance - if(health > line && after <= line && !ai_surrender_rolled) - ai_surrender_rolled = TRUE - chance = ai_surrender_chance() - else if(health <= line && world.time >= ai_next_surrender_roll) - chance = BOUNTY_SURRENDER_RETRY - else - return FALSE - ai_next_surrender_roll = world.time + BOUNTY_SURRENDER_RETRY_GAP - if(bounty_ai_hunters_near(src, BOUNTY_SURRENDER_HUNTER_RANGE) < BOUNTY_SURRENDER_HUNTERS || !prob(chance)) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in side.ai_companions()) + if(buddy != npc && buddy.ai_can_fight() && buddy.body_position == STANDING_UP) + . += buddy + +/// Not while blended in at a trader outpost (nothing gives the fugitive away but P6's rules, C1) +/datum/human_npc_surrender/bounty/should_consider(mob/living/carbon/human/npc/npc, list/hostiles, list/allies) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = npc + if(istype(criminal) && criminal.blended) return FALSE - ai_surrender() - return TRUE + return ..() -/// The chance on the crossing hit: less for each companion still standing with them -/mob/living/basic/bounty_criminal/proc/ai_surrender_chance() - var/standing = 0 - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) - if(buddy.ai_can_fight()) - standing++ - return max(0, BOUNTY_SURRENDER_CHANCE - standing * BOUNTY_SURRENDER_PER_COMPANION) +/// The chance on the first roll of a fight: less for each companion still standing with them +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_surrender_chance() + var/datum/human_npc_surrender/rules = npc_surrender_rules() || human_npc_singleton(/datum/human_npc_surrender/bounty) + return rules.odds(src, list(src), rules.allies_near(src), TRUE) /** - * They give up: hands up (an overhead mark), no more attacks, cuffable in 2 s, and a full share at - * the pad until cuffed (P2's capture_state() reads the trait as STUNNED). Hitting them afterwards - * does not undo it. Their companions may lose heart. + * Gives up now: the human NPC base's npc_surrender() (the weapon stowed, the line, tg's *surrender). + * Returns TRUE if they are lying there with their hands on their head. Its AI side + * (ai_on_surrendered()) runs from the base's COMSIG_HUMAN_NPC_SURRENDERED. */ -/mob/living/basic/bounty_criminal/proc/ai_surrender() - if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED) || stat == DEAD) - return - ADD_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - body_note_state() - ai_stop_aiming() - ai_suspend_activity() - ai_set_mode(BOUNTY_AI_CALM) - ai_hand = bounty_ai_update_held(src, ai_hand, null) - ai_over_head = bounty_ai_update_held(src, ai_over_head, /obj/effect/abstract/bounty_surrender_mark, over_head = TRUE) - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - if(!QDELETED(ai_controller)) - ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) - ai_controller.ai_movement?.stop_moving_towards(ai_controller) - ai_controller.CancelActions() - visible_message(span_notice("[src] raises [p_their()] hands and gives up.")) - // Forced: it usually comes right after a "hurt" line, inside that line's cooldown (BUG-1) - ai_bark("surrender", TRUE) - ai_companions_lose_heart() - -/// Only its look is used: the hands-up mark over a surrendered criminal's head -/obj/effect/abstract/bounty_surrender_mark - icon = 'icons/hud/screen_alert.dmi' - icon_state = "surrender" +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_surrender() + return npc_surrender() // ===== RETREAT ===== /// Badly hurt with no hunter near: they break off rather than wait to be found -/mob/living/basic/bounty_criminal/proc/ai_should_retreat() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_should_retreat() if(!ai_can_act() || health > maxHealth * BOUNTY_NORMAL_RETREAT_BELOW) return FALSE return !bounty_ai_hunters_near(src, BOUNTY_SURRENDER_HUNTER_RANGE) /** * Gets away from `from` (or where they stand) to somewhere about BOUNTY_NORMAL_RETREAT_DISTANCE - * off on their site, picked from a handful of random tiles once, and sits it out there. They do not - * heal; a hunter coming close starts the fight again. + * off on their site, picked from a handful of random tiles once, and sits it out there, patching + * themselves up with what they carry. A hunter coming close starts the fight again. */ -/mob/living/basic/bounty_criminal/proc/ai_start_retreat(mob/living/from) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_retreat(mob/living/from) if(!ai_can_act() || QDELETED(ai_controller)) return FALSE var/turf/here = get_turf(src) @@ -401,8 +296,8 @@ best_distance = distance ai_suspend_activity() ai_set_mode(BOUNTY_AI_RETREATING) - ai_hand = bounty_ai_update_held(src, ai_hand, null) - ai_set_pace(BOUNTY_NORMAL_SPEED_RANGED) + bounty_ai_holster(src) + ai_set_pace(BOUNTY_RUN_SPEED) ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) if(best) ai_controller.set_blackboard_key(BB_BOUNTY_DESTINATION, best) @@ -411,11 +306,11 @@ ai_bark("hurt") return TRUE -/// Walks to where they retreat to, then sits down there, winded and hurt +/// Walks to where they retreat to, then sits down on the floor there and treats themselves (N6) /datum/ai_planning_subtree/bounty_retreat /datum/ai_planning_subtree/bounty_retreat/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_mode != BOUNTY_AI_RETREATING) return var/turf/destination = controller.blackboard[BB_BOUNTY_DESTINATION] @@ -427,411 +322,7 @@ criminal.ai_set_pace(BOUNTY_CALM_SPEED) criminal.ai_crouch() criminal.visible_message(span_notice("[criminal] sits down heavily, catching [criminal.p_their()] breath.")) + if(criminal.health < criminal.maxHealth && bounty_ai_queue_treat_self(controller)) + return SUBTREE_RETURN_FINISH_PLANNING controller.queue_behavior(/datum/ai_behavior/bounty_watch) return SUBTREE_RETURN_FINISH_PLANNING - -// ===== STYLES ===== - -/// Every fighting style by key (BOUNTY_STYLE_*), one datum each -GLOBAL_LIST_INIT(bounty_styles, init_bounty_styles()) - -/proc/init_bounty_styles() - var/list/styles = list() - for(var/datum/bounty_style/style_type as anything in subtypesof(/datum/bounty_style)) - var/datum/bounty_style/style = new style_type - if(style.key) - styles[style.key] = style - return styles - -/// A fighter's style: from its controller's blackboard -/proc/bounty_ai_style(mob/living/basic/fighter) - var/datum/ai_controller/controller = fighter?.ai_controller - if(QDELETED(controller)) - return null - return GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] - -/// The item a fighter fights with and shows in their hand (a typepath), or null for bare fists -/proc/bounty_ai_held_look(mob/living/basic/fighter) - var/datum/ai_controller/controller = fighter?.ai_controller - if(QDELETED(controller)) - return null - return controller.blackboard[BB_BOUNTY_WEAPON] - -/// The real weapon a fighter fights with: the item in their hand, loaded as their style loads it. Null with no style. -/proc/bounty_ai_real_weapon(mob/living/basic/fighter) - var/datum/bounty_style/style = bounty_ai_style(fighter) - if(!style) - return null - return bounty_real_weapon(bounty_ai_held_look(fighter), style.ammo_type) - -// A style's health multiplier is P2's bounty_style_health_mult() (bounty_criminal.dm), from the combat.md defines. - -/// Stamina damage multiplier of a style (the brawler shrugs off some): P2 folds it into damage_coeff[STAMINA] -/proc/bounty_style_stamina_mult(style_key) - var/datum/bounty_style/style = GLOB.bounty_styles[style_key] - return style ? style.stamina_mult : 1 - -/** - * Puts style `key` on `fighter`: its weapon (the style's heavy_look if `heavy` and it has one), a - * full magazine, and the blow and the gun that weapon gives. - */ -/proc/bounty_ai_apply_style(mob/living/basic/fighter, key, heavy = FALSE) - var/datum/bounty_style/style = GLOB.bounty_styles[key] - var/datum/ai_controller/controller = fighter?.ai_controller - if(!style || QDELETED(controller)) - return FALSE - var/weapon_type = (heavy && style.heavy_look) || style.held_look - var/datum/bounty_real_weapon/weapon = bounty_real_weapon(weapon_type, style.ammo_type) - controller.set_blackboard_key(BB_BOUNTY_STYLE, key) - if(weapon_type) - controller.set_blackboard_key(BB_BOUNTY_WEAPON, weapon_type) - else - controller.clear_blackboard_key(BB_BOUNTY_WEAPON) - controller.set_blackboard_key(BB_BOUNTY_AMMO, weapon.magazine) - controller.clear_blackboard_key(BB_BOUNTY_RELOAD_UNTIL) - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - style.apply(fighter, weapon) - return TRUE - -/** - * How someone fights (combat.md 6.2). Criminals, companions and the meek's holdout share these. - * Every blow and shot is the real weapon's (bounty_weapons.dm): the item the style puts in their - * hand gives its damage, wounds, armour penetration, rounds and magazine, so a 9mm is a 9mm - * whoever fires it. The style only sets the timing: how fast they swing or fire, the telegraph, the - * reload and the distance they keep. Melee styles hit through the mob's own melee attack; ranged - * ones shoot through its ranged_attacks component. Nothing is spawned for the fight, and nothing - * drops. - */ -/datum/bounty_style - /// BOUNTY_STYLE_* - var/key - /// Fights at range - var/ranged = FALSE - /// Health multiplier (P2 applies it to a criminal; companions apply it themselves) - var/health_mult = 1 - /// Stamina damage multiplier (P2 applies it to a criminal) - var/stamina_mult = 1 - /// Between attacks - var/interval = 1.4 SECONDS - /// Speed in a fight - var/speed = BOUNTY_NORMAL_SPEED_MELEE - /// The real item they fight with, shown in their hand (a typepath; null for bare fists) - var/held_look - /// The heavier real item a Wanted criminal carries instead, if the style has one - var/heavy_look - /// What a gun is loaded with instead of what it comes with (a shotgun's buckshot) - var/ammo_type - /// Ranged: the NPCs' own projectile a round flies as, and the sound of a throw (a gun sounds like itself) - var/projectile_type - var/projectile_sound - /// The ranged behavior that keeps this style's distance - var/ranged_behavior = /datum/ai_behavior/basic_ranged_attack/bounty - /// Anyone closer than this and they step back (0: never) - var/min_range = 0 - /// The telegraph before a shot or throw, and what it says and sounds like - var/windup = 0 - var/windup_message - var/windup_sound - /// How long a reload takes once the magazine is spent, and what it sounds like - var/reload_time = 0 - var/reload_sound - -/// Sets `weapon`'s blow and gun on `fighter`, at this style's pace -/datum/bounty_style/proc/apply(mob/living/basic/fighter, datum/bounty_real_weapon/weapon) - fighter.melee_attack_cooldown = interval - weapon.apply_melee(fighter) - var/datum/component/ranged_attacks/gun = fighter.GetComponent(/datum/component/ranged_attacks) - if(!gun || !projectile_type) - return - gun.casing_type = null - gun.projectile_type = projectile_type - gun.projectile_sound = weapon.fire_sound || projectile_sound - gun.cooldown_time = interval - gun.burst_shots = null - gun.burst_intervals = 0 - -/// Anything extra a melee hit does -/datum/bounty_style/proc/on_melee_hit(mob/living/basic/fighter, mob/living/target) - return - -/// Whether this shot needs the telegraph first -/datum/bounty_style/proc/needs_windup(datum/ai_controller/controller) - return windup > 0 - -/// The telegraph: what they say, show and sound like before the shot -/datum/bounty_style/proc/telegraph(mob/living/basic/fighter, atom/target) - fighter.face_atom(target) - var/look = bounty_ai_held_look(fighter) - if(look) - bounty_ai_hold(fighter, look) - if(windup_message) - fighter.visible_message(span_warning(replacetext(windup_message, "%USER", "[fighter]"))) - if(windup_sound) - playsound(fighter, windup_sound, 50, TRUE) - -/** - * One tick of a ranged fight: reloading, winding up, or firing through the ranged_attacks - * component when it is ready. Returns the AI behavior flags. - */ -/datum/bounty_style/proc/ranged_tick(mob/living/basic/fighter, atom/target) - var/datum/ai_controller/controller = fighter.ai_controller - var/datum/bounty_real_weapon/weapon = bounty_ai_real_weapon(fighter) - var/magazine = weapon?.magazine - var/now = world.time - var/reload_until = controller.blackboard[BB_BOUNTY_RELOAD_UNTIL] - if(reload_until) - if(now < reload_until) - return AI_BEHAVIOR_DELAY - controller.clear_blackboard_key(BB_BOUNTY_RELOAD_UNTIL) - controller.set_blackboard_key(BB_BOUNTY_AMMO, magazine) - if(magazine && controller.blackboard[BB_BOUNTY_AMMO] <= 0) - controller.set_blackboard_key(BB_BOUNTY_RELOAD_UNTIL, now + reload_time) - fighter.visible_message(span_notice("[fighter] reloads.")) - if(reload_sound) - playsound(fighter, reload_sound, 50, TRUE) - return AI_BEHAVIOR_DELAY - var/datum/component/ranged_attacks/gun = fighter.GetComponent(/datum/component/ranged_attacks) - if(!gun) - return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED - var/windup_until = controller.blackboard[BB_BOUNTY_WINDUP_UNTIL] - if(windup_until && now > windup_until + 1 SECONDS) - // A wind-up left over from an interrupted fight: it has to be done again - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - windup_until = null - if(!windup_until) - if(gun.fire_cooldown > now + windup) - return AI_BEHAVIOR_DELAY - if(needs_windup(controller)) - controller.set_blackboard_key(BB_BOUNTY_WINDUP_UNTIL, now + windup) - telegraph(fighter, target) - return AI_BEHAVIOR_DELAY - else if(now < windup_until) - fighter.face_atom(target) - return AI_BEHAVIOR_DELAY - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - fighter.RangedAttack(target) - return AI_BEHAVIOR_DELAY - -/// After each shot: the magazine and the clock for the next string -/datum/bounty_style/proc/after_shot(mob/living/basic/fighter) - var/datum/ai_controller/controller = fighter.ai_controller - if(QDELETED(controller)) - return - controller.set_blackboard_key(BB_BOUNTY_LAST_SHOT, world.time) - var/datum/bounty_real_weapon/weapon = bounty_ai_real_weapon(fighter) - if(weapon?.magazine) - controller.set_blackboard_key(BB_BOUNTY_AMMO, max(0, controller.blackboard[BB_BOUNTY_AMMO] - 1)) - -/** - * Loads a shot as it leaves the gun: the real round's numbers and its spread, and the rest of a - * shell's pellets fired alongside it (one shell, one shot from the magazine). A thrown bottle - * takes the bottle's throw. Called inside the projectile's fire(): must not sleep. - */ -/datum/bounty_style/proc/prepare_projectile(mob/living/basic/fighter, obj/projectile/shot) - var/datum/bounty_real_weapon/weapon = bounty_ai_real_weapon(fighter) - if(!weapon) - return - if(istype(shot, /obj/projectile/bounty_bottle)) - weapon.load_thrown(shot) - return - var/obj/projectile/bullet/bounty/bullet = shot - if(!istype(bullet) || bullet.round_loaded) - return - bullet.round_loaded = TRUE - if(!weapon.load(bullet)) - return - bullet.set_angle(bullet.angle + weapon.shot_spread()) - for(var/pellet in 2 to weapon.pellets) - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_ai_fire_pellet), fighter, weapon, bullet.type, bullet.original) - -/// Another pellet of the shell `fighter` just fired at `target`, with its own spread -/proc/bounty_ai_fire_pellet(mob/living/basic/fighter, datum/bounty_real_weapon/weapon, shot_type, atom/target) - var/turf/start = get_turf(fighter) - if(!start || QDELETED(fighter) || QDELETED(target)) - return null - var/obj/projectile/bullet/bounty/pellet = new shot_type(start) - pellet.round_loaded = TRUE - weapon.load(pellet) - pellet.firer = fighter - pellet.fired_from = fighter - pellet.aim_projectile(target, fighter, null, weapon.shot_spread()) - if(QDELETED(pellet)) - return null - pellet.fire() - return pellet - -/// Bare fists: a human's punch -/datum/bounty_style/brawler - key = BOUNTY_STYLE_BRAWLER - health_mult = BOUNTY_BRAWLER_HEALTH - stamina_mult = BOUNTY_BRAWLER_STAMINA - interval = BOUNTY_BRAWLER_INTERVAL - -/// Now and then a punch shoves them back a tile (never onto lava, into a chasm or out into space, M5) and staggers them; no stun -/datum/bounty_style/brawler/on_melee_hit(mob/living/basic/fighter, mob/living/target) - if(target.stat != CONSCIOUS || !prob(BOUNTY_BRAWLER_SHOVE_CHANCE)) - return - bounty_ai_shove_back(fighter, target) - target.apply_status_effect(/datum/status_effect/staggered, BOUNTY_BRAWLER_STAGGER) - fighter.visible_message(span_warning("[fighter] shoves [target] back!")) - playsound(target, 'sound/items/weapons/shove.ogg', 50, TRUE) - -/// The blade's own cuts bleed, as a real one's do -/datum/bounty_style/knife - key = BOUNTY_STYLE_KNIFE - health_mult = BOUNTY_KNIFE_HEALTH - interval = BOUNTY_KNIFE_INTERVAL - held_look = /obj/item/knife/kitchen - heavy_look = /obj/item/knife/combat - -/datum/bounty_style/club - key = BOUNTY_STYLE_CLUB - health_mult = BOUNTY_CLUB_HEALTH - interval = BOUNTY_CLUB_INTERVAL - held_look = /obj/item/melee/baseball_bat - heavy_look = /obj/item/melee/baseball_bat/ablative - -/// A bat knocks them back a tile or two, gently, as tg's bat does; never onto lava, into a chasm or out into space -/datum/bounty_style/club/on_melee_hit(mob/living/basic/fighter, mob/living/target) - if(QDELETED(target) || target.stat == DEAD) - return - bounty_throw_back(fighter, target, rand(1, 2), prob(60) ? 1 : 4, gentle = TRUE) - -/datum/bounty_style/pistol - key = BOUNTY_STYLE_PISTOL - ranged = TRUE - health_mult = BOUNTY_PISTOL_HEALTH - interval = BOUNTY_PISTOL_INTERVAL - speed = BOUNTY_NORMAL_SPEED_RANGED - held_look = /obj/item/gun/ballistic/automatic/pistol/aps - projectile_type = /obj/projectile/bullet/bounty - windup = BOUNTY_PISTOL_WINDUP - windup_message = "%USER raises a pistol." - windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' - min_range = BOUNTY_PISTOL_MIN_RANGE - reload_time = BOUNTY_PISTOL_RELOAD - reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' - -/// Only the first shot of a string needs the gun raised -/datum/bounty_style/pistol/needs_windup(datum/ai_controller/controller) - var/last_shot = controller.blackboard[BB_BOUNTY_LAST_SHOT] - return !last_shot || world.time - last_shot > BOUNTY_PISTOL_STRING_GAP - -/// A reload starts a new string -/datum/bounty_style/pistol/ranged_tick(mob/living/basic/fighter, atom/target) - var/datum/ai_controller/controller = fighter.ai_controller - if(controller.blackboard[BB_BOUNTY_RELOAD_UNTIL] && world.time >= controller.blackboard[BB_BOUNTY_RELOAD_UNTIL]) - controller.clear_blackboard_key(BB_BOUNTY_LAST_SHOT) - return ..() - -/// Buckshot: every pellet of a shell, one shell a shot -/datum/bounty_style/shotgun - key = BOUNTY_STYLE_SHOTGUN - ranged = TRUE - health_mult = BOUNTY_SHOTGUN_HEALTH - interval = BOUNTY_SHOTGUN_INTERVAL - speed = BOUNTY_NORMAL_SPEED_RANGED - held_look = /obj/item/gun/ballistic/shotgun - heavy_look = /obj/item/gun/ballistic/shotgun/automatic/combat - ammo_type = /obj/item/ammo_casing/shotgun/buckshot - projectile_type = /obj/projectile/bullet/bounty - ranged_behavior = /datum/ai_behavior/basic_ranged_attack/bounty/close - windup = BOUNTY_SHOTGUN_WINDUP - windup_message = "%USER pumps the shotgun." - windup_sound = 'sound/items/weapons/gun/shotgun/rack.ogg' - reload_time = BOUNTY_SHOTGUN_RELOAD - reload_sound = 'sound/items/weapons/gun/shotgun/insert_shell.ogg' - -/// A beer bottle, thrown: it hits with the bottle's throwforce, and breaks -/datum/bounty_style/bottle - key = BOUNTY_STYLE_BOTTLE - ranged = TRUE - health_mult = BOUNTY_BOTTLE_HEALTH - interval = BOUNTY_BOTTLE_INTERVAL - speed = BOUNTY_NORMAL_SPEED_RANGED - held_look = /obj/item/reagent_containers/cup/glass/bottle/beer - projectile_type = /obj/projectile/bounty_bottle - projectile_sound = 'sound/items/weapons/punchmiss.ogg' - ranged_behavior = /datum/ai_behavior/basic_ranged_attack/bounty/throw - min_range = BOUNTY_BOTTLE_MIN_RANGE - windup = BOUNTY_BOTTLE_WINDUP - windup_message = "%USER winds up to throw a bottle." - -/** - * The meek's little gun: a quick volley when cornered, once per cornering. The volley is fired - * one shot at a time by the meek AI (bounty_ai_meek.dm), each shot checking they can still shoot, - * so the gun here only has to be ready again between shots. - */ -/datum/bounty_style/holdout - key = BOUNTY_STYLE_HOLDOUT - ranged = TRUE - interval = BOUNTY_MEEK_PISTOL_GAP * 0.8 - speed = BOUNTY_MEEK_SPRINT_SPEED - held_look = /obj/item/gun/ballistic/automatic/pistol - projectile_type = /obj/projectile/bullet/bounty/holdout - -// ===== WHAT THEY FIRE ===== - -/** - * A criminal's bullet or pellet: the real round's numbers are put on it as it is fired - * (/datum/bounty_style/proc/prepare_projectile()), embedding and all. It never hits the shooter's - * own gang, and off their site or at a trader outpost it passes anyone the body's rules leave alone - * (AR-C8, AR-D1; bounty_ai_may_hit(), so a companion's shots don't depend on its criminal being up, - * H3): only a direct target is hit regardless. - */ -/obj/projectile/bullet/bounty - name = "bullet" - /// The round's numbers are on it already: a pellet fired alongside a shell's first - var/round_loaded = FALSE - -/obj/projectile/bullet/bounty/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - . = ..() - if(!. || direct_target || !isliving(target) || !firer) - return - if(!bounty_ai_may_hit(firer, target)) - return FALSE - -/// The meek's holdout fires these -/obj/projectile/bullet/bounty/holdout - name = "small bullet" - -/** - * A thrown bottle: it flies slower than a bullet, hits with the bottle's throw, and smashes where it - * lands, leaving broken glass on the floor (a decal, never a weapon). - */ -/obj/projectile/bounty_bottle - name = "bottle" - icon = 'icons/obj/drinks/bottles.dmi' - icon_state = "beer" - damage_type = BRUTE - armor_flag = MELEE - speed = 0.6 - range = BOUNTY_BOTTLE_REACH - hitsound = 'sound/effects/glass/glassbr1.ogg' - hitsound_wall = 'sound/effects/glass/glassbr2.ogg' - /// Smashed already, so it leaves glass once - var/smashed = FALSE - -/obj/projectile/bounty_bottle/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - . = ..() - if(!. || direct_target || !isliving(target) || !firer) - return - if(!bounty_ai_may_hit(firer, target)) - return FALSE - -/obj/projectile/bounty_bottle/on_hit(atom/target, blocked = 0, pierce_hit) - . = ..() - smash(get_turf(target)) - -/obj/projectile/bounty_bottle/on_range() - smash(get_turf(src)) - return ..() - -/// Broken glass where it landed -/obj/projectile/bounty_bottle/proc/smash(turf/where) - if(smashed || !where) - return - smashed = TRUE - if(!isgroundlessturf(where) && !isspaceturf(where)) - new /obj/effect/decal/cleanable/glass(where) - playsound(where, 'sound/effects/glass/glassbr3.ogg', 50, TRUE) diff --git a/voidcrew/modules/bounties/bounty_board.dm b/voidcrew/modules/bounties/bounty_board.dm index 7f17d045fe0..28617021795 100644 --- a/voidcrew/modules/bounties/bounty_board.dm +++ b/voidcrew/modules/bounties/bounty_board.dm @@ -186,7 +186,7 @@ SUBSYSTEM_DEF(criminal_bounties) /datum/controller/subsystem/criminal_bounties/proc/board_live_criminals() var/count = 0 for(var/datum/criminal_bounty/posting as anything in GLOB.criminal_bounties) - var/mob/living/basic/bounty_criminal/criminal = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = posting.criminal() if(criminal && criminal.stat != DEAD) count++ return count diff --git a/voidcrew/modules/bounties/bounty_boss.dm b/voidcrew/modules/bounties/bounty_boss.dm index 94d389fabdf..5ada259688a 100644 --- a/voidcrew/modules/bounties/bounty_boss.dm +++ b/voidcrew/modules/bounties/bounty_boss.dm @@ -5,36 +5,62 @@ * the tests in voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm. Do not edit this file * outside P4. * - * What it owns: /mob/living/basic/bounty_criminal/boss (its body, built-in values and AI tree), the - * bounty ability base with its wind-up, telegraph and stale-serial guard (a port of the prison - * horror's pattern, not a reuse of its type), health that grows with the posse, tiredness below - * BOUNTY_BOSS_TIRED_BELOW percent, the limits on what it may break, and on_confronted() for bosses - * (spec section 5, 12.3 C8). + * What it owns: /mob/living/carbon/human/npc/bounty_criminal/boss (its body's built-in values, its gear + * and its AI), the bounty ability base with its wind-up, telegraph and stale-serial guard (a port of + * the prison horror's pattern), the posse and its reinforcements, the limits on what it may break, its + * scripted fire and blasts, and on_confronted() for bosses (spec section 5, 12.3 C8). * - * The shared vars are declared in bounty_types.dm and nowhere else; a var only P4 uses goes here - * with a `boss_` prefix, or on the /boss subtypes. + * A boss is a real human (the human NPC base, voidcrew/modules/human_npcs/; owner decision D5). Its + * crit, stamina, stamcrit, stuns, cuffs, wounds, bleeding and death are tg's, exactly as a player's: + * - Toughness is real armour from its kit (locked to its body, D3) and npc_damage_taken + * (BOUNTY_BOSS_DAMAGE_TAKEN), fixed whatever the posse (defaults 1.5, OC-8). Never health. + * - Resistance to stuns is its armour, its kit's own answer to a disabler (bounty_boss_kits.dm, C8) and + * the real stimulant pens it carries, used the way combat sense uses them (human_npc_combat_sense.dm): + * when it tires in a fight, never while one is still in its blood (/datum/npc_combat_sense/bounty/boss). + * - "Worn out" is its AI reading real state (stamina loss over half its pool, or health under 40%): it + * says so, takes a pen if it wants one, and falls back (CB-2, CR-4). + * - It fights with what it holds (real knives, a real torch, a real gun, its fists) through its hands, + * the way a player does (controller.ai_interact()). + * - It never surrenders. Bosses execute nobody this phase (the core's hv_can_execute(), OC-9). * * How a fight goes: * - A boss waits (its activity, if P3 gives it one) until it spots a hunter, is attacked or is * confronted. Then it turns hostile: every player within BOUNTY_BOSS_POSSE_RANGE who can see it - * joins its posse, and its health is set from the kit's table for that many hunters. Anyone who - * hurts it later joins too, raising its health again (never lowering it). - * - Its AI: nothing while busy or disabled; a target; an ability, never the same one twice in a - * row; the kit's way of closing in or keeping away; its melee attack; and with nobody in sight, - * the hunt for the nearest hunter still on its site. It calms down after BOUNTY_BOSS_CALM_TIME - * with nobody around. + * joins its posse, and so does every crew member of a ship hunting it who is playing and at its site + * (on it, on its dock strip or aboard a ship landed there), seen or not. Each hunter in the posse + * beyond the first brings one more companion in from out of their view, one at a time (OC-8, CB-1), + * up to BOUNTY_BOSS_REINFORCEMENTS_MAX. Never health. + * - Its AI: soft crit and breaking free first (the human NPC base's and the core's); nothing while + * busy or disabled; worn out, it falls back; a target; its combat sense (its stimulant, and the kit's + * reaction to stun fire, if it has one); its kit's gear; an ability, + * never the same one twice in a row; the kit's way of closing in or keeping away; up to and through + * whatever is in its way, if its kit can go through it; its melee; patching itself up with nobody + * about; with nobody in sight, the hunt for the nearest hunter still on its site that it can get at; + * and with nobody it can get at, out of the sight of those shooting at it from where it can't. It + * calms down after BOUNTY_BOSS_CALM_TIME with nobody around. + * - Hurt by someone it can't get at (behind glass it can't break, a shut door, a gap, or off its site), + * it doesn't stand there: it stops going after them for BOUNTY_BOSS_SHUN_TIME and gets out of their + * sight (boss_find_cover()), however far off they are, with a line as it goes. Someone far off with a + * way to them on foot is in reach, and it goes after them. With real Gorilla Arms and an empty hand it + * punches through a window, grille, table or door it can break, after a tell (boss_start_smash()); the + * punches are the implant's own, and it only starts if it can break everything on the way. * - Every ability winds up first, where everyone can see and hear it (telegraph()), and does its - * harm only when the wind-up is over (effect()). Downed, restrained, stunned or dead in between, - * and nothing happens. - * - Below BOUNTY_BOSS_TIRED_BELOW percent health it tires: slower, longer cooldowns, and stamina - * weapons work fully, so it can be stunned and cuffed. That is the capture window. + * harm only when the wind-up is over (effect()). Downed, restrained, stunned, knocked down or dead + * in between (tg's states, CB-24), and nothing happens. + * - Who it chooses to go after is never someone already down (npc_target_stat, its targeting). What + * its fire and blasts catch is whoever stands there, down or not (CB-11, defaults 1.11). * - What it breaks is scripted, never a real explosion or atmos fire: tables, chairs, windows and * grilles, and at most BOUNTY_BOSS_WALL_BUDGET walls or doors a fight, only indoors on its own - * site, never anything with space, the outdoors or another place on its far side. + * site, never anything with space, the outdoors or another place on its far side. People its fire + * reaches burn with tg's fire stacks; people its blasts reach take tg's light explosion. */ /// Trait source for standing still while busy with an ability #define BOUNTY_BOSS_BUSY_TRAIT "bounty_boss_busy" +/// What boss_busy says while its Gorilla Arms go at a window, grille, table or door +#define BOUNTY_BOSS_BUSY_SMASH "smash" +/// The glow on it while they do +#define BOUNTY_BOSS_SMASH_FILTER "bounty_boss_smash" /// Fire pools the bosses have burning (/obj/effect/bounty_boss_fire_pool), for the cap GLOBAL_LIST_EMPTY(bounty_boss_fire_pools) @@ -47,41 +73,49 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /proc/bounty_boss_is_hunter(mob/living/candidate) if(!isliving(candidate) || QDELETED(candidate) || candidate.stat == DEAD) return FALSE - if(istype(candidate, /mob/living/basic/bounty_criminal) || istype(candidate, /mob/living/basic/bounty_companion)) + if(bounty_is_gang_body(candidate)) return FALSE return !isnull(candidate.mind) || !isnull(candidate.client) +/** + * Whether a boss's fire or blast catches `thing`, whatever state it is in (CB-11): a person who isn't + * on the boss's side (`side`, its factions), or a mech with one in it. Never a criminal or a companion, + * never anyone in godmode. Where it reaches is the caller's business (boss_reach_ok()). + */ +/proc/bounty_boss_effect_victim(atom/thing, list/side) + if(QDELETED(thing)) + return FALSE + if(ismecha(thing)) + var/obj/vehicle/sealed/mecha/mech = thing + for(var/mob/living/pilot in mech.occupants) + if(bounty_boss_effect_person(pilot, side)) + return TRUE + return FALSE + var/mob/living/person = thing + // A pilot is reached through the mech's hull. + return isliving(person) && !ismecha(person.loc) && bounty_boss_effect_person(person, side) + +/// Whether `person` is someone a boss's fire and blasts catch: anyone not on its side, down, dead or standing +/proc/bounty_boss_effect_person(mob/living/person, list/side) + if(!isliving(person) || QDELETED(person) || HAS_TRAIT(person, TRAIT_GODMODE) || bounty_is_gang_body(person)) + return FALSE + return !length(side) || !faction_check(side, person.faction) + // ===== THE BOSS ===== -/mob/living/basic/bounty_criminal/boss +/mob/living/carbon/human/npc/bounty_criminal/boss name = "wanted criminal" desc = "Someone with a big price on their head, and the gear to keep it." - maxHealth = BOUNTY_JUGGERNAUT_HEALTH_1 - health = BOUNTY_JUGGERNAUT_HEALTH_1 - // Fresh, it shrugs off stuns and knockdowns; boss_update_tired() adds them back once it tires. - status_flags = CANPUSH - move_resist = MOVE_FORCE_STRONG - mob_size = MOB_SIZE_HUMAN - combat_mode = TRUE - // The placement adds the site's own factions (spec section 3), so the site's hostiles leave it be. - faction = list(FACTION_HOSTILE) - environment_smash = ENVIRONMENT_SMASH_NONE - // max_stamina and the stamina crit threshold are P2's (100 and 99.9). Stamina never slows a boss: - // the tired slowdown is the only one the spec asks for, so steady disabler fire can't pin it (H3). - max_stamina_slowdown = 0 - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_damage_lower = 10 - melee_damage_upper = 14 - melee_attack_cooldown = 1.5 SECONDS - attack_verb_continuous = "hits" - attack_verb_simple = "hit" - attack_sound = 'sound/items/weapons/punch4.ogg' - attack_vis_effect = ATTACK_EFFECT_PUNCH ai_controller = /datum/ai_controller/basic_controller/bounty_boss + // Aimed crits like a player's (D4); executions stay off for every bounty NPC (the core's hv_can_execute()). + npc_rank = HUMAN_NPC_RANK_BOSS + // Fixed toughness, whatever its tier or posse (defaults 1.5, OC-8). Its armour does the rest. + npc_damage_taken = BOUNTY_BOSS_DAMAGE_TAKEN + body_tier_toughness = FALSE + // Its stimulant pens, and a kit's answer to stun fire (C8). A kit with its own reaction uses a subtype. + npc_combat_sense = /datum/npc_combat_sense/bounty/boss /// BOUNTY_KIT_*: which kit it is, and the prefix of its dialogue contexts var/boss_kit - /// The real item in its hand it strikes with (bounty_real_weapon()): its blows are that item's. Null keeps the kit's own blow. - var/boss_weapon /// The ability types it is given var/list/boss_ability_types = list() /// Its abilities (/datum/action/cooldown/mob_cooldown/bounty_boss), granted at Initialize @@ -92,26 +126,23 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) var/boss_strict_rotation = BOUNTY_BOSS_STRICT_ROTATION /// world.time the next ability may start var/boss_next_ability_at = 0 - /// What keeps it still with its AI quiet ("windup", "charge", "dash", "burst", "stagger"), or null + /// What keeps it still with its AI quiet ("windup", "charge", "dash", "burst"), or null var/boss_busy /// Goes up each time it becomes busy, so a stale timer knows to do nothing var/boss_busy_serial = 0 var/boss_busy_timer /// The ability winding up now, whose telegraph goes if it is interrupted var/datum/weakref/boss_winding_ref - /// Stamina damage it takes to crit it once tired, and its coefficient while fresh - var/boss_stamina_pool = BOUNTY_BOSS_STAMINA - var/boss_stamina_fresh = BOUNTY_BOSS_STAMINA_FRESH - /// At or below BOUNTY_BOSS_TIRED_BELOW percent health - var/boss_tired = FALSE - /// Its speed while fresh, and its brute and burn coefficients (a stagger's weakness puts them back) - var/boss_base_speed - var/boss_brute_coeff = 1 - var/boss_burn_coeff = 1 /// Turned hostile: it spotted a hunter, or someone attacked or confronted it var/boss_hostile = FALSE - /// REF() of each hunter in its posse -> TRUE; its health follows how many + /// REF() of each hunter in its posse -> TRUE var/list/boss_engaged = list() + /// Companions its posse has brought in (OC-8), and how many more are owed and not yet called + var/boss_reinforcements_sent = 0 + var/boss_reinforcements_owed = 0 + var/boss_reinforce_timer + /// Weakrefs to the companions that came in for it + var/list/boss_reinforcement_refs /// Walls and doors it may still break this fight var/boss_walls_left = BOUNTY_BOSS_WALL_BUDGET /// Whoever hurt it last (a person, or the mech they are in), and when @@ -119,54 +150,72 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) var/boss_last_attacked_at = 0 /// world.time it calms down if it still finds no hunter on its site var/boss_calm_at = 0 - -/mob/living/basic/bounty_criminal/boss/Initialize(mapload) + /// Worn out (boss_is_worn_out(), read by its AI): said so, and falls back until boss_fallback_until + var/boss_worn_out = FALSE + var/boss_fallback_until = 0 + /// Hunters who hurt it from where it couldn't get at them: weakref -> world.time of the last such hit (boss_note_reach()) + var/list/boss_shunned = list() + /// The tiles it can walk to (boss_walkable()): turf -> steps, the tile it stood on then, and when that was worked out + var/list/boss_walk_cache + var/turf/boss_walk_origin + var/boss_walk_at = 0 + /// world.time it may look for new cover + var/boss_cover_rethink_at = 0 + /// world.time it may say a line again as it breaks for cover + var/boss_cover_bark_at = 0 + /// What its Gorilla Arms are going at (boss_start_smash()), the floor mark on it, and the punches thrown so far + var/datum/weakref/boss_smash_target + var/datum/weakref/boss_smash_mark + var/boss_smash_blows = 0 + /// A thrown item of its own, kept out of the world, that its throws are checked with (boss_throw_reaches()) + var/obj/item/bounty_boss_throw_probe/boss_throw_probe + +/mob/living/carbon/human/npc/bounty_criminal/boss/Initialize(mapload, datum/human_npc_order/order) . = ..() - if(boss_weapon) - bounty_real_weapon(boss_weapon).apply_melee(src) - damage_coeff = damage_coeff.Copy() - boss_brute_coeff = damage_coeff[BRUTE] - boss_burn_coeff = damage_coeff[BURN] - boss_base_speed = speed for(var/ability_type in boss_ability_types) var/datum/action/cooldown/mob_cooldown/bounty_boss/ability = new ability_type(src) ability.Grant(src) boss_abilities += ability - AddElement(/datum/element/relay_attackers) AddComponent(/datum/component/bounty_boss_signals) - boss_update_tired(silent = TRUE, force = TRUE) - // Dressed by the spawner when it has a record; an admin spawn with none gets the kit's look. - addtimer(CALLBACK(src, PROC_REF(boss_dress_fallback)), 1, TIMER_DELETE_ME) -/mob/living/basic/bounty_criminal/boss/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/boss/Destroy() boss_interrupt() QDEL_LIST(boss_abilities) deltimer(boss_busy_timer) boss_busy_timer = null + deltimer(boss_reinforce_timer) + boss_reinforce_timer = null + boss_reinforcement_refs = null boss_winding_ref = null boss_last_attacker = null + boss_shunned = null + boss_walk_cache = null + boss_walk_origin = null + QDEL_NULL(boss_throw_probe) return ..() -/mob/living/basic/bounty_criminal/boss/death(gibbed) +/mob/living/carbon/human/npc/bounty_criminal/boss/death(gibbed) boss_interrupt() - . = ..() - boss_update_hold() + deltimer(boss_reinforce_timer) + boss_reinforce_timer = null + boss_reinforcements_owed = 0 + return ..() -/mob/living/basic/bounty_criminal/boss/examine(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/examine(mob/user) . = ..() - if(stat == DEAD) + if(stat == DEAD || hidden) return var/gear = boss_gear_text() if(gear) . += span_warning(gear) - if(boss_tired) + if(boss_worn_out) . += span_notice("[p_They()] look[p_s()] worn out.") /** * `user` confronted them: showed the warrant, or accused them. A boss doesn't run or argue. It * starts its fight, with `user` first on its list. */ -/mob/living/basic/bounty_criminal/boss/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/on_confronted(mob/user) if(stat != CONSCIOUS || !isliving(user)) return body_add_grudge(user) @@ -175,135 +224,91 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) if(boss_valid_target(target)) ai_controller?.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) -/** - * Its blows land on a mech's hull in full (H2): tg's mech defence scales a mob's blow by how much it - * smashes, and a boss smashes nothing, so every blow would do nothing. - */ -/mob/living/basic/bounty_criminal/boss/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - // Each swing picks one of the weapon's own verbs, as a player's does - if(boss_weapon) - bounty_real_weapon(boss_weapon).pick_verb(src) - if(!ismecha(target)) - return ..() - if(!early_melee_attack(target, modifiers, ignore_cooldown)) - return FALSE - do_attack_animation(target, attack_vis_effect) - if(attack_sound) - playsound(target, attack_sound, 50, TRUE) - target.visible_message(span_danger("[src] [attack_verb_continuous] [target]!")) - boss_hit(target, rand(melee_damage_lower, melee_damage_upper), melee_damage_type, MELEE, 0, armour_penetration, TRUE) - SEND_SIGNAL(src, COMSIG_HOSTILE_POST_ATTACKINGTARGET, target, TRUE) - if(!ignore_cooldown) - changeNext_move(melee_attack_cooldown) - return TRUE - // ----- its kit ----- -/// Health for 1, 2, 3 and 4+ engaged hunters. Each kit has its own. -/mob/living/basic/bounty_criminal/boss/proc/boss_health_table() - return list(initial(maxHealth)) +// What each kit wears and carries (bounty_boss_kits.dm): real armour, its weapon carried or held, spare +// ammunition, its stimulants and something to patch itself up with. The base adds vacuum and cold layers. +/mob/living/carbon/human/npc/bounty_criminal/boss/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss)) -/// The outfit its look is built in: each kit has one, so the five read differently at a glance. P2's look uses it. -/mob/living/basic/bounty_criminal/boss/boss_kit_outfit() - return /datum/outfit/bounty_boss - -/// With no record to dress it from (an admin spawn), the kit's look on a stock face -/mob/living/basic/bounty_criminal/boss/proc/boss_dress_fallback() - if(QDELETED(src) || record) - return - apply_dynamic_human_appearance(src, boss_kit_outfit(), /datum/species/human) +/** + * Packs what every boss carries into `outfit`, an instance: BOUNTY_BOSS_STIMULANTS stimulant pens and one + * combat stimpack (BOUNTY_BOSS_COMBAT_STIMULANT; its combat sense uses them when it tires, one at a time) + * and a bruise pack, in a bag on its back (one is added if it wears none; in vacuum its MODsuit's storage + * takes them, bounty_suits.dm). They are consumables: used up, never loot (human_npc_gear.dm). Returns + * the outfit. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_supplies(datum/outfit/outfit) + if(!istype(outfit)) + return outfit + outfit.back ||= /obj/item/storage/backpack + LAZYINITLIST(outfit.backpack_contents) + outfit.backpack_contents[BOUNTY_BOSS_STIMULANT] = (outfit.backpack_contents[BOUNTY_BOSS_STIMULANT] || 0) + BOUNTY_BOSS_STIMULANTS + outfit.backpack_contents[BOUNTY_BOSS_COMBAT_STIMULANT] = (outfit.backpack_contents[BOUNTY_BOSS_COMBAT_STIMULANT] || 0) + 1 + outfit.backpack_contents[/obj/item/stack/medical/bruise_pack] = (outfit.backpack_contents[/obj/item/stack/medical/bruise_pack] || 0) + 1 + return outfit /// A line on examine about its gear, if the kit has one -/mob/living/basic/bounty_criminal/boss/proc/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gear_text() return null /** * Says a line for "_", or for "_" if there is none for it. Async, so a * signal handler can call it. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_bark(suffix, fallback) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_bark(suffix, fallback) if(!boss_kit || stat != CONSCIOUS) return INVOKE_ASYNC(src, PROC_REF(boss_bark_now), suffix, fallback) -/mob/living/basic/bounty_criminal/boss/proc/boss_bark_now(suffix, fallback) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_bark_now(suffix, fallback) if(QDELETED(src) || stat != CONSCIOUS) return FALSE if(bounty_say("[boss_kit]_[suffix]")) return TRUE return fallback ? bounty_say("[boss_kit]_[fallback]") : FALSE -// ----- state ----- - -/// Downed: P2's state, read from the shared var too so a test can set it -/mob/living/basic/bounty_criminal/boss/proc/boss_is_downed() - return downed || is_downed() - -/// Restrained: P2's state, read from the shared var too so a test can set it -/mob/living/basic/bounty_criminal/boss/proc/boss_is_restrained() - return !isnull(restraints) || is_restrained() +// ----- state, all tg's ----- -/// Whether it can act at all: alive and awake, not downed, restrained, given up, stunned or knocked down -/mob/living/basic/bounty_criminal/boss/proc/boss_can_act() +/// Whether it can act at all: alive and awake, not in crit, restrained, stunned, knocked down or in stamina crit +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_act() if(QDELETED(src) || stat != CONSCIOUS || !isturf(loc)) return FALSE - if(boss_is_downed() || boss_is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) + if(is_downed() || is_restrained() || npc_surrendering) return FALSE return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) -/// Whether it may start an ability now: able, not busy, and past the gap since the last one -/mob/living/basic/bounty_criminal/boss/proc/boss_can_use_ability() - return boss_can_act() && !boss_busy && world.time >= boss_next_ability_at - -/// Its cooldowns stretch once it tires -/mob/living/basic/bounty_criminal/boss/proc/boss_cooldown_mult() - return boss_tired ? BOUNTY_BOSS_TIRED_COOLDOWN_MULT : 1 - -/// Pulled by anyone once it is dead, downed or cuffed; hard to shift otherwise -/mob/living/basic/bounty_criminal/boss/proc/boss_update_hold() - move_resist = (stat == DEAD || boss_is_downed() || boss_is_restrained()) ? MOVE_RESIST_DEFAULT : initial(move_resist) +/// Whether it may start an ability now: able, not busy, not falling back, and past the gap since the last one +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_use_ability() + return boss_can_act() && !boss_busy && world.time >= boss_next_ability_at && world.time >= boss_fallback_until /** - * Tired at or below BOUNTY_BOSS_TIRED_BELOW percent health: stamina weapons work fully, stuns and - * knockdowns land, it moves slower and its cooldowns stretch. Healed back above the line, it is - * fresh again. Returns TRUE if that changed. P2's rally calls it with `force` when the rally ends. - * - * On tiring, its stamina loss starts again from zero, so the capture takes its whole pool - * (BOUNTY_BOSS_STAMINA: five disabler hits) whatever was thrown at it while it was fresh (H3). + * Worn out, read from its body (CB-2): stamina loss over BOUNTY_BOSS_WORN_OUT_STAMINA of its pool, or + * health under BOUNTY_BOSS_WORN_OUT_HEALTH of its max. tg's own slowdown from both does the rest. */ -/mob/living/basic/bounty_criminal/boss/boss_update_tired(silent = FALSE, force = FALSE) - var/now_tired = stat != DEAD && maxHealth > 0 && health <= maxHealth * BOUNTY_BOSS_TIRED_BELOW / 100 - if(now_tired == boss_tired && !force) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_is_worn_out() + if(stat == DEAD) return FALSE - var/was_tired = boss_tired - boss_tired = now_tired - damage_coeff[STAMINA] = boss_stamina_coefficient() - if(boss_tired && !was_tired && staminaloss > 0) - adjustStaminaLoss(-staminaloss, forced = TRUE) - status_flags &= ~(CANSTUN | CANKNOCKDOWN) - if(boss_tired) - status_flags |= CANSTUN | CANKNOCKDOWN - set_varspeed(boss_base_speed + (boss_tired ? BOUNTY_BOSS_TIRED_SLOWDOWN : 0)) - if(boss_tired && !silent) - visible_message(span_warning("[src] is breathing hard and slowing down. [p_They()] look[p_s()] worn out.")) - // The stun window is open: the balloon says so where the fight is, not only in chat (P4 polish) - body_alert_hunters("worn out") - boss_bark("exhausted") - return TRUE + return getStaminaLoss() > max_stamina * BOUNTY_BOSS_WORN_OUT_STAMINA || health < maxHealth * BOUNTY_BOSS_WORN_OUT_HEALTH -/// Stamina damage coefficient now: the fresh or tired one, folded against its pool, as max_stamina stays 100 -/mob/living/basic/bounty_criminal/boss/proc/boss_stamina_coefficient() - var/coefficient = boss_tired ? BOUNTY_BOSS_STAMINA_TIRED : boss_stamina_fresh - return coefficient * max_stamina / max(boss_stamina_pool, 1) +/// It has just worn out: it says so where the fight is, drops what it was winding up and falls back for a while +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_wear_out() + boss_worn_out = TRUE + boss_fallback_until = world.time + BOUNTY_BOSS_FALLBACK_TIME + boss_interrupt() + visible_message(span_warning("[src] is breathing hard and slowing down. [p_They()] look[p_s()] worn out.")) + body_alert_hunters("worn out") + boss_bark("exhausted") // ----- busy ----- /** - * Busy with `state`, its AI quiet. `immobile` keeps it standing still (a wind-up, a stagger); a - * charge or dash moves it itself. With a `duration` it ends on its own. Returns the serial a timer - * checks against, so a stale one does nothing. + * Busy with `state`, its AI quiet. `immobile` keeps it standing still (a wind-up, a burst); a charge or + * dash moves it itself. A wind-up is a telegraph: tg's stuns, knockdowns, stamcrit, crit and cuffs + * interrupt it (the bounty_boss_signals component, and boss_can_act() when it ends; CB-24). With a + * `duration` it ends on its own. Returns the serial a timer checks against, so a stale one does nothing. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_set_busy(state, duration, immobile = TRUE) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_set_busy(state, duration, immobile = TRUE) boss_busy = state boss_busy_serial++ deltimer(boss_busy_timer) @@ -318,7 +323,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_busy_serial /// Free again, unless `serial` is stale -/mob/living/basic/bounty_criminal/boss/proc/boss_clear_busy(serial) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_clear_busy(serial) if(serial && serial != boss_busy_serial) return FALSE boss_busy = null @@ -328,36 +333,28 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE /// Whether it is still busy with `state` under `serial`: what a charge or burst checks before each step -/mob/living/basic/bounty_criminal/boss/proc/boss_still(state, serial) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_still(state, serial) return !QDELETED(src) && boss_busy == state && boss_busy_serial == serial && boss_can_act() /// Knocked out of whatever it was doing: a wind-up's telegraph goes, and nothing lands -/mob/living/basic/bounty_criminal/boss/proc/boss_interrupt() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_interrupt() var/datum/action/cooldown/mob_cooldown/bounty_boss/winding = boss_winding_ref?.resolve() boss_winding_ref = null winding?.cancel_windup() + boss_end_smash() if(boss_busy) boss_clear_busy() boss_on_interrupted() /// A kit's own clean-up when it is interrupted, downed or killed -/mob/living/basic/bounty_criminal/boss/proc/boss_on_interrupted() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_on_interrupted() return -/// Reeling for `duration` (charged into a wall): still, quiet, and taking `vulnerable` times the damage. Not a stun, so it can't be cuffed for it. -/mob/living/basic/bounty_criminal/boss/proc/boss_stagger(duration, vulnerable = 1) - if(stat != CONSCIOUS) - return - boss_set_busy("stagger", duration) - Shake(2, 1, duration) - if(vulnerable != 1) - damage_coeff[BRUTE] = boss_brute_coeff * vulnerable - damage_coeff[BURN] = boss_burn_coeff * vulnerable - addtimer(CALLBACK(src, PROC_REF(boss_end_vulnerable)), duration, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_DELETE_ME) - -/mob/living/basic/bounty_criminal/boss/proc/boss_end_vulnerable() - damage_coeff[BRUTE] = boss_brute_coeff - damage_coeff[BURN] = boss_burn_coeff +/// The ability winding up now, if any +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_winding() + if(boss_busy != "windup") + return null + return boss_winding_ref?.resolve() // ----- abilities ----- @@ -366,7 +363,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * while another is ready. With BOUNTY_BOSS_STRICT_ROTATION (boss_strict_rotation) the last one is * never picked again straight away, even when it is the only one ready. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_choose_ability(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_choose_ability(atom/target) if(QDELETED(target) || !boss_can_use_ability()) return null var/list/options = list() @@ -383,7 +380,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_strict_rotation ? null : last_one /// Starts `ability`'s wind-up. Returns the serial its timer checks, or 0 if it can't start now. -/mob/living/basic/bounty_criminal/boss/proc/boss_begin_ability(datum/action/cooldown/mob_cooldown/bounty_boss/ability) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_begin_ability(datum/action/cooldown/mob_cooldown/bounty_boss/ability) if(!boss_can_use_ability()) return 0 boss_last_ability = ability.type @@ -392,7 +389,11 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_set_busy("windup", ability.windup + 1 SECONDS) /// The kit's way of fighting after abilities: keep range, break off. Returns TRUE if it queued something. -/mob/living/basic/bounty_criminal/boss/proc/boss_engage_plan(datum/ai_controller/controller, atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_engage_plan(datum/ai_controller/controller, atom/target) + return FALSE + +/// The kit's own gear to see to before it fights on (a Heavy's belt change). Returns TRUE if it queued something. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gear_plan(datum/ai_controller/controller) return FALSE // ----- the posse and turning hostile ----- @@ -400,9 +401,10 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Turns hostile, with `instigator` (who attacked, confronted or was spotted) in its posse if they * count (boss_counts_for_posse()). The first time, everyone who counts within - * BOUNTY_BOSS_POSSE_RANGE and can see it joins too, mech pilots included, and it says its intro line. + * BOUNTY_BOSS_POSSE_RANGE and can see it joins too, mech pilots included, then the rest of every + * hunting crew at its site (boss_engage_hunting_crews()), and it says its intro line. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_turn_hostile(mob/living/instigator) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_turn_hostile(mob/living/instigator) if(stat == DEAD) return FALSE boss_calm_at = world.time + BOUNTY_BOSS_CALM_TIME @@ -418,12 +420,45 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) for(var/obj/vehicle/sealed/mecha/mech in view(BOUNTY_BOSS_POSSE_RANGE, src)) for(var/mob/living/pilot in mech.occupants) boss_engage(pilot, announce = FALSE) - boss_apply_posse_health(announce = FALSE) + boss_engage_hunting_crews() boss_bark("intro") return TRUE -/// With no hunter left on its site, it stops hunting and waits again. Its posse and health stay. -/mob/living/basic/bounty_criminal/boss/proc/boss_calm_down() +/** + * Every crew member of a ship hunting its bounty who is playing (not SSD) and at its site joins its + * posse, seen or not (boss_crew_at_site()), so each owes a companion like anyone in sight: one hunter + * can't wake it alone while the rest of the crew wait on the dock strip or aboard their landed ship + * (review r1). Crew on another site sharing its level, or aboard a ship anywhere else, don't count + * (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_engage_hunting_crews() + var/datum/criminal_bounty/posting = posting() + if(!posting || !get_turf(src)) + return + for(var/obj/structure/overmap/ship/ship as anything in posting.hunter_ships()) + for(var/datum/mind/member as anything in ship.ship_team?.members) + var/mob/living/body = member?.current + if(isliving(body) && GET_CLIENT(body) && boss_crew_at_site(body, ship)) + boss_engage(body, announce = FALSE) + +/** + * Whether `body`, of the crew of `ship`, is at its site for its posse: on its level, and on the site + * itself, or anywhere in the site's rectangle, which holds its dock strip and the ships landed on it. + * For a site with no rectangle (a pirate ship), aboard `ship` close alongside it. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_crew_at_site(mob/living/body, obj/structure/overmap/ship/ship) + var/turf/here = get_turf(src) + var/turf/where = get_turf(body) + if(!here || !where || where.z != here.z) + return FALSE + if(body_on_site(where)) + return TRUE + if(site_bounds) + return where.z == site_bounds[5] && where.x >= site_bounds[1] && where.x <= site_bounds[3] && where.y >= site_bounds[2] && where.y <= site_bounds[4] + return ship?.is_aboard(where) && get_dist(here, where) <= BOUNTY_BOSS_HUNT_RANGE + +/// With no hunter left on its site, it stops hunting and waits again. Its posse stays. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_calm_down() boss_hostile = FALSE ai_controller?.clear_blackboard_key(BB_BOUNTY_BOSS_PREY) @@ -432,7 +467,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * confronted or cuffed it), or one whose ship is hunting its bounty. Being seen or targeted alone * doesn't count. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_counts_for_posse(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_counts_for_posse(mob/living/hunter) if(!bounty_boss_is_hunter(hunter)) return FALSE if(body_has_grudge(hunter)) @@ -445,55 +480,109 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE return FALSE -/// Adds `hunter` to its posse if they count, raising its health for the bigger posse. Returns TRUE if they are new. -/mob/living/basic/bounty_criminal/boss/proc/boss_engage(mob/living/hunter, announce = TRUE) +/** + * Adds `hunter` to its posse if they count. Each hunter beyond the first owes it one more companion + * (OC-8); with `announce`, it sizes the bigger posse up out loud. Returns TRUE if they are new. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_engage(mob/living/hunter, announce = TRUE) if(stat == DEAD || !boss_counts_for_posse(hunter)) return FALSE var/key = REF(hunter) if(boss_engaged[key]) return FALSE boss_engaged[key] = TRUE - boss_apply_posse_health(announce) - return TRUE - -/** - * Max health for the posse it has: the kit's table entry for that many hunters (never more than the - * 4+ entry), raised and never lowered (M4). - * - Fresh, current health rises by the same step (spec: "raised, current health too"). - * - Tired, it keeps the same share of its health, so a newcomer never heals it out of its capture - * window. - * - Downed or cuffed, the raise waits; it is applied when it gets up (on_freed), keeping its share. - * Returns TRUE if max health rose. - */ -/mob/living/basic/bounty_criminal/boss/proc/boss_apply_posse_health(announce = TRUE) - if(stat == DEAD || boss_is_downed() || boss_is_restrained()) - return FALSE - var/list/table = boss_health_table() var/count = length(boss_engaged) - if(!length(table) || !count) - return FALSE - var/wanted = table[clamp(count, 1, length(table))] - var/raw_health = wanted - wanted *= npc_zone_health_factor - if(wanted <= maxHealth) - return FALSE - npc_zone_original_max_health = raw_health - var/share = maxHealth > 0 ? health / maxHealth : 1 - setMaxHealth(wanted) - if(boss_tired) - // Basic mobs keep all their damage as bruteloss. Not rounded, so it never lands over the tired line. - bruteloss = wanted * (1 - share) - updatehealth() - boss_update_tired(silent = TRUE) + boss_owe_reinforcements(count) if(announce && count > 1) visible_message(span_warning("[src] looks over the [count] of you and squares up.")) boss_bark("posse", "intro") return TRUE +/// A posse of `count` hunters owes one companion for each beyond the first, up to BOUNTY_BOSS_REINFORCEMENTS_MAX, called one at a time +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_owe_reinforcements(count) + var/wanted = clamp(count - 1, 0, BOUNTY_BOSS_REINFORCEMENTS_MAX) + var/more = wanted - boss_reinforcements_sent - boss_reinforcements_owed + if(more <= 0) + return + boss_reinforcements_owed += more + boss_schedule_reinforcement() + +/// The next companion comes after BOUNTY_BOSS_REINFORCE_DELAY, never two at once +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_schedule_reinforcement() + if(boss_reinforce_timer || boss_reinforcements_owed <= 0 || stat == DEAD) + return + boss_reinforce_timer = addtimer(CALLBACK(src, PROC_REF(boss_reinforce_tick)), BOUNTY_BOSS_REINFORCE_DELAY, TIMER_STOPPABLE | TIMER_DELETE_ME) + +/// Calls the next companion owed, from somewhere no player can see; tries again later if there is nowhere yet +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reinforce_tick() + boss_reinforce_timer = null + if(QDELETED(src) || stat == DEAD || !boss_hostile || boss_reinforcements_owed <= 0) + return + var/turf/spot = boss_reinforcement_spot() + if(spot) + boss_send_reinforcement(spot) + boss_schedule_reinforcement() + +/** + * Calls one companion owed in at `where` (the criminal-AI package's bounty_spawn_companion(): a queued, + * throttled human NPC joined to it). Returns what that returns. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_send_reinforcement(turf/where) + if(boss_reinforcements_owed <= 0 || !isturf(where)) + return null + boss_reinforcements_owed-- + boss_reinforcements_sent++ + return bounty_spawn_companion(src, where, CALLBACK(src, PROC_REF(boss_reinforcement_arrived))) + +/// A companion it called is here: it goes after whoever the boss is fighting +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reinforcement_arrived(mob/living/carbon/human/npc/companion) + if(QDELETED(src) || !ismob(companion) || QDELETED(companion)) + return + LAZYADD(boss_reinforcement_refs, WEAKREF(companion)) + var/atom/target = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!QDELETED(target)) + companion.ai_controller?.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + +/** + * A spot for a companion to come in at: open ground on its site, BOUNTY_BOSS_REINFORCE_MIN_DIST to + * _MAX_DIST tiles off, where no player within BOUNTY_BOSS_REINFORCE_SIGHT can see. Null if there is none. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reinforcement_spot() + var/turf/here = get_turf(src) + if(!here) + return null + var/list/watchers = list() + if(here.z <= length(SSmobs.clients_by_zlevel)) + for(var/mob/living/person as anything in SSmobs.clients_by_zlevel[here.z]) + if(person.stat != DEAD && get_dist(person, here) <= BOUNTY_BOSS_REINFORCE_MAX_DIST + BOUNTY_BOSS_REINFORCE_SIGHT) + watchers += person + var/list/candidates = list() + for(var/turf/open/spot in RANGE_TURFS(BOUNTY_BOSS_REINFORCE_MAX_DIST, here)) + if(get_dist(spot, here) < BOUNTY_BOSS_REINFORCE_MIN_DIST) + continue + if(isspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot) || spot.is_blocked_turf()) + continue + if(!leash_ok(spot) || !boss_reach_ok(spot)) + continue + candidates += spot + shuffle_inplace(candidates) + var/tries = 0 + for(var/turf/spot as anything in candidates) + if(++tries > 40) + break + var/seen = FALSE + for(var/mob/living/watcher as anything in watchers) + if(get_dist(watcher, spot) <= BOUNTY_BOSS_REINFORCE_SIGHT && can_see(watcher, spot, BOUNTY_BOSS_REINFORCE_SIGHT)) + seen = TRUE + break + if(!seen) + return spot + return null + // ----- targets ----- /// The people behind `thing`: the person, or everyone in the mech -/mob/living/basic/bounty_criminal/boss/proc/boss_people_in(atom/thing) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_people_in(atom/thing) . = list() if(isliving(thing)) . += thing @@ -503,60 +592,72 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) . += pilot /// What to go after for `who`: the mech they sit in, or them -/mob/living/basic/bounty_criminal/boss/proc/boss_target_for(atom/who) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_target_for(atom/who) var/mob/living/person = who if(isliving(person) && ismecha(person.loc)) return person.loc return who -/// Whether it goes after `target`: a hunter, or a mech with a hunter in it, that it may hurt -/mob/living/basic/bounty_criminal/boss/proc/boss_valid_target(atom/target) - return boss_can_hurt(target) +/** + * Whether it chooses to go after `target`: one it may go after (boss_can_go_after()), and not one it + * keeps away from (boss_shuns()). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_valid_target(atom/target) + return boss_can_go_after(target) && !boss_shuns(target) /** - * Whether it and its abilities may hurt `victim` (C4, H2, M2, L1): - * - an awake hunter not on its side, or a mech with one in it (a pilot is hurt through the mech); - * - standing on its site, and never in a ship, a hangar or an outpost; - * - someone it may attack from where it is (P2's may_attack()). - * Never a criminal, one of their friends, fauna or anyone else on the site. + * Whether it may go after `target` (C4, H2, M2, L1): an awake hunter not on its side, or a mech with + * one in it (a pilot is hurt through the mech); standing on its site, never in a ship, a hangar or an + * outpost; someone it may attack from where it is (P2's may_attack()). This is its choice of target + * only: what its fire and blasts catch is boss_effect_hits() (CB-11). */ -/mob/living/basic/bounty_criminal/boss/proc/boss_can_hurt(atom/victim) - if(QDELETED(victim) || victim == src) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_go_after(atom/target) + if(QDELETED(target) || target == src) return FALSE - if(!boss_reach_ok(get_turf(victim))) + if(!boss_reach_ok(get_turf(target))) return FALSE - if(ismecha(victim)) - var/obj/vehicle/sealed/mecha/mech = victim + if(ismecha(target)) + var/obj/vehicle/sealed/mecha/mech = target return boss_hunter_aboard(mech) && may_attack(mech) - var/mob/living/person = victim + var/mob/living/person = target if(!isliving(person) || ismecha(person.loc) || !boss_hunter_awake(person)) return FALSE return may_attack(person) -/// An awake hunter not on its side -/mob/living/basic/bounty_criminal/boss/proc/boss_hunter_awake(mob/living/person) +/// An awake hunter not on its side: who it picks to fight. Never used for what its fire and blasts catch. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_hunter_awake(mob/living/person) return bounty_boss_is_hunter(person) && person.stat == CONSCIOUS && !HAS_TRAIT(person, TRAIT_GODMODE) && !faction_check_atom(person) /// Whether an awake hunter not on its side sits in `mech` -/mob/living/basic/bounty_criminal/boss/proc/boss_hunter_aboard(obj/vehicle/sealed/mecha/mech) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_hunter_aboard(obj/vehicle/sealed/mecha/mech) for(var/mob/living/pilot in mech.occupants) if(boss_hunter_awake(pilot)) return TRUE return FALSE -/// Everyone on `turfs` its abilities may hurt: hunters, and mechs with hunters in them -/mob/living/basic/bounty_criminal/boss/proc/boss_victims_on(list/turfs) +/** + * Whether its fire, blasts, charge or slam catch `victim` where it stands: anywhere its abilities may + * reach (boss_reach_ok()), anyone not on its side, in whatever state (CB-11: down, cuffed or standing). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_effect_hits(atom/victim) + if(QDELETED(victim) || victim == src) + return FALSE + return boss_reach_ok(get_turf(victim)) && bounty_boss_effect_victim(victim, faction) + +/// Everyone on `turfs` its abilities catch: people not on its side, in any state, and mechs with one in them +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_victims_on(list/turfs) . = list() for(var/turf/spot as anything in turfs) for(var/atom/movable/thing as anything in spot) - if((isliving(thing) || ismecha(thing)) && boss_can_hurt(thing)) + if((isliving(thing) || ismecha(thing)) && boss_effect_hits(thing)) . += thing /** - * One hit from an ability on `victim`: `damage` of `damage_type` against `armour_flag` armour, and - * a knockdown. A mech takes it on its hull (H2). + * One scripted hit from an ability (a charge or a slam) on `victim`: `damage` of `damage_type` against + * `armour_flag` armour on the chest, and a knockdown, both through tg (CB-25). A mech takes it on its + * hull (H2). `already_scaled` is for the zone-damage helper, which leaves human bosses at a person's. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_hit(atom/victim, damage, damage_type = BRUTE, armour_flag = MELEE, knockdown = 0, armour_penetration = 0, already_scaled = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_hit(atom/victim, damage, damage_type = BRUTE, armour_flag = MELEE, knockdown = 0, armour_penetration = 0, already_scaled = FALSE) damage = npc_zone_damage(damage, src, already_scaled) if(ismecha(victim)) var/obj/vehicle/sealed/mecha/mech = victim @@ -572,7 +673,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE /// Whoever hurt it within BOUNTY_BOSS_REVENGE_TIME (their mech, if they were in one), if it may still go after them -/mob/living/basic/bounty_criminal/boss/proc/boss_revenge_target() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_revenge_target() if(world.time > boss_last_attacked_at + BOUNTY_BOSS_REVENGE_TIME) return null var/atom/attacker = boss_target_for(boss_last_attacker?.resolve()) @@ -582,7 +683,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * `attacker` went for it: a person, or a mech and everyone in it (H2). The hunters behind it go on * its grudge list and into its posse, it turns hostile, and it remembers whom to turn on. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_note_attacker(atom/attacker) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_note_attacker(atom/attacker) if(stat == DEAD || QDELETED(attacker) || attacker == src) return FALSE var/list/hunters = list() @@ -596,32 +697,42 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) for(var/mob/living/hunter as anything in hunters) body_add_grudge(hunter) boss_turn_hostile(hunter) + boss_note_reach(hunter) return TRUE -/// The nearest hunter (or their mech) still on its site within BOUNTY_BOSS_HUNT_RANGE, for the hunt. Only players with a client count, from SSmobs' list for its level. -/mob/living/basic/bounty_criminal/boss/proc/boss_nearest_hunter() +/** + * The nearest hunter (or their mech) still on its site within BOUNTY_BOSS_HUNT_RANGE that it can get + * at (boss_can_reach()), for the hunt: it never walks back out to stand in front of someone behind + * glass. Only players with a client count, from SSmobs' list for its level; a test passes its own + * `candidates`. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_nearest_hunter(list/candidates) var/turf/here = get_turf(src) - if(!here || here.z > length(SSmobs.clients_by_zlevel)) + if(!here) return null + if(isnull(candidates)) + if(here.z > length(SSmobs.clients_by_zlevel)) + return null + candidates = SSmobs.clients_by_zlevel[here.z] var/atom/best var/best_distance = BOUNTY_BOSS_HUNT_RANGE + 1 - for(var/mob/living/person as anything in SSmobs.clients_by_zlevel[here.z]) + for(var/mob/living/person in candidates) var/atom/target = boss_target_for(person) - if(!boss_valid_target(target)) - continue var/turf/spot = get_turf(target) - if(!spot || !leash_ok(spot)) + if(!spot || spot.z != here.z) continue var/distance = get_dist(here, spot) - if(distance < best_distance) - best = target - best_distance = distance + // Nearest first: the costly checks only for someone nearer than the best so far + if(distance >= best_distance || !leash_ok(spot) || !boss_valid_target(target) || !boss_can_reach(target)) + continue + best = target + best_distance = distance return best // ----- where it may reach, and what it may break ----- /// Whether an ability's effect may reach `spot` (M2): on its site, and never in a ship (other than the one it is wanted aboard), a hangar or an outpost -/mob/living/basic/bounty_criminal/boss/proc/boss_reach_ok(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reach_ok(turf/spot) spot = get_turf(spot) return !isnull(spot) && body_on_site(spot) && !body_forbidden_turf(spot) @@ -630,24 +741,24 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * P2's environment_damage_allowed(): its own site, never a trader outpost, and never within * BOUNTY_ENV_SAFE_RANGE tiles of a player ship, a hangar, a player outpost or a planet's dock strip. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_environment_allowed(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_environment_allowed(turf/spot) spot = get_turf(spot) if(!spot || spot.z != z) return FALSE return environment_damage_allowed(spot) /// Whether a thrown or fired ability may be aimed at `spot` (M2): somewhere it could walk itself, and allowed for environment damage -/mob/living/basic/bounty_criminal/boss/proc/boss_landing_ok(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_landing_ok(turf/spot) spot = get_turf(spot) return !isnull(spot) && leash_ok(spot) && boss_environment_allowed(spot) /// The air pressure on `spot`, in kPa -/mob/living/basic/bounty_criminal/boss/proc/boss_turf_pressure(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_turf_pressure(turf/spot) var/datum/gas_mixture/air = spot?.return_air() return air ? air.return_pressure() : 0 /// Whether `spot` holds an airlock with space, another ship or somewhere off its site on one side: an outer airlock -/mob/living/basic/bounty_criminal/boss/proc/boss_outer_airlock_turf(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_outer_airlock_turf(turf/spot) if(!(locate(/obj/machinery/door/airlock) in spot)) return FALSE for(var/direction in GLOB.cardinals) @@ -661,7 +772,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * another ship, a hangar or an outpost, with air in it. A ship's tile counts only if nothing around * it is space, off its site, another ship, or an outer airlock. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_indoor_turf(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_indoor_turf(turf/spot) if(!spot || isspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot)) return FALSE var/area/spot_area = get_area(spot) @@ -682,7 +793,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * than BOUNTY_BOSS_INTERIOR_MAX_PRESSURE_GAP between their pressures (L2). Never a hull, never an * outside wall, never a window onto space, the outdoors, a vacuum or another ship. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_interior_spot(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_interior_spot(turf/spot) if(!spot) return FALSE var/list/open_sides = list() @@ -712,15 +823,16 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Whether its abilities may damage `thing`: allowed there (boss_environment_allowed(), and P2's - * may_attack(), which refuses structures off its site), indoors on both sides, and a kind it breaks - * (tables, chairs, windows, grilles, barricades; plain walls and doors only while its - * BOUNTY_BOSS_WALL_BUDGET lasts). Never anything indestructible or outpost property. + * may_attack_scope(), which refuses structures off its site), indoors on both sides, and a kind it + * breaks (tables, chairs, windows, grilles, barricades; plain walls and doors only while its + * BOUNTY_BOSS_WALL_BUDGET lasts). Never anything indestructible or outpost property. It is about the + * place and the thing, not about the boss's own state: a grenade already thrown still lands. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_may_break(atom/thing) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_may_break(atom/thing) if(QDELETED(thing)) return FALSE var/turf/spot = get_turf(thing) - if(!boss_environment_allowed(spot) || !may_attack(thing) || !boss_interior_spot(spot)) + if(!boss_environment_allowed(spot) || !may_attack_scope(thing) || !boss_interior_spot(spot)) return FALSE if(isturf(thing)) if(!iswallturf(thing) || istype(thing, /turf/closed/wall/r_wall) || isindestructiblewall(thing)) @@ -741,7 +853,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * with its frame, so the way through really opens; either spends the fight's wall budget, as does a * door that damage alone destroys. Returns TRUE if it did damage. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_damage_structure(atom/thing, damage, list/tally, damage_flag = MELEE) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_damage_structure(atom/thing, damage, list/tally, damage_flag = MELEE) if(length(tally) && tally[1] <= 0) return FALSE if(!boss_may_break(thing)) @@ -772,156 +884,644 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) boss_walls_left-- return TRUE -/// With its boss gone, whom its leftover fire or blast may still hurt: awake hunters, and mechs with one in them -/proc/bounty_boss_orphan_victim(atom/thing) - if(ismecha(thing)) - var/obj/vehicle/sealed/mecha/mech = thing - for(var/mob/living/pilot in mech.occupants) - if(bounty_boss_is_hunter(pilot) && pilot.stat == CONSCIOUS) - return TRUE +// ===== HURT FROM WHERE IT CAN'T REACH (review r1 F4, r2) ===== + +/** + * Whether it can get at `target` (a person, or a mech) from where it stands: it may go after them + * there, and it is beside them, or nothing on the straight way stops it, or its kit goes through + * everything that does (boss_gets_through()), or it can walk round to them (boss_walks_to()). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_reach(atom/target) + if(!boss_can_go_after(target)) return FALSE - var/mob/living/person = thing - return isliving(person) && !ismecha(person.loc) && person.stat == CONSCIOUS && bounty_boss_is_hunter(person) + var/turf/here = get_turf(src) + var/turf/there = get_turf(target) + if(!here || !there || here.z != there.z) + return FALSE + if(Adjacent(target)) + return TRUE + var/list/blockers = boss_line_blockers(here, there) + if(!length(blockers) || boss_gets_through(blockers)) + return TRUE + return boss_walks_to(there) + +/** + * Whether it can walk to `there`, or beside it (someone up on a table or a crate), through nothing it + * can't open (boss_walkable()). Someone beyond BOUNTY_BOSS_REACH_RANGE is past the walk it works out: + * they count as reachable if that walk reaches its edge at least half its range closer to them, so a + * far shooter with open ground between is chased, and one across a gap or behind a wall near it is + * not (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_walks_to(turf/there) + var/turf/here = get_turf(src) + if(!here || !there || here.z != there.z) + return FALSE + var/list/steps = boss_walkable() + if(!isnull(steps[there])) + return TRUE + for(var/direction in GLOB.cardinals) + var/turf/beside = get_step(there, direction) + if(beside && !isnull(steps[beside]) && !boss_border_between(beside, there)) + return TRUE + var/distance = get_dist(here, there) + if(distance <= BOUNTY_BOSS_REACH_RANGE) + return FALSE + var/closer_than = distance - BOUNTY_BOSS_REACH_RANGE / 2 + for(var/turf/spot as anything in steps) + if(get_dist(here, spot) >= BOUNTY_BOSS_REACH_RANGE && get_dist(spot, there) <= closer_than) + return TRUE + return FALSE + +/** + * Whether its kit gets through `barrier` (from boss_line_blockers()) to someone behind it: with real + * Gorilla Arms and an empty hand to punch with (boss_gorilla_goes_through()). Each kit adds its own + * (bounty_boss_kits.dm): a charge, a grenade or a molotov thrown over, a breaching charge, rounds that + * chip through. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_goes_through(atom/barrier) + return boss_gorilla_goes_through(barrier) + +/// All or nothing: whether its kit goes through every one of `blockers` (boss_line_blockers()), so it never breaks a grille only to meet glass it can't +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gets_through(list/blockers) + if(!length(blockers)) + return TRUE + for(var/atom/blocker as anything in blockers) + if(!boss_goes_through(blocker)) + return FALSE + return TRUE + +/** + * Whether what gets it through `barrier` is ready now, so walking up to it is worth more than going + * round: Gorilla Arms that aren't already at something. Each kit adds its own ability that breaks or + * crosses it, off cooldown. With it on cooldown, the boss goes round on foot if it can (the breach + * planner). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_breaks_through_now(atom/barrier) + return boss_busy != BOUNTY_BOSS_BUSY_SMASH && boss_gorilla_goes_through(barrier) + +/// Its ability of `ability_type`, if it has one off cooldown +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_ability_off_cooldown(ability_type) + for(var/datum/action/cooldown/mob_cooldown/bounty_boss/ability as anything in boss_abilities) + if(!QDELETED(ability) && istype(ability, ability_type)) + return ability.next_use_time <= world.time ? ability : null + return null + +/// What it can open on its own feet, for working out a walk: the access its AI's pathing uses +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_pass_info() + return new /datum/can_pass_info(src, ai_controller ? ai_controller.get_access() : get_access()) + +/** + * The first thing on the straight way from `start` to `finish` that stops it walking: a wall, a gap + * (space, a drop, lava, a chasm), or something solid it can't open or climb (a window, a grille, a + * shut door, a table). Null if the way is clear. People never count. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_barrier_between(turf/start, turf/finish) + var/list/blockers = boss_line_blockers(start, finish, TRUE) + return length(blockers) ? blockers[1] : null + +/** + * Everything on the straight way from `start` to `finish` that stops it walking, in order: walls, + * gaps, and every solid thing it can't open or climb on each step (a grille and the window on the + * same tile are both there). With `first_only`, only the first step that is blocked. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_line_blockers(turf/start, turf/finish, first_only = FALSE) + . = list() + if(!start || !finish || start.z != finish.z) + return + var/datum/can_pass_info/pass_info = boss_pass_info() + var/turf/previous = start + for(var/turf/spot as anything in get_line(start, finish)) + if(spot == start) + continue + if(spot.density) + . += spot + else if(spot != finish && (isspaceturf(spot) || isopenspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot))) + . += spot + else if(previous.LinkBlockedWithAccess(spot, pass_info)) + var/list/on_step = boss_blockers_on_step(previous, spot, pass_info) + if(length(on_step)) + // A full-tile window blocks the step off its tile as well as the step onto it: once is enough + . |= on_step + else + . += spot + if(first_only && length(.)) + return + previous = spot + +/** + * What stops a step from `origin` onto `destination`: windows or doors on the edge between them, a + * window filling `origin` (tg's pathing counts one as blocking the step off its tile too), and + * everything solid on `destination`. A diagonal step is checked both ways round, giving what stops the + * first way. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_blockers_on_step(turf/origin, turf/destination, datum/can_pass_info/pass_info) + . = list() + var/direction = get_dir(origin, destination) + if(origin.x != destination.x && origin.y != destination.y) + for(var/half in list(direction & (NORTH|SOUTH), direction & (EAST|WEST))) + var/turf/middle = get_step(origin, half) + if(!middle) + continue + if(middle.density) + return list(middle) + var/list/found = boss_blockers_on_step(origin, middle, pass_info) + boss_blockers_on_step(middle, destination, pass_info) + if(length(found)) + return found + return + for(var/obj/border in origin) + if(border.density && ((border.flags_1 & ON_BORDER_1) || istype(border, /obj/structure/window)) && !border.CanAStarPass(direction, pass_info)) + . += border + var/reverse = get_dir(destination, origin) + for(var/obj/thing in destination) + if(thing.density && !thing.CanAStarPass(reverse, pass_info)) + . += thing + +/// Whether a window or door stands on the edge between two tiles side by side +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_border_between(turf/origin, turf/destination) + var/direction = get_dir(origin, destination) + for(var/obj/border in origin) + if(border.density && (border.flags_1 & ON_BORDER_1) && (border.dir & direction)) + return TRUE + var/reverse = get_dir(destination, origin) + for(var/obj/border in destination) + if(border.density && (border.flags_1 & ON_BORDER_1) && (border.dir & reverse)) + return TRUE + return FALSE + +// ----- what a throw gets past ----- + +/** + * Something it throws, never in the world: an item with a throw of its own and the boss as its thrower, + * so tg's own CanPass() rules for a thrown item decide what stops it (windows, windoors, shut doors and + * grilles do; tables, railings and gaps don't). + */ +/obj/item/bounty_boss_throw_probe + name = "thrown thing" + item_flags = ABSTRACT + +/obj/item/bounty_boss_throw_probe/Destroy() + QDEL_NULL(throwing) + return ..() + +/// Its throw probe, made the first time it is needed +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_get_throw_probe() + if(QDELETED(boss_throw_probe)) + boss_throw_probe = new + if(QDELETED(boss_throw_probe.throwing)) + boss_throw_probe.throwing = new /datum/thrownthing(boss_throw_probe, src, SOUTH, world.maxx, 1, src) + return boss_throw_probe + +/** + * Whether something it throws from `start` lands on `finish`: it takes the steps tg's throw takes + * (throw_at() and /datum/thrownthing/tick()), and each one must be a move tg would let a thrown item + * make. People and mechs on the way never count. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_throw_reaches(turf/start, turf/finish) + start = get_turf(start) + finish = get_turf(finish) + if(!start || !finish || start.z != finish.z) + return FALSE + if(start == finish) + return TRUE + var/obj/item/probe = boss_get_throw_probe() + var/dist_x = abs(finish.x - start.x) + var/dist_y = abs(finish.y - start.y) + var/dx = (finish.x > start.x) ? EAST : WEST + var/dy = (finish.y > start.y) ? NORTH : SOUTH + var/pure_diagonal = dist_x == dist_y + if(!pure_diagonal && dist_x <= dist_y) + var/old_dist = dist_x + dist_x = dist_y + dist_y = old_dist + var/old_dir = dx + dx = dy + dy = old_dir + var/longest = max(dist_x, dist_y) + var/diagonal_error = dist_x / 2 - dist_y + var/turf/at = start + for(var/travelled in 0 to longest) + if(at == finish) + return TRUE + var/turf/step = get_step(at, get_dir(at, finish)) + if(!pure_diagonal) + if(diagonal_error >= 0 && longest - travelled != 1) + step = get_step(at, dx) + diagonal_error += (diagonal_error < 0) ? dist_x / 2 : -dist_y + if(!step || !bounty_boss_throw_move(at, step, probe)) + return FALSE + at = step + return at == finish + +/// Whether a thrown thing gets past `barrier` (from boss_line_blockers()), by tg's rules for a thrown item: a gap yes, a wall no +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_throw_passes(atom/barrier) + if(isturf(barrier)) + return !barrier.density + var/border_dir = (barrier.flags_1 & ON_BORDER_1) ? barrier.dir : get_dir(barrier, src) + return barrier.CanPass(boss_get_throw_probe(), border_dir) + +/** + * Whether `probe`, thrown, moves from `origin` onto `destination` beside it. A diagonal goes the way + * tg's Move() takes one, north or south first; whatever stops that half is bumped, and a bump ends a + * throw (/atom/movable/Bump()), so it never gets to try east or west first. + */ +/proc/bounty_boss_throw_move(turf/origin, turf/destination, obj/item/probe) + var/direction = get_dir(origin, destination) + if(!(direction & (direction - 1))) + return bounty_boss_throw_step(origin, destination, probe) + var/turf/first = get_step(origin, direction & (NORTH|SOUTH)) + return first && bounty_boss_throw_step(origin, first, probe) && bounty_boss_throw_step(first, destination, probe) + +/// One straight step of a thrown `probe`: nothing on the edge of `origin` it leaves by, and tg's CanPass() of `destination` and all on it but people and mechs +/proc/bounty_boss_throw_step(turf/origin, turf/destination, obj/item/probe) + var/direction = get_dir(origin, destination) + for(var/atom/movable/border as anything in origin) + if(border != probe && (border.flags_1 & ON_BORDER_1) && border.density && (border.dir & direction) && !border.CanPass(probe, direction)) + return FALSE + var/from_dir = get_dir(destination, origin) + if(!destination.CanPass(probe, from_dir)) + return FALSE + for(var/atom/movable/thing as anything in destination) + if(thing == probe || isliving(thing) || ismecha(thing)) + continue + if(!thing.CanPass(probe, from_dir)) + return FALSE + return TRUE + +/** + * Every tile it could walk to from where it stands, within BOUNTY_BOSS_REACH_RANGE and on its leash, + * through nothing it can't open: turf -> steps, nearest first. Worked out again once it stands on + * another tile, or after BOUNTY_BOSS_REACH_RETHINK on the same one, so the steps and the range are + * always counted from where it is (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_walkable() + var/turf/here = get_turf(src) + if(!here) + return list() + if(boss_walk_cache && boss_walk_origin == here && world.time < boss_walk_at + BOUNTY_BOSS_REACH_RETHINK) + return boss_walk_cache + var/datum/can_pass_info/pass_info = boss_pass_info() + var/list/steps = list() + steps[here] = 0 + var/list/queue = list(here) + var/index = 0 + while(index < length(queue)) + index++ + var/turf/spot = queue[index] + var/next_steps = steps[spot] + 1 + for(var/direction in GLOB.cardinals) + var/turf/next = get_step(spot, direction) + if(!next || next.density || !isnull(steps[next]) || get_dist(here, next) > BOUNTY_BOSS_REACH_RANGE) + continue + if(spot.LinkBlockedWithAccess(next, pass_info) || !leash_ok(next)) + continue + steps[next] = next_steps + queue += next + boss_walk_cache = steps + boss_walk_origin = here + boss_walk_at = world.time + return steps + +/// `hunter` just hurt it: if it can't get at them where they are, it keeps away from them for BOUNTY_BOSS_SHUN_TIME +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_note_reach(mob/living/hunter) + if(!boss_can_act() || boss_can_reach(boss_target_for(hunter))) + return FALSE + boss_shunned[WEAKREF(hunter)] = world.time + return TRUE + +/// Whether it keeps away from `target` (a person, or a mech): one of them hurt it lately from where it couldn't get at them, and it still can't +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_shuns(atom/target) + if(!length(boss_shunned)) + return FALSE + for(var/mob/living/person as anything in boss_people_in(target)) + var/last = person.weak_reference ? boss_shunned[person.weak_reference] : null + if(last && world.time < last + BOUNTY_BOSS_SHUN_TIME) + return !boss_can_reach(target) + return FALSE + +/// The hunters it shuns who are still up and still out of its reach (their mechs, for those in one): whose sight it keeps out of. Forgets those it has shunned long enough. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_watchers() + . = list() + for(var/datum/weakref/ref as anything in boss_shunned.Copy()) + var/mob/living/person = ref.resolve() + if(QDELETED(person) || world.time >= boss_shunned[ref] + BOUNTY_BOSS_SHUN_TIME) + boss_shunned -= ref + continue + if(person.stat != CONSCIOUS) + continue + var/atom/watcher = boss_target_for(person) + if(!boss_can_reach(watcher)) + . |= watcher + +/** + * Whether any of `watchers` can see `spot`: a clear line from them on its level. Within + * BOUNTY_BOSS_COVER_SIGHT, and as far as the line goes past that, so a shooter further off (a scope, + * the dock strip) never leaves it standing in the open thinking it is hidden (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_seen_from(turf/spot, list/watchers) + spot = get_turf(spot) + if(!spot) + return FALSE + for(var/atom/watcher as anything in watchers) + var/turf/from = get_turf(watcher) + if(!from || from.z != spot.z) + continue + if(can_see(from, spot, max(BOUNTY_BOSS_COVER_SIGHT, get_dist(from, spot)))) + return TRUE + return FALSE + +/// It breaks for cover from someone it can't get at: a line for it, at most every BOUNTY_BOSS_COVER_BARK_COOLDOWN, so it reads as biding its time, not running +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_cover_bark() + if(world.time < boss_cover_bark_at) + return FALSE + boss_cover_bark_at = world.time + BOUNTY_BOSS_COVER_BARK_COOLDOWN + boss_bark("cover") + return TRUE + +/// How far `spot` is from the nearest of `watchers` +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_distance_from(turf/spot, list/watchers) + . = INFINITY + for(var/atom/watcher as anything in watchers) + . = min(., get_dist(spot, watcher)) + +/** + * Cover from `watchers`: the nearest tile it can walk to within BOUNTY_BOSS_COVER_RANGE steps that + * none of them can see. With nowhere hidden, the one farthest from the nearest of them. Null if + * where it stands is as good as any. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_find_cover(list/watchers) + var/list/steps = boss_walkable() + var/turf/here = get_turf(src) + var/turf/far = here + var/far_distance = boss_distance_from(here, watchers) + // In order of steps, nearest first + for(var/turf/spot as anything in steps) + if(steps[spot] > BOUNTY_BOSS_COVER_RANGE || (spot != here && boss_someone_on(spot))) + continue + if(!boss_seen_from(spot, watchers)) + return spot + var/distance = boss_distance_from(spot, watchers) + if(distance > far_distance) + far = spot + far_distance = distance + return far == here ? null : far + +/// Whether someone else stands on `spot`, so it can't +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_someone_on(turf/spot) + for(var/mob/living/other in spot) + if(other != src && other.density) + return TRUE + return FALSE + +/// The tile it walks to, to go through `barrier` at `target`: the last one before it on the straight way, if it can walk there +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_spot_before(atom/barrier, atom/target) + var/turf/here = get_turf(src) + var/turf/barrier_turf = get_turf(barrier) + if(!here || !barrier_turf) + return null + var/turf/before + for(var/turf/spot as anything in get_line(here, barrier_turf)) + if(spot == barrier_turf) + break + before = spot + if(!before || before == here) + return null + var/list/steps = boss_walkable() + return isnull(steps[before]) ? null : before + +// ===== GORILLA ARMS THROUGH GLASS ===== + +/// Its working Gorilla Arms: the real implant, not EMP'd, browned out or failing +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gorilla_arms() + . = list() + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in organs) + var/datum/component/cyberware/ware = arm.GetComponent(/datum/component/cyberware) + if(ware?.is_running()) + . += arm + +/// The hand index of `arm`'s own hand, if that hand is empty (the implant only punches with its own fist), else 0 +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gorilla_hand(obj/item/organ/cyberimp/cyberware/gorilla_arms/arm) + var/obj/item/bodypart/limb = get_bodypart(arm.zone) + if(!limb?.held_index || !isnull(get_item_for_held_index(limb.held_index))) + return 0 + return limb.held_index + +/// Whether it has a working Gorilla Arm with an empty hand to punch with +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_has_gorilla_fist() + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in boss_gorilla_arms()) + if(boss_gorilla_hand(arm)) + return TRUE + return FALSE + +/// Whether its Gorilla Arms get it through `barrier`: one it may smash (boss_can_smash()), with a fist free to do it. Airlocks are left to the arms' own door forcing. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gorilla_goes_through(atom/barrier) + return boss_has_gorilla_fist() && boss_can_smash(barrier) + +/** + * Whether its Gorilla Arms may go at `barrier`: a window, a grille, a table or a door that isn't an + * airlock, where it may break things (boss_may_break(): its own site, indoors, never near a ship), and + * one the implant's punches break in at most BOUNTY_BOSS_SMASH_MOST_BLOWS through its armour. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_smash(atom/barrier) + if(!isobj(barrier) || istype(barrier, /obj/machinery/door/airlock)) + return FALSE + if(!istype(barrier, /obj/structure/window) && !istype(barrier, /obj/structure/grille) && !istype(barrier, /obj/structure/table) && !istype(barrier, /obj/machinery/door)) + return FALSE + var/obj/thing = barrier + if(!thing.uses_integrity || !boss_may_break(thing)) + return FALSE + var/blow = thing.run_atom_armor(BOUNTY_BOSS_SMASH_BLOW, BRUTE, MELEE, get_dir(thing, src)) + return blow > 0 && thing.get_integrity() <= blow * BOUNTY_BOSS_SMASH_MOST_BLOWS + +/** + * Its Gorilla Arms go at `barrier`, right beside it. The tell first, for BOUNTY_BOSS_SMASH_WINDUP: + * rooted, an orange glow, the barrier's tile lit and the pistons heard. Then real punches with its + * empty Gorilla fists (boss_smash_blow()), each the implant's own, until the barrier is down, the + * punches run out or something stops it. Nobody is hurt by it. Returns TRUE if it started. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_start_smash(atom/barrier) + if(!boss_can_use_ability() || !ai_controller || !Adjacent(barrier) || !boss_has_gorilla_fist() || !boss_can_smash(barrier)) + return FALSE + var/smash_time = BOUNTY_BOSS_SMASH_WINDUP + BOUNTY_BOSS_SMASH_MOST_BLOWS * 1 SECONDS + var/serial = boss_set_busy(BOUNTY_BOSS_BUSY_SMASH, smash_time + 1 SECONDS) + boss_next_ability_at = max(boss_next_ability_at, world.time + BOUNTY_BOSS_SMASH_WINDUP + BOUNTY_BOSS_SMASH_RECOVER) + boss_smash_target = WEAKREF(barrier) + boss_smash_blows = 0 + face_atom(barrier) + visible_message(span_boldwarning("[src] draws back a piston-driven fist at [barrier]!"), blind_message = span_hear("You hear pistons hiss.")) + playsound(src, 'sound/machines/piston/piston_raise.ogg', 60, TRUE) + add_filter(BOUNTY_BOSS_SMASH_FILTER, 2, outline_filter(1, BOUNTY_BOSS_SMASH_COLOR)) + boss_smash_mark = WEAKREF(new /obj/effect/temp_visual/bounty_boss_mark(get_turf(barrier), smash_time, BOUNTY_BOSS_SMASH_COLOR)) + addtimer(CALLBACK(src, PROC_REF(boss_smash_blow), serial), BOUNTY_BOSS_SMASH_WINDUP, TIMER_DELETE_ME) + return TRUE + +/** + * One punch of the smash under `serial` at its barrier: the empty fist of a working Gorilla Arm whose + * own cooldown is over is made the active hand, and the barrier is clicked with it in combat mode, the + * way a player punches (controller.ai_interact()). The implant does the damage. The next punch follows + * when the body and an arm are ready again. Returns TRUE if it threw one. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_smash_blow(serial) + if(!boss_still(BOUNTY_BOSS_BUSY_SMASH, serial)) + return FALSE + var/obj/barrier = boss_smash_target?.resolve() + if(QDELETED(barrier) || !barrier.density || !Adjacent(barrier) || boss_smash_blows >= BOUNTY_BOSS_SMASH_MOST_BLOWS || !boss_may_break(barrier) || !boss_has_gorilla_fist()) + boss_end_smash(serial) + return FALSE + var/threw = FALSE + if(next_move <= world.time) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in boss_gorilla_arms()) + var/hand = boss_gorilla_hand(arm) + if(!hand || !COOLDOWN_FINISHED(arm, slam_cooldown)) + continue + swap_hand(hand, silent = TRUE) + if(get_active_held_item()) + continue + var/is_door = istype(barrier, /obj/machinery/door) + boss_smash_blows++ + ai_controller.ai_interact(target = barrier, combat_mode = TRUE) + threw = TRUE + // A door it punches down spends its wall budget, as one any ability breaks does + if(is_door && (QDELETED(barrier) || barrier.get_integrity() <= 0)) + boss_walls_left-- + break + if(QDELETED(barrier) || !barrier.density || boss_smash_blows >= BOUNTY_BOSS_SMASH_MOST_BLOWS) + boss_end_smash(serial) + return threw + addtimer(CALLBACK(src, PROC_REF(boss_smash_blow), serial), boss_smash_wait(), TIMER_DELETE_ME) + return threw + +/// How long until it can throw its next smash punch: its body's click and the soonest arm's own cooldown, at least a tick +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_smash_wait() + var/soonest = INFINITY + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in boss_gorilla_arms()) + if(boss_gorilla_hand(arm)) + soonest = min(soonest, arm.slam_cooldown) + if(soonest == INFINITY) + soonest = world.time + return max(1, next_move - world.time, soonest - world.time) + +/// The smash is over, or never was: the tell goes, and with `serial` (or none) it is no longer busy with it +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_end_smash(serial) + boss_end_smash_telegraph() + boss_smash_target = null + boss_smash_blows = 0 + if(boss_busy == BOUNTY_BOSS_BUSY_SMASH && (!serial || serial == boss_busy_serial)) + boss_clear_busy(serial) + +/// The smash's glow and floor mark go +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_end_smash_telegraph() + remove_filter(BOUNTY_BOSS_SMASH_FILTER) + var/obj/effect/mark = boss_smash_mark?.resolve() + boss_smash_mark = null + if(!QDELETED(mark)) + qdel(mark) // ===== SIGNALS ===== /** * The boss's signal hooks, on a component of their own so they never replace a handler P2 or P3 - * registers on the same mob for the same signal. Each one hands over to a boss proc. + * registers on the same mob for the same signal. Each one hands over to a boss proc. Being disabled is + * read from tg's own state changes: crit, cuffs, stamcrit, stuns and knockdowns (CB-24). */ /datum/component/bounty_boss_signals /datum/component/bounty_boss_signals/Initialize() - if(!istype(parent, /mob/living/basic/bounty_criminal/boss)) + if(!istype(parent, /mob/living/carbon/human/npc/bounty_criminal/boss)) return COMPONENT_INCOMPATIBLE /datum/component/bounty_boss_signals/RegisterWithParent() - RegisterSignal(parent, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(on_health)) RegisterSignal(parent, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) RegisterSignal(parent, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target)) RegisterSignals(parent, list( - COMSIG_BOUNTY_CRIMINAL_DOWNED, - COMSIG_BOUNTY_CRIMINAL_RESTRAINED, - COMSIG_LIVING_ENTER_STAMCRIT, + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED), - SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), + COMSIG_LIVING_ENTER_STAMCRIT, ), PROC_REF(on_disabled)) - RegisterSignals(parent, list(COMSIG_BOUNTY_CRIMINAL_RECOVERED, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED), PROC_REF(on_freed)) RegisterSignal(parent, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_prehit)) - RegisterSignal(parent, COMSIG_LIVING_CHECK_BLOCK, PROC_REF(on_check_block)) RegisterSignal(parent, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) RegisterSignal(parent, COMSIG_ATOM_ATTACKBY, PROC_REF(on_attackby)) RegisterSignal(parent, COMSIG_MOB_FLASHED, PROC_REF(on_flashed)) - RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_MOB_ITEM_ATTACK, PROC_REF(on_item_attack)) /datum/component/bounty_boss_signals/UnregisterFromParent() UnregisterSignal(parent, list( - COMSIG_LIVING_HEALTH_UPDATE, COMSIG_ATOM_WAS_ATTACKED, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), - COMSIG_BOUNTY_CRIMINAL_DOWNED, - COMSIG_BOUNTY_CRIMINAL_RESTRAINED, - COMSIG_LIVING_ENTER_STAMCRIT, + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED), - SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), - COMSIG_BOUNTY_CRIMINAL_RECOVERED, - COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, + COMSIG_LIVING_ENTER_STAMCRIT, COMSIG_PROJECTILE_PREHIT, - COMSIG_LIVING_CHECK_BLOCK, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_ATOM_ATTACKBY, COMSIG_MOB_FLASHED, - COMSIG_MOVABLE_MOVED, + COMSIG_MOB_ITEM_ATTACK, )) -/datum/component/bounty_boss_signals/proc/on_health(mob/living/basic/bounty_criminal/boss/source) - SIGNAL_HANDLER - source.boss_update_tired() - // A person or a mech (relay_attackers reports mech melee with the mech as the attacker). -/datum/component/bounty_boss_signals/proc/on_attacked(mob/living/basic/bounty_criminal/boss/source, atom/attacker, attack_flags) +/datum/component/bounty_boss_signals/proc/on_attacked(mob/living/carbon/human/npc/bounty_criminal/boss/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) return source.boss_note_attacker(attacker) -/datum/component/bounty_boss_signals/proc/on_target(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_target(mob/living/carbon/human/npc/bounty_criminal/boss/source) SIGNAL_HANDLER var/atom/target = source.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] for(var/mob/living/person as anything in source.boss_people_in(target)) if(bounty_boss_is_hunter(person)) source.boss_turn_hostile(person) -/datum/component/bounty_boss_signals/proc/on_disabled(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_disabled(mob/living/carbon/human/npc/bounty_criminal/boss/source) SIGNAL_HANDLER source.boss_interrupt() - source.boss_update_hold() - -/datum/component/bounty_boss_signals/proc/on_freed(mob/living/basic/bounty_criminal/boss/source) - SIGNAL_HANDLER - source.boss_update_hold() - // Not forced: P2's rally may have just set the stamina coefficient for itself. - source.boss_update_tired(silent = TRUE) - // Hunters who joined while it was down or cuffed count now, keeping its share of health (M4). - source.boss_apply_posse_health(announce = FALSE) -/datum/component/bounty_boss_signals/proc/on_prehit(mob/living/basic/bounty_criminal/boss/source, obj/projectile/shot) +/datum/component/bounty_boss_signals/proc/on_prehit(mob/living/carbon/human/npc/bounty_criminal/boss/source, obj/projectile/shot) SIGNAL_HANDLER // relay_attackers drops shots whose firer isn't a mob, so a mech's gunfire is noted here (H2). if(ismecha(shot.firer) && shot.is_hostile_projectile()) source.boss_note_attacker(shot.firer) - return source.boss_projectile_prehit(shot) - -/datum/component/bounty_boss_signals/proc/on_check_block(mob/living/basic/bounty_criminal/boss/source, atom/hit_by, damage, attack_text, attack_type, armour_penetration, damage_type) - SIGNAL_HANDLER - return source.boss_check_block(hit_by, attack_text, attack_type) + return NONE -/datum/component/bounty_boss_signals/proc/on_damaged(mob/living/basic/bounty_criminal/boss/source, damage, damagetype) +/datum/component/bounty_boss_signals/proc/on_damaged(mob/living/carbon/human/npc/bounty_criminal/boss/source, damage, damagetype) SIGNAL_HANDLER if(damage > 0) source.boss_took_damage(damage, damagetype) -/datum/component/bounty_boss_signals/proc/on_attackby(mob/living/basic/bounty_criminal/boss/source, obj/item/attacking_item, mob/living/user) +/datum/component/bounty_boss_signals/proc/on_attackby(mob/living/carbon/human/npc/bounty_criminal/boss/source, obj/item/attacking_item, mob/living/user) SIGNAL_HANDLER var/obj/item/assembly/flash/flash = attacking_item if(istype(flash) && !flash.burnt_out && isliving(user)) source.boss_flashed(user) return NONE -/datum/component/bounty_boss_signals/proc/on_flashed(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_flashed(mob/living/carbon/human/npc/bounty_criminal/boss/source) SIGNAL_HANDLER source.boss_flashed() -/datum/component/bounty_boss_signals/proc/on_moved(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_item_attack(mob/living/carbon/human/npc/bounty_criminal/boss/source, mob/living/target, mob/living/user) SIGNAL_HANDLER - source.boss_moved() - -// Kit hooks for those signals. None of them may sleep. - -/// A projectile is about to hit it: return PROJECTILE_INTERRUPT_HIT_PHASE to dodge it -/mob/living/basic/bounty_criminal/boss/proc/boss_projectile_prehit(obj/projectile/shot) - SHOULD_NOT_SLEEP(TRUE) + if(user == source && isliving(target) && target != source) + source.boss_landed_blow(target, source.get_active_held_item()) return NONE -/// A blow or a thrown thing is about to land: return SUCCESSFUL_BLOCK to make it miss -/mob/living/basic/bounty_criminal/boss/proc/boss_check_block(atom/hit_by, attack_text, attack_type) - SHOULD_NOT_SLEEP(TRUE) - return NONE +// Kit hooks for those signals. None of them may sleep. /// Damage landed on it -/mob/living/basic/bounty_criminal/boss/proc/boss_took_damage(damage, damagetype) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_took_damage(damage, damagetype) SHOULD_NOT_SLEEP(TRUE) return /// Flashed, by a handheld flash (`user`) or a flash going off near it -/mob/living/basic/bounty_criminal/boss/proc/boss_flashed(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_flashed(mob/living/user) SHOULD_NOT_SLEEP(TRUE) return -/// It took a step -/mob/living/basic/bounty_criminal/boss/proc/boss_moved() +/// It swung `weapon` at `victim` with its own hand +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_landed_blow(mob/living/victim, obj/item/weapon) SHOULD_NOT_SLEEP(TRUE) return @@ -967,7 +1567,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) . = ..() if(!.) return FALSE - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner return istype(boss) && boss.boss_can_use_ability() /// Whether it is worth using on `target` now: in range, in sight, and so on @@ -980,17 +1580,22 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /// Whether a thrown or fired ability may be aimed at where `target` stands (M2) /datum/action/cooldown/mob_cooldown/bounty_boss/proc/landing_ok(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner return istype(boss) && boss.boss_landing_ok(get_turf(target)) +/// Whether something it throws from where it stands would land where `target` stands, and not stop on glass or a door on the way +/datum/action/cooldown/mob_cooldown/bounty_boss/proc/throw_reaches(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + return istype(boss) && boss.boss_throw_reaches(get_turf(boss), get_turf(target)) + /datum/action/cooldown/mob_cooldown/bounty_boss/Activate(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || QDELETED(target)) return FALSE var/serial = boss.boss_begin_ability(src) if(!serial) return FALSE - StartCooldown(cooldown_time * boss.boss_cooldown_mult()) + StartCooldown(cooldown_time) pending_serial = serial pending_target = WEAKREF(target) // Marks left lingering from the last use look after themselves. @@ -1012,7 +1617,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) pending_serial = 0 pending_target = null clear_telegraphs() - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || boss.boss_busy != "windup" || boss.boss_busy_serial != serial || !boss.boss_can_act()) return FALSE boss.boss_winding_ref = null @@ -1066,7 +1671,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /// Damages `thing` as part of this use, within its cap and the fight's wall budget /datum/action/cooldown/mob_cooldown/bounty_boss/proc/break_thing(atom/thing, damage, damage_flag = MELEE) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss)) return FALSE if(!structure_tally) @@ -1147,10 +1752,13 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * A patch of a boss's fire: flames on `turfs` for `lifetime`, only where its boss's abilities may - * reach (never off its site or in a ship, hangar or outpost, M2). A hunter it may hurt who stands in - * them is set alight (/datum/status_effect/bounty_boss_burning) every second; a mech with a hunter - * in it takes the burn on its hull. Effect-only fire: no gas, no hotspots, nothing flammable set - * burning, no fire alarms. Capped at BOUNTY_BOSS_FIRE_POOL_CAP server-wide. + * reach (never off its site or in a ship, hangar or outpost, M2). The patch itself is scripted: it + * makes no gas and no hotspot and lights nothing, so it never spreads or sets off fire alarms (site + * safety). Whoever it catches burns for real (CB-8): `landing_damage` burn against fire armour where it + * first lands, then tg's own fire stacks and ignite_mob() (`new_landing_stacks` on landing, + * BOUNTY_BOSS_FIRE_TICK_STACKS every second they stand in it), whatever state they are in. From then on + * it is tg's fire on them: firesuits, stop-drop-and-roll, extinguishers, showers and wet clothes all + * work. A mech with someone in it takes the burn on its hull. Capped at BOUNTY_BOSS_FIRE_POOL_CAP. */ /obj/effect/bounty_boss_fire_pool name = "fire" @@ -1162,16 +1770,23 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) light_color = LIGHT_COLOR_FIRE /// The tiles burning var/list/turfs - /// The boss whose fire it is, for who it may hurt + /// The boss whose fire it is, for who it catches var/datum/weakref/boss_ref + /// Its boss's factions when it was lit: its fire never catches its own side, even with the boss gone + var/list/side_factions + /// Its boss's zone damage factor when it was lit (1 for a human boss) var/damage_factor = 1 + /// Fire stacks it puts on whoever it catches where it first lands + var/landing_stacks = BOUNTY_BOSS_FIRE_TICK_STACKS -/obj/effect/bounty_boss_fire_pool/Initialize(mapload, list/fire_turfs, lifetime, mob/living/basic/bounty_criminal/boss/boss, landing_damage = 0) +/obj/effect/bounty_boss_fire_pool/Initialize(mapload, list/fire_turfs, lifetime, mob/living/carbon/human/npc/bounty_criminal/boss/boss, landing_damage = 0, new_landing_stacks) . = ..() if(length(GLOB.bounty_boss_fire_pools) >= BOUNTY_BOSS_FIRE_POOL_CAP) return INITIALIZE_HINT_QDEL GLOB.bounty_boss_fire_pools += src lifetime = lifetime || 4 SECONDS + if(!isnull(new_landing_stacks)) + landing_stacks = new_landing_stacks turfs = list() for(var/turf/spot as anything in fire_turfs) if(isturf(spot) && !isclosedturf(spot) && (!boss || boss.boss_reach_ok(spot))) @@ -1179,6 +1794,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) if(!length(turfs)) return INITIALIZE_HINT_QDEL boss_ref = boss ? WEAKREF(boss) : null + side_factions = boss ? boss.faction.Copy() : null damage_factor = boss ? boss.get_npc_zone_damage_factor() : 1 // Invisible, but the light comes from here: sit it where the fire is. var/turf/middle = turfs[round((length(turfs) + 1) / 2)] @@ -1186,7 +1802,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) forceMove(middle) for(var/turf/spot as anything in turfs) new /obj/effect/temp_visual/bounty_boss_flames(spot, lifetime) - scorch(landing_damage) + scorch(landing_damage, landing_stacks) addtimer(CALLBACK(src, PROC_REF(scorch)), 1 SECONDS, TIMER_LOOP | TIMER_DELETE_ME) addtimer(CALLBACK(src, PROC_REF(burn_out)), lifetime, TIMER_DELETE_ME) @@ -1194,111 +1810,101 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) GLOB.bounty_boss_fire_pools -= src turfs = null boss_ref = null + side_factions = null return ..() -/// Whether it may burn `victim`: its boss's rules, or with the boss gone, awake hunters and their mechs +/// Whether it catches `victim`: its boss's rules, or with the boss gone, anyone not on the boss's side. Never by the victim's state (CB-11). /obj/effect/bounty_boss_fire_pool/proc/may_burn(atom/victim) - var/mob/living/basic/bounty_criminal/boss/boss = boss_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = boss_ref?.resolve() if(!QDELETED(boss)) - return boss.boss_can_hurt(victim) - return bounty_boss_orphan_victim(victim) + return boss.boss_effect_hits(victim) + return bounty_boss_effect_victim(victim, side_factions) -/// Sets alight everyone it may burn in the flames, with `damage` burn first if given; mechs take the burn on the hull -/obj/effect/bounty_boss_fire_pool/proc/scorch(damage = 0) +/** + * Catches everyone in the flames: `damage` burn against fire armour if given (the jet or the bottle + * landing), then `stacks` of tg's fire stacks and ignite_mob(). Mechs take the burn on the hull. + */ +/obj/effect/bounty_boss_fire_pool/proc/scorch(damage = 0, stacks = BOUNTY_BOSS_FIRE_TICK_STACKS) for(var/turf/spot as anything in turfs) for(var/atom/movable/thing as anything in spot) if(!(isliving(thing) || ismecha(thing)) || !may_burn(thing)) continue if(ismecha(thing)) var/obj/vehicle/sealed/mecha/mech = thing - mech.take_damage(npc_zone_damage(max(damage, BOUNTY_BOSS_BURNING_DAMAGE), damage_factor), BURN, FIRE, FALSE) + mech.take_damage(npc_zone_damage(max(damage, 3), damage_factor), BURN, FIRE, FALSE) continue var/mob/living/victim = thing if(damage > 0) victim.apply_damage(npc_zone_damage(damage, damage_factor), BURN, blocked = victim.run_armor_check(null, FIRE, silent = TRUE), spread_damage = TRUE) to_chat(victim, span_userdanger("You're caught in the flames!")) - victim.apply_status_effect(/datum/status_effect/bounty_boss_burning, damage_factor) + if(stacks > 0) + victim.adjust_fire_stacks(stacks) + victim.ignite_mob() /obj/effect/bounty_boss_fire_pool/proc/burn_out() qdel(src) -/** - * Set alight by a boss's fire: BOUNTY_BOSS_BURNING_DAMAGE burn a second, against fire armour, for - * BOUNTY_BOSS_BURNING_TIME, renewed while standing in the flames. It goes out once they are down - * (C4, M3). Scripted: it never lights the air or anything else. - */ -/datum/status_effect/bounty_boss_burning - id = "bounty_boss_burning" - duration = BOUNTY_BOSS_BURNING_TIME - tick_interval = 1 SECONDS - status_type = STATUS_EFFECT_REFRESH - alert_type = null - /// The flames on them - var/mutable_appearance/flames - var/damage_factor = 1 - -/datum/status_effect/bounty_boss_burning/on_creation(mob/living/new_owner, factor = 1) - damage_factor = factor - return ..() - -/datum/status_effect/bounty_boss_burning/refresh(mob/living/new_owner, factor = 1) - damage_factor = factor - return ..() - -/datum/status_effect/bounty_boss_burning/on_apply() - if(owner.stat != CONSCIOUS || HAS_TRAIT(owner, TRAIT_NOFIRE)) - return FALSE - flames = mutable_appearance('icons/mob/effects/onfire.dmi', ishuman(owner) ? "human_small_fire" : "generic_fire", -HIGHEST_LAYER, appearance_flags = RESET_COLOR | KEEP_APART) - owner.add_overlay(flames) - return TRUE - -/datum/status_effect/bounty_boss_burning/tick(seconds_between_ticks) - if(owner.stat != CONSCIOUS) - qdel(src) - return - owner.apply_damage(npc_zone_damage(BOUNTY_BOSS_BURNING_DAMAGE, damage_factor), BURN, blocked = owner.run_armor_check(null, FIRE, silent = TRUE), spread_damage = TRUE) - -/datum/status_effect/bounty_boss_burning/on_remove() - if(flames) - owner.cut_overlay(flames) - flames = null - // ===== THE MIND ===== /** - * Its planning: nothing while it is busy or can't act; its target; an ability; the kit's way of - * closing in or keeping away; its melee attack; and with nobody in sight, the hunt for the nearest - * hunter still on its site. - * - * TODO(P3 integration): if start_activity() needs a planning subtree to run, add it to the end of - * this list, so a boss waiting for hunters does its activity. + * Its planning, on a real human body (contract 2.4): soft crit and breaking free first; nothing while + * it is busy or can't act; worn out, it falls back; its target; its combat sense (a stimulant when it + * tires, and a kit's own reaction to stun fire); its kit's gear; drawing + * its weapon once its fight has started; an ability; the kit's way of closing in or keeping away; up to + * and through whatever is in its way; its melee with what it holds; patching itself up with nobody + * about; with nobody in sight, the hunt for the nearest hunter still on its site that it can get at; + * and with nobody it can get at, out of the sight of those shooting at it from where it can't. Bosses + * never surrender. */ /datum/ai_controller/basic_controller/bounty_boss blackboard = list( - BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/bounty_boss, - BB_TARGET_MINIMUM_STAT = CONSCIOUS, + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc/bounty_boss, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, BB_AGGRO_RANGE = BOUNTY_BOSS_POSSE_RANGE, ) - ai_movement = /datum/ai_movement/jps + ai_movement = /datum/ai_movement/jps/bounty idle_behavior = null planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, /datum/ai_planning_subtree/bounty_boss_busy, + /datum/ai_planning_subtree/bounty_boss_worn_out, /datum/ai_planning_subtree/bounty_boss_target, /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/bounty_boss_gear, + /datum/ai_planning_subtree/human_npc_arm/bounty_boss, /datum/ai_planning_subtree/bounty_boss_abilities, /datum/ai_planning_subtree/bounty_boss_engage, - /datum/ai_planning_subtree/basic_melee_attack_subtree, + /datum/ai_planning_subtree/bounty_boss_breach, + /datum/ai_planning_subtree/human_npc_melee, + /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_boss_hunt, + /datum/ai_planning_subtree/bounty_boss_cover, ) +/datum/ai_controller/basic_controller/bounty_boss/TryPossessPawn(atom/new_pawn) + if(!ishumannpc(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + return ..() + +// Cuffs and soft crit don't stop it (the human NPC base's run check), so it can work at its cuffs and crawl. +/datum/ai_controller/basic_controller/bounty_boss/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/bounty_boss/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + /** - * Hunters it may hurt, and mechs with one in them (H2); never anyone already down (C4). tg's own mech - * branch would test the pilot, whom it only hurts through the mech, so a mech is judged here. + * Hunters it may go after, and mechs with one in them (H2); never anyone already down (C4, + * npc_target_stat). tg's own mech branch would test the pilot, whom it only hurts through the mech, so + * a mech is judged here. */ -/datum/targeting_strategy/basic/bounty_boss +/datum/targeting_strategy/basic/human_npc/bounty_boss -/datum/targeting_strategy/basic/bounty_boss/can_attack(mob/living/living_mob, atom/the_target, vision_range) - var/mob/living/basic/bounty_criminal/boss/boss = living_mob +/datum/targeting_strategy/basic/human_npc/bounty_boss/can_attack(mob/living/living_mob, atom/the_target, vision_range) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = living_mob if(!istype(boss) || !boss.boss_valid_target(the_target)) return FALSE if(ismecha(the_target)) @@ -1306,19 +1912,51 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return isturf(boss.loc) && the_target.z == boss.z && get_dist(boss, the_target) <= range && can_see(boss, the_target, range) return ..() -/// Nothing at all while it winds up, charges or reels, or while it can't act +/// Nothing at all while it winds up, charges or bursts, or while it can't act (tg's states) /datum/ai_planning_subtree/bounty_boss_busy /datum/ai_planning_subtree/bounty_boss_busy/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn if(!istype(boss) || boss.boss_busy || !boss.boss_can_act()) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Worn out (boss_is_worn_out(), its body's real state): the first time, it says so and shows it to + * the hunters (boss_wear_out()). If it wants a stimulant pen now (combat sense's own rule: winded, in a + * fight, none in its blood) it takes it first; then it falls back from whoever it was fighting for + * BOUNTY_BOSS_FALLBACK_TIME and fights on. Back above the line (healed, or its stamina back), it is fresh + * again. + */ +/datum/ai_planning_subtree/bounty_boss_worn_out + +/datum/ai_planning_subtree/bounty_boss_worn_out/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + if(!boss.boss_is_worn_out()) + boss.boss_worn_out = FALSE + boss.boss_fallback_until = 0 + return + if(!boss.boss_worn_out) + boss.boss_wear_out() + var/datum/component/npc_combat_sense/sense = boss.GetComponent(/datum/component/npc_combat_sense) + if(sense?.rules.can_act(boss, controller) && sense.rules.plan_stim(boss, controller, sense)) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time >= boss.boss_fallback_until) + boss.boss_fallback_until = 0 + return + var/atom/threat = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] || boss.boss_revenge_target() + if(QDELETED(threat)) + return + controller.set_blackboard_key(BB_BOUNTY_BOSS_FALL_BACK_FROM, threat) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_BOUNTY_BOSS_FALL_BACK_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + /// Drops a target it may no longer go after, and turns on whoever hurt it last when it has none /datum/ai_planning_subtree/bounty_boss_target /datum/ai_planning_subtree/bounty_boss_target/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[BB_TARGETING_STRATEGY]) var/atom/current = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] if(current && !strategy?.can_attack(boss, current, BOUNTY_BOSS_POSSE_RANGE)) @@ -1330,11 +1968,30 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) if(avenge && strategy?.can_attack(boss, avenge, BOUNTY_BOSS_POSSE_RANGE)) controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, avenge) +/// The kit's own gear, once its fight is on: a Heavy changing the belt in its gun +/datum/ai_planning_subtree/bounty_boss_gear + +/datum/ai_planning_subtree/bounty_boss_gear/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + if(boss.boss_gear_plan(controller)) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The human NPC base's arming (reload or draw what it carries), only once its fight has started: until then its weapon stays where it carries it +/datum/ai_planning_subtree/human_npc_arm/bounty_boss + +/datum/ai_planning_subtree/human_npc_arm/bounty_boss/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + return ..() + /// One ability, if one is ready, worth it, and not the one it used last /datum/ai_planning_subtree/bounty_boss_abilities /datum/ai_planning_subtree/bounty_boss_abilities/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] if(QDELETED(target)) return @@ -1349,7 +2006,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_behavior/bounty_boss_ability /datum/ai_behavior/bounty_boss_ability/perform(seconds_per_tick, datum/ai_controller/controller, ability_key, target_key) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/datum/action/cooldown/mob_cooldown/bounty_boss/ability = controller.blackboard[ability_key] var/atom/target = controller.blackboard[target_key] if(QDELETED(ability) || QDELETED(target)) @@ -1366,13 +2023,34 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_planning_subtree/bounty_boss_engage /datum/ai_planning_subtree/bounty_boss_engage/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] if(QDELETED(target)) return if(boss.boss_engage_plan(controller, target)) return SUBTREE_RETURN_FINISH_PLANNING +/** + * One real step away from `target_key`'s target, onto ground its leash allows (P2's + * body_step_away_from()): tg's step_away walks on a movement that knows nothing of the leash. + */ +/datum/ai_behavior/bounty_boss_back_off + action_cooldown = 0.3 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +// No quicker than its body walks: armour, wounds and stamina slow it as they slow anyone. +/datum/ai_behavior/bounty_boss_back_off/get_cooldown(datum/ai_controller/cooldown_for) + var/mob/living/pawn = cooldown_for?.pawn + return max(action_cooldown, pawn?.cached_multiplicative_slowdown || 0) + +/datum/ai_behavior/bounty_boss_back_off/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(!istype(boss) || QDELETED(target) || !boss.boss_can_act()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + boss.face_atom(target) + return boss.body_step_away_from(target) ? (AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED) : (AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED) + /** * Nobody in sight while hostile: it goes after the nearest hunter still on its site, looking again * every BOUNTY_BOSS_HUNT_INTERVAL. With nobody on the site for BOUNTY_BOSS_CALM_TIME it calms down. @@ -1380,7 +2058,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_planning_subtree/bounty_boss_hunt /datum/ai_planning_subtree/bounty_boss_hunt/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn if(!boss.boss_hostile || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) return var/atom/prey = controller.blackboard[BB_BOUNTY_BOSS_PREY] @@ -1399,9 +2077,132 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) controller.queue_behavior(/datum/ai_behavior/travel_towards/adjacent, BB_BOUNTY_BOSS_PREY) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Its target stands behind things its kit goes through, every one of them (boss_gets_through()): it + * walks up to the first, and with Gorilla Arms right beside it, smashes it. A kit's own ability (a + * charge, a grenade, a breaching charge) has had its turn already. It only does this while what breaks + * through is ready (boss_breaks_through_now()), or when there is no way round on foot; otherwise it + * leaves the walk round to the melee planner rather than wait at the glass (review r2). + */ +/datum/ai_planning_subtree/bounty_boss_breach + +/datum/ai_planning_subtree/bounty_boss_breach/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!istype(boss) || QDELETED(target) || boss.Adjacent(target)) + return + var/turf/here = get_turf(boss) + var/turf/there = get_turf(target) + var/list/blockers = boss.boss_line_blockers(here, there) + if(!length(blockers) || !boss.boss_gets_through(blockers)) + return + var/atom/barrier = blockers[1] + if(!boss.boss_breaks_through_now(barrier) && boss.boss_walks_to(there)) + return + if(get_dist(here, barrier) <= 1) + if(boss.boss_start_smash(barrier)) + return SUBTREE_RETURN_FINISH_PLANNING + return + var/turf/spot = boss.boss_spot_before(barrier, target) + if(!spot) + return + if(controller.blackboard[BB_BOUNTY_BOSS_SPOT] != spot) + controller.CancelActions() + controller.set_blackboard_key(BB_BOUNTY_BOSS_SPOT, spot) + controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_SPOT) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Hurt lately by someone it can't get at (boss_shuns()), and nobody it can reach in sight or on the + * hunt: it gets out of their sight (boss_find_cover()) and waits there, rather than stand in the open + * for them, with a line as it goes (boss_cover_bark()). With nowhere hidden in reach it backs off as + * far from them as it can, and keeps going until it gets there. Anyone it can reach brings it out. + */ +/datum/ai_planning_subtree/bounty_boss_cover + +/datum/ai_planning_subtree/bounty_boss_cover/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !length(boss.boss_shunned) || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/list/watchers = boss.boss_watchers() + if(!length(watchers)) + return + var/turf/here = get_turf(boss) + if(!boss.boss_seen_from(here, watchers)) + controller.clear_blackboard_key(BB_BOUNTY_BOSS_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + var/turf/spot = controller.blackboard[BB_BOUNTY_BOSS_COVER] + // On its way: it keeps walking (every plan, or the walk it queued last time is dropped) until it + // gets there, unless someone took the spot, or it may look again and finds the spot in their sight. + if(spot && spot != here && !boss.boss_someone_on(spot) && (world.time < boss.boss_cover_rethink_at || !boss.boss_seen_from(spot, watchers))) + controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time < boss.boss_cover_rethink_at) + return SUBTREE_RETURN_FINISH_PLANNING + boss.boss_cover_rethink_at = world.time + BOUNTY_BOSS_REACH_RETHINK + spot = boss.boss_find_cover(watchers) + if(!spot) + controller.clear_blackboard_key(BB_BOUNTY_BOSS_COVER) + return + if(controller.blackboard[BB_BOUNTY_BOSS_COVER] != spot) + controller.CancelActions() + controller.set_blackboard_key(BB_BOUNTY_BOSS_COVER, spot) + boss.boss_cover_bark() + controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + +// ===== COMBAT SENSE: ITS STIMULANT, AND A KIT'S ANSWER TO STUN FIRE (C8) ===== + +/** + * A boss's combat sense (voidcrew/modules/human_npcs/human_npc_combat_sense.dm): the bounty family's leash + * and gang rules, and the stimulant pens it carries (boss_supplies()), taken when it tires in a fight and + * never while one is still in its blood. Nothing else injects them: its worn-out fall back asks this for + * the pen too (bounty_boss_worn_out). By default stun fire changes nothing else about how it fights; a + * kit with its own reaction sets it on a subtype (bounty_boss_kits.dm), and a ready ability always goes + * first, its wind-up being the kit's own answer. + */ +/datum/npc_combat_sense/bounty/boss + react_to_stun_fire = FALSE + +/datum/npc_combat_sense/bounty/boss/can_act(mob/living/carbon/human/npc, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + return istype(boss) && boss.boss_hostile && !boss.boss_busy && ..() + +/datum/npc_combat_sense/bounty/boss/in_fight(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + return istype(boss) && boss.boss_hostile && (controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) || sense.under_fire()) + +/datum/npc_combat_sense/bounty/boss/plan(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + if(!can_act(npc, controller)) + sense.stand_down(controller) + return + var/under_fire = sense.under_fire() + if(!under_fire && sense.hits) + sense.end_exchange(controller) + if(plan_stim(npc, controller, sense)) + return SUBTREE_RETURN_FINISH_PLANNING + if(!under_fire || !react_to_stun_fire || !may_move(npc, controller)) + return + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!QDELETED(target) && boss.boss_choose_ability(target)) + return + if(close_in_reach && reach(npc, controller) <= close_in_reach) + return plan_close_in(npc, controller, sense) + return plan_cover(npc, controller, sense) + +// The second pen of a fight comes with its "recover" line ("Round two."), as its second wind always did. +/datum/npc_combat_sense/bounty/boss/telegraph(mob/living/carbon/human/npc, kind, atom/about, obj/item/used) + . = ..() + if(kind != HUMAN_NPC_BARK_STIMULANT) + return + var/datum/component/npc_combat_sense/sense = npc.GetComponent(/datum/component/npc_combat_sense) + if(sense?.stims_used) + INVOKE_ASYNC(npc, TYPE_PROC_REF(/mob/living, bounty_say), "recover", null, TRUE) + // ===== LOOKS ===== -/// A mini-boss's look before its kit's: plain dark clothes +/// A mini-boss's clothes before its kit's: plain dark clothes /datum/outfit/bounty_boss name = "Bounty mini-boss" uniform = /obj/item/clothing/under/color/black @@ -1409,3 +2210,5 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) gloves = /obj/item/clothing/gloves/color/black #undef BOUNTY_BOSS_BUSY_TRAIT +#undef BOUNTY_BOSS_BUSY_SMASH +#undef BOUNTY_BOSS_SMASH_FILTER diff --git a/voidcrew/modules/bounties/bounty_boss_kits.dm b/voidcrew/modules/bounties/bounty_boss_kits.dm index 22bfcd86bb2..19c23093c19 100644 --- a/voidcrew/modules/bounties/bounty_boss_kits.dm +++ b/voidcrew/modules/bounties/bounty_boss_kits.dm @@ -4,22 +4,56 @@ * Owner: P4 bosses. Do not edit this file outside P4. * * What it owns: the Juggernaut, Pyromaniac, Demolitionist, Ghost and Heavy - * (/mob/living/basic/bounty_criminal/boss/, BOUNTY_KIT_*): their health, armour, weapon and - * two abilities each (spec section 5, 12.3 C8). No kit drops real loot: the guns, grenades and - * flamers are scripted, and the gear exists only in the look. + * (/mob/living/carbon/human/npc/bounty_criminal/boss/, BOUNTY_KIT_*): what each wears and carries, + * and its two abilities (spec section 5, 12.3 C8). Every kit is a real human in real gear, locked to its + * body (D3): its armour protects by tg's rules and its weapon is a real item it fights with. Its + * stimulants and bruise pack are packed by boss_supplies(), and its combat sense (bounty_boss.dm) uses + * the pens when it tires. Nothing it carries is ever loot. * - * - Juggernaut: riot plates. Charges in a line, smashing tables, chairs and plain windows and - * tearing through an interior wall; bounces off anything else and reels. Slams the floor around - * it when someone is close. - * - Pyromaniac: a fire suit and a flamer. Sweeps a cone of fire, and throws molotovs that leave a - * burning patch. Fire can't hurt it; lasers can. - * - Demolitionist: a bandolier of grenades. Keeps its distance and lobs grenades with a visible - * fuse, and blows a hole through an interior wall or door to get at someone behind cover. - * - Ghost: a dark cloak and a knife. Dashes in along a line, cuts that bleed, cloaks into a faint - * shimmer that a flash or a hit breaks, dodges shots while moving, and backs off after a couple of - * cuts. - * - Heavy: heavy armour and a belt-fed gun. A laser sight and a spin-up, then a burst into a cone; - * drops a folding barrier for cover. Slow. + * - Juggernaut: riot armour, a body grown huge (tg's gigantism gene) and a tower shield it raises as its + * fight starts, stopping every shot from in front of it (C8: flank it). Charges in a line, smashing + * tables, chairs and plain windows and tearing through an interior wall; running into anything else + * hurts it and knocks it down, as running into a wall does anyone (CB-6). Slams the floor around it + * when someone is close. Fights with its shield and its free fist. + * - Pyromaniac: a real firefighter suit and helmet (tg's thermal protection, CB-7), a gas mask, a real + * flamethrower with a plasma tank and a molotov in hand, and an ID with its name. Hoses a short line of + * fire with the flamethrower and throws molotovs that leave a burning patch; whoever it catches burns with + * tg's fire stacks (CB-8). Every BOUNTY_PYROMANIAC_FLAMER_SWEEPS sweeps the tank runs dry and it stops to + * change it for one of its spares, open while it does. Its mask keeps its own smoke out of its lungs. Shot with a disabler, it goes for the shooter until its flamer reaches + * them (C8: keep your distance and keep moving). Shot from past its flamer's reach, it pulls the pin + * on one of its two real smoke grenades and throws it halfway to the shooter, then comes through the + * cloud to where the shots came from (C9: back out of the smoke, flank it, or see through it). + * - Demolitionist: a hard hat, a hi-vis vest, a bandolier and a crowbar in its bag. Keeps its distance + * and lobs grenades with a visible fuse, and blows a hole through an interior wall or door to get at + * someone behind cover. Whoever a blast catches takes tg's light explosion (CB-10). A real CNS + * rebooter in its head gets it up a second after its first stamina crit of a minute (C8: pile on, and + * cuff it on the second). + * - Ghost: a dark hood, a real combat knife and real chrome (Slipwire, Cascade; the OC-6 ruling). + * Dashes in along a line with its Cascade lattice firing and stabs at the end of it (CB-9); fades + * into a shimmer that is only a look, broken by a hit or a flash (CB-12); backs off after a couple of + * cuts. Shot with a disabler from beyond its dash, it breaks the line and comes again from cover (C8: + * corner it). + * - Heavy: an ablative trenchcoat with its hood up (C8: energy armour everywhere and half of all beams + * reflected; bring lethal, or a lot of shots) and a real L6 with a spare box. A laser sight and a + * spin-up, then a burst of real rounds into a cone (a hit while it aims spoils the shot); throws real + * barrier grenades for cover (CB-26); changes its own belts. + * + * Where the air would kill it, each kit seals up in a real MODsuit of its own theme, close to the armour + * of what it wears in good air (bounty_sealed_gear(), bounty_suits.dm): the Juggernaut a security suit, + * the Pyromaniac an atmospherics one, the Demolitionist an engineering one, the Ghost a civilian one and + * the Heavy a magnate one. The suit takes its bag's place on its back, the bag's things in its storage. + * A coat or riot plates don't go over a MODsuit: the Juggernaut keeps its buckler, and the Heavy has the + * magnate suit's energy armour (its coat's) but no reflection out there. + * + * Behind something it can't walk through, a hunter is out of reach unless the kit gets through it + * (boss_goes_through(), bounty_boss.dm): the Juggernaut's charge, the Demolitionist's grenades and + * breaching charge, the Pyromaniac's molotovs, the Heavy's rounds, and real Gorilla Arms (the cyberpsycho's, + * or an admin's pick; no kit rolls them). A grenade or a molotov only goes where a thrown item would + * (boss_throw_passes(), boss_throw_reaches()): over a table, a railing or a gap, never through glass, + * a windoor, a grille or a shut door. + * The Ghost has nothing for it, and takes cover instead. It walks up to the barrier only while that + * ability is off cooldown (boss_breaks_through_now()), or when there is no way round; otherwise it goes + * round on foot. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P4 uses goes here * with a `boss_` prefix, or on the /boss subtypes. @@ -27,41 +61,82 @@ // ===== JUGGERNAUT ===== -/mob/living/basic/bounty_criminal/boss/juggernaut +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut name = "armoured criminal" - desc = "A huge criminal in riot plates, built like a door." - maxHealth = BOUNTY_JUGGERNAUT_HEALTH_1 - health = BOUNTY_JUGGERNAUT_HEALTH_1 - mob_size = MOB_SIZE_LARGE - speed = BOUNTY_JUGGERNAUT_SPEED - damage_coeff = list(BRUTE = BOUNTY_JUGGERNAUT_BRUTE_MOD, BURN = BOUNTY_JUGGERNAUT_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_damage_lower = BOUNTY_JUGGERNAUT_MELEE_MIN - melee_damage_upper = BOUNTY_JUGGERNAUT_MELEE_MAX - armour_penetration = BOUNTY_JUGGERNAUT_MELEE_AP - melee_attack_cooldown = BOUNTY_JUGGERNAUT_MELEE_COOLDOWN - attack_verb_continuous = "pounds" - attack_verb_simple = "pound" - attack_sound = 'sound/effects/hit_punch.ogg' - attack_vis_effect = ATTACK_EFFECT_SMASH + desc = "A huge criminal in riot plates behind a tower shield, built like a door." boss_kit = BOUNTY_KIT_JUGGERNAUT boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/charge, /datum/action/cooldown/mob_cooldown/bounty_boss/slam, ) -/mob/living/basic/bounty_criminal/boss/juggernaut/Initialize(mapload) +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/Initialize(mapload, datum/human_npc_order/order) . = ..() - update_transform(1.15) + // Its size is a real gene (CB-21, PI-21): mutadone shrinks it back like anyone else. + dna?.add_mutation(/datum/mutation/gigantism, MUTATION_SOURCE_ACTIVATED) -/mob/living/basic/bounty_criminal/boss/juggernaut/boss_health_table() - return list(BOUNTY_JUGGERNAUT_HEALTH_1, BOUNTY_JUGGERNAUT_HEALTH_2, BOUNTY_JUGGERNAUT_HEALTH_3, BOUNTY_JUGGERNAUT_HEALTH_4) +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/juggernaut)) -/mob/living/basic/bounty_criminal/boss/juggernaut/boss_kit_outfit() - return /datum/outfit/bounty_boss/juggernaut +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_gear_text() + . = istype(wear_suit, /obj/item/clothing/suit/armor/riot) ? "Riot plates cover [p_them()] from neck to boots." : "[p_They()] [p_are()] built like a door." + if(boss_shield()) + . += " A tower shield covers [p_their()] front." + +/// The tower shield it holds, if it still has one (C8) +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/proc/boss_shield() + for(var/obj/item/shield/riot/phalanx_buckler/shield in held_items) + return shield + return null + +// Its fight on, it raises the shield it holds, the player's way (use in hand), and keeps it up. +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_gear_plan(datum/ai_controller/controller) + var/obj/item/shield/riot/phalanx_buckler/shield = boss_shield() + if(isnull(shield) || shield.raised || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED) || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return FALSE + controller.queue_behavior(/datum/ai_behavior/bounty_juggernaut_raise_shield) + return TRUE -/mob/living/basic/bounty_criminal/boss/juggernaut/boss_gear_text() - return "Riot plates cover [p_them()] from neck to boots." +/// The Juggernaut raises its shield: the shield in its active hand, then used in hand (tg's Z key) +/datum/ai_behavior/bounty_juggernaut_raise_shield + +/datum/ai_behavior/bounty_juggernaut_raise_shield/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/jugg = controller.pawn + var/obj/item/shield/riot/phalanx_buckler/shield = istype(jugg) ? jugg.boss_shield() : null + if(isnull(shield) || !human_npc_ready_item(jugg, shield)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!shield.raised) + jugg.execute_mode() + if(!shield.raised) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + jugg.visible_message(span_warning("[jugg] sets [jugg.p_their()] shield in front of [jugg.p_them()].")) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +// A try that got nowhere: it fights on and tries again in a while, rather than fumble with it every plan +/datum/ai_behavior/bounty_juggernaut_raise_shield/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + if(!succeeded) + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 5 SECONDS) + +// Out where the air kills: a security MODsuit, the nearest to its riot plates +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/bounty_sealed_gear() + return list(/obj/item/mod/control/pre_equipped/bounty_boss/security) + +// Its charge goes straight through a table, a chair, a plain window or a grille +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_goes_through(atom/barrier) + if(..()) + return TRUE + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = locate() in boss_abilities + return isobj(barrier) && charge?.smashes(barrier) && boss_may_break(barrier) + +// With its charge ready +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_breaks_through_now(atom/barrier) + if(..()) + return TRUE + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/charge) + return isobj(barrier) && charge?.smashes(barrier) && boss_may_break(barrier) +// The shield has no sling, so it is held from the start, in the off hand; the other fist stays free. /datum/outfit/bounty_boss/juggernaut name = "Bounty mini-boss: Juggernaut" uniform = /obj/item/clothing/under/syndicate/combat @@ -69,13 +144,14 @@ head = /obj/item/clothing/head/helmet/toggleable/riot gloves = /obj/item/clothing/gloves/combat shoes = /obj/item/clothing/shoes/jackboots + l_hand = BOUNTY_JUGGERNAUT_SHIELD /** - * Charge: a roar, a stamp and a line on the floor, then a run down that line. The first hunter in + * Charge: a roar, a stamp and a line on the floor, then a run down that line. The first person in * the way is slammed and knocked down (anyone else in the way only BOUNTY_JUGGERNAUT_CHARGE_BYSTANDER * percent of the time). Tables, chairs, plain windows and grilles on the line are smashed. With * nobody caught, it tears through an interior wall at the end of the line if its budget allows; - * anything else stops it dead, reeling and open to damage. + * anything else stops it dead, hurt and on the floor (bounce()). */ /datum/action/cooldown/mob_cooldown/bounty_boss/charge name = "Charge" @@ -131,7 +207,7 @@ mark_turfs(path, "#c8a060") /datum/action/cooldown/mob_cooldown/bounty_boss/charge/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !length(path)) path = null return @@ -145,7 +221,7 @@ /// One tile of the charge /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/charge_step(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !boss.boss_still("charge", serial)) end_charge(serial) return @@ -158,9 +234,9 @@ if(!boss.leash_ok(next) || isspaceturf(next) || isgroundlessturf(next) || islava(next) || ischasm(next)) end_charge(serial) return - // Someone in the way: a hunter, or a mech with one in it, is slammed rather than bounced off (H2). + // Someone in the way: a person, or a mech with one in it, is slammed rather than bounced off (H2). for(var/atom/movable/thing as anything in next) - if(!thing.density || !(isliving(thing) || ismecha(thing)) || !boss.boss_can_hurt(thing)) + if(!thing.density || !(isliving(thing) || ismecha(thing)) || !boss.boss_effect_hits(thing)) continue if(thing == charge_target?.resolve() || prob(BOUNTY_JUGGERNAUT_CHARGE_BYSTANDER)) slam_into(thing) @@ -217,8 +293,8 @@ playsound(spot, is_window ? 'sound/effects/glass/glassbr1.ogg' : 'sound/effects/woodhit.ogg', 70, TRUE) return QDELETED(thing) || !thing.density -/// Steps onto `next`; a plain directional window on its own tile facing that way is smashed first. Returns TRUE if it moved. -/datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/try_step(mob/living/basic/bounty_criminal/boss/boss, turf/next) +/// Steps onto `next` (its leash checked by charge_step()); a plain directional window on its own tile facing that way is smashed first. Returns TRUE if it moved. +/datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/try_step(mob/living/carbon/human/npc/bounty_criminal/boss/boss, turf/next) var/direction = get_dir(boss, next) if(boss.Move(next, direction)) return TRUE @@ -233,38 +309,47 @@ return FALSE return boss.Move(next, direction) -/// Slams into `victim`, a hunter or a mech: brute through armour, and a hunter is knocked down +/// Slams into `victim`, a person or a mech: brute through armour, and a person is knocked down (CB-25) /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/slam_into(atom/victim) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner victim.visible_message(span_danger("[boss] slams into [victim]!"), span_userdanger("[boss] slams into you!")) playsound(victim, 'sound/effects/meteorimpact.ogg', 70, TRUE) - boss.boss_hit(victim, BOUNTY_JUGGERNAUT_CHARGE_DAMAGE, BRUTE, MELEE, BOUNTY_JUGGERNAUT_CHARGE_KNOCKDOWN, BOUNTY_JUGGERNAUT_MELEE_AP) + boss.boss_hit(victim, BOUNTY_JUGGERNAUT_CHARGE_DAMAGE, BRUTE, MELEE, BOUNTY_JUGGERNAUT_CHARGE_KNOCKDOWN, BOUNTY_JUGGERNAUT_CHARGE_AP) -/// Ran into something it can't break: stopped dead, reeling +/** + * Ran into something it can't break: stopped dead, and it hurts, the way running into a wall at full + * tilt hurts anyone (tg's tackle splat, CB-6): brute to its head through its helmet, a blow to its + * stamina, a moment's paralysis and a knockdown, all tg's. No damage multiplier: on the floor it is + * simply easier to hit, and to cuff. + */ /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/bounce(atom/obstacle, serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner end_charge(serial) if(!istype(boss) || boss.stat != CONSCIOUS) return boss.visible_message(span_danger("[boss] slams into [obstacle] and reels!")) playsound(boss, 'sound/effects/bang.ogg', 80, TRUE) - boss.boss_stagger(BOUNTY_JUGGERNAUT_WALL_STAGGER, BOUNTY_JUGGERNAUT_WALL_VULNERABLE) + boss.Shake(2, 1, BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN) + boss.apply_damage(BOUNTY_JUGGERNAUT_WALL_BRUTE, BRUTE, BODY_ZONE_HEAD, boss.run_armor_check(BODY_ZONE_HEAD, MELEE, silent = TRUE)) + boss.apply_damage(BOUNTY_JUGGERNAUT_WALL_STAMINA, STAMINA) + boss.Paralyze(BOUNTY_JUGGERNAUT_WALL_PARALYZE) + boss.Knockdown(BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN) /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/end_charge(serial) path = null charge_target = null - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(istype(boss) && boss.boss_busy == "charge" && boss.boss_busy_serial == serial) boss.boss_clear_busy(serial) /** - * Slam: both fists up and a red ring on the floor around it, then down. Every hunter it may hurt in - * the ring takes brute through armour and is knocked down; a mech takes it on its hull. Only when - * someone is next to it. + * Slam: both fists up and a red ring on the floor around it, then down. Everyone it catches in the + * ring, standing or not, takes brute through armour and is knocked down; a mech takes it on its hull. + * Only when someone is next to it. */ /datum/action/cooldown/mob_cooldown/bounty_boss/slam name = "Ground Slam" - desc = "Bring both fists down on the floor and knock everyone around you off their feet." + desc = "Bring your whole weight down on the floor and knock everyone around you off their feet." cooldown_time = BOUNTY_JUGGERNAUT_SLAM_COOLDOWN windup = BOUNTY_JUGGERNAUT_SLAM_WINDUP @@ -272,7 +357,7 @@ return aimable(target) && get_dist(owner, target) <= 1 /datum/action/cooldown/mob_cooldown/bounty_boss/slam/telegraph(atom/target) - owner.visible_message(span_boldwarning("[owner] raises both fists high over [owner.p_their()] head!")) + owner.visible_message(span_boldwarning("[owner] raises both arms high over [owner.p_their()] head!")) playsound(owner, 'sound/effects/magic/tail_swing.ogg', 60, TRUE) mark_turfs(RANGE_TURFS(1, owner), COLOR_RED) var/matrix/rest = matrix(owner.transform) @@ -282,8 +367,8 @@ animate(transform = rest, time = 0.2 SECONDS) /datum/action/cooldown/mob_cooldown/bounty_boss/slam/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner - boss.visible_message(span_danger("[boss] brings both fists down on the floor!")) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + boss.visible_message(span_danger("[boss] brings [boss.p_their()] whole weight down on the floor!")) playsound(boss, 'sound/effects/meteorimpact.ogg', 80, TRUE) for(var/atom/victim as anything in boss.boss_victims_on(RANGE_TURFS(1, boss))) boss.boss_hit(victim, BOUNTY_JUGGERNAUT_SLAM_DAMAGE, BRUTE, MELEE, BOUNTY_JUGGERNAUT_SLAM_KNOCKDOWN) @@ -292,96 +377,349 @@ // ===== PYROMANIAC ===== -/mob/living/basic/bounty_criminal/boss/pyromaniac +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac name = "scorched criminal" - desc = "A criminal in a blackened fire suit, with a fuel tank on their back and a lit torch in hand." - maxHealth = BOUNTY_PYROMANIAC_HEALTH_1 - health = BOUNTY_PYROMANIAC_HEALTH_1 - speed = BOUNTY_PYROMANIAC_SPEED - damage_coeff = list(BRUTE = BOUNTY_PYROMANIAC_BRUTE_MOD, BURN = BOUNTY_PYROMANIAC_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_damage_lower = BOUNTY_PYROMANIAC_MELEE_MIN - melee_damage_upper = BOUNTY_PYROMANIAC_MELEE_MAX - armour_penetration = BOUNTY_PYROMANIAC_MELEE_AP - melee_damage_type = BURN - melee_attack_cooldown = BOUNTY_PYROMANIAC_MELEE_COOLDOWN - attack_verb_continuous = "burns" - attack_verb_simple = "burn" - attack_sound = 'sound/items/weapons/sear.ogg' - attack_vis_effect = ATTACK_EFFECT_SMASH - // Fireproof, not laser-proof: no heat damage and no fire stacks, but BURN still lands. - unsuitable_heat_damage = 0 + desc = "A criminal in a blackened fire suit and a gas mask, a flamethrower in one hand and a bottle in the other." boss_kit = BOUNTY_KIT_PYROMANIAC + npc_combat_sense = /datum/npc_combat_sense/bounty/boss/pyromaniac boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/flamer, /datum/action/cooldown/mob_cooldown/bounty_boss/molotov, + /datum/action/cooldown/mob_cooldown/bounty_boss/smoke, ) + /// Sweeps fired off the tank in its flamethrower now + var/boss_flamer_sweeps = 0 + /// world.time its tank change may finish: it takes BOUNTY_PYROMANIAC_RELOAD_TIME + var/boss_reload_ready_at = 0 + +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/bounty_build_outfit(zone) + var/datum/outfit/kit = boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/pyromaniac)) + if(istype(kit)) + kit.backpack_contents[/obj/item/tank/internals/plasma/full] = BOUNTY_PYROMANIAC_SPARE_TANKS + return kit + +// The flamethrower's tank runs dry: it changes it for a spare from its bag (bounty_pyro_reload) +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_gear_plan(datum/ai_controller/controller) + if(world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + return FALSE + var/obj/item/flamethrower/flamer = boss_flamethrower() + if(isnull(flamer) || bounty_pyro_flamer_fuelled(flamer) || isnull(boss_spare_tank())) + return FALSE + controller.queue_behavior(/datum/ai_behavior/bounty_pyro_reload) + return TRUE + +/// A full plasma tank it carries that isn't in its flamethrower, in hand or where it can draw it from +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_spare_tank() + var/obj/item/flamethrower/flamer = boss_flamethrower() + for(var/obj/item/tank/internals/plasma/tank in get_all_contents_type(/obj/item/tank/internals/plasma)) + if(tank == flamer?.ptank || !(is_holding(tank) || human_npc_can_draw(src, tank))) + continue + var/datum/gas_mixture/tank_air = tank.return_air() + if(tank_air?.return_pressure() >= BOUNTY_PYROMANIAC_FLAMER_EMPTY) + return tank + return null + +/** + * One sweep's plasma out of its flamethrower's tank: an even share of what is left, so the tank is dry + * after BOUNTY_PYROMANIAC_FLAMER_SWEEPS sweeps. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_spend_flamer_fuel(obj/item/flamethrower/flamer) + var/datum/gas_mixture/tank_air = flamer?.ptank?.return_air() + if(isnull(tank_air)) + return + boss_flamer_sweeps++ + var/sweeps_left = BOUNTY_PYROMANIAC_FLAMER_SWEEPS - boss_flamer_sweeps + tank_air.remove_ratio(sweeps_left <= 0 ? 1 : 1 / (sweeps_left + 1)) + if(sweeps_left <= 0) + visible_message(span_warning("[src]'s flamethrower coughs and sputters out.")) -/mob/living/basic/bounty_criminal/boss/pyromaniac/Initialize(mapload) +/** + * Changes the plasma tank on its flamethrower the way a player does: a hand freed (the bottle into its + * bag), a full tank out of the bag, and the flamethrower clicked with it, which swaps the tanks and drops + * the empty (tg's flamethrower; the empty is its locked gear, so it is gone once on the floor). It stops + * for it, with a line and the wrench of the fitting, and takes at least BOUNTY_PYROMANIAC_RELOAD_TIME. + * Gives up after a while and fights on. + */ +/datum/ai_behavior/bounty_pyro_reload + action_cooldown = 0.5 SECONDS + +/datum/ai_behavior/bounty_pyro_reload/setup(datum/ai_controller/controller) . = ..() - add_traits(list(TRAIT_NOFIRE, TRAIT_RESISTHEAT), BOUNTY_TRAIT) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = controller.pawn + var/obj/item/flamethrower/flamer = istype(pyro) ? pyro.boss_flamethrower() : null + if(isnull(flamer)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_BOSS_RELOAD_UNTIL, world.time + 12 SECONDS) + pyro.boss_reload_ready_at = world.time + BOUNTY_PYROMANIAC_RELOAD_TIME + if(flamer.lit) + flamer.toggle_igniter(pyro) + pyro.visible_message(span_warning("[pyro] stops and wrenches the empty tank off [pyro.p_their()] flamethrower.")) + playsound(pyro, 'sound/items/tools/ratchet_slow.ogg', 60, TRUE) + pyro.boss_bark("reload") + +/datum/ai_behavior/bounty_pyro_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = controller.pawn + var/obj/item/flamethrower/flamer = istype(pyro) ? pyro.boss_flamethrower() : null + if(isnull(flamer) || world.time >= controller.blackboard[BB_BOUNTY_BOSS_RELOAD_UNTIL] || HAS_TRAIT(pyro, TRAIT_HANDS_BLOCKED)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(bounty_pyro_flamer_fuelled(flamer)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(pyro.next_move > world.time) + return AI_BEHAVIOR_DELAY + var/obj/item/tank/internals/plasma/tank = pyro.boss_spare_tank() + if(isnull(tank)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!pyro.is_holding(tank)) + if(!length(pyro.get_empty_held_indexes())) + var/obj/item/bottle = locate(/obj/item/reagent_containers/cup/glass/bottle/molotov) in pyro.held_items + if(isnull(bottle) || !pyro.back?.atom_storage?.attempt_insert(bottle, pyro, messages = FALSE)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(pyro, tank)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(world.time < pyro.boss_reload_ready_at) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(pyro, tank)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = flamer, combat_mode = FALSE) + if(flamer.ptank == tank) + pyro.boss_flamer_sweeps = 0 + playsound(pyro, 'sound/items/tools/ratchet.ogg', 60, TRUE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_pyro_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + controller.clear_blackboard_key(BB_BOUNTY_BOSS_RELOAD_UNTIL) + if(!succeeded) + // A change that got nowhere: it fights on with what it has for a while + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 10 SECONDS) + +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_gear_text() + if(istype(wear_suit, /obj/item/clothing/suit/utility/fire)) + return "[p_Their()] fire suit is scorched black." + return "[p_They()] reek[p_s()] of fuel." + +// Out where the air kills: an atmospherics MODsuit, fireproof like its fire suit, its gas mask on under the helmet. Nothing burns in vacuum. +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/bounty_sealed_gear() + return list(/obj/item/mod/control/pre_equipped/bounty_boss/atmospheric) + +// A molotov goes over anything a thrown bottle gets past (a table, a railing, a gap), never through glass or a door +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_goes_through(atom/barrier) + return ..() || boss_throw_passes(barrier) + +// With a molotov ready +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_breaks_through_now(atom/barrier) + return ..() || (boss_throw_passes(barrier) && !isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/molotov))) + +// Shot from past its flamer's reach, the smoke goes before anything else (C9) +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_choose_ability(atom/target) + var/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/ability = boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/smoke) + if(ability && !QDELETED(target) && ability.IsAvailable() && ability.worth_using(target)) + return ability + return ..() -/mob/living/basic/bounty_criminal/boss/pyromaniac/boss_health_table() - return list(BOUNTY_PYROMANIAC_HEALTH_1, BOUNTY_PYROMANIAC_HEALTH_2, BOUNTY_PYROMANIAC_HEALTH_3, BOUNTY_PYROMANIAC_HEALTH_4) +// Its smoke has its own line +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_bark(suffix, fallback) + if(suffix == "ability" && istype(boss_winding(), /datum/action/cooldown/mob_cooldown/bounty_boss/smoke)) + return ..("smoke", "ability") + return ..() + +/// The flamethrower it holds, or null +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_flamethrower() + return locate(/obj/item/flamethrower) in held_items + +/// Whether `flamer` has the igniter and the plasma for a sweep +/proc/bounty_pyro_flamer_fuelled(obj/item/flamethrower/flamer) + if(QDELETED(flamer) || !flamer.igniter || !flamer.status || QDELETED(flamer.ptank)) + return FALSE + var/datum/gas_mixture/tank_air = flamer.ptank.return_air() + return tank_air?.return_pressure() >= BOUNTY_PYROMANIAC_FLAMER_EMPTY + +/// The molotov it carries, in hand or in its bag, or null +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_molotov() + for(var/obj/item/reagent_containers/cup/glass/bottle/molotov/bottle in get_all_contents_type(/obj/item/reagent_containers/cup/glass/bottle/molotov)) + if(is_holding(bottle) || human_npc_can_draw(src, bottle)) + return bottle + return null -/mob/living/basic/bounty_criminal/boss/pyromaniac/boss_kit_outfit() - return /datum/outfit/bounty_boss/pyromaniac +/// Whether it has a hand for a grenade: one free, or its bottle can go back in its bag +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_hand_for_grenade() + if(length(get_empty_held_indexes())) + return TRUE + var/obj/item/bottle = locate(/obj/item/reagent_containers/cup/glass/bottle/molotov) in held_items + return !isnull(bottle) && !!back?.atom_storage?.can_insert(bottle, src, messages = FALSE) + +/// A smoke grenade it carries with the pin still in, in hand or where it can draw it from +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_smoke_grenade() + // One already in hand first (kept from a wind-up it was stunned out of): a pocket one would need a free hand + for(var/obj/item/grenade/smokebomb/grenade in held_items) + if(!grenade.active) + return grenade + for(var/obj/item/grenade/smokebomb/grenade in get_all_contents_type(/obj/item/grenade/smokebomb)) + if(!grenade.active && (is_holding(grenade) || human_npc_can_draw(src, grenade))) + return grenade + return null -/mob/living/basic/bounty_criminal/boss/pyromaniac/boss_gear_text() - return "[p_Their()] fire suit is scorched black." +/** + * The Pyromaniac's answer to stun fire (C8): it goes for whoever is shooting it, until its flamer + * reaches them (combat sense's close-in), throwing smoke, a molotov or sweeping the flamer the moment one + * is ready (the boss rules let a ready ability go first). Keep your distance and keep moving. + * + * Out of its sight (in its own smoke, or round a corner) it makes for where the shots last came from, + * not for where the shooter is now (C9): tg's targeting drops whoever it can't see, so this is how it + * comes through the cloud. There it looks again; if they moved, it is the hunt's to find them. + */ +/datum/npc_combat_sense/bounty/boss/pyromaniac + react_to_stun_fire = TRUE + close_in_reach = BOUNTY_PYROMANIAC_FLAMER_RANGE + +// It closes until its flamer reaches, not to arm's length +/datum/npc_combat_sense/bounty/boss/pyromaniac/reach(mob/living/carbon/human/npc, datum/ai_controller/controller) + return BOUNTY_PYROMANIAC_FLAMER_RANGE + +// Whoever is shooting it becomes who it goes for, if it may go after them +/datum/npc_combat_sense/bounty/boss/pyromaniac/plan_close_in(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + var/atom/shooter = sense.threat() + if(istype(boss) && shooter) + var/atom/aim = boss.boss_target_for(shooter) + if(aim != controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] && boss.boss_valid_target(aim)) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, aim) + if(!can_see(npc, shooter, HUMAN_NPC_SIGHT_RANGE)) + var/turf/spot = sense.threat_turf(npc) + if(isnull(spot) || get_dist(npc, spot) <= 1 || !tile_ok(npc, controller, spot)) + return + sense.charged_at = world.time + sense.decided_at = world.time + controller.set_blackboard_key(BB_HUMAN_NPC_STUN_SHOOTER, spot) + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_HUMAN_NPC_STUN_SHOOTER, 1) + return SUBTREE_RETURN_FINISH_PLANNING + return ..() +// The firefighter suit and helmet are what keep its own fire off it (tg's thermal protection, CB-7); the gas +// mask keeps smoke out of its lungs. The flamethrower doesn't holster, so it is in hand from the start, the +// bottle in the other. A smoke grenade in each pocket (C9). /datum/outfit/bounty_boss/pyromaniac name = "Bounty mini-boss: Pyromaniac" uniform = /obj/item/clothing/under/color/black suit = /obj/item/clothing/suit/utility/fire/firefighter head = /obj/item/clothing/head/utility/hardhat/red + mask = /obj/item/clothing/mask/gas gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/workboots - r_hand = /obj/item/flamethrower + r_hand = BOUNTY_PYROMANIAC_FLAMETHROWER + l_hand = BOUNTY_PYROMANIAC_MOLOTOV_BOTTLE + l_pocket = BOUNTY_PYROMANIAC_SMOKE + r_pocket = BOUNTY_PYROMANIAC_SMOKE + id = /obj/item/card/id + +// Its ID carries its name, so with its face behind the mask it still goes by it (tg's own naming). No +// access and no account; a rename (the bounty record's name) follows it onto the card. +/datum/outfit/bounty_boss/pyromaniac/post_equip(mob/living/carbon/human/user, visuals_only = FALSE) + . = ..() + var/obj/item/card/id/card = user.wear_id + if(istype(card)) + card.registered_name = user.real_name + card.update_label() + +/// The Pyromaniac's flamethrower: tg's, built and fuelled. It is a kit item, so lighting it is no news for admins. +/obj/item/flamethrower/full/tank/bounty_boss + warned_admins = TRUE + +/// The Pyromaniac's bottle: tg's molotov, filled with welding fuel +/obj/item/reagent_containers/cup/glass/bottle/molotov/bounty_boss + list_reagents = list(/datum/reagent/fuel = 50) /** - * Flamer: the pilot light hisses and an orange cone shows on the floor, then a jet of fire sweeps - * it. Everyone it may hurt in the cone takes BOUNTY_PYROMANIAC_FLAMER_DAMAGE burn and is set - * alight; the floor burns on for BOUNTY_PYROMANIAC_FLAMER_FLOOR. + * Flamer: it lights its flamethrower and an orange line shows on the floor, then a jet of fire hoses the + * line: BOUNTY_PYROMANIAC_FLAMER_DAMAGE burn and tg's fire stacks on whoever it catches, and the floor + * burns on for BOUNTY_PYROMANIAC_FLAMER_FLOOR (the boss fire, not loose plasma). Each sweep spends a + * share of the tank (boss_spend_flamer_fuel()); with it dry it doesn't sweep until it has changed tanks. + * It shuts the valve after each sweep. */ /datum/action/cooldown/mob_cooldown/bounty_boss/flamer name = "Flamer" - desc = "Sweep a cone of fire in front of you." + desc = "Sweep a line of fire in front of you." cooldown_time = BOUNTY_PYROMANIAC_FLAMER_COOLDOWN windup = BOUNTY_PYROMANIAC_FLAMER_WINDUP - /// The tiles it will cover, from the telegraph - var/list/cone + /// The tiles it will hose, from the telegraph + var/list/fire_line /datum/action/cooldown/mob_cooldown/bounty_boss/flamer/Destroy() - cone = null + fire_line = null return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/flamer/worth_using(atom/target) - return aimable(target) && get_dist(owner, target) <= BOUNTY_PYROMANIAC_FLAMER_RANGE && can_see(owner, target, BOUNTY_PYROMANIAC_FLAMER_RANGE) && landing_ok(target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + if(!istype(boss) || !aimable(target) || !bounty_pyro_flamer_fuelled(boss.boss_flamethrower())) + return FALSE + return get_dist(owner, target) <= BOUNTY_PYROMANIAC_FLAMER_RANGE && can_see(owner, target, BOUNTY_PYROMANIAC_FLAMER_RANGE) && landing_ok(target) && !human_npc_ally_in_line(boss, target) && length(line_turfs(target)) + +/** + * The line its fire runs from it to `target`: as far as a flamethrower's flame gets (it stops where air + * can't pass, a wall, a window or a shut door) and as far as its fire may reach: never off its site or + * into a ship (M2) + */ +/datum/action/cooldown/mob_cooldown/bounty_boss/flamer/proc/line_turfs(atom/target) + . = list() + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + var/turf/start = get_turf(owner) + var/turf/aim = get_turf(target) + if(!istype(boss) || !start || !aim) + return + var/turf/previous = start + for(var/turf/spot as anything in get_line(start, aim)) + if(spot == start) + continue + if(!isopenturf(previous) || !(spot in previous.get_atmos_adjacent_turfs(alldir = TRUE)) || !boss.boss_reach_ok(spot)) + break + . += spot + previous = spot /datum/action/cooldown/mob_cooldown/bounty_boss/flamer/telegraph(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner - cone = list() - // Only where its fire may reach: never off its site or into a ship (M2). - for(var/turf/spot as anything in cone_turfs(target, BOUNTY_PYROMANIAC_FLAMER_RANGE, BOUNTY_PYROMANIAC_FLAMER_ARC)) - if(boss.boss_reach_ok(spot)) - cone += spot - owner.visible_message(span_boldwarning("[owner] opens the valve on [owner.p_their()] flamer. The pilot light hisses.")) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + var/list/line = line_turfs(target) + fire_line = line + var/obj/item/flamethrower/flamer = boss.boss_flamethrower() + if(flamer && human_npc_ready_item(boss, flamer) && !flamer.lit) + flamer.attack_self(boss) + owner.visible_message(span_boldwarning("[owner] opens the valve on [owner.p_their()] flamethrower. The pilot light hisses.")) playsound(owner, 'sound/effects/spray.ogg', 50, TRUE) - mark_turfs(cone, "#ff8a1f") + mark_turfs(line, "#ff8a1f") + +/datum/action/cooldown/mob_cooldown/bounty_boss/flamer/cancel_windup() + fire_line = null + shut_valve() + return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/flamer/effect(atom/target) - var/list/fire_turfs = cone - cone = null - if(!length(fire_turfs)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + var/list/fire_turfs = fire_line + fire_line = null + var/obj/item/flamethrower/flamer = boss?.boss_flamethrower() + if(!length(fire_turfs) || !flamer?.lit || !bounty_pyro_flamer_fuelled(flamer) || !human_npc_ready_item(boss, flamer)) + shut_valve() return - owner.visible_message(span_danger("[owner] sweeps a jet of fire across the floor!")) + owner.visible_message(span_danger("[owner] hoses a jet of fire across the floor!")) playsound(owner, 'sound/items/weapons/fwoosh.ogg', 80, TRUE) - new /obj/effect/bounty_boss_fire_pool(get_turf(owner), fire_turfs, BOUNTY_PYROMANIAC_FLAMER_FLOOR, owner, BOUNTY_PYROMANIAC_FLAMER_DAMAGE) + new /obj/effect/bounty_boss_fire_pool(get_turf(owner), fire_turfs, BOUNTY_PYROMANIAC_FLAMER_FLOOR, owner, BOUNTY_PYROMANIAC_FLAMER_DAMAGE, BOUNTY_PYROMANIAC_FLAMER_STACKS) + boss.boss_spend_flamer_fuel(flamer) + addtimer(CALLBACK(src, PROC_REF(shut_valve)), BOUNTY_PYROMANIAC_FLAMER_SHUTOFF, TIMER_DELETE_ME) + +/// Puts its flamethrower out, if it is lit and done firing +/datum/action/cooldown/mob_cooldown/bounty_boss/flamer/proc/shut_valve() + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + if(!istype(boss) || QDELETED(boss)) + return + var/obj/item/flamethrower/flamer = boss.boss_flamethrower() + if(flamer?.lit && !flamer.operating) + flamer.toggle_igniter(boss) /** * Molotov: it lights the rag on a bottle and a ring shows where it will land, then it throws. The * bottle breaks into a 3x3 patch of fire (not through walls) for BOUNTY_PYROMANIAC_MOLOTOV_POOL: - * BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE burn to anyone it may hurt there, and set alight. + * BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE burn to anyone it catches there, and they catch fire. */ /datum/action/cooldown/mob_cooldown/bounty_boss/molotov name = "Molotov" @@ -399,10 +737,16 @@ if(!aimable(target)) return FALSE var/distance = get_dist(owner, target) - return distance >= 2 && distance <= BOUNTY_PYROMANIAC_MOLOTOV_RANGE && can_see(owner, target, BOUNTY_PYROMANIAC_MOLOTOV_RANGE) && landing_ok(target) + return distance >= 2 && distance <= BOUNTY_PYROMANIAC_MOLOTOV_RANGE && can_see(owner, target, BOUNTY_PYROMANIAC_MOLOTOV_RANGE) && landing_ok(target) && throw_reaches(target) /datum/action/cooldown/mob_cooldown/bounty_boss/molotov/telegraph(atom/target) landing = get_turf(target) + // Its bottle back in hand, if the smoke put it away + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = owner + if(istype(pyro)) + var/obj/item/bottle = pyro.boss_molotov() + if(bottle) + human_npc_take_in_hand(pyro, bottle) owner.visible_message(span_boldwarning("[owner] lights the rag on a bottle and takes aim!")) playsound(owner, 'sound/items/match_strike.ogg', 60, TRUE) // The ring stays until the bottle lands (L3). @@ -411,7 +755,7 @@ /// The 3x3 around `center` that a bottle bursting there reaches: open floor, not through walls, and only where its fire may reach (M2) /datum/action/cooldown/mob_cooldown/bounty_boss/molotov/proc/splash_turfs(turf/center) . = list() - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!center || !istype(boss)) return for(var/turf/spot as anything in RANGE_TURFS(1, center)) @@ -421,7 +765,8 @@ /datum/action/cooldown/mob_cooldown/bounty_boss/molotov/effect(atom/target) var/turf/spot = landing landing = null - if(!spot) + // Glass or a door shut across the way during the wind-up: it holds the bottle. + if(!spot || !throw_reaches(spot)) return owner.visible_message(span_danger("[owner] throws a flaming bottle!")) playsound(owner, 'sound/items/weapons/fwoosh.ogg', 40, TRUE) @@ -433,7 +778,99 @@ if(QDELETED(owner)) return playsound(spot, 'sound/effects/glass/glassbr3.ogg', 70, TRUE) - new /obj/effect/bounty_boss_fire_pool(spot, splash_turfs(spot), BOUNTY_PYROMANIAC_MOLOTOV_POOL, owner, BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE) + new /obj/effect/bounty_boss_fire_pool(spot, splash_turfs(spot), BOUNTY_PYROMANIAC_MOLOTOV_POOL, owner, BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE, BOUNTY_PYROMANIAC_MOLOTOV_STACKS) + +/** + * Smoke: it takes out a real smoke grenade it carries, then pulls the pin and throws it halfway to + * whoever shot it from past its flamer's reach (C9). The fuse and the cloud are tg's own: the cloud + * blinds everyone, weakens beams that cross it and makes anyone breathing it without internals cough and + * drop what they hold. Downed during the wind-up, the pin stays in and nothing happens (the grenade is + * kept for later). Two grenades, two clouds: then it has none left. + */ +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke + name = "Smoke Grenade" + desc = "Throw a smoke grenade between you and whoever is shooting at you." + cooldown_time = BOUNTY_PYROMANIAC_SMOKE_COOLDOWN + windup = BOUNTY_PYROMANIAC_SMOKE_WINDUP + /// Where it will land, from the telegraph + var/turf/landing + +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/Destroy() + landing = null + return ..() + +// Someone it can see past its flamer's reach who has just shot it, with a grenade and a hand for it +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/worth_using(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + if(!aimable(target) || !istype(boss) || !boss.boss_valid_target(target)) + return FALSE + var/obj/item/grenade/smokebomb/grenade = boss.boss_smoke_grenade() + if(isnull(grenade) || (!boss.is_holding(grenade) && !boss.boss_hand_for_grenade())) + return FALSE + var/distance = get_dist(owner, target) + if(distance <= BOUNTY_PYROMANIAC_FLAMER_RANGE || distance > BOUNTY_PYROMANIAC_SMOKE_RANGE || !can_see(owner, target, BOUNTY_PYROMANIAC_SMOKE_RANGE) || !throw_reaches(target)) + return FALSE + var/datum/component/npc_combat_sense/sense = boss.GetComponent(/datum/component/npc_combat_sense) + var/shot = sense?.under_fire() && boss.boss_target_for(sense.threat()) == target + if(!shot && (world.time > boss.boss_last_attacked_at + BOUNTY_PYROMANIAC_SMOKE_HURT_WINDOW || boss.boss_last_attacker?.resolve() != target)) + return FALSE + return !isnull(find_spot(target)) + +/** + * Where it throws: halfway to `target` and at least two tiles out, short of anything in the way, on + * ground it may aim at. That rule keeps ships, hangars and outposts more than the cloud's reach away (M2). + */ +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/proc/find_spot(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + var/turf/here = get_turf(owner) + var/turf/there = get_turf(target) + if(!istype(boss) || !here || !there) + return null + var/turf/spot = here + for(var/i in 1 to max(2, floor(get_dist(here, there) / 2))) + var/turf/next = get_step_towards(spot, there) + if(isnull(next) || next == there || next.is_blocked_turf(exclude_mobs = TRUE)) + break + spot = next + if(spot == here || !boss.boss_landing_ok(spot) || !boss.boss_throw_reaches(here, spot)) + return null + return spot + +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/telegraph(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + landing = find_spot(target) + var/obj/item/grenade/smokebomb/grenade = boss.boss_smoke_grenade() + if(isnull(grenade)) + return + // Both hands full: the bottle goes back in its bag for the grenade + if(!boss.is_holding(grenade) && !length(boss.get_empty_held_indexes())) + var/obj/item/bottle = locate(/obj/item/reagent_containers/cup/glass/bottle/molotov) in boss.held_items + if(bottle) + boss.back?.atom_storage?.attempt_insert(bottle, boss, messages = FALSE) + if(!human_npc_take_in_hand(boss, grenade) || !human_npc_ready_item(boss, grenade)) + return + owner.visible_message(span_boldwarning("[owner] pulls out a smoke grenade!")) + if(landing) + mark_turfs(list(landing), "#d8d8d8", "target_circle", /obj/effect/temp_visual/bounty_boss_thrown::duration) + +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/effect(atom/target) + var/turf/spot = landing || get_turf(target) + landing = null + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + var/obj/item/grenade/smokebomb/grenade = locate() in boss.held_items + // Glass or a door shut across the way during the wind-up: the pin stays in. + if(isnull(grenade) || grenade.active || !spot || !throw_reaches(spot) || !human_npc_ready_item(boss, grenade)) + return + // The player's way: pull the pin in hand, then throw it. From here the fuse is the grenade's own. + grenade.attack_self(boss) + boss.throw_mode_on(THROW_MODE_TOGGLE) + if(!boss.throw_item(spot)) + boss.throw_mode_off(THROW_MODE_TOGGLE) + boss.dropItemToGround(grenade) + +/// The Pyromaniac's smoke grenades: tg's smoke grenade, its fuse turned down to 3 seconds +/obj/item/grenade/smokebomb/bounty_boss + det_time = BOUNTY_PYROMANIAC_SMOKE_FUSE /// Something thrown, flying from where it starts to `destination` /obj/effect/temp_visual/bounty_boss_thrown @@ -454,30 +891,66 @@ // ===== DEMOLITIONIST ===== -/mob/living/basic/bounty_criminal/boss/demolitionist +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist name = "criminal in a hard hat" - desc = "A criminal in a hard hat and a hi-vis vest, with a bandolier of grenades across their chest." - maxHealth = BOUNTY_DEMOLITIONIST_HEALTH_1 - health = BOUNTY_DEMOLITIONIST_HEALTH_1 - speed = BOUNTY_DEMOLITIONIST_SPEED - damage_coeff = list(BRUTE = BOUNTY_DEMOLITIONIST_BRUTE_MOD, BURN = BOUNTY_DEMOLITIONIST_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_attack_cooldown = BOUNTY_DEMOLITIONIST_MELEE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_SMASH + desc = "A criminal in a hard hat and a hi-vis vest, with a bandolier across their chest." boss_kit = BOUNTY_KIT_DEMOLITIONIST - boss_weapon = BOUNTY_DEMOLITIONIST_CROWBAR boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/grenade, /datum/action/cooldown/mob_cooldown/bounty_boss/breach, ) -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_health_table() - return list(BOUNTY_DEMOLITIONIST_HEALTH_1, BOUNTY_DEMOLITIONIST_HEALTH_2, BOUNTY_DEMOLITIONIST_HEALTH_3, BOUNTY_DEMOLITIONIST_HEALTH_4) - -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_kit_outfit() - return /datum/outfit/bounty_boss/demolitionist +/** + * Its rebooter (C8): tg's CNS rebooter, a real implant in its head, one neural load beside any chrome it + * rolled (a Demolitionist rolls at most Cascade and Lazarus, 17 of the body's 20). It is no chrome, so the + * chrome roll doesn't count it and it doesn't burn out when it dies. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/Initialize(mapload, datum/human_npc_order/order) + . = ..() + if(QDELETED(src)) + return + var/obj/item/organ/implant = new BOUNTY_DEMOLITIONIST_REBOOTER() + if(!implant.Insert(src, special = TRUE)) + qdel(implant) + RegisterSignal(src, SIGNAL_ADDTRAIT(TRAIT_STUNIMMUNE), PROC_REF(boss_rebooted)) + +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/demolitionist)) + +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_gear_text() + . = "A bandolier of grenades is slung across [p_their()] chest." + if(boss_rebooter()) + . += " A steel port glints at the base of [p_their()] skull." + +/// Its CNS rebooter, if it is still in its head +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/proc/boss_rebooter() + return locate(BOUNTY_DEMOLITIONIST_REBOOTER) in organs + +/// Its rebooter just fired (the implant's own stun immunity went on): everyone sees it get up +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/proc/boss_rebooted(datum/source) + SIGNAL_HANDLER + var/obj/item/organ/implant = boss_rebooter() + if(isnull(implant) || stat == DEAD || !HAS_TRAIT_FROM(src, TRAIT_STUNIMMUNE, REF(implant))) + return + visible_message(span_warning("[src] jerks back to [p_their()] feet, sparks spitting from the back of [p_their()] neck!")) + +// Out where the air kills: an engineering MODsuit over its work clothes, as tough as its hard hat +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/bounty_sealed_gear() + return list(/obj/item/mod/control/pre_equipped/bounty_boss/engineering) +// A grenade goes over anything a thrown grenade gets past (a table, a railing, a gap), and a breaching charge through an interior wall or door +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_goes_through(atom/barrier) + if(..() || boss_throw_passes(barrier)) + return TRUE + return (iswallturf(barrier) || istype(barrier, /obj/machinery/door)) && boss_may_break(barrier) -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_gear_text() - return "A bandolier of grenades is slung across [p_their()] chest." +// With a grenade ready for anything a thrown grenade gets past, or a breaching charge for an interior wall or door +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_breaks_through_now(atom/barrier) + if(..()) + return TRUE + if(boss_throw_passes(barrier) && boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/grenade)) + return TRUE + return (iswallturf(barrier) || istype(barrier, /obj/machinery/door)) && boss_may_break(barrier) \ + && !isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/breach)) /** * It keeps BOUNTY_DEMOLITIONIST_KEEP_MIN to _MAX tiles from its target while it has something it @@ -485,14 +958,14 @@ * ready for this target (out of its sight with no breach possible, or the strict rotation ruling * out the grenade), rather than standing idle. */ -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_engage_plan(datum/ai_controller/controller, atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_engage_plan(datum/ai_controller/controller, atom/target) if(!isliving(target) && !ismecha(target)) return FALSE var/distance = get_dist(src, target) if(distance <= 1 || !boss_has_next_throw(target)) return FALSE if(distance < BOUNTY_DEMOLITIONIST_KEEP_MIN) - controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/bounty_boss_back_off, BB_BASIC_MOB_CURRENT_TARGET) return TRUE if(distance > BOUNTY_DEMOLITIONIST_KEEP_MAX) controller.queue_behavior(/datum/ai_behavior/pursue_to_range, BB_BASIC_MOB_CURRENT_TARGET, BOUNTY_DEMOLITIONIST_KEEP_MAX) @@ -504,19 +977,20 @@ * Whether the rotation leaves it an ability it could use on `target` from its band. Cooldowns are * ignored on purpose, so it holds its range between grenades instead of stepping in and out. */ -/mob/living/basic/bounty_criminal/boss/demolitionist/proc/boss_has_next_throw(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/proc/boss_has_next_throw(atom/target) for(var/datum/action/cooldown/mob_cooldown/bounty_boss/ability as anything in boss_abilities) if(QDELETED(ability) || (boss_strict_rotation && ability.type == boss_last_ability)) continue - // A grenade needs the target in sight; its range is checked by the band itself. A breach only when one is possible now. + // A grenade needs the target in sight and a throw that lands on them; its range is checked by the band itself. A breach only when one is possible now. if(istype(ability, /datum/action/cooldown/mob_cooldown/bounty_boss/grenade)) - if(can_see(src, target, get_dist(src, target))) + if(can_see(src, target, get_dist(src, target)) && boss_throw_reaches(get_turf(src), get_turf(target))) return TRUE continue if(ability.worth_using(target)) return TRUE return FALSE +// Its crowbar rides in its bag until the fight starts; welding goggles keep a flash out of its eyes (tg's flash protection). /datum/outfit/bounty_boss/demolitionist name = "Bounty mini-boss: Demolitionist" uniform = /obj/item/clothing/under/misc/overalls @@ -526,7 +1000,8 @@ glasses = /obj/item/clothing/glasses/welding gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/workboots - r_hand = BOUNTY_DEMOLITIONIST_CROWBAR + back = /obj/item/storage/backpack/industrial + backpack_contents = list(BOUNTY_DEMOLITIONIST_CROWBAR = 1) /** * Grenade: it pulls the pin and a mark shows where it is aiming, then it throws. The grenade lands @@ -550,7 +1025,7 @@ if(!aimable(target)) return FALSE var/distance = get_dist(owner, target) - return distance >= 2 && distance <= BOUNTY_DEMOLITIONIST_GRENADE_RANGE && can_see(owner, target, BOUNTY_DEMOLITIONIST_GRENADE_RANGE) && landing_ok(target) + return distance >= 2 && distance <= BOUNTY_DEMOLITIONIST_GRENADE_RANGE && can_see(owner, target, BOUNTY_DEMOLITIONIST_GRENADE_RANGE) && landing_ok(target) && throw_reaches(target) /datum/action/cooldown/mob_cooldown/bounty_boss/grenade/telegraph(atom/target) landing = get_turf(target) @@ -567,19 +1042,20 @@ throw_grenade(spot) if(!prob(BOUNTY_DEMOLITIONIST_SECOND_GRENADE)) return - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner var/list/near = list() for(var/turf/other as anything in RANGE_TURFS(2, spot)) - if(other != spot && !isclosedturf(other) && boss.boss_reach_ok(other) && bounty_boss_clear_line(get_turf(owner), other)) + if(other != spot && !isclosedturf(other) && boss.boss_reach_ok(other) && boss.boss_throw_reaches(get_turf(owner), other)) near += other if(length(near)) addtimer(CALLBACK(src, PROC_REF(throw_grenade), pick(near)), 0.5 SECONDS, TIMER_DELETE_ME) /// Throws one grenade at `spot`: it flies, lands and starts its fuse /datum/action/cooldown/mob_cooldown/bounty_boss/grenade/proc/throw_grenade(turf/spot) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner // The second grenade comes half a second later: not if it was downed, cuffed or stunned meanwhile. - if(!istype(boss) || !boss.boss_can_act() || !spot) + // Nor with glass or a door shut across the way since it aimed: it holds the grenade. + if(!istype(boss) || !boss.boss_can_act() || !spot || !throw_reaches(spot)) return boss.visible_message(span_danger("[boss] throws a grenade!")) new /obj/effect/temp_visual/bounty_boss_thrown(get_turf(boss), spot) @@ -610,7 +1086,7 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/breach/worth_using(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!aimable(target) || !istype(boss) || boss.boss_walls_left <= 0) return FALSE if(get_dist(boss, target) > BOUNTY_DEMOLITIONIST_BREACH_RANGE) @@ -622,7 +1098,7 @@ /// The interior wall or door beside it that leads toward `target`, if it may break one /datum/action/cooldown/mob_cooldown/bounty_boss/breach/proc/breach_spot(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner var/toward = get_dir(boss, target) for(var/direction in list(toward, turn(toward, 45), turn(toward, -45))) var/turf/spot = get_step(boss, direction) @@ -645,7 +1121,7 @@ mark_turfs(list(get_turf(spot)), COLOR_RED) /datum/action/cooldown/mob_cooldown/bounty_boss/breach/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner var/atom/spot = breach_ref?.resolve() breach_ref = null if(!istype(boss) || QDELETED(spot) || !boss.boss_may_break(spot)) @@ -654,11 +1130,13 @@ boss.visible_message(span_danger("The charge on [spot] starts beeping!")) /** - * A boss's grenade or breaching charge: a fuse with a blinking red ring and beeping, then a - * scripted blast. No explosion(), no fire: brute against bomb armour and a knockdown to everyone - * the boss may hurt within `radius` (not through walls), and damage to structures within - * `object_radius` through the boss's rules (boss_damage_structure()). Capped at - * BOUNTY_BOSS_EXPLOSIVE_CAP server-wide. With its boss gone, it still goes off but breaks nothing. + * A boss's grenade or breaching charge: a fuse with a blinking red ring and beeping, then a blast. + * The object is scripted so nothing breaches a hull: no explosion() and no fire, and structures within + * `object_radius` take damage only through the boss's rules (boss_damage_structure()). Whoever the blast + * catches within `radius` (not through walls), standing or down, takes tg's own light explosion + * (ex_act(EXPLODE_LIGHT), CB-10): bomb armour, knockdown, ears, wounds and limbs are all tg's. A mech + * with someone in it takes `damage` on its hull. Capped at BOUNTY_BOSS_EXPLOSIVE_CAP server-wide. With + * its boss gone, it still goes off but breaks nothing. */ /obj/effect/bounty_boss_explosive name = "grenade" @@ -669,12 +1147,17 @@ layer = ABOVE_OBJ_LAYER /// The boss who threw it var/datum/weakref/boss_ref + /// Its boss's factions when it was thrown: its blast never catches its own side, even with the boss gone + var/list/side_factions var/fuse = BOUNTY_DEMOLITIONIST_GRENADE_FUSE - /// Who it catches, and how hard + /// Who it catches var/radius = BOUNTY_DEMOLITIONIST_GRENADE_RADIUS + /// What it does to a mech's hull var/damage = BOUNTY_DEMOLITIONIST_GRENADE_DAMAGE + /// Its boss's zone damage factor when it was thrown (1 for a human boss) var/damage_factor = 1 - var/knockdown = BOUNTY_DEMOLITIONIST_GRENADE_KNOCKDOWN + /// Unused by the field kit (people get tg's own knockdown from the explosion); the prison's rigged charge sets it + var/knockdown = 0 /// What it breaks, and how hard var/object_radius = BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_RADIUS var/object_damage = BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_DAMAGE @@ -683,12 +1166,13 @@ /// Its ring var/obj/effect/temp_visual/bounty_boss_mark/ring -/obj/effect/bounty_boss_explosive/Initialize(mapload, mob/living/basic/bounty_criminal/boss/boss) +/obj/effect/bounty_boss_explosive/Initialize(mapload, mob/living/carbon/human/npc/bounty_criminal/boss/boss) . = ..() if(length(GLOB.bounty_boss_explosives) >= BOUNTY_BOSS_EXPLOSIVE_CAP) return INITIALIZE_HINT_QDEL GLOB.bounty_boss_explosives += src boss_ref = boss ? WEAKREF(boss) : null + side_factions = boss ? boss.faction.Copy() : null damage_factor = boss ? boss.get_npc_zone_damage_factor() : 1 structure_tally = list(BOUNTY_BOSS_ABILITY_STRUCTURE_CAP) ring = new(get_turf(src), fuse, COLOR_RED, "target_circle") @@ -701,6 +1185,7 @@ /obj/effect/bounty_boss_explosive/Destroy() GLOB.bounty_boss_explosives -= src boss_ref = null + side_factions = null structure_tally = null if(!QDELETED(ring)) qdel(ring) @@ -710,18 +1195,18 @@ /obj/effect/bounty_boss_explosive/proc/beep() playsound(src, 'sound/machines/beep/beep.ogg', 30, TRUE) -/// Whether the blast may hurt `victim`: its boss's rules, or with the boss gone, awake hunters and their mechs -/obj/effect/bounty_boss_explosive/proc/may_hit(atom/victim, mob/living/basic/bounty_criminal/boss/boss) +/// Whether the blast catches `victim`: its boss's rules, or with the boss gone, anyone not on the boss's side. Never by the victim's state (CB-11). +/obj/effect/bounty_boss_explosive/proc/may_hit(atom/victim, mob/living/carbon/human/npc/bounty_criminal/boss/boss) if(!QDELETED(boss)) - return boss.boss_can_hurt(victim) - return bounty_boss_orphan_victim(victim) + return boss.boss_effect_hits(victim) + return bounty_boss_effect_victim(victim, side_factions) /obj/effect/bounty_boss_explosive/proc/detonate() var/turf/here = get_turf(src) if(!here) qdel(src) return - var/mob/living/basic/bounty_criminal/boss/boss = boss_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = boss_ref?.resolve() if(QDELETED(boss)) boss = null blast_structures(boss, here) @@ -730,19 +1215,21 @@ for(var/atom/movable/victim in range(radius, here)) if(!(isliving(victim) || ismecha(victim)) || !may_hit(victim, boss) || !bounty_boss_clear_line(here, get_turf(victim))) continue - if(boss) - boss.boss_hit(victim, npc_zone_damage(damage, damage_factor), BRUTE, BOMB, knockdown, already_scaled = TRUE) - else if(ismecha(victim)) - victim.take_damage(npc_zone_damage(damage, damage_factor), BRUTE, BOMB, FALSE) - else - var/mob/living/person = victim - person.apply_damage(npc_zone_damage(damage, damage_factor), BRUTE, BODY_ZONE_CHEST, person.run_armor_check(BODY_ZONE_CHEST, BOMB, silent = TRUE)) - if(knockdown) - person.Knockdown(knockdown) + blast(victim) qdel(src) +/// Catches `victim` in the blast: a mech on its hull, a person by tg's light explosion +/obj/effect/bounty_boss_explosive/proc/blast(atom/movable/victim) + if(ismecha(victim)) + var/obj/vehicle/sealed/mecha/mech = victim + mech.take_damage(npc_zone_damage(damage, damage_factor), BRUTE, BOMB, FALSE, get_dir(mech, src)) + return + var/mob/living/person = victim + EX_ACT(person, EXPLODE_LIGHT) + shake_camera(person, 3, 2) + /// Structures in reach take the blast, through the boss's rules -/obj/effect/bounty_boss_explosive/proc/blast_structures(mob/living/basic/bounty_criminal/boss/boss, turf/here) +/obj/effect/bounty_boss_explosive/proc/blast_structures(mob/living/carbon/human/npc/bounty_criminal/boss/boss, turf/here) if(!boss || object_damage <= 0) return for(var/obj/thing in range(object_radius, here)) @@ -758,13 +1245,12 @@ fuse = BOUNTY_DEMOLITIONIST_BREACH_FUSE radius = 1 damage = BOUNTY_DEMOLITIONIST_BREACH_DAMAGE - knockdown = 0 object_radius = 0 object_damage = 0 /// The wall or door it is on var/datum/weakref/breach_ref -/obj/effect/bounty_boss_explosive/breach/Initialize(mapload, mob/living/basic/bounty_criminal/boss/boss, atom/breach_target) +/obj/effect/bounty_boss_explosive/breach/Initialize(mapload, mob/living/carbon/human/npc/bounty_criminal/boss/boss, atom/breach_target) . = ..() breach_ref = breach_target ? WEAKREF(breach_target) : null @@ -775,7 +1261,7 @@ /obj/effect/bounty_boss_explosive/breach/beep() playsound(src, 'sound/machines/beep/beep.ogg', 60, TRUE) -/obj/effect/bounty_boss_explosive/breach/blast_structures(mob/living/basic/bounty_criminal/boss/boss, turf/here) +/obj/effect/bounty_boss_explosive/breach/blast_structures(mob/living/carbon/human/npc/bounty_criminal/boss/boss, turf/here) var/atom/target = breach_ref?.resolve() if(!boss || QDELETED(target)) return @@ -783,46 +1269,56 @@ // ===== GHOST ===== -/mob/living/basic/bounty_criminal/boss/ghost +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost name = "hooded criminal" - desc = "A lean criminal in a dark hooded cloak, holding a long knife low." - maxHealth = BOUNTY_GHOST_HEALTH_1 - health = BOUNTY_GHOST_HEALTH_1 - speed = BOUNTY_GHOST_SPEED - damage_coeff = list(BRUTE = BOUNTY_GHOST_BRUTE_MOD, BURN = BOUNTY_GHOST_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_attack_cooldown = BOUNTY_GHOST_MELEE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_SLASH + desc = "A lean criminal in a dark hooded cloak, a knife at the ready." boss_kit = BOUNTY_KIT_GHOST - boss_weapon = BOUNTY_GHOST_KNIFE + npc_combat_sense = /datum/npc_combat_sense/bounty/boss/ghost + // Its quickness is real chrome (the OC-6 ruling): a Slipwire shunt and a Cascade lattice. An EMP or a + // brownout takes it away as it would from a player, and it burns out when the Ghost dies. + npc_cyberware = list( + /obj/item/organ/cyberimp/cyberware/slipwire, + /obj/item/organ/cyberimp/cyberware/cascade, + ) boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/dash, /datum/action/cooldown/mob_cooldown/bounty_boss/cloak, ) - /// Cloaked: faint, and hard to hit, until a flash, a hit or the time runs out + /// Shimmering: only a look (alpha and blur), until a flash, a hit or the time runs out var/boss_cloaked = FALSE var/boss_cloak_timer - /// world.time of its last step, for dodging while moving - var/boss_last_moved = 0 /// Cuts since it last broke off, and when it may close in again var/boss_cuts = 0 var/boss_breakoff_until = 0 - /// Its dodge and miss messages show at most every BOUNTY_BOSS_MESSAGE_COOLDOWN (L5) - COOLDOWN_DECLARE(boss_message_cooldown) -/mob/living/basic/bounty_criminal/boss/ghost/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/Destroy() deltimer(boss_cloak_timer) boss_cloak_timer = null return ..() -/mob/living/basic/bounty_criminal/boss/ghost/boss_health_table() - return list(BOUNTY_GHOST_HEALTH_1, BOUNTY_GHOST_HEALTH_2, BOUNTY_GHOST_HEALTH_3, BOUNTY_GHOST_HEALTH_4) - -/mob/living/basic/bounty_criminal/boss/ghost/boss_kit_outfit() - return /datum/outfit/bounty_boss/ghost +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/ghost)) -/mob/living/basic/bounty_criminal/boss/ghost/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_gear_text() return "[p_They()] move[p_s()] quick and light, and the knife is never still." +// Out where the air kills: a civilian MODsuit, hardly more armour than its hood and no slower +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/bounty_sealed_gear() + return list(/obj/item/mod/control/pre_equipped/bounty_boss/civilian) + +/** + * The Ghost's answer to stun fire (C8): from beyond its dash, it breaks the shooter's line, running a few + * steps for cover (combat sense's cover, Slipwire dodging as it goes), and keeps down a moment, longer + * while winded; from cover it comes again with its dash once someone is in reach (the boss rules let a + * ready ability go first). Corner it. + */ +/datum/npc_combat_sense/bounty/boss/ghost + react_to_stun_fire = TRUE + close_in_reach = 0 + cover_steps = BOUNTY_GHOST_COVER_STEPS + cover_moves = BOUNTY_GHOST_COVER_MOVES + +// The hood goes up as the outfit is put on (tg's hooded suits do that). The knife rides in a pocket until the fight starts. /datum/outfit/bounty_boss/ghost name = "Bounty mini-boss: Ghost" uniform = /obj/item/clothing/under/color/black @@ -830,81 +1326,66 @@ mask = /obj/item/clothing/mask/bandana/black gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/sneakers/black - r_hand = BOUNTY_GHOST_KNIFE + l_pocket = BOUNTY_GHOST_KNIFE -/// Its cuts bleed, and after a couple of them it breaks off for a while -/mob/living/basic/bounty_criminal/boss/ghost/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - . = ..() - if(!. || !isliving(target)) - return - boss_cut(target) +/// The knife it holds, if it has one in hand +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_knife() + for(var/obj/item/knife/blade in held_items) + return blade + return null + +/** + * Stabs `victim` with the knife it holds, as a player would: the knife in its active hand, a body + * zone picked, and a click through controller.ai_interact(). Damage, armour, wounds and bleeding are + * the knife's and tg's (CB-9). Returns TRUE if it swung. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_knife_attack(atom/victim) + var/obj/item/knife/blade = boss_knife() + if(isnull(blade) || isnull(ai_controller) || next_move > world.time || !human_npc_ready_item(src, blade)) + return FALSE + victim.visible_message(span_danger("[src] darts in and slashes [victim]!"), span_userdanger("[src] darts in and slashes you!")) + playsound(victim, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) + zone_selected = human_npc_pick_aim_zone() + return ai_controller.ai_interact(target = victim, combat_mode = TRUE) -/// A cut on `victim`: it bleeds, and the hit-and-run count goes up -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_cut(mob/living/victim) - if(QDELETED(victim) || victim.stat == DEAD) +// Every knife blow it lands counts toward its hit and run: after a couple, it breaks off for a while. +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_landed_blow(mob/living/victim, obj/item/weapon) + if(!istype(weapon, /obj/item/knife)) return - victim.apply_status_effect(/datum/status_effect/bounty_boss_bleed, get_npc_zone_damage_factor()) boss_cuts++ if(boss_cuts >= BOUNTY_GHOST_CUTS_BEFORE_BREAKOFF) boss_cuts = 0 boss_breakoff_until = world.time + BOUNTY_GHOST_BREAKOFF_TIME /// Breaking off: it backs away from its target for a while -/mob/living/basic/bounty_criminal/boss/ghost/boss_engage_plan(datum/ai_controller/controller, atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_engage_plan(datum/ai_controller/controller, atom/target) if(world.time >= boss_breakoff_until || !(isliving(target) || ismecha(target)) || get_dist(src, target) > 4) return FALSE - controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/bounty_boss_back_off, BB_BASIC_MOB_CURRENT_TARGET) return TRUE -/mob/living/basic/bounty_criminal/boss/ghost/boss_moved() - boss_last_moved = world.time - -/// Whether it is on the move, for dodging -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_moving() - return world.time <= boss_last_moved + BOUNTY_GHOST_MOVING_WINDOW && boss_can_act() - -/// It dodges some shots while moving, and cloaked, half of them miss -/mob/living/basic/bounty_criminal/boss/ghost/boss_projectile_prehit(obj/projectile/shot) - if(shot.firer == src || stat != CONSCIOUS) - return NONE - var/hit_chance = 100 - if(boss_moving()) - hit_chance *= (100 - BOUNTY_GHOST_DODGE) / 100 - if(boss_cloaked) - hit_chance *= (100 - BOUNTY_GHOST_CLOAK_MISS) / 100 - if(prob(hit_chance)) - return NONE - boss_miss_message(span_warning("[src] slips aside from [shot]!")) - return PROJECTILE_INTERRUPT_HIT_PHASE - -/// A dodge or miss message, at most every BOUNTY_BOSS_MESSAGE_COOLDOWN so automatic fire doesn't flood chat (L5) -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_miss_message(message) - if(!COOLDOWN_FINISHED(src, boss_message_cooldown)) - return - COOLDOWN_START(src, boss_message_cooldown, BOUNTY_BOSS_MESSAGE_COOLDOWN) - visible_message(message) - -/// Cloaked, half the blows find nothing -/mob/living/basic/bounty_criminal/boss/ghost/boss_check_block(atom/hit_by, attack_text, attack_type) - if(!boss_cloaked || stat != CONSCIOUS || !prob(BOUNTY_GHOST_CLOAK_MISS)) - return NONE - boss_miss_message(span_warning("[attack_text] swings through the shimmer where [src] was!")) - return SUCCESSFUL_BLOCK +/// Fires its Cascade lattice (the chrome's own action), if it is ready. Returns TRUE if it fired. +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_surge() + for(var/obj/item/organ/cyberimp/cyberware/cascade/lattice in organs) + for(var/datum/action/cooldown/cyberware/cascade_surge/surge in lattice.actions) + if(surge.IsAvailable()) + return surge.Trigger() + return FALSE -/mob/living/basic/bounty_criminal/boss/ghost/boss_took_damage(damage, damagetype) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_took_damage(damage, damagetype) if(boss_cloaked) boss_uncloak("A hit breaks the shimmer around [src].") -/mob/living/basic/bounty_criminal/boss/ghost/boss_flashed(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_flashed(mob/living/user) if(boss_cloaked) boss_uncloak("The flash burns away the shimmer around [src]!") -/mob/living/basic/bounty_criminal/boss/ghost/boss_on_interrupted() +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_on_interrupted() if(boss_cloaked) boss_uncloak() -/// Fades to a faint shimmer for BOUNTY_GHOST_CLOAK_TIME -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_cloak() +/// Fades to a faint shimmer for BOUNTY_GHOST_CLOAK_TIME: a look only, nothing misses it for it (CB-12) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_cloak() if(stat != CONSCIOUS) return FALSE boss_cloaked = TRUE @@ -914,8 +1395,8 @@ boss_cloak_timer = addtimer(CALLBACK(src, PROC_REF(boss_uncloak), "The shimmer around [src] fades."), BOUNTY_GHOST_CLOAK_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME) return TRUE -/// Plain to see again, with `message` if something broke the cloak -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_uncloak(message) +/// Plain to see again, with `message` if something broke the shimmer +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_uncloak(message) if(!boss_cloaked) return FALSE boss_cloaked = FALSE @@ -928,8 +1409,8 @@ return TRUE /** - * Dash: it crouches and a blurred line shows on the floor to its target, then it darts down it, - * leaving after-images, and cuts at the end: BOUNTY_GHOST_DASH_DAMAGE and a bleed. + * Dash: it crouches and a blurred line shows on the floor to its target, then it darts down it with + * its Cascade lattice firing, leaving after-images, and stabs at the end with the knife it holds. */ /datum/action/cooldown/mob_cooldown/bounty_boss/dash name = "Dash" @@ -949,7 +1430,8 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/dash/worth_using(atom/target) - if(!aimable(target)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner + if(!aimable(target) || !istype(boss) || isnull(boss.boss_knife())) return FALSE var/distance = get_dist(owner, target) return distance >= 2 && distance <= BOUNTY_GHOST_DASH_RANGE + 1 && bounty_boss_clear_line(get_turf(owner), get_turf(target)) @@ -970,11 +1452,13 @@ mark_turfs(path, "#9a9ab8") /datum/action/cooldown/mob_cooldown/bounty_boss/dash/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner if(!istype(boss)) return dash_target = aimable(target) ? WEAKREF(target) : null step_index = 0 + // The lattice fires as it goes (the OC-6 ruling): real chrome, with its own crash after. + boss.boss_surge() if(!length(path)) cut_at_end(target) return @@ -983,7 +1467,7 @@ dash_step(serial) /datum/action/cooldown/mob_cooldown/bounty_boss/dash/proc/dash_step(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !boss.boss_still("dash", serial)) end_dash(serial, FALSE) return @@ -1003,7 +1487,7 @@ addtimer(CALLBACK(src, PROC_REF(dash_step), serial), 1, TIMER_DELETE_ME) /datum/action/cooldown/mob_cooldown/bounty_boss/dash/proc/end_dash(serial, cut) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner path = null var/atom/victim = dash_target?.resolve() dash_target = null @@ -1012,30 +1496,25 @@ if(cut) cut_at_end(victim) -/// The cut at the end of the dash, if its target (a hunter, or a mech) is within reach +/** + * The cut at the end of the dash: a real knife attack on whoever it dashed at, if they are within + * reach, standing or not (CB-11). Returns TRUE if it swung. + */ /datum/action/cooldown/mob_cooldown/bounty_boss/dash/proc/cut_at_end(atom/victim) - var/mob/living/basic/bounty_criminal/boss/ghost/boss = owner - if(!istype(boss) || !boss.boss_can_act() || !aimable(victim) || !boss.Adjacent(victim) || !boss.boss_can_hurt(victim)) - return - boss.do_attack_animation(victim, ATTACK_EFFECT_SLASH) - playsound(victim, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) - victim.visible_message(span_danger("[boss] darts in and slashes [victim]!"), span_userdanger("[boss] darts in and slashes you!")) - if(ismecha(victim)) - boss.boss_hit(victim, BOUNTY_GHOST_DASH_DAMAGE, BRUTE, MELEE, 0, BOUNTY_GHOST_DASH_AP) - return - var/mob/living/person = victim - person.apply_damage(npc_zone_damage(BOUNTY_GHOST_DASH_DAMAGE, boss), BRUTE, BODY_ZONE_CHEST, person.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = BOUNTY_GHOST_DASH_AP, silent = TRUE), sharpness = SHARP_EDGED) - boss.boss_cut(person) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner + if(!istype(boss) || !boss.boss_can_act() || !aimable(victim) || !boss.Adjacent(victim) || !boss.boss_effect_hits(victim)) + return FALSE + return boss.boss_knife_attack(victim) -/// Cloak: the air around it shimmers, then it fades to a faint distortion that half of all hits miss +/// Cloak: the air around it shimmers, then it fades to a faint distortion. A look only (CB-12). /datum/action/cooldown/mob_cooldown/bounty_boss/cloak name = "Cloak" - desc = "Fade into a shimmer that is hard to hit." + desc = "Fade into a shimmer." cooldown_time = BOUNTY_GHOST_CLOAK_COOLDOWN windup = BOUNTY_GHOST_CLOAK_WINDUP /datum/action/cooldown/mob_cooldown/bounty_boss/cloak/worth_using(atom/target) - var/mob/living/basic/bounty_criminal/boss/ghost/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner return aimable(target) && istype(boss) && !boss.boss_cloaked /datum/action/cooldown/mob_cooldown/bounty_boss/cloak/telegraph(atom/target) @@ -1045,91 +1524,183 @@ animate(alpha = 255, time = windup / 2) /datum/action/cooldown/mob_cooldown/bounty_boss/cloak/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/ghost/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner if(istype(boss) && boss.boss_cloak()) boss.visible_message(span_warning("[boss] fades into a faint shimmer!")) -/** - * The Ghost's cuts: BOUNTY_GHOST_BLEED_PER_CUT brute a second for each cut, up to - * BOUNTY_GHOST_BLEED_CAP, for BOUNTY_GHOST_BLEED_TIME after the last one, with the blood to show it. - * It stops once they are down (C4, M3). - */ -/datum/status_effect/bounty_boss_bleed - id = "bounty_boss_bleed" - duration = BOUNTY_GHOST_BLEED_TIME - tick_interval = 1 SECONDS - status_type = STATUS_EFFECT_REFRESH - alert_type = null - /// Cuts bleeding - var/cuts = 1 - var/damage_factor = 1 - -/datum/status_effect/bounty_boss_bleed/on_creation(mob/living/new_owner, factor = 1) - damage_factor = factor - return ..() - -/datum/status_effect/bounty_boss_bleed/on_apply() - return owner.stat == CONSCIOUS - -/datum/status_effect/bounty_boss_bleed/refresh(mob/living/new_owner, factor = 1) - damage_factor = factor - . = ..() - cuts++ - -/datum/status_effect/bounty_boss_bleed/tick(seconds_between_ticks) - if(owner.stat != CONSCIOUS) - qdel(src) - return - var/rate = min(cuts * BOUNTY_GHOST_BLEED_PER_CUT, BOUNTY_GHOST_BLEED_CAP) - owner.apply_damage(npc_zone_damage(rate, damage_factor), BRUTE, spread_damage = TRUE) - owner.bleed(rate) - // ===== HEAVY ===== -/mob/living/basic/bounty_criminal/boss/heavy +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy name = "heavily armed criminal" - desc = "A big criminal in heavy armour, carrying a belt-fed gun." - maxHealth = BOUNTY_HEAVY_HEALTH_1 - health = BOUNTY_HEAVY_HEALTH_1 - mob_size = MOB_SIZE_LARGE - speed = BOUNTY_HEAVY_SPEED - damage_coeff = list(BRUTE = BOUNTY_HEAVY_BRUTE_MOD, BURN = BOUNTY_HEAVY_BURN_MOD, TOX = 1, STAMINA = BOUNTY_HEAVY_STAMINA_FRESH * 100 / BOUNTY_HEAVY_STAMINA, OXY = 1) - melee_attack_cooldown = BOUNTY_HEAVY_MELEE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_SMASH + desc = "A big criminal in an ablative coat, carrying a belt-fed gun." boss_kit = BOUNTY_KIT_HEAVY - boss_weapon = BOUNTY_HEAVY_GUN - boss_stamina_pool = BOUNTY_HEAVY_STAMINA - boss_stamina_fresh = BOUNTY_HEAVY_STAMINA_FRESH boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/burst, /datum/action/cooldown/mob_cooldown/bounty_boss/barricade, ) -/mob/living/basic/bounty_criminal/boss/heavy/boss_health_table() - return list(BOUNTY_HEAVY_HEALTH_1, BOUNTY_HEAVY_HEALTH_2, BOUNTY_HEAVY_HEALTH_3, BOUNTY_HEAVY_HEALTH_4) +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/heavy)) -/mob/living/basic/bounty_criminal/boss/heavy/boss_kit_outfit() - return /datum/outfit/bounty_boss/heavy - -/mob/living/basic/bounty_criminal/boss/heavy/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_gear_text() + if(istype(wear_suit, BOUNTY_HEAVY_COAT)) + return "[p_Their()] coat and hood are lined with reflective panels." return "[p_They()] [p_are()] in heavy armour." +// Out where the air kills: a magnate MODsuit (its coat and hood hold no pressure), with the coat's energy armour but no reflection +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/bounty_sealed_gear() + return list(/obj/item/mod/control/pre_equipped/bounty_boss/magnate) +// Its rounds chip through a window or grille it may break, and fly over a gap +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_goes_through(atom/barrier) + if(..()) + return TRUE + if(isturf(barrier)) + return !barrier.density + return (istype(barrier, /obj/structure/window) || istype(barrier, /obj/structure/grille)) && boss_may_break(barrier) + +// With its burst ready and a belt in its gun +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_breaks_through_now(atom/barrier) + if(..()) + return TRUE + var/obj/item/gun/gun = boss_gun() + if(!gun?.can_shoot() || isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/burst))) + return FALSE + if(isturf(barrier)) + return !barrier.density + return (istype(barrier, /obj/structure/window) || istype(barrier, /obj/structure/grille)) && boss_may_break(barrier) + +// The L6 has no sling and won't holster, so it is in hand from the start, its other hand free to fire it. +// A spare box rides in the bag; a barrier grenade in each pocket. The coat's hood goes up as it is put on +// (tg's hooded suits do that), so its own reflecting hood covers its head (C8). /datum/outfit/bounty_boss/heavy name = "Bounty mini-boss: Heavy" uniform = /obj/item/clothing/under/syndicate/combat - suit = /obj/item/clothing/suit/armor/swat - head = /obj/item/clothing/head/helmet/swat + suit = BOUNTY_HEAVY_COAT gloves = /obj/item/clothing/gloves/combat shoes = /obj/item/clothing/shoes/jackboots r_hand = BOUNTY_HEAVY_GUN + l_pocket = /obj/item/grenade/barrier/bounty_boss + r_pocket = /obj/item/grenade/barrier/bounty_boss + back = /obj/item/storage/backpack + backpack_contents = list(BOUNTY_HEAVY_SPARE_AMMO = 1) + +/// The belt-fed gun it holds, if it holds one +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/proc/boss_gun() + for(var/obj/item/gun/ballistic/automatic/l6_saw/gun in held_items) + return gun + return null + +/** + * Whether `gun` is the only thing in its hands. tg fires a heavy gun only with the other hand empty, + * whichever hand is active now: the gun may sit in either, and is made the active one to fire. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/proc/boss_hands_free_for(obj/item/gun/gun) + for(var/obj/item/held in held_items) + if(held != gun) + return FALSE + return !isnull(gun) && is_holding(gun) + +/// A barrier grenade it carries, not yet thrown +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/proc/boss_barrier() + for(var/obj/item/grenade/barrier/bounty_boss/barrier in get_all_contents_type(/obj/item/grenade/barrier/bounty_boss)) + if(!barrier.active && (is_holding(barrier) || human_npc_can_draw(src, barrier))) + return barrier + return null + +// Its gun's belt runs out: it changes it the way a player does (bounty_heavy_reload), if it has a spare. +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_gear_plan(datum/ai_controller/controller) + if(world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + return FALSE + var/obj/item/gun/ballistic/automatic/l6_saw/gun = boss_gun() + if(isnull(gun) || (gun.can_shoot() && !gun.cover_open)) + return FALSE + if(!gun.cover_open && isnull(human_npc_find_ammo(src, gun))) + return FALSE + controller.queue_behavior(/datum/ai_behavior/bounty_heavy_reload) + return TRUE + +// A hit while it aims spoils the burst (CB-13). +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_took_damage(damage, damagetype) + var/datum/action/cooldown/mob_cooldown/bounty_boss/burst/aiming = boss_winding() + if(!istype(aiming) || damage < BOUNTY_HEAVY_FUMBLE_DAMAGE) + return + visible_message(span_warning("[src] fumbles the shot.")) + boss_interrupt() + +/** + * Changes the belt in its L6 the way a player does, one step per action: open the feed cover, pull + * the empty box with its free hand and let it fall, take the spare from its bag, click the gun with it, + * close the cover. tg's own sounds and procs throughout. Gives up after a while and fights on. + */ +/datum/ai_behavior/bounty_heavy_reload + action_cooldown = 0.8 SECONDS + +/datum/ai_behavior/bounty_heavy_reload/setup(datum/ai_controller/controller) + . = ..() + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = controller.pawn + var/obj/item/gun/ballistic/automatic/l6_saw/gun = istype(heavy) ? heavy.boss_gun() : null + if(isnull(gun)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_BOSS_RELOAD_UNTIL, world.time + 12 SECONDS) + if(!gun.cover_open) + heavy.visible_message(span_notice("[heavy] flips open the feed cover on [gun].")) + +/datum/ai_behavior/bounty_heavy_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = controller.pawn + var/obj/item/gun/ballistic/automatic/l6_saw/gun = istype(heavy) ? heavy.boss_gun() : null + if(isnull(gun) || world.time >= controller.blackboard[BB_BOUNTY_BOSS_RELOAD_UNTIL] || HAS_TRAIT(heavy, TRAIT_HANDS_BLOCKED)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(heavy.next_move > world.time) + return AI_BEHAVIOR_DELAY + var/obj/item/spent = human_npc_spent_ammo_in_hand(heavy, gun) + if(spent) + heavy.dropItemToGround(spent, force = TRUE) + return AI_BEHAVIOR_DELAY + if(gun.magazine && gun.can_shoot()) + if(gun.cover_open) + gun.click_alt(heavy) + return AI_BEHAVIOR_DELAY + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!gun.cover_open) + gun.click_alt(heavy) + return AI_BEHAVIOR_DELAY + if(gun.magazine) + // The empty box comes out into its free hand. + if(!human_npc_ready_empty_hand(heavy)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + var/obj/item/ammo = human_npc_find_ammo(heavy, gun) + if(isnull(ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!heavy.is_holding(ammo)) + if(!human_npc_take_in_hand(heavy, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(heavy, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_heavy_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + controller.clear_blackboard_key(BB_BOUNTY_BOSS_RELOAD_UNTIL) + if(succeeded) + return + // A change that got nowhere: the cover goes back down and it fights on with what it has for a while. + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 10 SECONDS) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = controller.pawn + var/obj/item/gun/ballistic/automatic/l6_saw/gun = istype(heavy) ? heavy.boss_gun() : null + if(gun?.cover_open) + gun.click_alt(heavy) /** * Suppressive burst: the gun spins up with a whirr, a red laser sight settles on the target's tile - * and a red cone shows on the floor, staying there until the last round (L3). Then - * BOUNTY_HEAVY_BURST_SHOTS rounds go into that cone over BOUNTY_HEAVY_BURST_TIME, where it aimed, not - * where the target went, and no further than the cone is marked. The rounds damage only windows, - * grilles, tables and barricades the boss may break (interior, on its site), at - * BOUNTY_HEAVY_BURST_DEMOLITION of their damage (M5). + * and a red cone shows on the floor, staying there until the last round (L3). Then it pulls the + * trigger of the real gun it holds BOUNTY_HEAVY_BURST_SHOTS times over BOUNTY_HEAVY_BURST_TIME, at the + * far edge of the cone where it aimed, not where the target went (CB-13). Real rounds from its real + * belt: when the belt runs dry the burst stops and it changes belts. It holds its fire for a round + * with one of its own side in the way. A hit while it aims spoils the burst (boss_took_damage()). */ /datum/action/cooldown/mob_cooldown/bounty_boss/burst name = "Suppressive Burst" @@ -1148,7 +1719,12 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/burst/worth_using(atom/target) - if(!aimable(target)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner + if(!aimable(target) || !istype(boss)) + return FALSE + var/obj/item/gun/gun = boss.boss_gun() + // Its gun loaded, its other hand free to fire it. + if(isnull(gun) || !gun.can_shoot() || !boss.boss_hands_free_for(gun)) return FALSE var/distance = get_dist(owner, target) return distance >= 2 && distance <= BOUNTY_HEAVY_BURST_RANGE && can_see(owner, target, BOUNTY_HEAVY_BURST_RANGE) && landing_ok(target) @@ -1166,7 +1742,7 @@ mark_turfs(cone_turfs(target, BOUNTY_HEAVY_BURST_RANGE, BOUNTY_HEAVY_BURST_ARC + BOUNTY_HEAVY_BURST_MARK_MARGIN), "#ff2020", linger = BOUNTY_HEAVY_BURST_TIME) /datum/action/cooldown/mob_cooldown/bounty_boss/burst/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !aim_turf) return shots_left = BOUNTY_HEAVY_BURST_SHOTS @@ -1174,65 +1750,54 @@ var/serial = boss.boss_set_busy("burst", effect_time + 1 SECONDS) fire_round(serial) -/// One round of the burst, and the next after it +/// Where one round goes: a tile at the cone's far edge, somewhere within its arc +/datum/action/cooldown/mob_cooldown/bounty_boss/burst/proc/round_spot() + var/turf/origin = get_turf(owner) + if(!origin) + return null + var/angle = aim_angle + rand(-BOUNTY_HEAVY_BURST_ARC / 2, BOUNTY_HEAVY_BURST_ARC / 2) + return locate( + clamp(origin.x + round(BOUNTY_HEAVY_BURST_RANGE * sin(angle)), 1, world.maxx), + clamp(origin.y + round(BOUNTY_HEAVY_BURST_RANGE * cos(angle)), 1, world.maxy), + origin.z, + ) + +/// One pull of the trigger, and the next after it /datum/action/cooldown/mob_cooldown/bounty_boss/burst/proc/fire_round(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner if(!istype(boss) || !boss.boss_still("burst", serial) || shots_left <= 0 || !aim_turf) end_burst(serial) return + var/obj/item/gun/gun = boss.boss_gun() + if(isnull(gun) || !gun.can_shoot()) + // Out of belt: it changes it on its next plan. + end_burst(serial) + return shots_left-- - var/turf/origin = get_turf(boss) - var/obj/projectile/bullet/bounty_boss_heavy/bullet = new(origin) - // Its gun's own rounds, no further than the marked cone - bounty_real_weapon(BOUNTY_HEAVY_GUN).load(bullet) - bullet.range = BOUNTY_HEAVY_BURST_RANGE - bullet.maximum_range = BOUNTY_HEAVY_BURST_RANGE - bullet.firer = boss - bullet.fired_from = boss - bullet.ignored_factions = boss.faction.Copy() - bullet.aim_projectile(aim_turf, boss) - if(!QDELETED(bullet)) - bullet.fire(aim_angle + rand(-BOUNTY_HEAVY_BURST_ARC / 2, BOUNTY_HEAVY_BURST_ARC / 2)) - playsound(boss, 'sound/items/weapons/gun/l6/shot.ogg', 60, TRUE) + var/turf/spot = round_spot() + if(spot && !human_npc_ally_in_line(boss, spot) && boss.boss_hands_free_for(gun) && human_npc_ready_item(boss, gun)) + boss.ai_controller?.ai_interact(target = spot, combat_mode = TRUE) if(shots_left <= 0) end_burst(serial) return - addtimer(CALLBACK(src, PROC_REF(fire_round), serial), max(1, round(BOUNTY_HEAVY_BURST_TIME / BOUNTY_HEAVY_BURST_SHOTS)), TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(fire_round), serial), max(2, round(BOUNTY_HEAVY_BURST_TIME / BOUNTY_HEAVY_BURST_SHOTS)), TIMER_DELETE_ME) /datum/action/cooldown/mob_cooldown/bounty_boss/burst/proc/end_burst(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner shots_left = 0 aim_turf = null if(istype(boss) && boss.boss_busy == "burst" && boss.boss_busy_serial == serial) boss.boss_clear_busy(serial) /** - * The Heavy's rounds: its belt-fed gun's own (BOUNTY_HEAVY_GUN, loaded as they are fired), no - * further than the marked cone, and hard on structures only where the boss may break them - */ -/obj/projectile/bullet/bounty_boss_heavy - name = "heavy round" - range = BOUNTY_HEAVY_BURST_RANGE - demolition_mod = 0 - -// Judged at what it hits, not where it was fired from (M5): never a window onto space, never off the site. -// A mech is a target, not a structure: it takes the round in full. -/obj/projectile/bullet/bounty_boss_heavy/get_demolition_modifier(obj/target) - if(ismecha(target)) - return 1 - var/mob/living/basic/bounty_criminal/boss/boss = firer - if(!istype(boss) || QDELETED(boss) || !istype(target) || istype(target, /obj/machinery/door)) - return 0 - return boss.boss_may_break(target) ? BOUNTY_HEAVY_BURST_DEMOLITION : 0 - -/** - * Barricade: it unslings a folding barrier and drops it on the tile in front of it, toward its - * target: tg's security barrier (/obj/structure/barricade/security/bounty_boss). It keeps - * BOUNTY_BOSS_BARRICADES_PER_BOSS standing; each folds away after BOUNTY_BOSS_BARRICADE_LIFE. + * Barricade: it unslings a folded barrier, arms a real barrier grenade it carries and tosses it onto + * the tile in front of it, toward its target (CB-26). The grenade opens into the Heavy's folding barrier + * (/obj/structure/barricade/security/bounty_boss). It keeps BOUNTY_BOSS_BARRICADES_PER_BOSS standing; + * each folds away after BOUNTY_BOSS_BARRICADE_LIFE. Two grenades, two barriers: then it has none left. */ /datum/action/cooldown/mob_cooldown/bounty_boss/barricade name = "Deploy Barricade" - desc = "Drop a folding barrier in front of you." + desc = "Toss a barrier grenade in front of you." cooldown_time = BOUNTY_HEAVY_BARRICADE_COOLDOWN windup = BOUNTY_HEAVY_BARRICADE_WINDUP /// Where it will stand, from the telegraph @@ -1246,14 +1811,17 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/barricade/worth_using(atom/target) - if(!aimable(target) || length(GLOB.bounty_boss_barricades) >= BOUNTY_BOSS_BARRICADE_CAP) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner + if(!aimable(target) || !istype(boss) || length(GLOB.bounty_boss_barricades) >= BOUNTY_BOSS_BARRICADE_CAP) + return FALSE + if(isnull(boss.boss_barrier()) || !length(boss.get_empty_held_indexes())) return FALSE var/distance = get_dist(owner, target) return distance >= 3 && distance <= 9 && !isnull(find_spot(target)) /// The open tile in front of it toward `target`, on its site, with no barrier on it /datum/action/cooldown/mob_cooldown/bounty_boss/barricade/proc/find_spot(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss)) return null var/turf/spot = get_step(boss, get_dir(boss, target)) @@ -1273,16 +1841,26 @@ /datum/action/cooldown/mob_cooldown/bounty_boss/barricade/effect(atom/target) var/turf/spot = barricade_spot barricade_spot = null - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner if(!istype(boss) || !spot || spot.is_blocked_turf() || (locate(/obj/structure/barricade) in spot) || !boss.boss_environment_allowed(spot)) return if(length(GLOB.bounty_boss_barricades) >= BOUNTY_BOSS_BARRICADE_CAP) return - var/obj/structure/barricade/security/bounty_boss/barrier = new(spot) + var/obj/item/grenade/barrier/bounty_boss/grenade = boss.boss_barrier() + if(isnull(grenade) || !human_npc_take_in_hand(boss, grenade) || !human_npc_ready_item(boss, grenade)) + return + grenade.ability_ref = WEAKREF(src) + // The player's way: pull the pin in hand, then throw it. + grenade.attack_self(boss) + boss.throw_mode_on(THROW_MODE_TOGGLE) + if(!boss.throw_item(spot)) + boss.throw_mode_off(THROW_MODE_TOGGLE) + boss.dropItemToGround(grenade) + +/// One of its barriers went up: it keeps count of it +/datum/action/cooldown/mob_cooldown/bounty_boss/barricade/proc/note_barrier(obj/structure/barricade/security/bounty_boss/barrier) if(QDELETED(barrier)) return - boss.visible_message(span_warning("[boss] slams a barrier down in front of [boss.p_them()].")) - playsound(spot, 'sound/effects/bang.ogg', 50, TRUE) barriers += WEAKREF(barrier) prune_barriers() @@ -1297,6 +1875,39 @@ var/obj/structure/barricade/security/bounty_boss/barrier = oldest.resolve() barrier?.fold_away() +/** + * The Heavy's barrier grenades: tg's barrier grenade on a short fuse, that opens into its folding + * barrier instead of a standing one. Issued as a consumable (it may leave the Heavy's body, thrown, but + * in anyone else's hands it is gone). + */ +/obj/item/grenade/barrier/bounty_boss + det_time = BOUNTY_HEAVY_BARRIER_FUSE + /// The Heavy's barricade ability that threw it, to count the barrier it makes + var/datum/weakref/ability_ref + +/obj/item/grenade/barrier/bounty_boss/Destroy() + ability_ref = null + return ..() + +// tg's barrier grenade, but what it makes is the folding barrier (and only ever the one). +/obj/item/grenade/barrier/bounty_boss/detonate(mob/living/lanced_by) + if(dud_flags) + active = FALSE + update_appearance() + return FALSE + dud_flags |= GRENADE_USED + SEND_SIGNAL(src, COMSIG_GRENADE_DETONATE, lanced_by) + var/turf/spot = get_turf(src) + if(spot && !(locate(/obj/structure/barricade) in spot)) + var/obj/structure/barricade/security/bounty_boss/barrier = new(spot) + if(!QDELETED(barrier)) + spot.visible_message(span_warning("[src] snaps open into a barrier.")) + playsound(spot, 'sound/effects/bang.ogg', 50, TRUE) + var/datum/action/cooldown/mob_cooldown/bounty_boss/barricade/ability = ability_ref?.resolve() + ability?.note_barrier(barrier) + qdel(src) + return TRUE + /// The Heavy's folding barrier: tg's security barrier that folds itself away after BOUNTY_BOSS_BARRICADE_LIFE. Drops nothing. /obj/structure/barricade/security/bounty_boss name = "folding barrier" diff --git a/voidcrew/modules/bounties/bounty_chrome.dm b/voidcrew/modules/bounties/bounty_chrome.dm new file mode 100644 index 00000000000..faf8dc603f3 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_chrome.dm @@ -0,0 +1,1450 @@ +/** + * # Bounty hunting: chrome + * + * Owner: C0 chrome core. The pieces (C1-C4, voidcrew/modules/bounties/chrome/) and the cyberpsycho (C6) + * build on this file; the seams are listed in the port's SEAMS notes and in the doc comments below. + * + * Some criminals and their companions carry cyberware: the real implants players buy at a ripperdoc + * (voidcrew/modules/cyberware/), installed in their real body through the human NPC base's + * npc_cyberware (owner decision 5). Nothing here stands in for an implant. A Gorilla Arms carrier + * punches with the implant's own empowered punch, a Lazarus node jolts its bearer up by itself, an EMP + * reboots the chrome on its real timer, a brownout browns it out, and it all burns out to scrap when the + * NPC dies (the human NPC base, owner ruling). + * + * What this file adds, all of it AI or looks: + * - Who carries what (/datum/bounty_chrome_piece, GLOB.bounty_chrome_pieces): a roll when the record is + * made, by tier (bounty_chrome_roll()), and a rarer one for companions (bounty_chrome_roll_companion()). + * A roll never puts two implants in one organ slot and never goes over the body's capacity, kit chrome + * included, so nothing browns out. + * - The body (bounty_chrome_prepare(), bounty_chrome_equip()): the record's pieces become npc_cyberware + * before the base installs it, and carriers dress light enough that their worn chrome shows + * (bounty_chrome_expose_outfit()). A pressure suit or modsuit stays shut over it. + * - How the AI uses it (/datum/bounty_chrome_move, /datum/component/bounty_chrome): a tell everyone can + * see and hear, at least BOUNTY_CHROME_TELL_MIN long, then the implant's own action, Trigger()ed the + * way a player's click does. Downed, stunned, cuffed, killed or EMP'd during the tell, nothing fires. + * An area move never starts while someone outside the fight stands where it would land + * (bounty_chrome_area_clear()). Mini-bosses use their moves through their own ability rotation + * (/datum/action/cooldown/mob_cooldown/bounty_boss/chrome). + * - An EMP that knocks chrome out shows it: sparks, arcs and a flicker, as a picture only + * (bounty_chrome_malfunction_fx()). + * - A bounty NPC feels a chrome read and tells the reader off (COMSIG_CARBON_CHROME_READ). + */ + +// ===== THE PIECES ===== + +/// Every piece by key (BOUNTY_CHROME_*): built from the subtypes, so a piece needs no registry edit +GLOBAL_LIST_INIT(bounty_chrome_pieces, init_bounty_chrome_pieces()) + +/proc/init_bounty_chrome_pieces() + var/list/pieces = list() + for(var/datum/bounty_chrome_piece/piece_type as anything in subtypesof(/datum/bounty_chrome_piece)) + if(!initial(piece_type.key)) + continue + var/datum/bounty_chrome_piece/piece = new piece_type + pieces[piece.key] = piece + // The moves each piece drives: every /datum/bounty_chrome_move subtype names its piece by key + for(var/datum/bounty_chrome_move/move_type as anything in subtypesof(/datum/bounty_chrome_move)) + var/datum/bounty_chrome_piece/piece = pieces[initial(move_type.piece_key)] + piece?.move_types += move_type + return pieces + +/** + * The chrome a mini-boss kit (BOUNTY_KIT_*) already carries in its body, as organ typepaths: what a roll + * must fit around. It has to match the kit's own npc_cyberware (bounty_boss_kits.dm); a test spawns + * each kit and checks. + */ +/proc/bounty_chrome_kit_organs(kit) + switch(kit) + if(BOUNTY_KIT_GHOST) + return list(/obj/item/organ/cyberimp/cyberware/slipwire, /obj/item/organ/cyberimp/cyberware/cascade) + if(BOUNTY_KIT_CYBERPSYCHO) + return bounty_psycho_organs() + return list() + +/** + * One piece of chrome a criminal can carry: which real implants it is, and who can roll it. One + * subtype per key, below. The moves that drive it in a fight are /datum/bounty_chrome_move subtypes that + * name it by key (C1-C4), collected into move_types at start. + */ +/datum/bounty_chrome_piece + /// BOUNTY_CHROME_*; the subtype with no key is abstract + var/key + /// What admins see it called + var/name = "chrome" + /// Roll weight among the pieces that suit. 0 never rolls and isn't offered to admins. + var/weight = 10 + /// Lowest tier that rolls it (BOUNTY_TIER_*); a companion is checked against its criminal's tier + var/min_tier = BOUNTY_TIER_PETTY + /// The criminal archetypes that roll it (BOUNTY_ARCHETYPE_*). Never the meek: they run. + var/list/archetypes = list(BOUNTY_ARCHETYPE_NORMAL, BOUNTY_ARCHETYPE_BOSS) + /// Whether a companion can roll it + var/companion = TRUE + /// The fighting styles (BOUNTY_STYLE_*) that roll it, or null for any. Mini-bosses have no style. + var/list/styles + /// Only for styles that shoot (bounty_chrome_style_shoots()) + var/shooter = FALSE + /// The mini-boss kits (BOUNTY_KIT_*) that roll it, or null for any kit + var/list/kits + /// Keys it never rolls alongside (a design choice; organ slots are checked on their own) + var/list/exclusive_with + /// The real implant organs it is, as typepaths + var/list/organs + /// The /datum/bounty_chrome_move types that drive it, filled at start + var/list/move_types = list() + +/// Whether a new `archetype` fighter of `tier`, with `style` or `kit` and the keys `picked` so far, may roll it +/datum/bounty_chrome_piece/proc/can_roll(tier, archetype, style, kit, list/picked) + if(weight <= 0 || !tier || tier < min_tier) + return FALSE + if(archetype == BOUNTY_CHROME_COMPANION) + if(!companion) + return FALSE + else if(!(archetype in archetypes)) + return FALSE + if(archetype == BOUNTY_ARCHETYPE_BOSS) + if(kits && !(kit in kits)) + return FALSE + else + if(styles && !(style in styles)) + return FALSE + if(shooter && !bounty_chrome_style_shoots(style)) + return FALSE + for(var/other in picked) + if(other in exclusive_with) + return FALSE + var/datum/bounty_chrome_piece/other_piece = GLOB.bounty_chrome_pieces[other] + if(key in other_piece?.exclusive_with) + return FALSE + return TRUE + +/// A melee style this piece fights with when it has to change someone's style (an admin's pick), or null if any style will do +/datum/bounty_chrome_piece/proc/fitting_style(style) + if(!styles || (style in styles)) + return null + return styles[1] + +/// Whether fighting style `style` (BOUNTY_STYLE_*) shoots a gun +/proc/bounty_chrome_style_shoots(style) + var/datum/bounty_style/fighting_style = GLOB.bounty_styles[style] + return !!fighting_style?.ranged && ispath(fighting_style.weapon_type, /obj/item/gun) + +/datum/bounty_chrome_piece/cascade + key = BOUNTY_CHROME_CASCADE + name = "Cascade lattice" + // A legend piece: rare, and only on the dangerous + weight = 6 + min_tier = BOUNTY_TIER_WANTED + // The Ghost carries its own; the Heavy's whole fight is being slow and flanked + kits = list(BOUNTY_KIT_JUGGERNAUT, BOUNTY_KIT_PYROMANIAC, BOUNTY_KIT_DEMOLITIONIST) + organs = list(/obj/item/organ/cyberimp/cyberware/cascade) + +/datum/bounty_chrome_piece/redline + key = BOUNTY_CHROME_REDLINE + name = "Redline core" + weight = 6 + min_tier = BOUNTY_TIER_WANTED + // Not on the Juggernaut's riot plates or the Heavy's armour + kits = list(BOUNTY_KIT_PYROMANIAC, BOUNTY_KIT_DEMOLITIONIST, BOUNTY_KIT_GHOST) + organs = list(/obj/item/organ/cyberimp/cyberware/redline) + +/datum/bounty_chrome_piece/piledriver + key = BOUNTY_CHROME_PILEDRIVER + name = "Meteor Piledriver" + // The rarest: Wanted or worse, never a companion + weight = 4 + min_tier = BOUNTY_TIER_WANTED + companion = FALSE + // Of the mini-bosses only the Pyromaniac: the others already charge, dash or fight from range + kits = list(BOUNTY_KIT_PYROMANIAC) + // Two gap-closers that knock people down would be one too many + exclusive_with = list(BOUNTY_CHROME_MANTIS) + organs = list(/obj/item/organ/cyberimp/cyberware/piledriver) + +/datum/bounty_chrome_piece/gorilla + key = BOUNTY_CHROME_GORILLA + name = "Gorilla Arms" + weight = 12 + // The implant powers a bare-handed punch: brawlers only. Never a mini-boss: the Juggernaut's shield fills a hand, so its grab never fires + styles = list(BOUNTY_STYLE_BRAWLER) + archetypes = list(BOUNTY_ARCHETYPE_NORMAL) + organs = list( + /obj/item/organ/cyberimp/cyberware/gorilla_arms, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + ) + +/datum/bounty_chrome_piece/mantis + key = BOUNTY_CHROME_MANTIS + name = "Mantis Blades" + weight = 10 + // Fighters who close in. Never a mini-boss: the Juggernaut's charge and the Ghost's dash already close the gap. + archetypes = list(BOUNTY_ARCHETYPE_NORMAL) + styles = list(BOUNTY_STYLE_BRAWLER, BOUNTY_STYLE_KNIFE, BOUNTY_STYLE_CLUB) + exclusive_with = list(BOUNTY_CHROME_PILEDRIVER) + organs = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left, + ) + +/datum/bounty_chrome_piece/lazarus + key = BOUNTY_CHROME_LAZARUS + name = "Lazarus node" + // A second round in one fight is a lot. Never a companion: putting one down has to stay the answer to every companion. + weight = 8 + min_tier = BOUNTY_TIER_WANTED + companion = FALSE + organs = list(/obj/item/organ/cyberimp/cyberware/lazarus) + +/datum/bounty_chrome_piece/deadeye + key = BOUNTY_CHROME_DEADEYE + name = "Deadeye link" + weight = 12 + // It walks rounds onto a mark: only for those who shoot + archetypes = list(BOUNTY_ARCHETYPE_NORMAL) + shooter = TRUE + organs = list(/obj/item/organ/eyes/robotic/cyberware/deadeye) + +// ===== WHAT AN IMPLANT TAKES ===== + +/// Organ type -> list("slot", "zone", "load", "bonus", "shows"), read off the type once +GLOBAL_LIST_EMPTY(bounty_chrome_organ_facts) + +/** + * What an organ type takes in a body, read off the type (no copy is made, so it works at any time): + * its organ slot and zone, the neural load and capacity bonus it brings (cyberware_component.dm), and + * whether it shows on the body as worn chrome (an aug_overlay). + */ +/proc/bounty_chrome_organ_facts(organ_type) + var/list/facts = GLOB.bounty_chrome_organ_facts[organ_type] + if(facts) + return facts + if(!ispath(organ_type, /obj/item/organ)) + return null + var/obj/item/organ/organ = organ_type + var/load = 0 + var/bonus = 0 + var/shows = FALSE + if(ispath(organ_type, /obj/item/organ/cyberimp/cyberware)) + var/obj/item/organ/cyberimp/cyberware/ware = organ_type + load = initial(ware.chrome_load) + bonus = initial(ware.chrome_capacity_bonus) + else if(ispath(organ_type, /obj/item/organ/cyberimp/arm/toolkit/cyberware)) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = organ_type + load = initial(arm_ware.chrome_load) + bonus = initial(arm_ware.chrome_capacity_bonus) + else if(ispath(organ_type, /obj/item/organ/eyes/robotic/cyberware)) + var/obj/item/organ/eyes/robotic/cyberware/optics = organ_type + load = initial(optics.chrome_load) + bonus = initial(optics.chrome_capacity_bonus) + else if(ispath(organ_type, /obj/item/organ/cyberimp) || (initial(organ.organ_flags) & ORGAN_ROBOTIC)) + // Robotic hardware that isn't chrome costs the body's flat rate (get_installed_capacity_cost()) + load = GLOB.installed_capacity_costs["robotic_organ"] + if(ispath(organ_type, /obj/item/organ/cyberimp)) + var/obj/item/organ/cyberimp/implant = organ_type + shows = !!initial(implant.aug_overlay) + facts = list("slot" = initial(organ.slot), "zone" = initial(organ.zone), "load" = load, "bonus" = bonus, "shows" = shows) + GLOB.bounty_chrome_organ_facts[organ_type] = facts + return facts + +/// Whether the organ types `organ_types` can all be in one body at once: one per organ slot, and no more load than the body's capacity (CYBERWARE_BASE_CAPACITY plus any Governor), so nothing browns out +/proc/bounty_chrome_organs_fit(list/organ_types) + var/list/slots = list() + var/load = 0 + var/capacity = CYBERWARE_BASE_CAPACITY + for(var/organ_type in organ_types) + var/list/facts = bounty_chrome_organ_facts(organ_type) + if(!facts) + return FALSE + var/slot = facts["slot"] + if(slot) + if(slots[slot]) + return FALSE + slots[slot] = TRUE + load += facts["load"] + capacity += facts["bonus"] + return load <= capacity + +/// The organ typepaths of the pieces `keys`, in order +/proc/bounty_chrome_organ_types(list/keys) + . = list() + for(var/key in keys) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + if(piece) + . += piece.organs + +/** + * The keys of `keys` that go in together on top of `existing_organs` (typepaths already bound for the + * body: a kit's own chrome), in order: a piece that would share an organ slot or take the body over its + * capacity is left out, and so is an unknown key or one listed twice. + */ +/proc/bounty_chrome_fit_keys(list/keys, list/existing_organs) + . = list() + var/list/organs = existing_organs ? existing_organs.Copy() : list() + for(var/key in keys) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + if(!piece || (key in .)) + continue + if(!bounty_chrome_organs_fit(organs + piece.organs)) + continue + organs += piece.organs + . += key + +// ===== ROLLING ===== + +/// Percent chance a criminal of `tier` carries chrome +/proc/bounty_chrome_tier_chance(tier) + switch(tier) + if(BOUNTY_TIER_MOST_WANTED) + return BOUNTY_CHROME_CHANCE_MOST_WANTED + if(BOUNTY_TIER_WANTED) + return BOUNTY_CHROME_CHANCE_WANTED + return BOUNTY_CHROME_CHANCE_PETTY + +/** + * The chrome keys a new criminal carries, or null for none: a roll by tier, then one piece (two, now and + * then, from Wanted up), weighted among the pieces that suit its archetype, style and kit and that fit in + * its body on top of `existing_organs` (its kit's own chrome, bounty_chrome_kit_organs()). The meek + * never carry any. Doesn't sleep. + */ +/proc/bounty_chrome_roll(tier, archetype, style, kit, list/existing_organs) + if(archetype != BOUNTY_ARCHETYPE_NORMAL && archetype != BOUNTY_ARCHETYPE_BOSS) + return null + if(!prob(bounty_chrome_tier_chance(tier))) + return null + var/count = (tier >= BOUNTY_TIER_WANTED && prob(BOUNTY_CHROME_SECOND_CHANCE)) ? 2 : 1 + return bounty_chrome_pick(count, tier, archetype, style, kit, existing_organs) + +/** + * The chrome a new companion of a `tier` criminal carries as a list of one key, or null: rarer than its + * criminal's, one piece at most, never Lazarus or the Piledriver. `style` is the companion's own. + * Companions call this as they are dressed (C4); see bounty_chrome_prepare_body(). + */ +/proc/bounty_chrome_roll_companion(tier, style) + if(!prob(bounty_chrome_tier_chance(tier) / BOUNTY_CHROME_COMPANION_DIVISOR)) + return null + return bounty_chrome_pick(1, tier, BOUNTY_CHROME_COMPANION, style, null, null) + +/// Up to `count` different keys, weighted, among the pieces that suit and fit together on top of `existing_organs`. Null if none do. +/proc/bounty_chrome_pick(count, tier, archetype, style, kit, list/existing_organs) + var/list/picked = list() + var/list/organs = existing_organs ? existing_organs.Copy() : list() + for(var/i in 1 to count) + var/list/weights = list() + for(var/key in GLOB.bounty_chrome_pieces) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + if((key in picked) || !piece.can_roll(tier, archetype, style, kit, picked)) + continue + if(!bounty_chrome_organs_fit(organs + piece.organs)) + continue + weights[key] = piece.weight + if(!length(weights)) + break + var/key = pick_weight(weights) + picked += key + var/datum/bounty_chrome_piece/chosen = GLOB.bounty_chrome_pieces[key] + organs += chosen.organs + return length(picked) ? picked : null + +/** + * The most pieces that fit in one body on top of `existing_organs`, whatever they would normally roll + * for, for the Bounty Panel's "All of it": the largest set, then the one with the most chrome in it. The + * cyberpsycho (C6) replaces this choice with its own kit. + */ +/proc/bounty_chrome_all_that_fits(list/existing_organs) + var/list/keys = list() + for(var/key in GLOB.bounty_chrome_pieces) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + if(piece.weight > 0) + keys += key + var/list/best = list() + var/best_load = -1 + var/combinations = 1 << length(keys) + for(var/mask in 1 to combinations - 1) + var/list/chosen = list() + for(var/i in 1 to length(keys)) + if(mask & (1 << (i - 1))) + chosen += keys[i] + if(length(chosen) < length(best)) + continue + var/list/organs = (existing_organs ? existing_organs.Copy() : list()) + bounty_chrome_organ_types(chosen) + if(!bounty_chrome_organs_fit(organs)) + continue + var/load = 0 + for(var/organ_type in bounty_chrome_organ_types(chosen)) + var/list/facts = bounty_chrome_organ_facts(organ_type) + load += facts["load"] + if(length(chosen) > length(best) || load > best_load) + best = chosen + best_load = load + return best + +/** + * Puts a normal criminal's record on a style its chrome fights with, for chrome it was given rather + * than rolled (an admin's pick): Gorilla Arms and Mantis Blades never come with a gun or a bottle. Rolled + * chrome already suits its style. Returns TRUE if the style changed. + */ +/proc/bounty_chrome_fit_style(datum/bounty_record/record) + if(!record || record.archetype != BOUNTY_ARCHETYPE_NORMAL) + return FALSE + for(var/key in record.chrome) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + var/new_style = piece?.fitting_style(record.style) + if(new_style) + record.style = new_style + return TRUE + return FALSE + +// ===== THE BODY ===== + +/// Whether `npc` is a bounty human whose record's chrome goes in its body: a normal criminal or a mini-boss. Never the meek, a decoy (it copies its fugitive's organs, body_match()) or the kingpin. +/proc/bounty_chrome_carrier(mob/living/carbon/human/npc/npc) + return istype(npc, /mob/living/carbon/human/npc/bounty_criminal/normal) || istype(npc, /mob/living/carbon/human/npc/bounty_criminal/boss) + +/** + * Before `npc`'s body is built: the organs of the pieces `keys` that fit on top of what it already + * carries go into its npc_cyberware, which the human NPC base installs once it is dressed (so its outfit + * can be cut to show them, bounty_chrome_expose_outfit()). Returns the keys that went in. + */ +/proc/bounty_chrome_prepare_body(mob/living/carbon/human/npc/npc, list/keys) + if(!ishumannpc(npc) || !length(keys)) + return list() + var/list/existing = npc.npc_cyberware ? npc.npc_cyberware.Copy() : list() + var/list/fitted = bounty_chrome_fit_keys(keys, existing) + if(length(fitted)) + npc.npc_cyberware = existing + bounty_chrome_organ_types(fitted) + return fitted + +/** + * A criminal's Initialize(), before the body is built: its record's chrome becomes npc_cyberware. What + * doesn't fit (an admin's pick that clashes with a kit's chrome) comes off the record, so the record + * stays the truth about the criminal. + */ +/proc/bounty_chrome_prepare(mob/living/carbon/human/npc/bounty_criminal/criminal) + if(!bounty_chrome_carrier(criminal) || !length(criminal.record?.chrome)) + return + var/list/fitted = bounty_chrome_prepare_body(criminal, criminal.record.chrome) + criminal.record.chrome = length(fitted) ? fitted : null + +/// A criminal's Initialize(), once its body is built: its chrome is watched for EMPs, and a carrier gets its moves +/proc/bounty_chrome_ready(mob/living/carbon/human/npc/bounty_criminal/criminal) + // Every criminal, decoys included: a decoy carries its fugitive's chrome and must spark the same way + criminal.AddElement(/datum/element/bounty_chrome_body) + if(bounty_chrome_carrier(criminal) && length(criminal.record?.chrome)) + bounty_chrome_equip(criminal, criminal.record.chrome) + +/** + * Once the chrome `keys` are in `npc`'s body: the AI side of it (the component and the moves of each + * piece), the EMP look, and for a mini-boss one ability in its rotation per move. Adds to chrome it + * has. Returns the component, or null for no keys. + */ +/proc/bounty_chrome_equip(mob/living/carbon/human/npc/npc, list/keys) + if(!ishumannpc(npc) || QDELETED(npc) || !length(keys)) + return null + npc.AddElement(/datum/element/bounty_chrome_body) + return npc.AddComponent(/datum/component/bounty_chrome, keys) + +/** + * Installs the pieces `keys` in `npc`'s live body now, real organs through the base's + * npc_install_cyberware(), as many as fit with what it already has in it, then equips them. For tests and + * admins with a body already built; a spawn goes through bounty_chrome_prepare_body(). Returns the keys + * that went in. + */ +/proc/bounty_chrome_install_now(mob/living/carbon/human/npc/npc, list/keys) + . = list() + if(!ishumannpc(npc) || QDELETED(npc)) + return + var/list/installed = list() + for(var/obj/item/organ/organ in npc.organs) + if(organ.GetComponent(/datum/component/cyberware)) + installed += organ.type + for(var/key in bounty_chrome_fit_keys(keys, installed)) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + for(var/organ_type in piece.organs) + npc.npc_install_cyberware(organ_type) + . += key + if(length(.)) + bounty_chrome_equip(npc, .) + +// A criminal or companion built with chrome that takes the place of an organ it was born with (a Deadeye +// link its eyes): the old organ goes, as tg's own regenerate_organs() does it, instead of the base's Insert() +// dropping it at its feet, a pair of loose eyes on the floor. Decoys copying their fugitive's chrome +// (body_match()) come through here too. +/mob/living/carbon/human/npc/bounty_criminal/npc_install_cyberware(ware_type) + bounty_chrome_clear_natural_slot(src, ware_type) + return ..() + +/mob/living/carbon/human/npc/bounty_companion/npc_install_cyberware(ware_type) + bounty_chrome_clear_natural_slot(src, ware_type) + return ..() + +/// Takes out and deletes the organ (never chrome) in the slot chrome of `ware_type` is about to fill in `npc` +/proc/bounty_chrome_clear_natural_slot(mob/living/carbon/human/npc/npc, ware_type) + var/list/facts = bounty_chrome_organ_facts(ware_type) + var/slot = facts?["slot"] + if(!slot) + return + var/obj/item/organ/born_with = npc.get_organ_slot(slot) + if(!born_with || born_with.GetComponent(/datum/component/cyberware)) + return + born_with.Remove(npc, special = TRUE) + qdel(born_with) + +// ===== DRESSED TO SHOW IT ===== + +/// Where the worn chrome in `organ_types` shows on a body: list("arms", "chest", "legs") of TRUE/FALSE, or null if it shows nowhere +/proc/bounty_chrome_shows(list/organ_types) + var/arms = FALSE + var/chest = FALSE + var/legs = FALSE + for(var/organ_type in organ_types) + var/list/facts = bounty_chrome_organ_facts(organ_type) + if(!facts?["shows"]) + continue + switch(facts["zone"]) + if(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM) + arms = TRUE + if(BODY_ZONE_CHEST) + chest = TRUE + if(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG) + legs = TRUE + if(!arms && !chest && !legs) + return null + return list("arms" = arms, "chest" = chest, "legs" = legs) + +/// Whether its clothes are cut to show the chrome it carries (bounty_chrome_expose_outfit(), from the bounty outfit table): yes, but for a mini-boss, whose kit is its look. The cyberpsycho (C6) may say yes. +/mob/living/carbon/human/npc/proc/bounty_dresses_to_show_chrome() + return !istype(src, /mob/living/carbon/human/npc/bounty_criminal/boss) + +/** + * `outfit` (a typepath or an instance) cut so the worn chrome in `organ_types` shows, as an instance + * (an instance passed in is changed in place). Worn chrome draws over the jumpsuit and under everything + * else (CYBERWARE_WORN_LAYER), so the jumpsuit stays and only what covers it goes: no coat over chest or + * arm chrome, no gloves over arm chrome. Armour stays on: a vest is the tier's toughness. A pressure suit + * or a modsuit stays shut, and so does everything under it. Chrome that doesn't show changes nothing. The + * site's layers (a space suit, cold-weather wear) go on afterwards, over it, as they would on anyone. + */ +/proc/bounty_chrome_expose_outfit(outfit, list/organ_types) + var/list/shows = bounty_chrome_shows(organ_types) + if(!shows) + return outfit + var/datum/outfit/dressed = ispath(outfit, /datum/outfit) ? new outfit() : outfit + if(!istype(dressed)) + return outfit + var/obj/item/clothing/suit = dressed.suit + if((ispath(suit, /obj/item/clothing) && (initial(suit.clothing_flags) & STOPSPRESSUREDAMAGE)) || ispath(dressed.back, /obj/item/mod/control)) + return dressed + var/arms = shows["arms"] + var/chest = shows["chest"] + if((arms || chest) && dressed.suit && !ispath(dressed.suit, /obj/item/clothing/suit/armor)) + // What the coat carried goes in the bag; with no bag the coat stays, it holds their gun + if(dressed.suit_store && dressed.back) + LAZYADDASSOC(dressed.backpack_contents, dressed.suit_store, 1) + dressed.suit_store = null + if(!dressed.suit_store) + dressed.suit = null + if(arms) + dressed.gloves = null + return dressed + +/** + * Bares the worn chrome in `npc`'s live body, as bounty_chrome_expose_outfit() does for an outfit: the + * coat and gloves over it come off. What comes off is deleted (it was locked gear, D3). A pressure suit + * or a modsuit stays shut. For a fugitive whose act drops (bounty_outpost.dm). + * Returns TRUE if anything came off. Doesn't sleep. + */ +/proc/bounty_chrome_bare(mob/living/carbon/human/npc/npc) + if(!ishumannpc(npc) || QDELETED(npc)) + return FALSE + var/list/installed = list() + for(var/obj/item/organ/organ in npc.organs) + if(organ.GetComponent(/datum/component/cyberware)) + installed += organ.type + var/list/shows = bounty_chrome_shows(installed) + if(!shows) + return FALSE + var/obj/item/clothing/suit = npc.wear_suit + if((istype(suit) && (suit.clothing_flags & STOPSPRESSUREDAMAGE)) || istype(npc.back, /obj/item/mod/control)) + return FALSE + var/arms = shows["arms"] + var/chest = shows["chest"] + var/coat_name + . = FALSE + if((arms || chest) && suit && !istype(suit, /obj/item/clothing/suit/armor) && !npc.s_store) + coat_name = "[suit.name]" + npc.temporarilyRemoveItemFromInventory(suit, force = TRUE) + qdel(suit) + . = TRUE + if(arms && npc.gloves) + var/obj/item/hand_wear = npc.gloves + npc.temporarilyRemoveItemFromInventory(hand_wear, force = TRUE) + qdel(hand_wear) + . = TRUE + if(coat_name) + npc.visible_message(span_warning("[npc] shrugs off the [coat_name].")) + +// ===== IN A FIGHT ===== + +/** + * Whether `npc` can use its chrome now: awake, on its feet and on a turf, out of crit, not cuffed, + * stunned or knocked down, not giving up, not hiding or passing as a patron, and not driven by a + * player. All read from tg. + */ +/proc/bounty_chrome_can_act(mob/living/carbon/human/npc/npc) + if(!ishumannpc(npc) || QDELETED(npc) || npc.stat != CONSCIOUS || !isturf(npc.loc) || npc.client) + return FALSE + if(HAS_TRAIT(npc, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(npc, TRAIT_RESTRAINED) || HAS_TRAIT(npc, TRAIT_INCAPACITATED) || HAS_TRAIT(npc, TRAIT_FLOORED)) + return FALSE + if(npc.npc_surrendering) + return FALSE + var/mob/living/carbon/human/npc/bounty_criminal/criminal = npc + if(istype(criminal) && (criminal.hidden || criminal.blended)) + return FALSE + return TRUE + +/// Whether `npc` is in a fight, by its AI's mode: a criminal fighting, a companion in it, a mini-boss turned hostile. Its state (stuns and the like) is bounty_chrome_can_act()'s. +/proc/bounty_chrome_in_fight(mob/living/carbon/human/npc/npc) + if(QDELETED(npc) || npc.stat == DEAD) + return FALSE + if(istype(npc, /mob/living/carbon/human/npc/bounty_criminal/boss)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + return boss.boss_hostile + if(istype(npc, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = npc + return criminal.ai_mode == BOUNTY_AI_FIGHTING + if(istype(npc, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = npc + return companion.ai_in_fight + return FALSE + +/// Who `npc` is going for now (a person, or a mech), or null +/proc/bounty_chrome_target(mob/living/carbon/human/npc/npc) + var/datum/ai_controller/controller = npc?.ai_controller + if(QDELETED(controller)) + return null + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] || controller.blackboard[BB_BOUNTY_BOSS_PREY] + return QDELETED(target) ? null : target + +/** + * The first action of `action_type` on chrome in `npc` (its implants' own buttons), preferring one + * that can be used now, or null. + */ +/proc/bounty_chrome_action(mob/living/carbon/human/npc/npc, action_type) + if(!ispath(action_type, /datum/action) || QDELETED(npc)) + return null + var/datum/action/found + for(var/obj/item/organ/organ in npc.organs) + if(!organ.GetComponent(/datum/component/cyberware)) + continue + for(var/datum/action/action as anything in organ.actions) + if(!istype(action, action_type) || action.owner != npc) + continue + if(action.IsAvailable()) + return action + found ||= action + return found + +/** + * Fires `npc`'s implant action of `action_type` the way a player's click does: Trigger(), aimed at + * `target` for an action that is clicked onto something. Only an action that can be used now. Returns + * TRUE if it fired. + */ +/proc/bounty_chrome_trigger(mob/living/carbon/human/npc/npc, action_type, atom/target) + var/datum/action/action = bounty_chrome_action(npc, action_type) + if(!action || !action.IsAvailable()) + return FALSE + var/datum/action/cooldown/cooldown_action = action + if(istype(cooldown_action) && cooldown_action.click_to_activate) + if(QDELETED(target)) + return FALSE + return !!action.Trigger(target = target) + return !!action.Trigger() + +// ===== WHO AN AREA MOVE MAY CATCH ===== + +/** + * Whether `person` is in `npc`'s fight, so an area move may catch them: the one it is going for or + * `aimed` at, or a hunter after it. That is someone its side holds a grudge against (they attacked, + * confronted or cuffed it or a companion), in a mini-boss's posse, or crewing a ship hunting its + * bounty. Its own side never is, and nor is anyone else on the site. + */ +/proc/bounty_chrome_in_posse(mob/living/carbon/human/npc/npc, mob/living/person, atom/aimed) + if(QDELETED(npc) || !isliving(person) || person == npc || bounty_ai_same_gang(npc, person)) + return FALSE + for(var/atom/target in list(bounty_chrome_target(npc), aimed)) + if(target == person || (ismecha(target) && person.loc == target)) + return TRUE + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(npc) + if(QDELETED(side)) + return FALSE + if(side.body_has_grudge(person)) + return TRUE + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = side + if(istype(boss) && boss.boss_engaged[REF(person)]) + return TRUE + if(!person.mind) + return FALSE + var/datum/criminal_bounty/posting = side.posting() + for(var/obj/structure/overmap/ship/ship as anything in posting?.hunter_ships()) + if(person.mind in ship.ship_team?.members) + return TRUE + // A crewmate of anyone in the fight is in it too: a crew can't keep the area moves off by standing an idle friend beside the shooter + for(var/atom/target in list(bounty_chrome_target(npc), aimed)) + if(bounty_chrome_crewmates(person, target)) + return TRUE + for(var/datum/weakref/enemy_ref as anything in side.grudge) + if(bounty_chrome_crewmates(person, enemy_ref?.resolve())) + return TRUE + return FALSE + +/// Whether `person` and `other` (a person, or a mech with one in it) crew the same ship +/proc/bounty_chrome_crewmates(mob/living/person, atom/other) + var/mob/living/other_person = ismecha(other) ? bounty_ai_pilot_of(other) : other + if(!isliving(other_person) || other_person == person || !LAZYLEN(person?.mind?.ship_teams) || !LAZYLEN(other_person.mind?.ship_teams)) + return FALSE + for(var/datum/team/voidcrew/crew as anything in person.mind.ship_teams) + if(crew in other_person.mind.ship_teams) + return TRUE + return FALSE + +/** + * Whether `npc` may start an area move over `turfs` (owner decision 5): nobody stands there who is + * outside its fight. Its own side and the site's people count as outside it (it doesn't land on its + * friends); the dead and creatures with nobody behind them (site fauna) don't count. The implant itself + * is never changed: once it fires it hits whoever is there, as it would for a player. + */ +/proc/bounty_chrome_area_clear(mob/living/carbon/human/npc/npc, list/turfs, atom/aimed, ignore_faction = FALSE) + if(QDELETED(npc)) + return FALSE + for(var/turf/spot as anything in turfs) + if(!isturf(spot)) + continue + for(var/mob/living/person in spot) + if(person == npc || person.stat == DEAD) + continue + // A move that spares its own side (the Redline roar) doesn't wait on its own companions + if(ignore_faction && bounty_ai_same_gang(npc, person)) + continue + if(!bounty_chrome_in_posse(npc, person, aimed) && bounty_chrome_counts_as_bystander(npc, person)) + return FALSE + for(var/obj/vehicle/sealed/mecha/mech in spot) + for(var/mob/living/pilot in mech.occupants) + if(!bounty_chrome_in_posse(npc, pilot, aimed) && bounty_chrome_counts_as_bystander(npc, pilot)) + return FALSE + return TRUE + +/// Whether `person`, not in `npc`'s fight, keeps an area move from starting: anyone with a player behind them, any person, and anyone on its side. Not a creature with nobody behind it. +/proc/bounty_chrome_counts_as_bystander(mob/living/carbon/human/npc/npc, mob/living/person) + if(person.mind || person.client || ishuman(person)) + return TRUE + return bounty_ai_same_gang(npc, person) || npc.faction_check_atom(person) + +// ===== MOVES ===== + +/** + * # A chrome move + * + * One thing an NPC's AI does with one piece of chrome: a tell, then the implant's own action. C1-C4 + * subtype it, one subtype per move, naming the piece by `piece_key`; nothing else registers it. Each + * NPC carrying the piece gets its own instance (/datum/component/bounty_chrome), so cooldowns are its + * own. + * + * How a normal criminal or a companion uses one (the bounty_chrome planning subtree): while it is in a + * fight and can act, plan() first (a move that queues behaviours of its own, a grab, returns TRUE), then + * a move whose can_start() passes starts: it stands still (tell_holds_still), its AI goes quiet, and + * tell() shows the outline, plays the sound and says the line. After tell_time (never under + * BOUNTY_CHROME_TELL_MIN) fire() runs effect(), which Trigger()s the implant's action. Downed, stunned, + * knocked down, cuffed, EMP'd or killed during the tell: cancel() and nothing fires. A mini-boss runs the + * same tell() and effect() through its ability rotation instead. + * + * The hooks below are called for every move of the NPC, in a fight or not. None may sleep. + */ +/datum/bounty_chrome_move + /// The BOUNTY_CHROME_* key of the piece this move drives; null for an abstract type + var/piece_key + /// What it is called (admins, the mini-boss's ability) + var/name = "chrome" + /// The implant action it fires (a /datum/action subtype on the piece's organs), or null for a move that fires nothing by itself + var/action_type + /// How long its tell lasts. Never under BOUNTY_CHROME_TELL_MIN. + var/tell_time = BOUNTY_CHROME_TELL_MIN + /// It stands still through the tell (TRAIT_IMMOBILIZED, as a mini-boss winding up does) + var/tell_holds_still = TRUE + /// Its AI carries on through the tell (drawing, shooting, moving) instead of going quiet: a tell that is only a look, a gunman's Deadeye lock. Interrupts still cut it short. + var/tell_keeps_acting = FALSE + /// The outline it glows with through the tell + var/tell_color = BOUNTY_CHROME_TELL_COLOR + /// The sound at the start of the tell + var/tell_sound = 'sound/effects/seedling_chargeup.ogg' + /// Lines it may say as the tell starts (short, in character), or null + var/list/tell_lines + /// It catches everyone in an area: it never starts while someone outside the fight stands in area_turfs() + var/area_move = FALSE + /// Furthest a target may be for it to start, in tiles + var/max_range = BOUNTY_FIGHT_VISION + /// Nearest a target may be for it to start, in tiles + var/min_range = 0 + /// How long after it fires before the NPC starts another chrome move (at least BOUNTY_CHROME_MOVE_GAP) + var/recover_time = BOUNTY_CHROME_MOVE_GAP + /// A mini-boss gets it as an ability in its rotation (a tell-then-fire move); a move that only plans behaviours sets FALSE + var/boss_usable = TRUE + /// The NPC it is in + var/mob/living/carbon/human/npc/owner + /// The mini-boss ability wrapping it, if its owner is a mini-boss + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/boss_ability + /// world.time it last fired, 0 if never + var/last_fired = 0 + /// Weakrefs to the marks it put down for its tell, removed when the tell ends + var/list/marks + +/datum/bounty_chrome_move/Destroy() + clear_tell() + if(boss_ability) + boss_ability.move = null + boss_ability = null + owner = null + return ..() + +/// Goes into `npc`. Subtypes register what they need here; call parent first. +/datum/bounty_chrome_move/proc/attach(mob/living/carbon/human/npc/npc) + owner = npc + +/// Comes out of its NPC, which may be being deleted. Undo what attach() and the fight did; call parent last. +/datum/bounty_chrome_move/proc/detach() + clear_tell() + owner = null + +/// Whether its implant can be used now: the action is there, its chrome running and off cooldown (an action-less move is always ready) +/datum/bounty_chrome_move/proc/ready() + if(!action_type) + return TRUE + var/datum/action/action = bounty_chrome_action(owner, action_type) + return !!action?.IsAvailable() + +/** + * Whether it should start on `target` now: its owner can act, the target is someone it may hit, in + * sight and in range, the implant is ready, and for an area move nobody outside the fight stands where + * it lands. Subtypes add their own reasons and call parent. + */ +/datum/bounty_chrome_move/proc/can_start(atom/target) + if(!bounty_chrome_can_act(owner) || QDELETED(target) || !bounty_ai_may_hit(owner, target)) + return FALSE + var/distance = bounty_ai_dist(owner, target) + if(distance > max_range || distance < min_range || !bounty_ai_can_see(owner, target, max_range)) + return FALSE + if(!ready()) + return FALSE + if(area_move && !area_clear(target)) + return FALSE + return TRUE + +/// Whether nobody outside the fight stands where it would land on `target` (the bystander gate). A move whose implant spares its own side overrides it. +/datum/bounty_chrome_move/proc/area_clear(atom/target) + return bounty_chrome_area_clear(owner, area_turfs(target), target) + +/// The turfs it would catch if it fired on `target` now, for the bystander gate. Area moves override it. +/datum/bounty_chrome_move/proc/area_turfs(atom/target) + return list() + +/// What its action is clicked on (a click-to-activate action): `target`, or for a leap the turf to land on +/datum/bounty_chrome_move/proc/trigger_target(atom/target) + return target + +/** + * The tell on `target`: an outline on its owner, the sound, a line. Subtypes add marks (add_mark()) and + * call parent. Doesn't sleep. + */ +/datum/bounty_chrome_move/proc/tell(atom/target) + if(QDELETED(owner)) + return + owner.add_filter(tell_filter(), 2, outline_filter(1, tell_color)) + var/outline = owner.get_filter(tell_filter()) + if(outline) + // A pulse, faint to full: the colour's own alpha, as filters have no alpha of their own + animate(outline, color = "[tell_color]40", time = 0.15 SECONDS, loop = -1) + animate(color = "[tell_color]ff", time = 0.15 SECONDS) + if(tell_sound) + playsound(owner, tell_sound, 60, TRUE) + var/text = tell_text(target) + if(text) + owner.visible_message(span_danger(text)) + if(length(tell_lines)) + INVOKE_ASYNC(src, PROC_REF(say_line), pick(tell_lines)) + +/// What onlookers see as the tell starts, or null. Plain, in the world: "[owner]'s spine lights up." +/datum/bounty_chrome_move/proc/tell_text(atom/target) + return null + +/// Its owner says `line`. Sleeps (say()). +/datum/bounty_chrome_move/proc/say_line(line) + if(!QDELETED(owner) && owner.stat == CONSCIOUS) + owner.say(line, forced = "bounty chrome tell") + +/// The outline's filter name, its own per move +/datum/bounty_chrome_move/proc/tell_filter() + return "bounty_chrome_tell_[REF(src)]" + +/// Keeps `thing` (a mark on the floor) until the tell ends +/datum/bounty_chrome_move/proc/add_mark(datum/thing) + if(thing) + LAZYADD(marks, WEAKREF(thing)) + return thing + +/// Ends the tell's look: the outline and the marks go +/datum/bounty_chrome_move/proc/clear_tell() + if(!QDELETED(owner)) + owner.remove_filter(tell_filter()) + for(var/datum/weakref/mark_ref as anything in marks) + var/datum/thing = mark_ref.resolve() + if(!QDELETED(thing)) + qdel(thing) + marks = null + +/// The tell is over: it fires on `target` (which may be gone by now). Returns what effect() does. +/datum/bounty_chrome_move/proc/fire(atom/target) + last_fired = world.time + return effect(target) + +/// What it does once the tell is over: by default, Trigger() the implant's action. Returns TRUE if it did something. +/datum/bounty_chrome_move/proc/effect(atom/target) + if(!action_type) + return FALSE + return bounty_chrome_trigger(owner, action_type, trigger_target(target)) + +/// The tell was cut short (BOUNTY_CHROME_CANCEL_*): nothing fires. The look is already gone. +/datum/bounty_chrome_move/proc/cancel(reason) + return + +/// For a move that drives behaviours itself (a grab, blades out): queue them on `controller` and return TRUE to end the planning. Called every planning tick in a fight, before any move starts. +/datum/bounty_chrome_move/proc/plan(datum/ai_controller/controller, atom/target) + return FALSE + +/// A fight starts against `target` +/datum/bounty_chrome_move/proc/on_fight_start(atom/target) + return + +/// The fight is over +/datum/bounty_chrome_move/proc/on_fight_end() + return + +/// Every Life tick of its owner (none while it is dormant); `fighting` is bounty_chrome_in_fight(), `target` who it is going for +/datum/bounty_chrome_move/proc/on_tick(seconds_per_tick, fighting, atom/target) + return + +/// Its owner went down into crit +/datum/bounty_chrome_move/proc/on_downed() + return + +/// Its owner died: end anything running +/datum/bounty_chrome_move/proc/on_death() + return + +/// An EMP of `severity` reached its owner (the implant reboots on its own timer) +/datum/bounty_chrome_move/proc/on_emp(severity) + return + +// ===== AN NPC'S CHROME, IN A FIGHT ===== + +/** + * A bounty NPC's chrome moves, and the one tell it may have going. Added by bounty_chrome_equip(); adding + * it again adds the new keys' moves. The tell is held to tg's state through signals: crit, a stun, a + * knockdown, cuffs, death or an EMP cuts it short. + */ +/datum/component/bounty_chrome + dupe_mode = COMPONENT_DUPE_UNIQUE_PASSARGS + /// The piece keys it carries, in order + var/list/keys = list() + /// Its moves (/datum/bounty_chrome_move), one per move type of each piece + var/list/moves = list() + /// The move whose tell is running, or null + var/datum/bounty_chrome_move/telling + /// Weakref to what that tell is aimed at + var/datum/weakref/tell_target + /// Goes up with each tell, so a stale timer does nothing + var/tell_serial = 0 + var/tell_timer + /// In a fight at the last look (bounty_chrome_in_fight()), for the fight's start and end + var/fighting = FALSE + /// world.time its next chrome move may start + var/next_move_at = 0 + +/datum/component/bounty_chrome/Initialize(list/new_keys) + if(!ishumannpc(parent)) + return COMPONENT_INCOMPATIBLE + add_keys(new_keys) + +/datum/component/bounty_chrome/InheritComponent(datum/component/new_comp, i_am_original, list/new_keys) + add_keys(new_keys) + +/datum/component/bounty_chrome/Destroy(force) + end_tell() + for(var/datum/bounty_chrome_move/move as anything in moves) + move.detach() + qdel(move) + moves.Cut() + tell_target = null + return ..() + +/datum/component/bounty_chrome/RegisterWithParent() + RegisterSignal(parent, COMSIG_LIVING_LIFE, PROC_REF(on_life)) + RegisterSignal(parent, COMSIG_HUMAN_NPC_DOWNED, PROC_REF(on_downed)) + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + RegisterSignal(parent, COMSIG_ATOM_EMP_ACT, PROC_REF(on_emp)) + RegisterSignals(parent, list(SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED)), PROC_REF(on_stunned)) + RegisterSignal(parent, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), PROC_REF(on_restrained)) + +/datum/component/bounty_chrome/UnregisterFromParent() + UnregisterSignal(parent, list( + COMSIG_LIVING_LIFE, + COMSIG_HUMAN_NPC_DOWNED, + COMSIG_LIVING_DEATH, + COMSIG_ATOM_EMP_ACT, + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + SIGNAL_ADDTRAIT(TRAIT_FLOORED), + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), + )) + +/// Adds the moves of the pieces `new_keys` it doesn't have yet; a mini-boss gets each as an ability in its rotation +/datum/component/bounty_chrome/proc/add_keys(list/new_keys) + var/mob/living/carbon/human/npc/npc = parent + for(var/key in new_keys) + var/datum/bounty_chrome_piece/piece = GLOB.bounty_chrome_pieces[key] + if(!piece || (key in keys)) + continue + keys += key + for(var/move_type in piece.move_types) + var/datum/bounty_chrome_move/move = new move_type + moves += move + move.attach(npc) + bounty_chrome_grant_boss_ability(npc, move) + +/// Its move of `move_type`, or null +/datum/component/bounty_chrome/proc/move_of(move_type) + for(var/datum/bounty_chrome_move/move as anything in moves) + if(istype(move, move_type)) + return move + return null + +/// Notes the fight starting or ending, and tells the moves +/datum/component/bounty_chrome/proc/update_fight() + var/now = bounty_chrome_in_fight(parent) + if(now == fighting) + return + fighting = now + if(now) + next_move_at = max(next_move_at, world.time + BOUNTY_CHROME_OPENING) + var/atom/target = bounty_chrome_target(parent) + for(var/datum/bounty_chrome_move/move as anything in moves) + move.on_fight_start(target) + return + interrupt(BOUNTY_CHROME_CANCEL_GONE) + for(var/datum/bounty_chrome_move/move as anything in moves) + move.on_fight_end() + +/** + * The bounty_chrome planning subtree's work: nothing else is planned while a tell runs (unless the move + * keeps acting through it, tell_keeps_acting, when only no other move starts); in a fight, a + * move that plans its own behaviours goes first, then, past the gap since the last one, a move that can + * start does. Returns the subtree's result. Doesn't sleep. + */ +/datum/component/bounty_chrome/proc/plan(datum/ai_controller/controller, seconds_per_tick) + if(telling) + return telling.tell_keeps_acting ? null : SUBTREE_RETURN_FINISH_PLANNING + update_fight() + if(!fighting || !bounty_chrome_can_act(parent)) + return + var/atom/target = bounty_chrome_target(parent) + if(QDELETED(target)) + return + for(var/datum/bounty_chrome_move/move as anything in moves) + if(move.plan(controller, target)) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time < next_move_at) + return + var/list/ready = list() + for(var/datum/bounty_chrome_move/move as anything in moves) + if(move.action_type && move.can_start(target)) + ready += move + if(length(ready) && start_move(pick(ready), target)) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Starts `move`'s tell on `target`: it stands still with its AI quiet, and after the tell the move + * fires, unless something cuts it short. Returns TRUE if the tell started. + */ +/datum/component/bounty_chrome/proc/start_move(datum/bounty_chrome_move/move, atom/target) + var/mob/living/carbon/human/npc/npc = parent + if(telling || !(move in moves) || !bounty_chrome_can_act(npc)) + return FALSE + tell_serial++ + telling = move + tell_target = target ? WEAKREF(target) : null + if(move.tell_holds_still) + ADD_TRAIT(npc, TRAIT_IMMOBILIZED, BOUNTY_CHROME_TRAIT) + if(!move.tell_keeps_acting) + npc.ai_controller?.CancelActions() + if(target) + npc.face_atom(target) + move.tell(target) + tell_timer = addtimer(CALLBACK(src, PROC_REF(finish_tell), tell_serial), max(move.tell_time, BOUNTY_CHROME_TELL_MIN), TIMER_STOPPABLE | TIMER_DELETE_ME) + return TRUE + +/// The tell under `serial` is over: the move fires, if its owner can still act +/datum/component/bounty_chrome/proc/finish_tell(serial) + if(serial != tell_serial || !telling) + return FALSE + var/datum/bounty_chrome_move/move = telling + var/atom/target = tell_target?.resolve() + tell_timer = null + end_tell() + if(!bounty_chrome_can_act(parent)) + move.cancel(BOUNTY_CHROME_CANCEL_STUNNED) + return FALSE + next_move_at = world.time + max(move.recover_time, BOUNTY_CHROME_MOVE_GAP) + move.fire(target) + return TRUE + +/// Ends the running tell's hold and look, without firing or cancelling it +/datum/component/bounty_chrome/proc/end_tell() + var/datum/bounty_chrome_move/move = telling + telling = null + tell_target = null + if(tell_timer) + deltimer(tell_timer) + tell_timer = null + if(!QDELETED(parent)) + REMOVE_TRAIT(parent, TRAIT_IMMOBILIZED, BOUNTY_CHROME_TRAIT) + move?.clear_tell() + +/// Cuts the running tell short for `reason` (BOUNTY_CHROME_CANCEL_*): nothing fires. A mini-boss's chrome wind-up is cut short too. +/datum/component/bounty_chrome/proc/interrupt(reason) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = parent + if(istype(boss) && istype(boss.boss_winding(), /datum/action/cooldown/mob_cooldown/bounty_boss/chrome)) + boss.boss_interrupt() + var/datum/bounty_chrome_move/move = telling + if(!move) + return FALSE + end_tell() + move.cancel(reason) + return TRUE + +/datum/component/bounty_chrome/proc/on_life(datum/source, seconds_per_tick, times_fired) + SIGNAL_HANDLER + update_fight() + var/atom/target = fighting ? bounty_chrome_target(parent) : null + for(var/datum/bounty_chrome_move/move as anything in moves) + move.on_tick(seconds_per_tick, fighting, target) + +/datum/component/bounty_chrome/proc/on_downed(datum/source) + SIGNAL_HANDLER + interrupt(BOUNTY_CHROME_CANCEL_DOWNED) + for(var/datum/bounty_chrome_move/move as anything in moves) + move.on_downed() + +/datum/component/bounty_chrome/proc/on_death(datum/source, gibbed) + SIGNAL_HANDLER + interrupt(BOUNTY_CHROME_CANCEL_DEATH) + fighting = FALSE + for(var/datum/bounty_chrome_move/move as anything in moves) + move.on_death() + +/datum/component/bounty_chrome/proc/on_emp(datum/source, severity, protection) + SIGNAL_HANDLER + if(protection & (EMP_PROTECT_SELF | EMP_PROTECT_CONTENTS)) + return + interrupt(BOUNTY_CHROME_CANCEL_EMP) + for(var/datum/bounty_chrome_move/move as anything in moves) + move.on_emp(severity) + +/datum/component/bounty_chrome/proc/on_stunned(datum/source) + SIGNAL_HANDLER + interrupt(BOUNTY_CHROME_CANCEL_STUNNED) + +/datum/component/bounty_chrome/proc/on_restrained(datum/source) + SIGNAL_HANDLER + interrupt(BOUNTY_CHROME_CANCEL_RESTRAINED) + +/** + * The AI's chrome: in the normal criminal's controller after bounty_fight_target (and in the + * companion's, C4). Nothing for an NPC with no chrome moves. + */ +/datum/ai_planning_subtree/bounty_chrome + +/datum/ai_planning_subtree/bounty_chrome/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/component/bounty_chrome/chrome = controller.pawn?.GetComponent(/datum/component/bounty_chrome) + if(!chrome) + return + return chrome.plan(controller, seconds_per_tick) + +// ===== MINI-BOSSES ===== + +/// Gives mini-boss `npc` an ability in its rotation for `move`, if it is a mini-boss and the move is one its rotation can run +/proc/bounty_chrome_grant_boss_ability(mob/living/carbon/human/npc/npc, datum/bounty_chrome_move/move) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + if(!istype(boss) || !move.boss_usable || !move.action_type || move.boss_ability) + return null + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/ability = new(boss) + ability.set_move(move) + ability.Grant(boss) + boss.boss_abilities += ability + return ability + +/** + * A mini-boss's chrome move as one of its abilities, so its rotation, its busy rule and its interrupts + * (tg's stuns, crit, cuffs) run it the way they run its kit's: the wind-up is the move's tell, the effect + * the implant's action. + */ +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome + name = "Chrome" + desc = "A wanted criminal's chrome." + cooldown_time = BOUNTY_CHROME_MOVE_GAP + /// The move it runs, one of its boss's own (/datum/component/bounty_chrome) + var/datum/bounty_chrome_move/move + +// A chrome move with lines of its own says one of them in its tell (move.tell()): its kit's ability line ("Grenade!" before a roar) would be a second line, and the wrong one +/mob/living/carbon/human/npc/bounty_criminal/boss/boss_bark(suffix, fallback) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/winding = boss_winding() + if(suffix == "ability" && istype(winding) && length(winding.move?.tell_lines)) + return + return ..() + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/Destroy() + if(move?.boss_ability == src) + move.boss_ability = null + move = null + return ..() + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/proc/set_move(datum/bounty_chrome_move/new_move) + move = new_move + new_move.boss_ability = src + name = new_move.name + windup = max(new_move.tell_time, BOUNTY_CHROME_TELL_MIN) + cooldown_time = max(new_move.recover_time, BOUNTY_CHROME_MOVE_GAP) + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/worth_using(atom/target) + return ..() && move && move.owner == owner && move.can_start(target) + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/telegraph(atom/target) + move?.tell(target) + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/effect(atom/target) + if(!move) + return + move.clear_tell() + move.fire(target) + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/finish_windup(serial) + var/pending = serial && serial == pending_serial + . = ..() + // Its boss couldn't act when the wind-up ended: the tell's look goes too + if(pending && !. && move) + move.clear_tell() + move.cancel(BOUNTY_CHROME_CANCEL_STUNNED) + +/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/cancel_windup() + var/pending = pending_serial + . = ..() + if(pending && move) + move.clear_tell() + move.cancel(BOUNTY_CHROME_CANCEL_STUNNED) + +// ===== A MALFUNCTION ===== + +/** + * A bounty NPC's body with chrome in it, as others notice it: an EMP that knocks a piece out shows the + * malfunction (bounty_chrome_malfunction_fx()) for as long as the piece takes to reboot, and a chrome read + * is felt and answered (bounty_chrome_read_noticed()). On every criminal, decoys included (they carry + * their fugitive's chrome and must answer the same way), and on chromed companions. + */ +/datum/element/bounty_chrome_body + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/bounty_chrome_body/Attach(datum/target) + . = ..() + if(!ishumannpc(target)) + return ELEMENT_INCOMPATIBLE + // Added again by bounty_chrome_equip() to a criminal that already has it: the same hooks + RegisterSignal(target, COMSIG_ATOM_EMP_ACT, PROC_REF(on_emp), override = TRUE) + RegisterSignal(target, COMSIG_CARBON_CHROME_READ, PROC_REF(on_read), override = TRUE) + +/datum/element/bounty_chrome_body/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_ATOM_EMP_ACT, COMSIG_CARBON_CHROME_READ)) + return ..() + +/datum/element/bounty_chrome_body/proc/on_emp(mob/living/carbon/human/npc/source, severity, protection) + SIGNAL_HANDLER + if(protection & (EMP_PROTECT_SELF | EMP_PROTECT_CONTENTS)) + return + // The chrome takes the pulse after this signal (/mob/living/emp_act()): look a moment later + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_chrome_check_malfunction), WEAKREF(source)), 1) + +/datum/element/bounty_chrome_body/proc/on_read(mob/living/carbon/human/npc/source, mob/living/carbon/reader) + SIGNAL_HANDLER + bounty_chrome_read_noticed(source, reader) + +/// Whatever chrome of `npc_ref`'s an EMP just knocked out shows it, for as long as the longest reboot +/proc/bounty_chrome_check_malfunction(datum/weakref/npc_ref) + var/mob/living/carbon/human/npc/npc = npc_ref?.resolve() + if(QDELETED(npc) || npc.stat == DEAD) + return + var/longest = 0 + for(var/obj/item/organ/organ in npc.organs) + var/datum/component/cyberware/chrome = organ.GetComponent(/datum/component/cyberware) + if(!chrome?.emp_down) + continue + longest = max(longest, chrome.emp_timer ? timeleft(chrome.emp_timer) : 0) + if(longest <= 0) + return + var/fresh = !npc.has_status_effect(/datum/status_effect/bounty_chrome_malfunction) + bounty_chrome_malfunction_fx(npc, longest) + if(fresh) + npc.visible_message(span_warning("[npc]'s chrome sparks and stutters!")) + +/** + * Shows `npc`'s chrome malfunctioning for `duration`: arcs crawling over the body, tg's EMP sparkle, a + * flickering chrome-blue outline and light, twitches, and sparks spat onto the tiles around them with a + * crackle. Only a picture: nothing is hurt, set alight or switched off by it. A longer one extends it. + */ +/proc/bounty_chrome_malfunction_fx(mob/living/npc, duration) + if(QDELETED(npc) || npc.stat == DEAD || duration <= 0) + return null + return npc.apply_status_effect(/datum/status_effect/bounty_chrome_malfunction, duration) + +/// The arcs over a malfunctioning body, and their glow in the dark when `body` is given. For the overlay and the renders. +/proc/bounty_chrome_malfunction_arcs(atom/body) + . = list() + var/mutable_appearance/arcs = mutable_appearance('icons/effects/effects.dmi', "electricity", ABOVE_MOB_LAYER) + arcs.color = BOUNTY_CHROME_MALFUNCTION_MATRIX + . += arcs + . += mutable_appearance('icons/effects/effects.dmi', "empdisable", ABOVE_MOB_LAYER) + if(body) + . += emissive_appearance('icons/effects/effects.dmi', "electricity", body) + +/// The look of bounty_chrome_malfunction_fx(), for its duration. Purely visual; it ends early if they die. +/datum/status_effect/bounty_chrome_malfunction + id = "bounty_chrome_malfunction" + duration = 4 SECONDS + tick_interval = BOUNTY_CHROME_MALFUNCTION_PULSE + status_type = STATUS_EFFECT_REFRESH + alert_type = null + /// The arcs and the sparkle on the body + var/list/arcs + /// The flickering light they give off + var/obj/effect/dummy/lighting_obj/moblight/glow + +/datum/status_effect/bounty_chrome_malfunction/on_creation(mob/living/new_owner, new_duration) + if(new_duration > 0) + duration = new_duration + return ..() + +/datum/status_effect/bounty_chrome_malfunction/refresh(effect, new_duration) + duration = max(duration, world.time + max(new_duration, 0)) + +/datum/status_effect/bounty_chrome_malfunction/on_apply() + if(owner.stat == DEAD) + return FALSE + arcs = bounty_chrome_malfunction_arcs(owner) + owner.add_overlay(arcs) + owner.add_filter(BOUNTY_CHROME_MALFUNCTION_FILTER, 3, outline_filter(1, BOUNTY_CHROME_MALFUNCTION_COLOR)) + var/outline = owner.get_filter(BOUNTY_CHROME_MALFUNCTION_FILTER) + if(outline) + // A flicker, never steady + animate(outline, color = "#7fd4ff20", time = 0.1 SECONDS, loop = -1) + animate(color = "#7fd4ffff", time = 0.1 SECONDS) + animate(color = "#7fd4ff60", time = 0.2 SECONDS) + animate(color = "#c8f0ffff", time = 0.1 SECONDS) + glow = owner.mob_light(2, 1, BOUNTY_CHROME_MALFUNCTION_COLOR) + // A corpse's chrome is burnt out: the look ends the moment it dies + RegisterSignal(owner, COMSIG_LIVING_DEATH, PROC_REF(on_owner_death)) + playsound(owner, 'sound/effects/empulse.ogg', 40, TRUE) + playsound(owner, SFX_SPARKS, 60, TRUE) + pulse() + return TRUE + +/datum/status_effect/bounty_chrome_malfunction/tick(seconds_between_ticks) + if(owner.stat == DEAD) + qdel(src) + return + pulse() + +/// One beat of it: a twitch, sparks spat onto a tile or two around them, a crackle, the light jumping +/datum/status_effect/bounty_chrome_malfunction/proc/pulse() + if(prob(60)) + owner.Shake(rand(1, 2), rand(0, 1), 0.2 SECONDS) + glow?.set_light_power(pick(0.4, 1, 1.6)) + var/turf/here = get_turf(owner) + if(!here) + return + var/list/spots = list() + for(var/turf/spot as anything in RANGE_TURFS(1, here)) + if(spot != here && !isclosedturf(spot)) + spots += spot + for(var/i in 1 to rand(1, 2)) + if(!length(spots)) + break + var/turf/spot = pick_n_take(spots) + new /obj/effect/temp_visual/bounty_chrome_spark(spot, (here.x - spot.x) * ICON_SIZE_X / 2, (here.y - spot.y) * ICON_SIZE_Y / 2) + if(prob(50)) + playsound(owner, SFX_SPARKS, 35, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/status_effect/bounty_chrome_malfunction/proc/on_owner_death(datum/source, gibbed) + SIGNAL_HANDLER + qdel(src) + +/datum/status_effect/bounty_chrome_malfunction/on_remove() + UnregisterSignal(owner, COMSIG_LIVING_DEATH) + if(arcs) + owner.cut_overlay(arcs) + arcs = null + owner.remove_filter(BOUNTY_CHROME_MALFUNCTION_FILTER) + QDEL_NULL(glow) + +/datum/status_effect/bounty_chrome_malfunction/Destroy() + QDEL_NULL(glow) + return ..() + +/// Sparks flung from a malfunctioning body onto a tile beside it: tg's spark art, and nothing else. Starts `from_x`, `from_y` pixels back toward the body. +/obj/effect/temp_visual/bounty_chrome_spark + icon = 'icons/effects/effects.dmi' + icon_state = "sparks" + layer = ABOVE_MOB_LAYER + duration = 0.5 SECONDS + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/obj/effect/temp_visual/bounty_chrome_spark/Initialize(mapload, from_x = 0, from_y = 0) + . = ..() + setDir(pick(GLOB.cardinals)) + pixel_x = from_x + pixel_y = from_y + animate(src, pixel_x = 0, pixel_y = 0, time = 0.2 SECONDS, easing = QUAD_EASING | EASE_OUT) + animate(alpha = 0, time = 0.3 SECONDS) + +// ===== A CHROME READ ===== + +/mob/living/carbon/human/npc + /// world.time it may next answer a chrome read (bounty_chrome_read_noticed()) + var/chrome_read_answer_at = 0 + +/** + * `reader` just read `npc`'s chrome with cyber optics, and it felt it (COMSIG_CARBON_CHROME_READ, + * cyberware_scan.dm): one that isn't fighting turns to the reader and tells them off, at most once every + * BOUNTY_CHROME_READ_NOTICE_COOLDOWN. Never while hiding. Everyone at a trader outpost answers the same + * way, the fugitive and its decoys alike. Returns TRUE if it answers. Doesn't sleep. + */ +/proc/bounty_chrome_read_noticed(mob/living/carbon/human/npc/npc, mob/living/reader) + if(!ishumannpc(npc) || !isliving(reader) || reader == npc || world.time < npc.chrome_read_answer_at) + return FALSE + if(npc.stat != CONSCIOUS || npc.client || bounty_chrome_in_fight(npc) || !length(cyberware_scannable_hardware(npc))) + return FALSE + var/mob/living/carbon/human/npc/bounty_criminal/criminal = npc + if(istype(criminal) && criminal.hidden) + return FALSE + npc.chrome_read_answer_at = world.time + BOUNTY_CHROME_READ_NOTICE_COOLDOWN + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_chrome_answer_read), npc, reader) + return TRUE + +/// `npc` looks round at `reader` and says something about being read. Sleeps (say()). +/proc/bounty_chrome_answer_read(mob/living/carbon/human/npc/npc, mob/living/reader) + if(QDELETED(npc) || QDELETED(reader) || npc.stat != CONSCIOUS) + return + if(isturf(npc.loc) && !npc.buckled && npc.body_position == STANDING_UP) + npc.face_atom(reader) + npc.say(pick("Who said you could scan me?", "Stop reading me, creep.", "Quit scanning me.", "Eyes off my chrome."), forced = "bounty chrome read") diff --git a/voidcrew/modules/bounties/bounty_combat_sense.dm b/voidcrew/modules/bounties/bounty_combat_sense.dm new file mode 100644 index 00000000000..d0dcbbff588 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_combat_sense.dm @@ -0,0 +1,51 @@ +/** + * Combat sense for bounty fighters (voidcrew/modules/human_npcs/human_npc_combat_sense.dm): normal + * criminals and their companions. Meek criminals (they run), decoys (patrons) and bosses (their own + * stimulant and fallback, bounty_boss.dm) don't have it. + * + * What they carry: a Wanted normal criminal also packs a stimpack (bounty_ai_normal.dm, + * bounty_build_outfit(); every Most Wanted is a mini-boss with its own pens), used when it tires in a fight, when its style leaves a + * pocket free (brawler, club, bottle, shotgun; not pistol or knife). Petty criminals and companions carry + * none. Under stun fire a criminal whose style shoots or throws from range runs for + * cover; a brawler, knife, club or shotgun style charges the shooter. Cover stays on the criminal's leash + * (leash_ok(); a companion keeps to its criminal's), and the movement is the bounty AI's own leash pathing. + */ +/datum/npc_combat_sense/bounty + +/mob/living/carbon/human/npc/bounty_criminal/normal + npc_combat_sense = /datum/npc_combat_sense/bounty + +/mob/living/carbon/human/npc/bounty_companion + npc_combat_sense = /datum/npc_combat_sense/bounty + +/datum/npc_combat_sense/bounty/can_act(mob/living/carbon/human/npc, datum/ai_controller/controller) + if(!..()) + return FALSE + if(istype(npc, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = npc + return criminal.ai_can_act() && !criminal.hidden && !criminal.blended + if(istype(npc, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = npc + return companion.ai_can_fight() + return TRUE + +// A criminal shares the players' faction until its placement says otherwise: its gang is its side. +/datum/npc_combat_sense/bounty/allied(mob/living/carbon/human/npc, atom/shooter) + return bounty_ai_same_gang(npc, shooter) + +/datum/npc_combat_sense/bounty/in_fight(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + return bounty_ai_fighting(npc) || sense.under_fire() + +/// Its style's distance: a gun style's shooting range, 1 for the melee styles +/datum/npc_combat_sense/bounty/reach(mob/living/carbon/human/npc, datum/ai_controller/controller) + var/datum/bounty_style/style = bounty_ai_style(npc) + if(!style?.ranged) + return 1 + var/datum/ai_behavior/shoot = GET_AI_BEHAVIOR(style.ranged_behavior) + return shoot?.required_distance || BOUNTY_PISTOL_RANGE + +/datum/npc_combat_sense/bounty/tile_ok(mob/living/carbon/human/npc, datum/ai_controller/controller, turf/tile) + if(!..()) + return FALSE + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(npc) + return isnull(side) || side.leash_ok(tile) diff --git a/voidcrew/modules/bounties/bounty_companions.dm b/voidcrew/modules/bounties/bounty_companions.dm index 909d993cbd6..26eb0c8da65 100644 --- a/voidcrew/modules/bounties/bounty_companions.dm +++ b/voidcrew/modules/bounties/bounty_companions.dm @@ -3,179 +3,328 @@ * * Owner: P3 AI. Do not edit this file outside P3. * - * What it owns: /mob/living/basic/bounty_companion (its body, its built-in values and its AI) and - * spawn_companions(). Companions (0-2, spec C7) keep a criminal company: they sit at the bar with - * them, take turns in their talk, sit round their camp, and fight beside them with a style of their - * own (BOUNTY_COMPANION_HEALTH x the style's health; its weapon hits as the real one does). The - * AI is the trooper's peaceful pattern: retaliation, then calling the rest of the gang in, then the - * attack, then going to whoever called. A badly hurt companion may run; when their criminal goes - * down, is cuffed or gives up, each may run; a stamina-crit companion gives up for good. + * What it owns: /mob/living/carbon/human/npc/bounty_companion (its built-in values, its outfit and its + * AI), bounty_spawn_companion() and spawn_companions(). Companions (0-2, spec C7) keep a criminal + * company: they sit at the bar with them (a real chair and a real glass), take turns in their talk, sit + * round their camp, and fight beside them with a style of their own: a real weapon they carry and draw + * (bounty_styles.dm). They are real human bodies (CR-11): a person's health and stamina, toughness 1, + * no multipliers. The AI is the trooper's peaceful pattern: retaliation, then calling the rest of the + * gang in, then the attack, then going to whoever called. Badly hurt, a companion may run; when their + * criminal goes down, is cuffed or gives up, each may run; up from a real stamina crit with hunters + * around, it gives up (tg's *surrender) or runs. It gives up the way its criminal does + * (/datum/human_npc_surrender/bounty). * - * They are not bounties, pay nothing, drop nothing and carry no real weapon (what is in their hand - * is only a picture, though its blows and shots are the real item's). P5 deletes them when the posting closes; deleting one at any time - * is safe. + * They are not bounties, pay nothing and drop nothing (their kit is locked to them, D3). P5 deletes + * them when the posting closes; deleting one at any time is safe. + * + * Who they are (A1): people of every species criminals come from (GLOB.bounty_species_weights), in rough + * clothes picked by their criminal's site (bounty_company_clothes()), and dressed for what their criminal + * was dressed for, so they are sealed up wherever it is (bounty_suits.dm). They are made with their + * criminal's spawn context (/datum/npc_spawn_context/bounty_company), which is how they know all that + * before they are dressed. + * + * They back their criminal up (C4): at most one of a criminal's company carries a gun + * (bounty_roll_company()), and a posting rolls its company with its site, so the sighting can say who + * they were seen with and the same company comes back each time the site loads (bounty_placement.dm). + * Joining a fight, they get up first and get their weapon out BOUNTY_COMPANION_DRAW_DELAY after their + * criminal does, and their guns fire shorter pistol strings and SMG bursts (bounty_styles.dm). Now and + * then one carries a piece of real chrome, rarer than their criminal's (bounty_chrome_roll_companion()), + * installed in their body like a criminal's and driven by the same chrome subtree. * * The shared vars are declared in bounty_types.dm and nowhere else (leader_ref); the rest here * have an `ai_` prefix. */ -/mob/living/basic/bounty_companion +/mob/living/carbon/human/npc/bounty_companion name = "associate" - desc = "Someone keeping bad company." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE - maxHealth = BOUNTY_COMPANION_HEALTH - health = BOUNTY_COMPANION_HEALTH - speed = BOUNTY_CALM_SPEED - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - basic_mob_flags = NONE - // Stamina crit compares raw loss with a percentage when it ends; a pool of 100 keeps the two the same. - max_stamina = 100 - max_stamina_slowdown = 1 - // Dressed for the site, as their criminal is: no dying of the air or the cold on the way. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - blood_volume = BLOOD_VOLUME_NORMAL - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - response_help_continuous = "pats" - response_help_simple = "pat" faction = list(FACTION_HOSTILE) ai_controller = /datum/ai_controller/basic_controller/bounty/companion - /// What they hold, drawn in their vis_contents - var/obj/effect/abstract/bounty_held/ai_hand - /// Hands up, over their head, once they give up - var/obj/effect/abstract/bounty_held/ai_over_head - /// Put their hands up (stamina crit): out of it for good - var/ai_gave_up = FALSE + npc_outfit_table = /datum/human_npc_outfit_table/bounty + npc_surrender = /datum/human_npc_surrender/bounty + // They stop once their target is down, as their criminal does (spec C4) + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // A person's toughness: no multipliers (CR-11) + npc_damage_taken = 1 + /// Their fighting style (BOUNTY_STYLE_*), picked when they are dressed + var/ai_style /// Ran off: out of it for good var/ai_fled = FALSE /// The one roll to run when badly hurt has been made var/ai_flee_rolled = FALSE - /// In the fight now (weapon out, fighting pace) + /// In the fight now (weapon out, at a run) var/ai_in_fight = FALSE - /// Their drink at the bar, in their contents + /// Went into a real stamina crit, and hasn't decided yet what to do about it + var/ai_stamcritted = FALSE + /// Their glass at the bar var/obj/item/reagent_containers/cup/glass/drinkingglass/ai_drink /// Weakref to the seat they sat on var/datum/weakref/ai_seat_ref - /// Sitting on the floor + /// Sitting on the floor (tg's resting, of their own choosing) var/ai_crouching = FALSE /// Where they mean to be while nothing is happening, and when that is next thought about var/turf/ai_idle_spot var/ai_next_idle_think = 0 /// world.time of their next word in the conversation var/ai_next_reply = 0 - -/mob/living/basic/bounty_companion/Destroy() + /// BOUNTY_PLACEMENT_* of their criminal's site, known as they are made (Initialize()), or null + var/company_site_kind + /// The /datum/outfit their rough clothes came from (bounty_site_clothes()), or null for their plain ones + var/company_clothes + /// Their criminal's tier (BOUNTY_TIER_*), known as they are made: what their chrome rolls by + var/company_tier + /// Who the posting rolled them as (bounty_roll_company()): their style and chrome, or null to roll their own as they are dressed + var/list/company_member + /// Their criminal has an armed companion already: they roll no gun of their own + var/company_unarmed = FALSE + /// The chrome keys in their body (BOUNTY_CHROME_*), and whether that was settled as they were dressed + var/list/ai_chrome + var/ai_chrome_rolled = FALSE + /// world.time they are up and may get their weapon out, in a fight they just joined + var/ai_draw_at = 0 + +/mob/living/carbon/human/npc/bounty_companion/Initialize(mapload, datum/human_npc_order/order) + // Before the base dresses them: where their criminal is, and what it was dressed for, so they are too + var/datum/npc_spawn_context/bounty_company/company = order?.context + var/mob/living/carbon/human/npc/bounty_criminal/leader = istype(company) ? company.company_leader_ref?.resolve() : null + if(!QDELETED(leader)) + company_site_kind = leader.posting()?.placement_kind + npc_always_site |= leader.npc_site + company_tier = leader.record?.tier + company_unarmed = bounty_company_armed(leader) + else + npc_always_site |= bounty_site_dress_for(order?.context) + company_site_kind ||= bounty_site_kind_of(order?.context) + // Who the posting rolled them as: the style they are dressed for, and their chrome + if(istype(company) && company.company_member) + company_member = company.company_member.Copy() + ai_style ||= company_member[BOUNTY_COMPANY_STYLE] + . = ..() + // Their chrome is in their body now (the base installed npc_cyberware): the AI side of it + if(length(ai_chrome)) + bounty_chrome_equip(src, ai_chrome) + +/mob/living/carbon/human/npc/bounty_companion/Destroy() ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) ai_idle_spot = null leader_ref = null return ..() -/mob/living/basic/bounty_companion/death(gibbed) +/mob/living/carbon/human/npc/bounty_companion/death(gibbed) ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) return ..() -/// Their criminal, if still around -/mob/living/basic/bounty_companion/proc/ai_leader() - var/mob/living/basic/bounty_criminal/leader = leader_ref?.resolve() - return QDELETED(leader) ? null : leader - -/// Whether they can still fight: awake, on their feet (a knockdown is a cuff window, H2), not run off, not given up -/mob/living/basic/bounty_companion/proc/ai_can_fight() - return stat == CONSCIOUS && !ai_gave_up && !ai_fled && !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) - /** - * Joins `leader`: their faction, a style of their own, a look for the site, and a name. Called by - * spawn_companions(). + * What they wear: plain clothes for the zone they are made in, their style's weapon (never a Wanted + * criminal's heavier one) and its spares, and a bruise pack. The style is the one the posting rolled + * for them, or picked here (never a second gun in their criminal's company), so the weapon they carry + * is the one they fight with. Their chrome is settled here too, once: the posting's, or a roll of their + * own (bounty_chrome_roll_companion()), into npc_cyberware, so the base installs it and the outfit table + * dresses them to show it. */ -/mob/living/basic/bounty_companion/proc/ai_join(mob/living/basic/bounty_criminal/leader) - leader_ref = WEAKREF(leader) - faction = leader.faction.Copy() - gender = pick(MALE, FEMALE) - name = generate_random_name_species_based(gender, TRUE, /datum/species/human) - real_name = name - ai_controller?.set_blackboard_key(BB_BOUNTY_LEADER, leader) - ai_take_style(pick_weight(list( +/mob/living/carbon/human/npc/bounty_companion/bounty_build_outfit(zone) + ai_style ||= bounty_roll_companion_style(no_gun = company_unarmed) + if(!ai_chrome_rolled) + ai_chrome_rolled = TRUE + var/list/keys = company_member ? company_member[BOUNTY_COMPANY_CHROME] : bounty_chrome_roll_companion(company_tier, ai_style) + ai_chrome = bounty_chrome_prepare_body(src, keys) + return bounty_fighter_outfit(BOUNTY_ARCHETYPE_NORMAL, zone, BOUNTY_TIER_PETTY, ai_style, FALSE, /obj/item/stack/medical/bruise_pack) + +/// Fighting style weights for a companion (BOUNTY_STYLE_* => weight): mostly fists and blades, a gun now and then +/proc/bounty_companion_style_weights() + return list( BOUNTY_STYLE_BRAWLER = 3, BOUNTY_STYLE_KNIFE = 2, BOUNTY_STYLE_CLUB = 2, BOUNTY_STYLE_PISTOL = 1, BOUNTY_STYLE_BOTTLE = 1, BOUNTY_STYLE_SHOTGUN = 1, - ))) - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(set_outpost_npc_look), src, bounty_companion_outfit(leader), gender, random_outpost_npc_look_number()) + BOUNTY_STYLE_SMG = 1, + BOUNTY_STYLE_REVOLVER = 1, + ) -/// Takes style `key`: health BOUNTY_COMPANION_HEALTH x the style's, and the style's own weapon (never a Wanted criminal's heavier one) -/mob/living/basic/bounty_companion/proc/ai_take_style(key) - var/datum/bounty_style/style = GLOB.bounty_styles[key] - if(!style) +/** + * A companion's fighting style (BOUNTY_STYLE_*), by weight. `no_gun` (their criminal has an armed + * companion already) rolls among the rest: fists, a knife, a bat or bottles. + */ +/proc/bounty_roll_companion_style(no_gun = FALSE) + var/list/weights = bounty_companion_style_weights() + if(no_gun) + for(var/key in weights.Copy()) + if(bounty_style_is_gun(key)) + weights -= key + return pick_weight(weights) + +/** + * The company of `count` companions for a criminal of `tier` (BOUNTY_TIER_*): one list per companion, + * its fighting style (BOUNTY_COMPANY_STYLE) and its chrome (BOUNTY_COMPANY_CHROME, rolled for that style, + * or null). At most one of them carries a gun, and that one comes first, so a server short of room for + * companions still spawns the armed one the sighting names. + */ +/proc/bounty_roll_company(count, tier) + var/list/company = list() + var/armed = FALSE + for(var/i in 1 to count) + var/style = bounty_roll_companion_style(no_gun = armed) + var/list/member = list(BOUNTY_COMPANY_STYLE = style, BOUNTY_COMPANY_CHROME = bounty_chrome_roll_companion(tier, style)) + if(bounty_style_is_gun(style)) + armed = TRUE + company.Insert(1, list(member)) + else + company += list(member) + return company + +/// Whether any of `leader`'s company carries a gun: their style is one (bounty_style_is_gun()) +/proc/bounty_company_armed(mob/living/carbon/human/npc/bounty_criminal/leader) + if(QDELETED(leader)) return FALSE - maxHealth = round(BOUNTY_COMPANION_HEALTH * style.health_mult) - health = maxHealth - damage_coeff = damage_coeff.Copy() - damage_coeff[STAMINA] = style.stamina_mult - return bounty_ai_apply_style(src, key) - -/// What a companion of `leader` wears, by where the criminal is -/proc/bounty_companion_outfit(mob/living/basic/bounty_criminal/leader) - var/datum/criminal_bounty/posting = leader?.posting() - switch(posting?.placement_kind) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in leader.ai_companions()) + if(bounty_style_is_gun(buddy.ai_style)) + return TRUE + return FALSE + +// Someone of any species a criminal can be, with a face from the criminals' own look builder and a name of their species +/mob/living/carbon/human/npc/bounty_companion/npc_apply_random_look() + var/species = bounty_company_species() + npc_apply_bounty_look(bounty_roll_look(species, pick(MALE, FEMALE))) + real_name = generate_random_name_species_based(gender, TRUE, dna.species.type) + name = real_name + +/// A species for a companion, weighted as for criminals (GLOB.bounty_species_weights) +/proc/bounty_company_species() + return pick_weight(GLOB.bounty_species_weights) + +// Their plain clothes give way to rough ones picked by their criminal's site +/mob/living/carbon/human/npc/bounty_companion/bounty_site_clothes(outfit) + var/list/choices = bounty_company_clothes(company_site_kind) + if(!length(choices) || isnull(outfit)) + return outfit + company_clothes = pick_weight(choices) + return bounty_wear_clothes_of(outfit, company_clothes) + +/** + * Rough clothes for a criminal's company at a site of `site_kind` (BOUNTY_PLACEMENT_*), each a /datum/outfit + * typepath => weight, so no two crews look alike; null where they keep their plain clothes for the zone. + * Only the clothes are taken from these (bounty_wear_clothes_of()), never what they carry. + */ +/proc/bounty_company_clothes(site_kind) + switch(site_kind) if(BOUNTY_PLACEMENT_NPC_SHIP) - return /datum/outfit/outpost_off_duty_pirate - if(BOUNTY_PLACEMENT_TRADER_OUTPOST) - return /datum/outfit/job/assistant/greytide - return pick(/datum/outfit/hermit/survivor, /datum/outfit/hermit/survivor/hunter) + return list( + /datum/outfit/outpost_off_duty_pirate = 1, + /datum/outfit/bounty_criminal/hauler = 1, + /datum/outfit/bounty_criminal/tough = 1, + /datum/outfit/bounty_criminal/drifter = 1, + ) + if(BOUNTY_PLACEMENT_PLANET, BOUNTY_PLACEMENT_RUIN) + return list( + /datum/outfit/hermit/survivor = 2, + /datum/outfit/bounty_criminal/spacer = 1, + /datum/outfit/bounty_criminal/hauler = 1, + /datum/outfit/bounty_criminal/drifter = 1, + /datum/outfit/bounty_criminal/tough = 1, + ) + return null + +/** + * `outfit` (a typepath or an instance) in the clothes of `clothes_type`: its uniform, suit, hat, mask, + * glasses, gloves, shoes and neck, empty where that outfit leaves them empty. Weapons, bags, belts, pockets + * and anything in hand stay as they were. Returns an instance (one passed in is changed in place). + */ +/proc/bounty_wear_clothes_of(outfit, datum/outfit/clothes_type) + var/datum/outfit/dressed = ispath(outfit, /datum/outfit) ? new outfit() : outfit + if(!istype(dressed) || !ispath(clothes_type, /datum/outfit)) + return outfit + dressed.uniform = initial(clothes_type.uniform) + dressed.suit = initial(clothes_type.suit) + dressed.head = initial(clothes_type.head) + dressed.mask = initial(clothes_type.mask) + dressed.glasses = initial(clothes_type.glasses) + dressed.gloves = initial(clothes_type.gloves) + dressed.shoes = initial(clothes_type.shoes) + dressed.neck = initial(clothes_type.neck) + return dressed + +/// Their criminal, if still around +/mob/living/carbon/human/npc/bounty_companion/proc/ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = leader_ref?.resolve() + return QDELETED(leader) ? null : leader + +/** + * Whether they can still fight: awake, out of crit, on their feet (a knockdown is a cuff window, H2), + * free, not giving up and not run off. All read from tg. + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_can_fight() + if(stat != CONSCIOUS || ai_fled || npc_surrendering || HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(src, TRAIT_RESTRAINED)) + return FALSE + return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) + +/** + * Joins `leader`: their faction, the style they were dressed for, and a place on the criminal's and + * the posting's list of companions (whoever spawned them). Called by bounty_spawn_companion(). + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_join(mob/living/carbon/human/npc/bounty_criminal/leader) + if(QDELETED(leader)) + return FALSE + leader_ref = WEAKREF(leader) + faction = leader.faction.Copy() + ai_controller?.set_blackboard_key(BB_BOUNTY_LEADER, leader) + bounty_ai_apply_style(src, ai_style || BOUNTY_STYLE_BRAWLER) + var/datum/weakref/our_ref = WEAKREF(src) + leader.companions |= our_ref + var/datum/criminal_bounty/posting = leader.posting() + if(posting) + posting.companions |= our_ref + bounty_ai_set_pace(src, BOUNTY_CALM_SPEED) + return TRUE /// Called in by their criminal: `target` is theirs to fight too -/mob/living/basic/bounty_companion/proc/ai_join_fight(mob/living/target) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_join_fight(mob/living/target) if(!ai_can_fight() || !isliving(target) || QDELETED(ai_controller) || bounty_ai_same_gang(src, target)) return ai_mark_enemy(target) - var/mob/living/basic/bounty_criminal/leader = ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = ai_leader() if(leader && get_dist(src, leader) > 4) ai_controller.set_blackboard_key(BB_BASIC_MOB_REINFORCEMENT_TARGET, leader) ai_enter_fight() -/// Weapon out, up from their seat, fighting pace -/mob/living/basic/bounty_companion/proc/ai_enter_fight() +/** + * Up from their seat, drink down, at a run. Their weapon comes out through the arm subtree + * (bounty_styles.dm) once they are up, BOUNTY_COMPANION_DRAW_DELAY later (bounty_companion_getting_up), + * a beat after their criminal's. + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_enter_fight() if(ai_in_fight) return ai_in_fight = TRUE + ai_draw_at = world.time + BOUNTY_COMPANION_DRAW_DELAY ai_put_drink_away() ai_stand_up() - var/datum/bounty_style/style = bounty_ai_style(src) - set_varspeed(style?.speed || BOUNTY_NORMAL_SPEED_MELEE) - ai_hand = bounty_ai_update_held(src, ai_hand, bounty_ai_held_look(src)) + bounty_ai_set_pace(src, BOUNTY_RUN_SPEED) -/// The fight is over for them: weapon away, easy pace -/mob/living/basic/bounty_companion/proc/ai_leave_fight() +/// The fight is over for them: weapon away, at a walk +/mob/living/carbon/human/npc/bounty_companion/proc/ai_leave_fight() if(!ai_in_fight) return ai_in_fight = FALSE - set_varspeed(BOUNTY_CALM_SPEED) - ai_hand = bounty_ai_update_held(src, ai_hand, null) + ai_draw_at = 0 + bounty_ai_set_pace(src, BOUNTY_CALM_SPEED) + bounty_ai_holster(src) /// Their criminal is down, cuffed or gave up: half the time they run for it -/mob/living/basic/bounty_companion/proc/ai_lose_heart() +/mob/living/carbon/human/npc/bounty_companion/proc/ai_lose_heart() if(!ai_can_fight() || !prob(50)) return var/mob/living/threat = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] ai_run_off(isliving(threat) ? threat : null) /// Runs off, out of the fight for good -/mob/living/basic/bounty_companion/proc/ai_run_off(mob/living/threat) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_run_off(mob/living/threat) if(ai_fled || stat != CONSCIOUS) return ai_fled = TRUE ai_let_go() - set_varspeed(BOUNTY_NORMAL_SPEED_MELEE) + bounty_ai_holster(src) + bounty_ai_set_pace(src, BOUNTY_RUN_SPEED) visible_message(span_notice("[src] backs off and runs for it.")) if(QDELETED(ai_controller)) return @@ -186,25 +335,36 @@ ai_controller.set_blackboard_key(BB_BASIC_MOB_FLEE_TARGET, bounty_ai_target_for(threat)) ai_controller.CancelActions() -/// Stamina crit: hands up, done with it -/mob/living/basic/bounty_companion/proc/ai_give_up() - if(ai_gave_up || stat == DEAD) - return - ai_gave_up = TRUE - ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, /obj/effect/abstract/bounty_surrender_mark, over_head = TRUE) +/// They gave up (tg's *surrender, through the human NPC base): the fight dropped +/mob/living/carbon/human/npc/bounty_companion/proc/ai_on_surrendered() visible_message(span_notice("[src] puts [p_their()] hands up.")) + ai_let_go() if(QDELETED(ai_controller)) return ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) ai_controller.clear_blackboard_key(BB_BASIC_MOB_RETALIATE_LIST) - ai_controller.CancelActions() + +/** + * Up from a real stamina crit (CR-11): with hunters around, they give up (tg's *surrender) or run; + * with nobody about, they get back to it. + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_after_stamcrit() + ai_stamcritted = FALSE + if(!ai_can_fight()) + return + var/datum/human_npc_surrender/rules = npc_surrender_rules() + var/list/hostiles = rules?.hostiles_near(src) + if(!length(hostiles)) + return + if(prob(BOUNTY_COMPANION_GIVE_UP_CHANCE) && npc_surrender()) + return + ai_run_off(get_closest_atom(/mob/living, hostiles, src)) /** * `enemy` is someone to fight: onto their retaliation list, and the mech they sit in with them (H4: * the targeting takes the mech while they are in it, and them once they climb out). */ -/mob/living/basic/bounty_companion/proc/ai_mark_enemy(mob/living/enemy) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_mark_enemy(mob/living/enemy) if(QDELETED(ai_controller) || !isliving(enemy)) return ai_controller.insert_blackboard_key_lazylist(BB_BASIC_MOB_RETALIATE_LIST, enemy) @@ -218,8 +378,8 @@ * blow from their own side of the site with nobody playing it is let go (M1). Knocked down, they * still remember who did it, and fight once up. */ -/mob/living/basic/bounty_companion/proc/ai_attacked_by(mob/living/attacker) - if(stat == DEAD || ai_gave_up || ai_fled || !isliving(attacker) || bounty_ai_same_gang(src, attacker)) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_attacked_by(mob/living/attacker) + if(stat == DEAD || ai_fled || !isliving(attacker) || bounty_ai_same_gang(src, attacker)) return if(bounty_ai_friendly_fire(src, attacker)) return @@ -228,24 +388,24 @@ ai_enter_fight() ai_leader()?.ai_ally_attacked(attacker) -/// About to take damage: badly hurt, they may run (one roll) -/mob/living/basic/bounty_companion/proc/ai_took_damage(damage, damagetype) +/// They took a hit: badly hurt (a person's health, read after the hit), they may run (one roll) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_took_damage(damage, damagetype) if(ai_flee_rolled || damage <= 0 || !ai_can_fight() || !(damagetype in list(BRUTE, BURN))) return - if(health - damage > maxHealth * BOUNTY_COMPANION_FLEE_BELOW) + if(health > maxHealth * BOUNTY_COMPANION_FLEE_BELOW) return ai_flee_rolled = TRUE if(prob(BOUNTY_COMPANION_FLEE_CHANCE)) var/mob/living/threat = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] INVOKE_ASYNC(src, PROC_REF(ai_run_off), isliving(threat) ? threat : null) -/// Everything down: drink, seat, weapon -/mob/living/basic/bounty_companion/proc/ai_let_go() +/// Everything down: drink, seat or the floor. What they hold stays held (D3). +/mob/living/carbon/human/npc/bounty_companion/proc/ai_let_go() ai_put_drink_away() ai_stand_up() - ai_hand = bounty_ai_update_held(src, ai_hand, null) -/mob/living/basic/bounty_companion/proc/ai_sit_on(obj/structure/chair/seat) +/// Sits on `seat` from its tile: buckled to it, as anyone sitting down is +/mob/living/carbon/human/npc/bounty_companion/proc/ai_sit_on(obj/structure/chair/seat) if(QDELETED(seat) || loc != seat.loc || (seat.has_buckled_mobs() && buckled != seat)) return FALSE if(buckled == seat) @@ -256,40 +416,49 @@ ai_seat_ref = WEAKREF(seat) return TRUE -/mob/living/basic/bounty_companion/proc/ai_crouch() - if(ai_crouching || buckled) +/// Sits down on the ground: tg's resting +/mob/living/carbon/human/npc/bounty_companion/proc/ai_crouch() + if(ai_crouching || buckled || resting) return ai_crouching = TRUE - add_offsets(BOUNTY_AI_TRAIT, y_add = -4) + set_resting(TRUE, silent = TRUE) -/// Up from a seat they sat on themselves, or the floor -/mob/living/basic/bounty_companion/proc/ai_stand_up() +/// Up from a seat they sat on themselves, or the floor they lay down on themselves +/mob/living/carbon/human/npc/bounty_companion/proc/ai_stand_up() if(ai_crouching) ai_crouching = FALSE - remove_offsets(BOUNTY_AI_TRAIT) + if(resting) + set_resting(FALSE, silent = TRUE) var/obj/structure/chair/seat = ai_seat_ref?.resolve() ai_seat_ref = null if(seat && buckled == seat) seat.unbuckle_mob(src, force = TRUE) -/mob/living/basic/bounty_companion/proc/ai_take_drink() - if(!QDELETED(ai_drink)) +/// A glass of something poured into a free hand +/mob/living/carbon/human/npc/bounty_companion/proc/ai_take_drink() + if(!QDELETED(ai_drink) && is_holding(ai_drink)) + return ai_drink + ai_drink = bounty_ai_pour_drink(src) + return ai_drink + +/// The glass goes: on the table beside them if there is one, else it is gone (M4, L7) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_put_drink_away() + if(QDELETED(ai_drink)) + ai_drink = null return - ai_drink = new(src) - ai_drink.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) - ai_hand = bounty_ai_update_held(src, ai_hand, ai_drink) - -/mob/living/basic/bounty_companion/proc/ai_put_drink_away() - if(ai_drink) - QDEL_NULL(ai_drink) - ai_hand = bounty_ai_update_held(src, ai_hand, null) + var/obj/item/glass = ai_drink + ai_drink = null + var/obj/structure/table/table = locate() in range(1, src) + if(stat == CONSCIOUS && bounty_ai_set_glass_down(src, glass, table)) + return + qdel(glass) /** * Where they mean to be while nothing is happening, thought about every few seconds rather than * every plan: beside their criminal when they talk, on a seat near them at the bar, beside the fire * at the camp, otherwise within BOUNTY_COMPANION_FOLLOW of them. Null means right where they are. */ -/mob/living/basic/bounty_companion/proc/ai_pick_idle_spot(mob/living/basic/bounty_criminal/leader) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_pick_idle_spot(mob/living/carbon/human/npc/bounty_criminal/leader) var/datum/bounty_activity/activity = leader.ai_activity if(istype(activity, /datum/bounty_activity/chat)) var/datum/bounty_activity/chat/talk = activity @@ -312,9 +481,9 @@ return ai_free_tile_near(leader, BOUNTY_COMPANION_FOLLOW) /// The nearest free tile within `distance` of `thing` (not its own), on their criminal's site -/mob/living/basic/bounty_companion/proc/ai_free_tile_near(atom/thing, distance) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_free_tile_near(atom/thing, distance) var/turf/center = get_turf(thing) - var/mob/living/basic/bounty_criminal/leader = ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = ai_leader() if(!center) return null var/turf/best @@ -334,18 +503,22 @@ /** * Hanging out with their criminal, about once a second: sat down where there is a seat (with a - * drink at the bar), sat by the fire at the camp, facing them, and now and then a word back. + * drink at the bar, which they really drink now and then), sat by the fire at the camp, facing them, + * and now and then a word back. */ -/mob/living/basic/bounty_companion/proc/ai_hang_out() - var/mob/living/basic/bounty_criminal/leader = ai_leader() +/mob/living/carbon/human/npc/bounty_companion/proc/ai_hang_out() + var/mob/living/carbon/human/npc/bounty_criminal/leader = ai_leader() if(!leader) return var/obj/structure/chair/seat = locate() in loc if(seat && istype(leader.buckled, /obj/structure/chair) && ai_sit_on(seat)) - ai_take_drink() + if(QDELETED(ai_drink)) + ai_take_drink() + else if(prob(5)) + bounty_ai_sip(src, ai_drink) else if(istype(leader.ai_activity, /datum/bounty_activity/camp)) ai_crouch() - if(!buckled && get_dist(src, leader) <= 3) + if(!buckled && !resting && get_dist(src, leader) <= 3) face_atom(leader) if(world.time < ai_next_reply || leader.ai_mode != BOUNTY_AI_CALM || get_dist(src, leader) > 3) return @@ -353,19 +526,85 @@ if(prob(50) && !istype(leader.ai_activity, /datum/bounty_activity/chat)) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "companion_reply") +// ===== SPAWNING ===== + +/** + * Queues one companion for `leader` at `where` (the human NPC spawn queue: never a room of humans in + * one tick), who joins them as it is made: their faction, their side, their posting's list. Then + * `on_spawned` is called with it. If the criminal is gone by then, the companion is not kept. + * `immediate` builds it now (tests, admin tools). Returns the spawn order (qdel() cancels it). It is + * made with its criminal's spawn context, so it is dressed for where they are. `member` (one entry of + * bounty_roll_company()) is who it is: its style and chrome; without it, it rolls its own. + */ +/proc/bounty_spawn_companion(mob/living/carbon/human/npc/bounty_criminal/leader, turf/where, datum/callback/on_spawned, immediate = FALSE, list/member) + where = get_turf(where) + if(QDELETED(leader) || !where) + return null + var/datum/npc_spawn_context/bounty_company/company = new(leader, null, "ordinary") + company.company_leader_ref = WEAKREF(leader) + company.company_member = member + return spawn_human_npc(/mob/living/carbon/human/npc/bounty_companion, where, CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_companion_spawned), WEAKREF(leader), on_spawned), context = company, immediate = immediate) + +/** + * A companion's spawn context: its criminal's own (the site, zone and source it inherits from the + * criminal it is made from), which criminal that is, and who the posting rolled the companion as, for + * the companion to read as it is dressed. + */ +/datum/npc_spawn_context/bounty_company + /// Weakref to the criminal the companion is made for + var/datum/weakref/company_leader_ref + /// One entry of bounty_roll_company() (BOUNTY_COMPANY_STYLE, BOUNTY_COMPANY_CHROME), or null to roll their own + var/list/company_member + +/datum/npc_spawn_context/bounty_company/Destroy() + company_leader_ref = null + company_member = null + return ..() + +/** + * Whether a criminal may hide aboard `npc_ship` (board_pick_npc_ship()): a crew of ordinary people, the + * grey tide's salvagers, Interdyne's rogue researchers or a crew with no faction. Never a themed crew's + * (skeletons, geodes, lizard nobles, knights, customs and the like). + */ +/proc/bounty_ship_hides_criminals(obj/structure/overmap/ship/npc/pirate/npc_ship) + if(!istype(npc_ship)) + return FALSE + return !npc_ship.pirate_faction || (npc_ship.pirate_faction in list("grey", "interdyne")) + +/// A queued companion is made: it joins its criminal, or goes if its criminal is gone +/proc/bounty_companion_spawned(datum/weakref/leader_ref, datum/callback/on_spawned, mob/living/carbon/human/npc/bounty_companion/companion) + var/mob/living/carbon/human/npc/bounty_criminal/leader = leader_ref?.resolve() + if(QDELETED(companion)) + return + if(QDELETED(leader) || !companion.ai_join(leader)) + qdel(companion) + return + on_spawned?.Invoke(companion) + // ===== THE CRIMINAL'S SIDE ===== /** * Spawns `count` companions (clamped to BOUNTY_COMPANIONS_MIN-BOUNTY_COMPANIONS_MAX) on free tiles - * beside them, remembered in their own `companions`. Returns the companions spawned: the caller - * (P5) puts them on the posting, and keeps to the global cap on live companions. + * beside them, through the human NPC spawn queue (bounty_spawn_companion()). Each joins them, and their + * posting, as it is made. They are `company` (bounty_roll_company(), the posting's), and any more than + * it has are rolled here, never a second gun among them. Returns the companions already made: only with + * `immediate` (tests, admin tools), otherwise an empty list. */ -/mob/living/basic/bounty_criminal/proc/spawn_companions(count) +/mob/living/carbon/human/npc/bounty_criminal/proc/spawn_companions(count, immediate = FALSE, list/company) . = list() count = clamp(count, BOUNTY_COMPANIONS_MIN, BOUNTY_COMPANIONS_MAX) var/turf/home = get_turf(src) if(!home || count <= 0) return + company = company ? company.Copy() : list() + if(length(company) < count) + var/armed = bounty_company_armed(src) + for(var/list/member as anything in company) + armed ||= bounty_style_is_gun(member[BOUNTY_COMPANY_STYLE]) + for(var/i in length(company) + 1 to count) + var/style = bounty_roll_companion_style(no_gun = armed) + armed ||= bounty_style_is_gun(style) + company += list(list(BOUNTY_COMPANY_STYLE = style, BOUNTY_COMPANY_CHROME = bounty_chrome_roll_companion(record?.tier, style))) var/list/spots = list() for(var/turf/tile as anything in RANGE_TURFS(1, home)) if(tile != home && ai_standable(tile)) @@ -377,16 +616,16 @@ for(var/i in 1 to count) if(!length(spots)) break - var/mob/living/basic/bounty_companion/buddy = new(pick_n_take(spots)) - buddy.ai_join(src) - companions |= WEAKREF(buddy) - . += buddy + var/datum/human_npc_order/order = bounty_spawn_companion(src, pick_n_take(spots), null, immediate, company[i]) + var/mob/living/carbon/human/npc/bounty_companion/buddy = order?.spawned_ref?.resolve() + if(!QDELETED(buddy)) + . += buddy /** * One of their companions was attacked by `attacker`: it goes on their grudge list, and the gang * turns on them. Never while blended in (C1), and never over friendly fire (M1). */ -/mob/living/basic/bounty_criminal/proc/ai_ally_attacked(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_ally_attacked(mob/living/attacker) if(blended || !isliving(attacker) || bounty_ai_same_gang(src, attacker) || bounty_ai_friendly_fire(src, attacker)) return ai_add_grudge(attacker) @@ -397,18 +636,31 @@ // ===== AI ===== /** - * A companion's controller: the trooper's peaceful pattern (retaliate, call the others in, - * attack by style, go to whoever called), then hanging out with their criminal. + * A companion's controller: the trooper's peaceful pattern (retaliate, call the others in, get up, + * attack by style with the real weapon or their chrome, go to whoever called), patching themselves up + * when it is quiet (N6), then hanging out with their criminal. It gives up as its criminal does + * (human_npc_surrender). */ /datum/ai_controller/basic_controller/bounty/companion planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/human_npc_surrender, /datum/ai_planning_subtree/bounty_companion_gate, /datum/ai_planning_subtree/flee_target/bounty, /datum/ai_planning_subtree/target_retaliate/bounty, /datum/ai_planning_subtree/call_reinforcements/bounty, - /datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty, - /datum/ai_planning_subtree/basic_melee_attack_subtree/bounty, + // Just joined the fight: up first, their criminal draws before they do + /datum/ai_planning_subtree/bounty_companion_getting_up, + // Their chrome, if they carry any: a tell, then the implant's own action (C0, bounty_chrome.dm) + /datum/ai_planning_subtree/bounty_chrome, + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/bounty_arm, + /datum/ai_planning_subtree/bounty_ranged, + /datum/ai_planning_subtree/bounty_melee, /datum/ai_planning_subtree/travel_to_point/and_clear_target/reinforce/bounty, + /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_companion_idle, ) @@ -418,53 +670,61 @@ * takes a pilot's mech on with them (H4). */ /datum/ai_controller/basic_controller/bounty/companion/TryPossessPawn(atom/new_pawn) - if(!istype(new_pawn, /mob/living/basic/bounty_companion)) + if(!istype(new_pawn, /mob/living/carbon/human/npc/bounty_companion)) return AI_CONTROLLER_INCOMPATIBLE - var/mob/living/basic/bounty_companion/companion = new_pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = new_pawn RegisterSignal(companion, COMSIG_LIVING_ENTER_STAMCRIT, PROC_REF(on_stamcrit)) + RegisterSignal(companion, COMSIG_HUMAN_NPC_RECOVERED, PROC_REF(on_recovered)) + RegisterSignal(companion, COMSIG_HUMAN_NPC_SURRENDERED, PROC_REF(on_surrendered)) return ..() /datum/ai_controller/basic_controller/bounty/companion/UnpossessPawn(destroy) if(pawn) - UnregisterSignal(pawn, COMSIG_LIVING_ENTER_STAMCRIT) + UnregisterSignal(pawn, list(COMSIG_LIVING_ENTER_STAMCRIT, COMSIG_HUMAN_NPC_RECOVERED, COMSIG_HUMAN_NPC_SURRENDERED)) return ..() /datum/ai_controller/basic_controller/bounty/companion/may_shoot(atom/target) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn return istype(companion) && companion.ai_can_fight() && ..() /datum/ai_controller/basic_controller/bounty/companion/may_strike(atom/target) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn return istype(companion) && companion.ai_can_fight() && ..() /datum/ai_controller/basic_controller/bounty/companion/pawn_attacked(mob/living/attacker) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn if(istype(companion)) companion.ai_attacked_by(attacker) /datum/ai_controller/basic_controller/bounty/companion/pawn_damaged(damage, damagetype) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn if(istype(companion)) companion.ai_took_damage(damage, damagetype) -/datum/ai_controller/basic_controller/bounty/companion/place_held(facing) - var/mob/living/basic/bounty_companion/companion = pawn - if(istype(companion)) - bounty_ai_place_held(companion, companion.ai_hand, facing) +/datum/ai_controller/basic_controller/bounty/companion/proc/on_stamcrit(mob/living/carbon/human/npc/bounty_companion/source) + SIGNAL_HANDLER + source.ai_stamcritted = TRUE + +/// Back up: from a real stamina crit, they decide whether to give up or run +/datum/ai_controller/basic_controller/bounty/companion/proc/on_recovered(mob/living/carbon/human/npc/bounty_companion/source) + SIGNAL_HANDLER + if(source.ai_stamcritted && source.stat == CONSCIOUS) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_companion, ai_after_stamcrit)) -/datum/ai_controller/basic_controller/bounty/companion/proc/on_stamcrit(mob/living/basic/bounty_companion/source) +/datum/ai_controller/basic_controller/bounty/companion/proc/on_surrendered(mob/living/carbon/human/npc/bounty_companion/source) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_companion, ai_give_up)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_companion, ai_on_surrendered)) /** - * First in a companion's tree: nothing while given up or down; one that ran off only runs, and - * once clear of whoever it ran from, stays out of it. + * A companion's own subtrees start here, after soft crit, breaking free and giving up: nothing while + * it can't act (in crit, cuffed, stunned, giving up); one that ran off only runs, and once clear of + * whoever it ran from, stays out of it. */ /datum/ai_planning_subtree/bounty_companion_gate /datum/ai_planning_subtree/bounty_companion_gate/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_companion/companion = controller.pawn - if(!istype(companion) || companion.stat != CONSCIOUS || companion.ai_gave_up) + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn + if(!istype(companion) || companion.stat != CONSCIOUS || companion.npc_surrendering || HAS_TRAIT(companion, TRAIT_RESTRAINED)) return SUBTREE_RETURN_FINISH_PLANNING // Knocked down or stunned: nothing at all until they are up (H2) if(HAS_TRAIT(companion, TRAIT_FLOORED) || HAS_TRAIT(companion, TRAIT_INCAPACITATED)) @@ -475,6 +735,22 @@ if(QDELETED(threat) || bounty_ai_dist(companion, threat) >= (controller.blackboard[BB_BASIC_MOB_FLEE_DISTANCE] || BOUNTY_FLEE_DISTANCE)) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Just in the fight (ai_enter_fight()), a companion gets up from where they were first: for + * BOUNTY_COMPANION_DRAW_DELAY they turn to face whoever they are going for, and no weapon comes out and + * no blow or shot is planned, so their criminal's draw comes first. Then the arm subtree draws as usual. + */ +/datum/ai_planning_subtree/bounty_companion_getting_up + +/datum/ai_planning_subtree/bounty_companion_getting_up/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn + if(!istype(companion) || !companion.ai_in_fight || world.time >= companion.ai_draw_at) + return + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!QDELETED(target)) + companion.face_atom(target) + return SUBTREE_RETURN_FINISH_PLANNING + /// tg's retaliation, while they can still fight. Its list is filled by the controller's hooks, not tg's element. /datum/ai_planning_subtree/target_retaliate/bounty operational_datums = null @@ -497,7 +773,7 @@ /datum/ai_behavior/call_reinforcements/bounty/perform(seconds_per_tick, datum/ai_controller/controller) var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - var/mob/living/basic/bounty_criminal/leader = bounty_ai_side_of(controller.pawn) + var/mob/living/carbon/human/npc/bounty_criminal/leader = bounty_ai_side_of(controller.pawn) if(leader && isliving(target)) leader.ai_ally_attacked(target) controller.set_blackboard_key(BB_BASIC_MOB_REINFORCEMENTS_COOLDOWN, world.time + BOUNTY_REINFORCE_COOLDOWN) @@ -514,10 +790,10 @@ /datum/ai_planning_subtree/bounty_companion_idle /datum/ai_planning_subtree/bounty_companion_idle/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_companion/companion = controller.pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn if(!companion.ai_can_fight()) return - var/mob/living/basic/bounty_criminal/leader = companion.ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = companion.ai_leader() if(companion.ai_in_fight && !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && leader?.ai_mode != BOUNTY_AI_FIGHTING) companion.ai_leave_fight() if(!leader) @@ -541,7 +817,7 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/bounty_companion_hang_out/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_companion/companion = controller.pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn if(!istype(companion) || !companion.ai_can_fight()) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED companion.ai_hang_out() diff --git a/voidcrew/modules/bounties/bounty_criminal.dm b/voidcrew/modules/bounties/bounty_criminal.dm index e8f95e0702f..d4e10fb8479 100644 --- a/voidcrew/modules/bounties/bounty_criminal.dm +++ b/voidcrew/modules/bounties/bounty_criminal.dm @@ -5,97 +5,48 @@ * bounty exemption line in voidcrew/_HELPERS/hostile_creatures.dm and the tests in * voidcrew/modules/unit_tests/voidcrew_bounty_criminal.dm. Do not edit this file outside P2. * - * What it owns: the criminal mob's body (health, stamina, status flags and the built-in values of - * /mob/living/basic/bounty_criminal, /meek and /normal), the downed state and its mercy rule and - * recovery, capture_state(), refusing teleports and polymorph, the planet-sweep protection, - * spawn_bounty_criminal(), the leash and who it may attack (spec sections 4 and 12). + * A criminal is a real human (/mob/living/carbon/human/npc, voidcrew/modules/human_npcs/), so its body + * is a player's: tg's soft and hard crit, stamina and stamcrit, stuns, wounds, bleeding, cuffs, grabs, + * fireman carries, fire, cold, suffocation, death and revival (owner decision D5, no stand-ins). What + * this file adds on top is the bounty's own rules: + * - Who it is (record, posting) and its look: its record's face (the human NPC base applies it), in the + * outfit its archetype and tier give it (bounty_outfits.dm). Toughness is the armour it wears and + * npc_damage_taken by tier (body_toughness()), never health. + * - capture_state(), read from tg at the moment it is asked: dead; in crit (soft or hard, even + * cuffed); restrained (tg's TRAIT_RESTRAINED: handcuffs, cable, zipties, a straitjacket); stunned + * (stamcrit, knockdown, stun, paralysis including tg's *surrender, unconscious, asleep); free. The pad + * pays on the state at the press (owner ruling 2026-11-08: no worst-state ratchet). + * - What it says: npc_bark() speaks through bounty_say() and strings/bounty_criminals.json, so the human + * NPC base's reactions (crit, getting up, cuffs, working the cuffs, breaking a grab, giving up) use the + * criminal lines. + * - Its site and leash (AR-D2, H1, M3): the site's rectangle, or the pirate ship it is wanted aboard, + * less every ship, hangar, player outpost and dock strip inside it. Its AI's movement keeps to it + * (/datum/ai_movement/jps/bounty); anyone can drag, carry or shove it anywhere. Off its site, and + * anywhere at a trader outpost, it fights only those on its grudge list and never structures. + * - No teleports (the pad deletes it), and a container it is in can't be teleported either; no fultons, + * no polymorph, no type change (M2). Turrets read its tg state (bounty_turret_ignores()). + * - Its body is the proof (AR-A5): a corpse is turned in for the dead share; a body destroyed with its + * posting open (gibbed, dusted, lost in a chasm or lava) leaves an evidence tag through P5's + * bounty_drop_proof(), unless it was removed on purpose (TRAIT_BOUNTY_REMOVED, or body_mark_removed()). + * A live body deleted never does. It can be revived like anyone; it pays by its condition when it + * reaches the pad. + * Cuffs and the reaction to a reach for its wrists are in bounty_restraints.dm. * - * The body, in short: - * - Health: meek BOUNTY_MEEK_HEALTH; normal by tier times its style; mini-bosses per kit (P4). - * - Stamina: max_stamina is always 100 (tg's basic mobs leave stamina crit by comparing raw points - * with a percentage), so a pool of another size is the stamina damage coefficient, 100 / pool. - * - Downed at BOUNTY_DOWNED_BELOW percent: floored, harmless and draggable. A hit on one still on its - * feet stops just under the line (the mercy rule), so killing it is always a choice; a hit on one - * that is down can kill. It gets up after its recovery time, groaning for the last - * BOUNTY_BODY_STIRRING_TELL, at BOUNTY_RECOVER_TO percent. Cuffed, it stays down; healing does not - * stand it up; a stamina hit starts the clock again (C1). A mini-boss that gets up rallies for - * BOUNTY_BODY_RALLY_TIME: stamina does nothing to it. - * - On its site, damage with nobody's mind behind it (turrets, traps, fire, fauna) stops at - * BOUNTY_AUTOMATED_FLOOR percent. /datum/component/bounty_body keeps the ledger (AR-D3): a hit is a - * player's when someone with a mind made it, fired it (a mech's or vehicle's shot counts its pilot), - * last touched the explosive, or stands near an explosion. - * - Fire and heat burn it like a suited person (L3), within the mercy line and the floor. - * - worst_state only ever gets worse (spec 12.1); P5 pays on it, and carries it to a respawned copy - * with body_restore_worst_state(). - * - On the floor and draggable (down, stunned, cuffed or dead), it can be fireman carried like a - * person. It squirms off the carrier's shoulders once it can't be dragged any more. - * - Dead, the body stays and can't be revived (AR-A5). A dead body destroyed with its posting open - * (gibbed, dusted, lost in a chasm) asks P5's bounty_drop_proof() for proof of death, unless it was - * removed on purpose (P5's TRAIT_BOUNTY_REMOVED, or body_mark_removed()). A live one deleted never - * does. P5 decides where the proof goes. - * - Its site: the site's rectangle (or the pirate ship it is wanted aboard), less every ship, hangar, - * player outpost and planet dock strip inside it (H1). Leashed to it (AR-D2): its own feet never - * take it off, nor onto a trader outpost's lift alcove, lava, a chasm or space. Once off (woken - * aboard a ship, dragged away), it may walk about where it is and back, but never on into another - * ship or hangar or into a player outpost (M3). Anyone can still drag it anywhere, and a scripted - * walk (TRAIT_BOUNTY_SCRIPTED_MOVE, or godmode) goes where it is sent. Off its site, and anywhere at - * a trader outpost, it fights only those on its grudge list and never structures (AR-D1, AR-C8). - * - No teleports (the pad deletes it), and a container it is in can't be teleported either; no - * fultons, no polymorph, no type change, no sentience (M2). Turrets at trader outposts leave it - * alone, and no turret shoots one that is down, stunned or restrained (bounty_turret_ignores()). - * Restraints are in bounty_restraints.dm. - * - * The shared vars are declared in bounty_types.dm and nowhere else; a var only P2 uses goes here - * with a `body_` prefix. + * What differs from a player's body is only the human NPC base's documented list (dormancy off-z, no + * mood or hunger, locked gear). The shared vars are declared in bounty_types.dm and nowhere else; a var + * only P2 uses goes here with a `body_` prefix. */ // ===== BUILT-IN VALUES ===== -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal name = "criminal" - desc = "Someone with a price on their head." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE - maxHealth = BOUNTY_NORMAL_HEALTH_WANTED - health = BOUNTY_NORMAL_HEALTH_WANTED - speed = BOUNTY_BODY_NORMAL_SPEED_MELEE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - mob_size = MOB_SIZE_HUMAN - sentience_type = BOUNTY_CRIMINAL_NO_SENTIENCE - density = TRUE - // Too heavy to shove about while free and on its feet; see body_update_drag(). - move_resist = MOVE_FORCE_VERY_STRONG - // No DEL_ON_DEATH: the body is the proof (AR-A5). - basic_mob_flags = NONE - // Batons knock them down, as they do people. - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - max_stamina = BOUNTY_CRIMINAL_MAX_STAMINA - stamina_crit_threshold = BOUNTY_CRIMINAL_STAMCRIT_AT - stamina_regen_time = BOUNTY_NORMAL_STAMCRIT_TIME - // Downed, stunned or dead, they lie on the floor like a person. - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - blood_volume = BLOOD_VOLUME_NORMAL - // Dressed for out there: vacuum and cold don't hurt them (D-A9). Weapons, lava, chasms and fire do; - // fire and heat through Life(), not tg's element, so the mercy line holds. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - // tg's human deathgasp - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - response_help_continuous = "taps" - response_help_simple = "tap" - /// Runs while it is down: the stirring tell, then getting up - var/body_recovery_timer - /// Down, and groaning as it tries to get up - var/body_stirring = FALSE - /// Its recovery came due while it was cuffed: it gets up once the cuffs are off - var/body_recovery_due = FALSE - /// world.time until which a mini-boss that just got up shrugs off stamina, and the timer that ends it - var/body_rally_until = 0 - var/body_rally_timer - /// world.time a projectile last got through to it, for the meek's dodge - var/body_last_shot_at = -1 + faction = list(FACTION_HOSTILE) + // Criminals stop once their target is down (spec C4, defaults 1.13). Their area effects still hit whoever stands there. + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // The body is the proof: its posting decides when it goes (bounty_posting.dm), never the corpse sweep. + npc_corpse_lifetime = 0 + npc_outfit_table = /datum/human_npc_outfit_table/bounty /// Removed on purpose (beamed out by the pad, cleaned up by its posting, its site unloading): being deleted is not a death, so it leaves no proof var/body_removed = FALSE /// Its proof of death is down already @@ -104,59 +55,45 @@ var/datum/weakref/body_site_ref /// The top row of its planet's dock strip, or null var/body_dock_top_y - /// Someone is putting restraints on it or taking them off (bounty_restraints.dm) - var/body_cuff_work = FALSE - /// The timers for its restraints slipping: the visible struggle, then the slip (bounty_restraints.dm) - var/body_slip_warn_timer - var/body_slip_timer /// Weakref to the container holding it (a locker, a crate, a body bag), kept from being teleported while it is inside var/datum/weakref/body_holder_ref /// The last balloon floated over it for hunters (body_alert_hunters()): "worn out" var/body_last_alert - -/mob/living/basic/bounty_criminal/meek - maxHealth = BOUNTY_MEEK_HEALTH - health = BOUNTY_MEEK_HEALTH - speed = BOUNTY_BODY_MEEK_SPEED - max_stamina_slowdown = BOUNTY_MEEK_STAMINA_SLOWDOWN - stamina_regen_time = BOUNTY_MEEK_STAMCRIT_TIME - -/mob/living/basic/bounty_criminal/Initialize(mapload) + /// Its toughness comes from its record's tier (body_toughness()). A subtype with a fixed toughness of its own (a mini-boss, the kingpin) turns this off. + var/body_tier_toughness = TRUE + +/mob/living/carbon/human/npc/bounty_criminal/Initialize(mapload, datum/human_npc_order/order) + // Who it is must be known before the base dresses it: the outfit table reads the record (bounty_outfits.dm). + if(istype(order?.look, /datum/bounty_record)) + record = order.look + // So must where it is: at a space ruin, or on a planet with air that would hurt it, it is dressed for that wherever it stands (bounty_suits.dm) + npc_always_site |= bounty_site_dress_for(order?.context) + // Its record's chrome joins npc_cyberware, so the base installs the real implants once it is dressed (C0, bounty_chrome.dm) + bounty_chrome_prepare(src) . = ..() - // Its own copy: the stamina pool here, and a mini-boss's armour (P4), change it per mob. - damage_coeff = damage_coeff.Copy() - body_update_stamina_coeff() - // TRAIT_CAN_MOUNT_HUMANS: someone can carry it over their shoulders (body_can_be_carried()) - add_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP, TRAIT_CAN_MOUNT_HUMANS), BOUNTY_BODY_TRAIT) + add_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP), BOUNTY_BODY_TRAIT) AddComponent(/datum/component/bounty_body) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) + body_hook_cuffs() + body_toughness() + // Its chrome's moves (a mini-boss's in its ability rotation), and the EMP look (C0) + bounty_chrome_ready(src) -/mob/living/basic/bounty_criminal/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/Destroy() if(body_leaves_proof()) body_drop_proof() - deltimer(body_recovery_timer) - body_recovery_timer = null - body_remove_cuffs() body_set_holder(null) grudge?.Cut() body_site_ref = null return ..() -// Fire and heat burn it like a suited person (L3). Not forced, so the mercy line and the automated floor hold. Mini-bosses keep P4's own fire rules. -/mob/living/basic/bounty_criminal/Life(seconds_per_tick = SSMOBS_DT, times_fired) - . = ..() - if(stat == DEAD || istype(src, /mob/living/basic/bounty_criminal/boss)) - return - if(on_fire || bodytemperature > BOUNTY_CRIMINAL_MAX_TEMP) - adjustFireLoss(BOUNTY_CRIMINAL_BURN_DAMAGE * seconds_per_tick) - // ===== SPAWNING ===== /** * Spawns the criminal for `record` at `where`, wanted on `posting` (or on nothing, for an admin - * spawn): the subtype for its archetype and kit, with the body for its tier and style, leashed to the - * posting's site, protected from the planet sweep and dressed in its look. Returns the mob. Decoys - * are made by P6 directly. + * spawn): a real human of the subtype for its archetype and kit, built now (one body, so it doesn't + * wait in the human NPC queue), in its record's face and name, dressed for its archetype, tier and + * site, leashed to the posting's site and protected from the planet sweep. Returns the mob. Decoys are + * made by P6 directly. */ /proc/spawn_bounty_criminal(datum/bounty_record/record, turf/where, datum/criminal_bounty/posting) where = get_turf(where) @@ -165,9 +102,12 @@ var/criminal_type = bounty_criminal_type(record, posting) var/atom/site = posting?.site_ref?.resolve() var/datum/npc_spawn_context/context = new(site || where, null, "ordinary", source_turf = site ? get_turf(site) : where) - var/mob/living/basic/bounty_criminal/criminal = spawn_npc_in_zone(criminal_type, where, context, FALSE) + var/datum/human_npc_order/order = spawn_human_npc(criminal_type, where, look = record, name = record?.name, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(criminal)) + return null criminal.body_setup(record, posting) - context.finalize() return criminal /// The criminal type for `record` wanted on `posting`: meek, normal, or the mini-boss of its kit. A mini-boss never goes to a trader outpost; a normal criminal stands in (D-A8). @@ -177,138 +117,83 @@ archetype = BOUNTY_ARCHETYPE_NORMAL switch(archetype) if(BOUNTY_ARCHETYPE_MEEK) - return /mob/living/basic/bounty_criminal/meek + return /mob/living/carbon/human/npc/bounty_criminal/meek if(BOUNTY_ARCHETYPE_BOSS) switch(record.kit) if(BOUNTY_KIT_JUGGERNAUT) - return /mob/living/basic/bounty_criminal/boss/juggernaut + return /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut if(BOUNTY_KIT_PYROMANIAC) - return /mob/living/basic/bounty_criminal/boss/pyromaniac + return /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac if(BOUNTY_KIT_DEMOLITIONIST) - return /mob/living/basic/bounty_criminal/boss/demolitionist + return /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist if(BOUNTY_KIT_GHOST) - return /mob/living/basic/bounty_criminal/boss/ghost + return /mob/living/carbon/human/npc/bounty_criminal/boss/ghost if(BOUNTY_KIT_HEAVY) - return /mob/living/basic/bounty_criminal/boss/heavy + return /mob/living/carbon/human/npc/bounty_criminal/boss/heavy + if(BOUNTY_KIT_CYBERPSYCHO) + return /mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho return pick( - /mob/living/basic/bounty_criminal/boss/juggernaut, - /mob/living/basic/bounty_criminal/boss/pyromaniac, - /mob/living/basic/bounty_criminal/boss/demolitionist, - /mob/living/basic/bounty_criminal/boss/ghost, - /mob/living/basic/bounty_criminal/boss/heavy, + /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, + /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, + /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist, + /mob/living/carbon/human/npc/bounty_criminal/boss/ghost, + /mob/living/carbon/human/npc/bounty_criminal/boss/heavy, ) - return /mob/living/basic/bounty_criminal/normal + return /mob/living/carbon/human/npc/bounty_criminal/normal /** - * Makes it the person in `new_record`, wanted on `new_posting` (either may be null): name, the body - * for its tier and style, the leash to the posting's site, the planet-sweep protection, and its look, - * put on in the background. spawn_bounty_criminal() calls it once, right after making the mob. + * Makes it the person in `new_record`, wanted on `new_posting` (either may be null): name, toughness + * for its tier, the leash to the posting's site and the planet-sweep protection. spawn_bounty_criminal() + * calls it once, right after making the mob; its face and clothes are already on (the human NPC base + * dresses it from the record as it is made). */ -/mob/living/basic/bounty_criminal/proc/body_setup(datum/bounty_record/new_record, datum/criminal_bounty/new_posting) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_setup(datum/bounty_record/new_record, datum/criminal_bounty/new_posting) record = new_record posting_ref = new_posting ? WEAKREF(new_posting) : null - if(record?.name) - name = record.name - real_name = record.name - if(record?.gender == MALE || record?.gender == FEMALE) - gender = record.gender - var/tier_health = body_tier_health() - if(tier_health) - maxHealth = tier_health - updatehealth() - if(istype(src, /mob/living/basic/bounty_criminal/normal)) - var/ranged = record?.style == BOUNTY_STYLE_PISTOL || record?.style == BOUNTY_STYLE_BOTTLE - set_varspeed(ranged ? BOUNTY_BODY_NORMAL_SPEED_RANGED : BOUNTY_BODY_NORMAL_SPEED_MELEE) - body_update_stamina_coeff() + if(record?.name && real_name != record.name) + fully_replace_character_name(real_name, record.name) + body_toughness() body_set_site(new_posting?.site_ref?.resolve()) if(new_posting) // The planet's fauna sweep leaves it be while it is wanted (missions/objectives/combat.dm). P5 takes it off when the posting ends. ADD_TRAIT(src, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) - INVOKE_ASYNC(src, PROC_REF(body_dress)) -/// A normal criminal's health: its tier's, times its style's. Null for anyone else, who keeps their built-in health. -/mob/living/basic/bounty_criminal/proc/body_tier_health() - if(!istype(src, /mob/living/basic/bounty_criminal/normal)) - return null - var/base = BOUNTY_NORMAL_HEALTH_WANTED +/** + * How much brute and burn it takes, as a multiple of a person's, through its physiology (the human + * NPC base's npc_damage_taken): by its record's tier, meek criminals at a person's. A subtype with a + * fixed value of its own sets body_tier_toughness = FALSE. Its armour does the rest. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/body_toughness() + if(!body_tier_toughness) + return + var/wanted = BOUNTY_TOUGHNESS_PETTY switch(record?.tier) - if(BOUNTY_TIER_PETTY) - base = BOUNTY_NORMAL_HEALTH_PETTY + if(BOUNTY_TIER_WANTED) + wanted = BOUNTY_TOUGHNESS_WANTED if(BOUNTY_TIER_MOST_WANTED) - base = BOUNTY_NORMAL_HEALTH_MOST - return round(base * bounty_style_health_mult(record?.style), 1) - -/// How much tougher or frailer than its tier a fighting style makes a normal criminal -/proc/bounty_style_health_mult(style) - switch(style) - if(BOUNTY_STYLE_BRAWLER) - return BOUNTY_STYLE_HEALTH_BRAWLER - if(BOUNTY_STYLE_KNIFE) - return BOUNTY_STYLE_HEALTH_KNIFE - if(BOUNTY_STYLE_PISTOL) - return BOUNTY_STYLE_HEALTH_PISTOL - if(BOUNTY_STYLE_SHOTGUN) - return BOUNTY_STYLE_HEALTH_SHOTGUN - if(BOUNTY_STYLE_CLUB) - return BOUNTY_STYLE_HEALTH_CLUB - if(BOUNTY_STYLE_BOTTLE) - return BOUNTY_STYLE_HEALTH_BOTTLE - return 1 - -/// How much stamina damage stamina-crits it: a meek criminal's pool, or anyone else's health -/mob/living/basic/bounty_criminal/proc/body_stamina_pool() - if(istype(src, /mob/living/basic/bounty_criminal/meek)) - return BOUNTY_MEEK_STAMINA - return maxHealth - -/// Sets its stamina damage coefficient from its pool (max_stamina / pool), times its style's. A mini-boss's is P4's. -/mob/living/basic/bounty_criminal/proc/body_update_stamina_coeff() - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - return - var/pool = body_stamina_pool() - if(pool <= 0) - return - var/style_mult = record?.style == BOUNTY_STYLE_BRAWLER ? BOUNTY_STYLE_STAMINA_BRAWLER : 1 - damage_coeff[STAMINA] = style_mult * max_stamina / pool + wanted = BOUNTY_TOUGHNESS_MOST_WANTED + if(istype(src, /mob/living/carbon/human/npc/bounty_criminal/meek)) + wanted = BOUNTY_TOUGHNESS_PETTY + if(wanted != npc_damage_taken) + set_npc_toughness(wanted) /** - * Gives it the same body as `other`: health, pace and stamina. For a trader-outpost decoy (P6), which - * must be as tough and as quick as the fugitive it stands in for (AR-C6). + * Gives it the same toughness and pace as `other`: a trader-outpost decoy (P6) must be as hard to put + * down and as quick as the fugitive it stands in for (AR-C6). Its armour comes from its own clothes. + * The fugitive's chrome is part of its pace (a meek criminal's Slipwire), so the same pieces go into + * this body too, real chrome that an EMP stops and that burns out when it dies. */ -/mob/living/basic/bounty_criminal/proc/body_match(mob/living/basic/bounty_criminal/other) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_match(mob/living/carbon/human/npc/bounty_criminal/other) if(QDELETED(other) || other == src) return - // Copy raw stats before finalizing the inherited profile, never its scaled pool. - var/datum/npc_spawn_context/context = npc_spawn_context - if(!context || context.source_id != other.npc_spawn_context?.source_id) - context = other.bounty_profile_context() - if(npc_zone_applied) - UnregisterSignal(src, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) - melee_damage_lower = npc_zone_original_melee_lower - melee_damage_upper = npc_zone_original_melee_upper - npc_zone_applied = FALSE - npc_zone_health_factor = 1 - npc_zone_damage_factor = 1 - npc_spawn_context = context - maxHealth = other.npc_zone_applied ? other.npc_zone_original_max_health : other.maxHealth - var/health_share = other.maxHealth > 0 ? other.health / other.maxHealth : 1 - setBruteLoss(maxHealth * (1 - health_share)) - updatehealth() - context.bodies |= WEAKREF(src) - context.finalize() - set_varspeed(other.speed) - damage_coeff[STAMINA] = other.damage_coeff[STAMINA] - stamina_regen_time = other.stamina_regen_time - if(max_stamina_slowdown != other.max_stamina_slowdown) - // The same arguments make_stamina_slowable() added it with, or the element is not found. - RemoveElement(/datum/element/basic_stamina_slowdown, minium_stamina_threshold = max_stamina / 3, maximum_stamina = max_stamina, maximum_slowdown = max_stamina_slowdown) - max_stamina_slowdown = other.max_stamina_slowdown - make_stamina_slowable() - -/// Decoys use the source's selected role before construction, including named boss bodies. -/mob/living/basic/bounty_criminal/proc/bounty_profile_context() - var/role = npc_zone_role || (npc_zone_boss_type(type) ? "boss" : npc_spawn_context?.role || "ordinary") - return new /datum/npc_spawn_context(src, null, role) + if(npc_damage_taken != other.npc_damage_taken) + set_npc_toughness(other.npc_damage_taken) + if(move_intent != other.move_intent) + toggle_move_intent() + for(var/obj/item/organ/ware as anything in other.organs) + if(!ware.GetComponent(/datum/component/cyberware) || (locate(ware.type) in organs)) + continue + npc_install_cyberware(ware.type) // ===== THE SITE ===== @@ -317,7 +202,7 @@ * site's rectangle, or for a pirate ship the hull itself. A site with no rectangle to read gets a box * around where it stands. With no site at all (an admin spawn) it has no leash. */ -/mob/living/basic/bounty_criminal/proc/body_set_site(atom/site) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_set_site(atom/site) body_site_ref = site ? WEAKREF(site) : null site_bounds = bounty_site_bounds(site) body_dock_top_y = bounty_site_dock_top(site) @@ -356,17 +241,17 @@ return planet.get_dock_strip_top_y(level) /// The overmap object of its site: the one it was spawned for, else its posting's -/mob/living/basic/bounty_criminal/proc/body_site() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_site() var/obj/structure/overmap/site = body_site_ref?.resolve() || posting()?.site_ref?.resolve() return (istype(site) && !QDELETED(site)) ? site : null /// The pirate ship that is its site, if it is wanted aboard one -/mob/living/basic/bounty_criminal/proc/body_site_ship() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_site_ship() var/obj/structure/overmap/ship/site_ship = body_site() return istype(site_ship) ? site_ship : null /// Whether it has a site to be leashed to -/mob/living/basic/bounty_criminal/proc/body_has_site() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_has_site() return !!site_bounds || !!body_site_ship() /** @@ -374,7 +259,7 @@ * on), and not part of a ship, a hangar, a player outpost or a planet's dock strip that happens to lie * inside that rectangle (H1). With no site, anywhere but those is. */ -/mob/living/basic/bounty_criminal/proc/body_on_site(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_site(turf/place) place = get_turf(place) if(!place || body_forbidden_turf(place)) return FALSE @@ -386,14 +271,14 @@ return TRUE /// Whether `place` is never its ground: a ship (other than the pirate ship it is wanted aboard), a hangar, a player outpost, or its planet's dock strip -/mob/living/basic/bounty_criminal/proc/body_forbidden_turf(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_forbidden_turf(turf/place) return !!body_hold(place) || body_on_dock_strip(place) /** * The ship, hangar or player outpost `place` is part of, or null: the pirate ship it is wanted aboard * is its site, not a hold. A ship is known by its docking port, so every room aboard is one hold. */ -/mob/living/basic/bounty_criminal/proc/body_hold(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_hold(turf/place) var/area/place_area = get_area(place) if(istype(place_area, /area/shuttle)) var/obj/structure/overmap/ship/site_ship = body_site_ship() @@ -406,22 +291,22 @@ return null /// Whether `place` is on its planet's dock strip, where ships land (L1) -/mob/living/basic/bounty_criminal/proc/body_on_dock_strip(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_dock_strip(turf/place) if(isnull(body_dock_top_y) || !place) return FALSE return (!site_bounds || place.z == site_bounds[5]) && place.y <= body_dock_top_y /// Whether it is at a trader outpost, wanted there or standing in one: there it fights only its grudge list (AR-C8) -/mob/living/basic/bounty_criminal/proc/body_at_trader_outpost() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_at_trader_outpost() return posting()?.placement_kind == BOUNTY_PLACEMENT_TRADER_OUTPOST || istype(get_area(src), /area/voidcrew/trader_outpost) /** * Whether it may go onto `destination` by itself (AR-D2, AR-E1, L1): on its site (so never a ship, a * hangar, a player outpost or a dock strip), never a lift alcove that would carry it off, and never - * lava, a chasm or open space. P3 checks hiding spots and flight paths with it; its own steps are held - * to it too (Move()). + * lava, a chasm or open space. P3 checks hiding spots and flight paths with it, and its AI's movement + * holds its own steps to it (body_move_allowed()). */ -/mob/living/basic/bounty_criminal/proc/leash_ok(turf/destination) +/mob/living/carbon/human/npc/bounty_criminal/proc/leash_ok(turf/destination) destination = get_turf(destination) if(!destination || !body_on_site(destination)) return FALSE @@ -430,23 +315,19 @@ return FALSE return destination.can_cross_safely(src) -// Its own steps stay on the leash (M3). Dragged, carried along, thrown or walked by a script, it goes where it is taken. -/mob/living/basic/bounty_criminal/Move(atom/newloc, direct, glide_size_override) - if(!body_move_allowed(newloc)) - return FALSE - return ..() - -/// Whether a script is walking it on purpose: TRAIT_BOUNTY_SCRIPTED_MOVE, or godmode (P6's walk out to the lift) -/mob/living/basic/bounty_criminal/proc/body_scripted() - return HAS_TRAIT(src, TRAIT_BOUNTY_SCRIPTED_MOVE) || HAS_TRAIT(src, TRAIT_GODMODE) +/// Whether a script is walking it on purpose (TRAIT_BOUNTY_SCRIPTED_MOVE), or it is walking out (the human NPC base's npc_walk_out()) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_scripted() + return HAS_TRAIT(src, TRAIT_BOUNTY_SCRIPTED_MOVE) || !isnull(npc_exit) /** - * Whether a step of its own onto `newloc` is allowed. Onto its leash, always. Off it from on it, - * never. Already off it (woken aboard a ship, dragged off its site, carried somewhere), it may walk - * about and back toward its site, but never into a hazard and never on into a ship, hangar or player - * outpost other than the one it is already in (M3). + * Whether a step of its own onto `newloc` is allowed: the leash its AI's movement keeps to + * (/datum/ai_movement/jps/bounty, and any step the AI takes by hand). Onto its leash, always. Off it + * from on it, never. Already off it (woken aboard a ship, dragged off its site, carried somewhere), it + * may walk about and back toward its site, but never into a hazard and never on into a ship, hangar or + * player outpost other than the one it is already in (M3). Its body is never held back: anyone can drag, + * carry, throw or shove it anywhere. */ -/mob/living/basic/bounty_criminal/proc/body_move_allowed(atom/newloc) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_move_allowed(atom/newloc) if(!isturf(newloc) || !isturf(loc) || pulledby || buckled || throwing || stat == DEAD || body_scripted()) return TRUE var/turf/destination = newloc @@ -463,7 +344,7 @@ * BOUNTY_ENV_SAFE_RANGE tiles of a ship (other than the pirate ship it is aboard), a hangar, a player * outpost or a planet's dock strip. */ -/mob/living/basic/bounty_criminal/proc/environment_damage_allowed(turf/target_turf) +/mob/living/carbon/human/npc/bounty_criminal/proc/environment_damage_allowed(turf/target_turf) target_turf = get_turf(target_turf) if(!target_turf || !body_on_site(target_turf) || body_at_trader_outpost()) return FALSE @@ -482,15 +363,16 @@ // ===== WHO IT FIGHTS ===== /** - * Whether it may attack `target` (AR-D1, AR-C8, C4). Never while down, restrained or surrendered, - * never someone who is down, and never its own side. Off its site, and anywhere at a trader outpost, - * only those on its grudge list, and no structures. On its site it may break structures that are on - * its site and not part of a ship, a hangar or an outpost. Its melee and ranged attacks are held to it. + * Whether it may attack `target` (AR-D1, AR-C8, C4). Never while it can't fight (dead, in crit, + * restrained, lying there with its hands on its head after giving up), and never someone who is down, + * or its own side. Off its site, and anywhere at a trader outpost, only those on its grudge list, and no + * structures. On its site it may break structures that are on its site and not part of a ship, a hangar + * or an outpost. Its AI's targeting is held to it. */ -/mob/living/basic/bounty_criminal/proc/may_attack(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/may_attack(atom/target) if(QDELETED(target) || target == src) return FALSE - if(stat != CONSCIOUS || downed || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) + if(stat != CONSCIOUS || is_downed() || is_restrained() || npc_surrendering) return FALSE return may_attack_scope(target) @@ -500,7 +382,7 @@ * grudge list off its site and at a trader outpost, structures only on its own ground. Its * companions go by this alone, so they fight on when it is down, cuffed, dead or has given up. */ -/mob/living/basic/bounty_criminal/proc/may_attack_scope(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/may_attack_scope(atom/target) if(QDELETED(target) || target == src) return FALSE // A ship, hangar, outpost or dock strip inside the site's rectangle is not its site (H1) @@ -520,15 +402,19 @@ var/turf/target_turf = get_turf(target) return body_on_site(target_turf) && !body_forbidden_turf(target_turf) for(var/mob/living/person as anything in people) - if(person.stat != CONSCIOUS || istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(person.stat != CONSCIOUS || bounty_is_gang_body(person)) continue if(!limited || body_has_grudge(person)) return TRUE return FALSE +/// Whether `thing` is a bounty criminal or a companion: never on anyone's grudge list, never counted as a player +/proc/bounty_is_gang_body(atom/thing) + return istype(thing, /mob/living/carbon/human/npc/bounty_criminal) || istype(thing, /mob/living/carbon/human/npc/bounty_companion) + /// Remembers `who` as someone it may fight anywhere: they attacked it, confronted it or cuffed it. P3 calls it when someone confronts it. -/mob/living/basic/bounty_criminal/proc/body_add_grudge(mob/living/who) - if(!isliving(who) || who == src || istype(who, /mob/living/basic/bounty_criminal) || istype(who, /mob/living/basic/bounty_companion)) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_add_grudge(mob/living/who) + if(!isliving(who) || who == src || bounty_is_gang_body(who)) return var/datum/weakref/who_ref = WEAKREF(who) if(who_ref in grudge) @@ -538,26 +424,25 @@ grudge.Cut(1, 2) /// Whether `who` is on its grudge list -/mob/living/basic/bounty_criminal/proc/body_has_grudge(mob/living/who) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_has_grudge(mob/living/who) return !isnull(who?.weak_reference) && (who.weak_reference in grudge) -// Its own fists follow may_attack(). -/mob/living/basic/bounty_criminal/early_melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - if(!may_attack(target)) - return FALSE - return ..() +// Bounty NPCs never land a gun execution this phase: a boss's deliberate, telegraphed execution (OC-9) +// isn't built yet, and an accidental point-blank head shot must not stand in for it. Aimed crits still +// follow npc_rank (D4). +/mob/living/carbon/human/npc/bounty_criminal/hv_can_execute() + return !!client // ===== CAPTURE STATE ===== /** - * What the pad pays on (BOUNTY_STATE_*), checked in order (AR-A3): dead; downed (even if cuffed or - * healed); restrained with its own restraints; stunned (stamina crit, knocked down, stunned, - * paralysed, unconscious, or surrendered); free. + * What the pad pays on (BOUNTY_STATE_*), read from tg in this order (AR-A3): dead; in crit, soft or hard, + * even if cuffed; restrained; stunned; free. The pad reads it once, at the press. */ -/mob/living/basic/bounty_criminal/proc/capture_state() +/mob/living/carbon/human/npc/bounty_criminal/proc/capture_state() if(stat == DEAD) return BOUNTY_STATE_DEAD - if(downed) + if(is_downed()) return BOUNTY_STATE_DOWNED if(is_restrained()) return BOUNTY_STATE_RESTRAINED @@ -565,17 +450,20 @@ return BOUNTY_STATE_STUNNED return BOUNTY_STATE_FREE -/// Whether it is down and alive: floored and harmless until it recovers -/mob/living/basic/bounty_criminal/proc/is_downed() - return downed && stat != DEAD +/// Whether it is alive and in crit (tg's TRAIT_CRITICAL_CONDITION: soft or hard crit) +/mob/living/carbon/human/npc/bounty_criminal/proc/is_downed() + return stat != DEAD && HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION) -/// Whether its own restraints are on it -/mob/living/basic/bounty_criminal/proc/is_restrained() - return !QDELETED(restraints) && restraints.loc == src +/// Whether it is restrained: tg's handcuffs, cable or zipties, or a straitjacket +/mob/living/carbon/human/npc/bounty_criminal/proc/is_restrained() + return HAS_TRAIT(src, TRAIT_RESTRAINED) -/// Whether it is stunned: stamina crit, knocked down, stunned, paralysed, out cold, or surrendered (TRAIT_BOUNTY_SURRENDERED) -/mob/living/basic/bounty_criminal/proc/body_is_stunned() - if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED) || has_status_effect(/datum/status_effect/incapacitating/stamcrit)) +/** + * Whether it is stunned: stamina crit, knocked down, stunned, paralysed (tg's *surrender is a 20 second + * paralysis), unconscious or asleep (a sedative dart counts). + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/body_is_stunned() + if(stat == UNCONSCIOUS || has_status_effect(/datum/status_effect/incapacitating/stamcrit)) return TRUE return IsKnockdown() || IsStun() || IsParalyzed() || IsUnconscious() || IsSleeping() @@ -590,192 +478,49 @@ return 3 return 4 -/// Records its capture state in worst_state if it is as bad or worse: once downed or dead, it never gets better (spec 12.1) -/mob/living/basic/bounty_criminal/proc/body_note_state() - var/state = capture_state() - if(state != BOUNTY_STATE_FREE) - posting()?.board_note_work_zone(src) - if(bounty_state_rank(state) <= bounty_state_rank(worst_state)) - worst_state = state - /** - * Carries a worst state over from an earlier copy of this criminal (M4): P5 respawns the same record - * when its site loads again, and pay must not forget that it was downed or killed before. Only ever - * makes worst_state worse. Returns the worst state now. + * Something about its state may have changed (stat, crit, cuffs, a stun): the first catch or death + * binds the zone the bounty's reputation counts in. Nothing else is remembered; the pad reads the + * body when it is pressed. */ -/mob/living/basic/bounty_criminal/proc/body_restore_worst_state(state) - if(state && bounty_state_rank(state) < bounty_state_rank(worst_state)) - worst_state = state - return worst_state - -/// Something about its state changed: worst_state, and whether it can be dragged or carried -/mob/living/basic/bounty_criminal/proc/body_state_changed() - body_note_state() - body_update_drag() - body_update_carry() - -/mob/living/basic/bounty_criminal/set_stat(new_stat) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_floored_trait_gain(datum/source) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_floored_trait_loss(datum/source) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_incapacitated_trait_gain(datum/source) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_incapacitated_trait_loss(datum/source) - . = ..() - body_state_changed() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_state_changed() + if(capture_state() != BOUNTY_STATE_FREE) + posting()?.board_note_work_zone(src) -// ===== DRAGGING ===== +// ===== WHAT IT SAYS ===== -/** - * Whether it can be dragged: dead, downed, restrained or stunned. Blending in at a trader outpost it - * shakes off any hand until it is down or cuffed (AR-C7), the same for the fugitive and its decoys. - * Free and on its feet it pulls away. - */ -/mob/living/basic/bounty_criminal/proc/body_can_be_dragged() - if(stat == DEAD || downed || is_restrained()) - return TRUE - if(blended) - return FALSE - return body_is_stunned() - -/// Too heavy to shove while free; ordinary once it can be dragged. Anyone dragging it loses their grip when it can't be. -/mob/living/basic/bounty_criminal/proc/body_update_drag() - var/draggable = body_can_be_dragged() - move_resist = draggable ? MOVE_RESIST_DEFAULT : MOVE_FORCE_VERY_STRONG - if(draggable || !pulledby) - return - visible_message(span_warning("[src] pulls free of [pulledby].")) - pulledby.stop_pulling() - -/mob/living/basic/bounty_criminal/can_be_pulled(user, force) - if(!body_can_be_dragged()) - // P3 REVIEW ROUND (L1): the pull attempt is still announced before it is refused, so P3 hears - // it (pulling at a hider finds it) and P6's blend hooks see it. A hider shows no balloon: it - // is being found instead. - SEND_SIGNAL(src, COMSIG_ATOM_CAN_BE_PULLED, user) - if(ismob(user) && !hidden) - balloon_alert(user, "[p_they()] pull[p_s()] away!") +// The human NPC base's reactions (human_npc_barks.dm) speak the criminal's own lines (strings/bounty_criminals.json). +/mob/living/carbon/human/npc/bounty_criminal/npc_bark(kind, force = FALSE) + if(QDELETED(src) || !istext(kind)) return FALSE - body_update_drag() - return ..() - -// ===== CARRYING ===== - -/// Whether someone can lift it over their shoulders (a fireman carry): on the floor, and in a state to be dragged -/mob/living/basic/bounty_criminal/proc/body_can_be_carried() - return body_position == LYING_DOWN && body_can_be_dragged() - -/// Carried, and no longer in a state to be dragged (it came round, or got up): it gets off whoever is carrying it -/mob/living/basic/bounty_criminal/proc/body_update_carry() - if(!ishuman(buckled) || body_can_be_dragged()) - return - var/mob/living/carrier = buckled - carrier.unbuckle_mob(src, force = TRUE) - visible_message(span_warning("[src] squirms off [carrier]'s shoulders.")) - -/** - * Turns `body` flat when it lies down out of a chair or off someone's shoulders (set_lying_down()). - * tg turns only carbons there (/mob/living/carbon/on_lying_down()); any other mob gets its angle from - * on_floored_start(), which is skipped while a chair holds it up. Without this, a criminal or a goon - * unbuckled while down or dead lies down but still looks like it is standing. - */ -/proc/bounty_body_lie_flat(mob/living/body, new_lying_angle) - if(!body.rotate_on_lying || body.body_position != LYING_DOWN || body.lying_angle) - return - if(body.buckled && body.buckled.buckle_lying == 0) - return - body.set_lying_angle(new_lying_angle || pick(LYING_ANGLE_EAST, LYING_ANGLE_WEST)) + // Crawling in soft crit it pleads with its downed lines. + if(kind == HUMAN_NPC_BARK_PLEAD) + kind = HUMAN_NPC_BARK_DOWNED + // The capture's moments are always heard, past the line cooldown (BUG-1): the cuffs go on moments after a fall. + if(kind == HUMAN_NPC_BARK_CUFFED) + force = TRUE + npc_last_bark = kind + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), kind, null, force) + return TRUE -/mob/living/basic/bounty_criminal/set_lying_down(new_lying_angle) +/mob/living/carbon/human/npc/bounty_criminal/npc_on_downed() + visible_message(span_danger("[src] collapses.")) . = ..() - bounty_body_lie_flat(src, new_lying_angle) - -// A criminal lying on the floor can be carried like a person, as long as it can be dragged -/mob/living/carbon/human/can_be_firemanned(mob/living/carbon/target) - if(istype(target, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = target - return criminal.body_can_be_carried() - return ..() - -// ===== DOWNED, MERCY AND RECOVERY ===== - -/// Its health at the downed line -/mob/living/basic/bounty_criminal/proc/body_downed_line() - return maxHealth * BOUNTY_DOWNED_BELOW / 100 - -// Every kind of damage a basic mob takes comes through here: the mercy rule and the automated floor hold it back. -/mob/living/basic/bounty_criminal/adjust_health(amount, updating_health = TRUE, forced = FALSE) - if(amount > 0 && !forced && stat != DEAD) - amount = body_allowed_damage(amount) - return ..(amount, updating_health, forced) - -/** - * How much of `amount` damage gets through. On its feet (stamina crit counts), a hit stops at 1 under - * the downed line: the rest is dropped. On its site, damage with no mind behind it stops at - * BOUNTY_AUTOMATED_FLOOR percent. Down, its own side's blows miss it entirely. Anything else gets through. - */ -/mob/living/basic/bounty_criminal/proc/body_allowed_damage(amount) - var/datum/component/bounty_body/ledger = GetComponent(/datum/component/bounty_body) - var/player_hit = ledger?.player_hit_now() - if(downed && !player_hit && ledger?.own_side_hit_now()) - return 0 - var/floor = downed ? 0 : body_downed_line() - 1 - if(!player_hit && body_has_site() && body_on_site(get_turf(src))) - floor = max(floor, maxHealth * BOUNTY_AUTOMATED_FLOOR / 100) - var/health_left = maxHealth - bruteloss - if(health_left - amount >= floor) - return amount - return max(0, health_left - floor) - -// An explosion names no attacker; the ledger works out whose it was before the blast lands (M1). -/mob/living/basic/bounty_criminal/ex_act(severity, target, origin) - var/datum/component/bounty_body/ledger = GetComponent(/datum/component/bounty_body) - ledger?.note_explosion(origin) - return ..() + body_state_changed() + SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_DOWNED) -/mob/living/basic/bounty_criminal/updatehealth() +/mob/living/carbon/human/npc/bounty_criminal/npc_on_recovered() + if(stat == CONSCIOUS && !is_restrained()) + visible_message(span_warning("[src] gets back up.")) . = ..() - body_check_downed() - -/// Downs it once its health reaches the downed line -/mob/living/basic/bounty_criminal/proc/body_check_downed() - if(downed || stat == DEAD || QDELETED(src) || health > body_downed_line()) - return - body_go_down() - -/// Down: floored, harmless, draggable, its AI paused, and its recovery clock running. The line is forced: it is the moment the fight ends (BUG-1). -/mob/living/basic/bounty_criminal/proc/body_go_down() - downed = TRUE - body_recovery_due = FALSE - body_rally_until = 0 - deltimer(body_rally_timer) - body_rally_timer = null - add_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), BOUNTY_BODY_DOWNED_TRAIT) - body_update_held() - ai_controller?.CancelActions() - if(pulling) - stop_pulling() - body_start_recovery() body_state_changed() - visible_message(span_danger("[src] collapses.")) - SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_DOWNED) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "downed", null, TRUE) + SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_RECOVERED) /** * Floats `text` over it for every hunter who can see it (bounty_ai_is_hunter()), and nobody else. * Kept in body_last_alert. Returns how many hunters saw it. */ -/mob/living/basic/bounty_criminal/proc/body_alert_hunters(text) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_alert_hunters(text) . = 0 body_last_alert = text for(var/mob/living/viewer in get_hearers_in_view(DEFAULT_MESSAGE_RANGE, src, RECURSIVE_CONTENTS_CLIENT_MOBS)) @@ -784,130 +529,19 @@ balloon_alert(viewer, text) .++ -/// Restrained or down, its AI plans nothing: TRAIT_BOUNTY_HELD for P3's controller, and TRAIT_AI_PAUSED -/mob/living/basic/bounty_criminal/proc/body_update_held() - if(downed || is_restrained()) - add_traits(list(TRAIT_BOUNTY_HELD, TRAIT_AI_PAUSED), BOUNTY_BODY_HELD_TRAIT) - else - remove_traits(list(TRAIT_BOUNTY_HELD, TRAIT_AI_PAUSED), BOUNTY_BODY_HELD_TRAIT) - -/// How long it stays down: meek, normal or mini-boss -/mob/living/basic/bounty_criminal/proc/body_recovery_time() - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - return BOUNTY_RECOVER_TIME_BOSS - if(istype(src, /mob/living/basic/bounty_criminal/meek)) - return BOUNTY_RECOVER_TIME_MEEK - return BOUNTY_RECOVER_TIME_NORMAL - -/// (Re)starts its recovery clock from the top: on going down, and on every stamina hit while down, cuffed or not (L4) -/mob/living/basic/bounty_criminal/proc/body_start_recovery() - deltimer(body_recovery_timer) - body_stirring = FALSE - body_recovery_due = FALSE - body_recovery_timer = addtimer(CALLBACK(src, PROC_REF(body_stir)), max(0, body_recovery_time() - BOUNTY_BODY_STIRRING_TELL), TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// Stops its recovery clock -/mob/living/basic/bounty_criminal/proc/body_cancel_recovery() - deltimer(body_recovery_timer) - body_recovery_timer = null - body_stirring = FALSE - -/// The tell before it gets up: it groans and tries to rise, and is up BOUNTY_BODY_STIRRING_TELL later. Cuffed, it waits for the cuffs to come off. -/mob/living/basic/bounty_criminal/proc/body_stir() - body_recovery_timer = null - if(!downed || stat == DEAD || QDELETED(src)) - return - if(is_restrained()) - body_recovery_due = TRUE - return - body_stirring = TRUE - visible_message(span_warning("[src] groans and tries to get up.")) - Shake(1, 0, 0.5 SECONDS) - body_recovery_timer = addtimer(CALLBACK(src, PROC_REF(body_stand_up)), BOUNTY_BODY_STIRRING_TELL, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/** - * Up again at BOUNTY_RECOVER_TO percent of its health, or more if it was healed while down. A - * mini-boss rallies. Cuffed, it stays down until the cuffs come off (C1). Returns TRUE if it got up. - */ -/mob/living/basic/bounty_criminal/proc/body_stand_up() - body_recovery_timer = null - body_stirring = FALSE - if(!downed || stat == DEAD || QDELETED(src)) - return FALSE - if(is_restrained()) - body_recovery_due = TRUE - return FALSE - var/stand_health = maxHealth * BOUNTY_RECOVER_TO / 100 - var/health_now = maxHealth - bruteloss - if(health_now < stand_health) - adjust_health(health_now - stand_health, forced = TRUE) - downed = FALSE - body_recovery_due = FALSE - remove_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), BOUNTY_BODY_DOWNED_TRAIT) - body_update_held() - var/rally = body_rally_time() - if(rally > 0) - body_rally_until = world.time + rally - deltimer(body_rally_timer) - body_rally_timer = addtimer(CALLBACK(src, PROC_REF(body_end_rally)), rally, TIMER_STOPPABLE | TIMER_DELETE_ME) - body_state_changed() - visible_message(span_warning("[src] gets back up.")) - SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_RECOVERED) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "recover") - return TRUE +// ===== DEATH AND PROOF ===== -/// How long it rallies after getting up: a mini-boss only (C1). P4 may override it per kit. -/mob/living/basic/bounty_criminal/proc/body_rally_time() - return istype(src, /mob/living/basic/bounty_criminal/boss) ? BOUNTY_BODY_RALLY_TIME : 0 - -/// Whether it is rallying after getting up: stamina does nothing, and it won't surrender (P3) -/mob/living/basic/bounty_criminal/proc/body_is_rallying() - return world.time < body_rally_until - -/// The rally is over: stamina works on it again, as far as its tiredness lets it (P4's boss_update_tired()) -/mob/living/basic/bounty_criminal/proc/body_end_rally() - body_rally_timer = null - body_rally_until = 0 - if(QDELETED(src) || stat == DEAD) - return - boss_update_tired(force = TRUE) - -/** - * A mini-boss re-reads how tired it is: its stamina coefficient and stun flags (P4). Nothing for - * anyone else. P4 overrides it on /boss; the override must not repeat the `proc/` keyword. - */ -/mob/living/basic/bounty_criminal/proc/boss_update_tired(silent = FALSE, force = FALSE) - return - -// A stamina hit while it is down starts its recovery over; rallying, it shrugs stamina off. -/mob/living/basic/bounty_criminal/adjustStaminaLoss(amount, updating_stamina = TRUE, forced = FALSE, required_biotype) - if(amount > 0 && !forced && stat != DEAD) - if(body_is_rallying()) - return 0 - if(downed) - body_start_recovery() - return ..() - -// ===== DEATH ===== - -// The body stays as proof; the cuffs come off it. -/mob/living/basic/bounty_criminal/death(gibbed) - body_cancel_recovery() +// The body stays as proof. Fallen into a chasm, it is in the chasm's depths for good: P5 puts the proof on +// safe ground beside the chasm (get_turf() here is the chasm itself). +/mob/living/carbon/human/npc/bounty_criminal/death(gibbed) . = ..() - if(QDELETED(src)) + if(QDELETED(src) || stat != DEAD) return - body_remove_cuffs() body_state_changed() - // Fallen into a chasm: the body is in the chasm's depths for good. P5 puts the proof on safe ground - // beside the chasm (get_turf() here is the chasm itself). if(istype(loc, /obj/effect/abstract/chasm_storage) && body_leaves_proof()) body_drop_proof() -// Nothing brings it back: dead is the dead share (AR-A5). -/mob/living/basic/bounty_criminal/can_be_revived() - return FALSE - -/mob/living/basic/bounty_criminal/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) +/mob/living/carbon/human/npc/bounty_criminal/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) . = ..() body_set_holder(isturf(loc) ? null : loc) // A body dragged into a chasm is gone for good too; P5 puts the proof beside it. @@ -917,9 +551,11 @@ /** * Keeps whatever holds it (`holder`, the outermost thing it is inside, or null on a turf) from being * teleported while it is inside (M2): a ship's transporter can't beam up a locker, crate or body bag - * with a criminal in it, any more than the criminal itself. Moves the block when it changes hands. + * with a criminal in it, any more than the criminal itself, and a person carrying a folded body bag + * with one inside can't teleport with it. Moves the block when it changes hands. (Carried over the + * shoulders it isn't inside anyone: it stands on the carrier's tile.) */ -/mob/living/basic/bounty_criminal/proc/body_set_holder(atom/movable/holder) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_set_holder(atom/movable/holder) while(ismovable(holder?.loc)) holder = holder.loc var/atom/movable/old_holder = body_holder_ref?.resolve() @@ -938,7 +574,7 @@ * no proof. P5 marks removals with TRAIT_BOUNTY_REMOVED instead (the pad, closing, relisting, a site * unloading); this is for anything else that takes a criminal away on purpose. */ -/mob/living/basic/bounty_criminal/proc/body_mark_removed() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_mark_removed() body_removed = TRUE /** @@ -946,171 +582,55 @@ * first; a live one deleted is never a death, M5), not removed on purpose, and its posting still open. * P5's bounty_drop_proof() makes the final call (a site unloading, a relist) and places the proof. */ -/mob/living/basic/bounty_criminal/proc/body_leaves_proof() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_leaves_proof() if(stat != DEAD || body_removed || body_proof_dropped || HAS_TRAIT(src, TRAIT_BOUNTY_REMOVED)) return FALSE var/datum/criminal_bounty/wanted_on = posting() return wanted_on?.status == BOUNTY_POSTING_OPEN /// Leaves proof of death through P5's bounty_drop_proof(), once -/mob/living/basic/bounty_criminal/proc/body_drop_proof() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_drop_proof() body_proof_dropped = TRUE bounty_drop_proof(src) -// ===== LOOKS ===== - -/// Puts its look on: its record's face in the outfit for its archetype and zone. Sleeps. -/mob/living/basic/bounty_criminal/proc/body_dress() - var/outfit = bounty_outfit() - if(record) - apply_bounty_look(src, record, outfit) - else - set_outpost_npc_look(src, outfit, gender, 1) - if(QDELETED(src)) - return - update_appearance(UPDATE_OVERLAYS) - -/// The outfit it wears: a mini-boss its kit's (boss_kit_outfit(), P4), anyone else plain clothes for their archetype and the zone of their site -/mob/living/basic/bounty_criminal/proc/bounty_outfit() - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - return boss_kit_outfit() || boss_outfit() - var/meek = istype(src, /mob/living/basic/bounty_criminal/meek) - switch(body_zone()) - if(ZONE_RED) - return meek ? /datum/outfit/bounty_criminal/drifter : /datum/outfit/bounty_criminal/tough - if(ZONE_YELLOW) - return meek ? /datum/outfit/bounty_criminal/spacer : /datum/outfit/bounty_criminal/hauler - return meek ? /datum/outfit/bounty_criminal : /datum/outfit/bounty_criminal/local - -/// A mini-boss's kit outfit, or null. P4 overrides it on /boss; the override must not repeat the `proc/` keyword. -/mob/living/basic/bounty_criminal/proc/boss_kit_outfit() - return null - -/// What a mini-boss wears when its kit has no outfit: plain rough clothes -/mob/living/basic/bounty_criminal/proc/boss_outfit() - return /datum/outfit/bounty_criminal/tough - -/// ZONE_GREEN, ZONE_YELLOW or ZONE_RED for where its site is (or where it stands), green if unknown -/mob/living/basic/bounty_criminal/proc/body_zone() - var/atom/site = body_site_ref?.resolve() - var/zone = site ? SSovermap_zones?.get_zone_type(get_turf(site)) : SSovermap_zones?.get_zone_type_anywhere(get_turf(src)) - return zone || ZONE_GREEN - -/mob/living/basic/bounty_criminal/update_overlays() - . = ..() - if(is_restrained() && !hidden) - // tg's own cuff overlay, as people wear it - . += mutable_appearance('icons/mob/simple/mob.dmi', "handcuff1") - -/// Plain clothes for a meek criminal in green space -/datum/outfit/bounty_criminal - name = "Bounty criminal: civilian" - uniform = /obj/item/clothing/under/pants/jeans - suit = /obj/item/clothing/suit/toggle/jacket/sweater - shoes = /obj/item/clothing/shoes/sneakers/brown - -/// A meek criminal in yellow space -/datum/outfit/bounty_criminal/spacer - name = "Bounty criminal: spacer" - uniform = /obj/item/clothing/under/color/grey - suit = /obj/item/clothing/suit/jacket/bomber - shoes = /obj/item/clothing/shoes/workboots - head = /obj/item/clothing/head/beanie - -/// A meek criminal in red space -/datum/outfit/bounty_criminal/drifter - name = "Bounty criminal: drifter" - uniform = /obj/item/clothing/under/color/black - suit = /obj/item/clothing/suit/jacket/leather - shoes = /obj/item/clothing/shoes/jackboots - head = /obj/item/clothing/head/soft/grey - -/// A normal criminal in green space -/datum/outfit/bounty_criminal/local - name = "Bounty criminal: local" - uniform = /obj/item/clothing/under/costume/buttondown/slacks - suit = /obj/item/clothing/suit/jacket/letterman - shoes = /obj/item/clothing/shoes/sneakers/black - -/// A normal criminal in yellow space -/datum/outfit/bounty_criminal/hauler - name = "Bounty criminal: hauler" - uniform = /obj/item/clothing/under/pants/jeans - suit = /obj/item/clothing/suit/jacket/miljacket - gloves = /obj/item/clothing/gloves/fingerless - shoes = /obj/item/clothing/shoes/workboots - -/// A normal criminal in red space, and the fallback for a mini-boss -/datum/outfit/bounty_criminal/tough - name = "Bounty criminal: tough" - uniform = /obj/item/clothing/under/color/black - suit = /obj/item/clothing/suit/jacket/leather/biker - gloves = /obj/item/clothing/gloves/fingerless - shoes = /obj/item/clothing/shoes/jackboots - // ===== EXAMINE ===== /** - * How hurt it looks, its distinguishing features, and whether it is restrained, down or has given up. - * Blending in, the fugitive and its decoys show one layout: how hurt, then the features. Hidden (a - * disguise, a locker), it adds nothing. + * Its distinguishing features, and whether it has given up. How hurt it is, whether it is restrained + * and whether it is down are tg's own human examine. Hidden (a disguise, a locker), it adds nothing. */ -/mob/living/basic/bounty_criminal/examine(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/examine(mob/user) . = ..() if(hidden) return - if(stat != DEAD) - . += body_health_line() for(var/line in record?.feature_lines()) . += span_notice(line) - if(blended || stat == DEAD) + if(blended || stat != CONSCIOUS) return - if(is_restrained()) - . += span_warning("[p_They()] [p_are()] restrained with [restraints].") - if(downed) - if(body_stirring) - . += span_warning("[p_They()] [p_are()] trying to get up.") - else - . += span_notice("[p_They()] [p_are()] down.") - else if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) + if(npc_surrendering && IsParalyzed()) . += span_notice("[p_They()] [p_have()] given up.") -/// One line on how hurt it looks -/mob/living/basic/bounty_criminal/proc/body_health_line() - var/fraction = maxHealth > 0 ? health / maxHealth : 0 - if(fraction >= 0.9) - return span_notice("[p_They()] look[p_s()] unhurt.") - if(fraction >= 0.6) - return span_warning("[p_They()] [p_have()] a few cuts and bruises.") - if(fraction > BOUNTY_DOWNED_BELOW / 100) - return span_warning("[p_They()] [p_are()] badly hurt.") - return span_danger("[p_They()] [p_are()] barely conscious.") - -// ===== THE LEDGER AND THE BODY'S SIGNALS ===== +// ===== THE BODY'S SIGNALS ===== /** - * Who has been hurting a criminal, and the rest of the body's signal hooks, kept on a component so the - * AI packages can register the same signals on the mob without clashing. The ledger (in the style of - * experiment_damage_ledger) tells a hit with someone's mind behind it from one without, so turrets, - * traps, fire and fauna can't finish off a criminal on its site (AR-D3), and puts attackers on its - * grudge list. Damage and the attack report arrive in the same tick: a projectile's pre-hit and an item - * attack's attackby come before the damage, so the tick's attacker is known when the damage lands. - * It also refuses teleports, polymorph and type changes, lets a sprinting meek criminal duck shots, - * holds ranged attacks to may_attack(), and stops a free criminal being dragged onto things. + * The body's own signal hooks, kept on a component so the AI packages can register the same signals on + * the mob without clashing: + * - who has been attacking it (tg's relay_attackers, an item's attackby, a projectile's firer, a mech's + * or a vehicle's pilot) goes on its grudge list; + * - teleports, polymorph and type changes are refused; + * - any change of tg state that capture_state() reads tells the body (body_state_changed()); + * - a hunter reaching through the strip menu with a pressure suit or helmet for a slot its own locked piece + * fills may put theirs on instead (bounty_suits.dm, suits_strip_reach()). */ /datum/component/bounty_body dupe_mode = COMPONENT_DUPE_UNIQUE - /// world.time someone with a mind behind them last attacked it - var/player_hit_at = -1 - /// world.time another criminal or a companion last attacked it - var/own_side_hit_at = -1 /datum/component/bounty_body/Initialize() - if(!istype(parent, /mob/living/basic/bounty_criminal)) + if(!istype(parent, /mob/living/carbon/human/npc/bounty_criminal)) return COMPONENT_INCOMPATIBLE /datum/component/bounty_body/RegisterWithParent() - var/mob/living/basic/bounty_criminal/criminal = parent + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent criminal.AddElement(/datum/element/relay_attackers) RegisterSignal(criminal, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) RegisterSignal(criminal, COMSIG_ATOM_ATTACKBY, PROC_REF(on_attackby)) @@ -1118,11 +638,18 @@ RegisterSignal(criminal, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(criminal, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(criminal, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - RegisterSignal(criminal, COMSIG_BASICMOB_PRE_ATTACK_RANGED, PROC_REF(on_pre_ranged_attack)) - RegisterSignal(criminal, COMSIG_MOUSEDROP_ONTO, PROC_REF(on_mousedrop)) - RegisterSignals(criminal, list(SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), SIGNAL_REMOVETRAIT(TRAIT_BOUNTY_SURRENDERED)), PROC_REF(on_state_signal)) + RegisterSignal(criminal, COMSIG_MOB_STATCHANGE, PROC_REF(on_state_signal)) + RegisterSignals(criminal, list( + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_FLOORED), + SIGNAL_REMOVETRAIT(TRAIT_FLOORED), + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + SIGNAL_REMOVETRAIT(TRAIT_INCAPACITATED), + ), PROC_REF(on_state_signal)) // A stun's traits land before the stun is on its list, so the trait handlers alone would miss it. RegisterSignals(criminal, list(COMSIG_LIVING_STATUS_APPLIED, COMSIG_LIVING_STATUS_REMOVED), PROC_REF(on_state_signal)) + RegisterSignal(criminal, COMSIG_BEING_STRIPPED, PROC_REF(on_being_stripped)) /datum/component/bounty_body/UnregisterFromParent() UnregisterSignal(parent, list( @@ -1132,29 +659,21 @@ COMSIG_MOVABLE_TELEPORTING, COMSIG_LIVING_PRE_WABBAJACKED, COMSIG_PRE_MOB_CHANGED_TYPE, - COMSIG_BASICMOB_PRE_ATTACK_RANGED, - COMSIG_MOUSEDROP_ONTO, - SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), - SIGNAL_REMOVETRAIT(TRAIT_BOUNTY_SURRENDERED), + COMSIG_MOB_STATCHANGE, + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_FLOORED), + SIGNAL_REMOVETRAIT(TRAIT_FLOORED), + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + SIGNAL_REMOVETRAIT(TRAIT_INCAPACITATED), COMSIG_LIVING_STATUS_APPLIED, COMSIG_LIVING_STATUS_REMOVED, + COMSIG_BEING_STRIPPED, )) -/// Whether someone with a mind behind them attacked it this tick -/datum/component/bounty_body/proc/player_hit_now() - return player_hit_at == world.time - -/// Whether its own side (another criminal, a companion) attacked it this tick -/datum/component/bounty_body/proc/own_side_hit_now() - return own_side_hit_at == world.time - -/// `attacker` went for it: noted for the ledger, and they go on its grudge list +/// `attacker` went for it: they go on its grudge list (a vehicle's pilots for a vehicle) /datum/component/bounty_body/proc/note_attacker(atom/attacker) - var/mob/living/basic/bounty_criminal/criminal = parent - if(bounty_player_behind(attacker)) - player_hit_at = world.time - if(istype(attacker, /mob/living/basic/bounty_criminal) || istype(attacker, /mob/living/basic/bounty_companion)) - own_side_hit_at = world.time + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent if(istype(attacker, /obj/vehicle/sealed)) var/obj/vehicle/sealed/vehicle = attacker for(var/mob/living/pilot in vehicle.occupants) @@ -1162,60 +681,35 @@ else if(isliving(attacker)) criminal.body_add_grudge(attacker) -/** - * An explosion is about to hit it (M1). ex_act() carries no attacker, so the blast counts as a - * player's when the explosive was last handled by one (`origin`'s last fingerprints: a grenade - * someone threw or set), or when someone with a mind stands within BOUNTY_EXPLOSION_WITNESS_RANGE - * tiles. Anything else is automated, like fire. - */ -/datum/component/bounty_body/proc/note_explosion(atom/origin) - var/mob/living/basic/bounty_criminal/criminal = parent - if(isatom(origin) && origin.fingerprintslast) - player_hit_at = world.time - return - var/turf/here = get_turf(criminal) - if(!here) - return - for(var/mob/living/person in range(BOUNTY_EXPLOSION_WITNESS_RANGE, here)) - if(bounty_player_behind(person)) - player_hit_at = world.time - return - /datum/component/bounty_body/proc/on_attacked(datum/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) return note_attacker(attacker) -// Before an item's damage lands (relay_attackers only reports it after), so the ledger knows whose it is. /datum/component/bounty_body/proc/on_attackby(datum/source, obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) SIGNAL_HANDLER if(attacking_item?.force > 0 || user?.combat_mode) note_attacker(user) -/// Whose shot it is, even a mech's or a vehicle's (relay_attackers only reports mobs, M1); and a sprinting meek criminal ducks some shots (C5), though one that gets through spoils the next second's dodging +/// Whose shot it is, even a mech's or a vehicle's (relay_attackers only reports mobs, M1) /datum/component/bounty_body/proc/on_projectile_prehit(datum/source, obj/projectile/shot) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent if(shot?.firer && shot.is_hostile_projectile()) note_attacker(shot.firer) - if(prob(criminal.body_dodge_chance())) - criminal.visible_message(span_warning("[criminal] ducks out of the way of [shot]!")) - return PROJECTILE_INTERRUPT_HIT_PHASE - criminal.body_last_shot_at = world.time return NONE /// The pad deletes a criminal; nothing teleports one (D-A10) /datum/component/bounty_body/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent if(isturf(criminal.loc)) criminal.visible_message(span_notice("[criminal] flickers for a moment, but stays where [criminal.p_they()] [criminal.p_are()].")) return TRUE /datum/component/bounty_body/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent criminal.visible_message(span_notice("[criminal] shimmers for a moment, then looks the same as before.")) return STOP_WABBAJACK @@ -1223,51 +717,36 @@ SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/datum/component/bounty_body/proc/on_pre_ranged_attack(datum/source, atom/target, modifiers) +/// Its stat, crit, a stun, a knockdown or stamina crit came or went: its capture state may have changed +/datum/component/bounty_body/proc/on_state_signal(datum/source) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent - if(!criminal.may_attack(target)) - return COMPONENT_CANCEL_RANGED_ATTACK + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent + if(!QDELING(criminal)) + criminal.body_state_changed() -/datum/component/bounty_body/proc/on_mousedrop(datum/source, atom/over, mob/user) +/// Someone reaches for a piece of it through the strip menu: a hunter's pressure suit may go on in place of its own (bounty_suits.dm) +/datum/component/bounty_body/proc/on_being_stripped(datum/source, mob/user, obj/item/item) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent - if(!criminal.body_can_be_dragged()) - return COMPONENT_CANCEL_MOUSEDROP_ONTO + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent + return criminal.suits_strip_reach(user, item) -/// It surrendered or stopped, or a stun, knockdown or stamina crit came or went: its capture state may have changed -/datum/component/bounty_body/proc/on_state_signal(datum/source) - SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent - criminal.body_state_changed() - -/// The chance, in percent, that it ducks a shot now: a meek criminal sprinting freely (TRAIT_BOUNTY_SPRINTING, from P3), not shot in the last second -/mob/living/basic/bounty_criminal/proc/body_dodge_chance() - if(!istype(src, /mob/living/basic/bounty_criminal/meek) || !HAS_TRAIT(src, TRAIT_BOUNTY_SPRINTING)) - return 0 - if(stat != CONSCIOUS || downed || is_restrained() || !isturf(loc) || pulledby || buckled) - return 0 - if(HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_IMMOBILIZED) || HAS_TRAIT(src, TRAIT_FLOORED)) - return 0 - if(world.time - body_last_shot_at < BOUNTY_MEEK_DODGE_AFTER_HIT) - return 0 - return BOUNTY_MEEK_DODGE +// ===== TURRETS AND PLAYERS ===== /** - * Whether a turret leaves `creature` alone for being part of a bounty (L2; hostile_creatures.dm). + * Whether a turret leaves `creature` alone for being part of a bounty (L2, AM-11; hostile_creatures.dm). * `at_trader_outpost`: it stands in a trader outpost's concourse. There, criminals, decoys and - * companions are left to the hunters (spec 4). Anywhere else a criminal is left alone only once it is - * down, stunned, restrained or dead, so a crew's own turrets don't finish off their catch; a free one, - * and any companion, is judged like any other creature. + * companions are left to the hunters (spec 4), unless an exposed fugitive hurt a bystander (P6's + * outpost_turret_target()). Anywhere else a criminal is left alone once it is in crit, stunned, + * restrained or dead (tg's states), so a crew's own turrets don't finish off their catch; a free one, and + * any companion, is judged like any other creature. */ /proc/bounty_turret_ignores(mob/living/creature, at_trader_outpost) - var/mob/living/basic/bounty_criminal/criminal = creature + var/mob/living/carbon/human/npc/bounty_criminal/criminal = creature if(istype(criminal)) - // At a trader outpost, turrets leave it alone unless an exposed fugitive hurt someone outside its grudge list (AR-C8, P6's outpost_turret_target()). if(at_trader_outpost) return !criminal.outpost_turret_target() return criminal.capture_state() != BOUNTY_STATE_FREE - return at_trader_outpost && istype(creature, /mob/living/basic/bounty_companion) + return at_trader_outpost && istype(creature, /mob/living/carbon/human/npc/bounty_companion) /// Whether someone with a mind is behind `attacker`: a person, a borg, or the pilot of a mech or other vehicle. Criminals and their companions never count. /proc/bounty_player_behind(atom/attacker) @@ -1278,6 +757,6 @@ return TRUE return FALSE var/mob/living/person = attacker - if(!istype(person) || istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(!istype(person) || bounty_is_gang_body(person)) return FALSE return !isnull(person.mind) || !isnull(person.ckey) diff --git a/voidcrew/modules/bounties/bounty_identity.dm b/voidcrew/modules/bounties/bounty_identity.dm index 43e82826b77..439a0e08695 100644 --- a/voidcrew/modules/bounties/bounty_identity.dm +++ b/voidcrew/modules/bounties/bounty_identity.dm @@ -47,6 +47,8 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( * * - No archetype: rolled from the tier (spec section 2). * - A mini-boss is never placed at a trader outpost (D-A8): there it becomes a normal criminal. + * - A trader-outpost fugitive fights only in a style whose weapon it can hide while it passes as a + * customer (bounty_roll_style()). * - At a trader outpost the record also gets an old look for its mugshot (make_old_look(), AR-C1). * - `species` forces a species (tests, admins); otherwise it is rolled from the pool. * @@ -70,9 +72,12 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( if(BOUNTY_ARCHETYPE_MEEK) record.style = BOUNTY_STYLE_HOLDOUT if(BOUNTY_ARCHETYPE_NORMAL) - record.style = bounty_roll_style() + record.style = bounty_roll_style(concealed = (placement_kind == BOUNTY_PLACEMENT_TRADER_OUTPOST)) if(BOUNTY_ARCHETYPE_BOSS) record.kit = bounty_roll_kit() + // C0 chrome (bounty_chrome.dm): rolled once, so the same criminal always carries the same chrome. The kingpin carries none. + if(placement_kind != BOUNTY_PLACEMENT_KINGPIN) + record.chrome = bounty_chrome_roll(tier, archetype, record.style, record.kit, bounty_chrome_kit_organs(record.kit)) record.alias = bounty_roll_alias(record) record.crime = bounty_roll_crime(tier) record.features = bounty_roll_features(record) @@ -90,17 +95,29 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( return prob(BOUNTY_WANTED_MEEK_CHANCE) ? BOUNTY_ARCHETYPE_MEEK : BOUNTY_ARCHETYPE_NORMAL return prob(BOUNTY_PETTY_MEEK_CHANCE) ? BOUNTY_ARCHETYPE_MEEK : BOUNTY_ARCHETYPE_NORMAL -/// A fighting style for a normal criminal, weighted -/proc/bounty_roll_style() +/** + * A fighting style for a normal criminal, weighted. `concealed`: only the styles whose weapon nobody sees + * while it is carried (the style's concealable), for a fugitive passing as a customer at a trader outpost. + */ +/proc/bounty_roll_style(concealed = FALSE) var/static/list/weights = list( - BOUNTY_STYLE_BRAWLER = 20, - BOUNTY_STYLE_PISTOL = 20, - BOUNTY_STYLE_KNIFE = 18, - BOUNTY_STYLE_CLUB = 15, - BOUNTY_STYLE_BOTTLE = 15, - BOUNTY_STYLE_SHOTGUN = 12, + BOUNTY_STYLE_BRAWLER = 18, + BOUNTY_STYLE_PISTOL = 14, + BOUNTY_STYLE_KNIFE = 16, + BOUNTY_STYLE_CLUB = 14, + BOUNTY_STYLE_BOTTLE = 12, + BOUNTY_STYLE_SHOTGUN = 10, + BOUNTY_STYLE_SMG = 8, + BOUNTY_STYLE_REVOLVER = 8, ) - return pick_weight(weights) + if(!concealed) + return pick_weight(weights) + var/list/hidden = list() + for(var/key in weights) + var/datum/bounty_style/style = GLOB.bounty_styles[key] + if(style?.concealable) + hidden[key] = weights[key] + return length(hidden) ? pick_weight(hidden) : BOUNTY_STYLE_BRAWLER /// A mini-boss kit, weighted /proc/bounty_roll_kit() @@ -110,6 +127,8 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( BOUNTY_KIT_PYROMANIAC = 20, BOUNTY_KIT_DEMOLITIONIST = 18, BOUNTY_KIT_GHOST = 18, + // The rarest: its kit is its chrome (chrome/bounty_cyberpsycho.dm) + BOUNTY_KIT_CYBERPSYCHO = 8, ) return pick_weight(weights) @@ -710,22 +729,22 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( /mob/living/proc/identity_line_values() return null -/mob/living/basic/bounty_criminal/identity_voice() +/mob/living/carbon/human/npc/bounty_criminal/identity_voice() // Blended in at a trader outpost, the fugitive talks exactly like its decoys (AR-C6). if(blended) return null return record?.archetype -/mob/living/basic/bounty_criminal/identity_line_values() +/mob/living/carbon/human/npc/bounty_criminal/identity_line_values() // A Most Wanted crime is no line to toss off ("All this over the Corvina Station massacre?"). if(!record?.crime || record.tier == BOUNTY_TIER_MOST_WANTED) return null return list("{crime}" = record.crime) /// A decoy is an innocent patron: always the shared, neutral lines, whether or not it is blended in -/mob/living/basic/bounty_criminal/decoy/identity_voice() +/mob/living/carbon/human/npc/bounty_criminal/decoy/identity_voice() return null /// A decoy has no crime to own up to -/mob/living/basic/bounty_criminal/decoy/identity_line_values() +/mob/living/carbon/human/npc/bounty_criminal/decoy/identity_line_values() return null diff --git a/voidcrew/modules/bounties/bounty_kingpin.dm b/voidcrew/modules/bounties/bounty_kingpin.dm index 29ab4fe6b61..21d3a97b198 100644 --- a/voidcrew/modules/bounties/bounty_kingpin.dm +++ b/voidcrew/modules/bounties/bounty_kingpin.dm @@ -21,32 +21,37 @@ * ckey), and anyone aboard for themselves, can never hunt him or turn him in, and his goons treat * them as guests. One run at a time (the job's mission_limit, counting the one he holds). He won't * hire drifters or crews who fought his people. - * - His posting (/datum/criminal_bounty/kingpin): P5's posting with his own shares (alive 100%, - * downed counts as alive, dead 80%), his own card lines and a clock that pauses during a shootout. + * - His posting (/datum/criminal_bounty/kingpin): P5's posting with his own shares (alive 100% + * however he is brought in, dead 80%), his own card lines and a clock that pauses during a shootout. * It goes up only on the clock (no earlier than 45 minutes in with 3+ active ships, the next 60-90 * minutes after the last resolves) or from the admin panel; a shootout never puts him on the * board. It adopts him wherever he is, alive (never a second body), so a crew that grabbed him * early can turn him in once he is posted; with his crew at ease every empty goon post gets a new - * goon. Killed before any posting, he is gone with nothing to claim, and his body fades. Closed - * unclaimed (run out, withdrawn), it leaves him in the lounge, alive, uncuffed and in business, - * when he is alive within his goons' leash of the sofa; otherwise he is removed as any criminal is, - * and gone. + * goon. Killed before any posting, he is gone with nothing to claim: his body is an ordinary corpse + * that the human NPC corpse sweep takes in time. Closed unclaimed (run out, withdrawn), it leaves + * him in the lounge, alive, uncuffed and in business, when he is alive within his goons' leash of + * the sofa; otherwise he is removed as any criminal is, and gone. * - Talking: a radial on him. "We're here for you" and "Walk away" only show once that crew's * ship has taken his open posting from the board; the first gets his confront line and starts the - * shootout. Any attack on him or a goon starts it too. A warrant shown to him, or a reach for his - * wrists, only gets the confront line. + * shootout. Any attack on him or a goon starts it too. A warrant shown to him only gets the + * confront line; a reach for his wrists while he can still move gets the line and a shove. * - The shootout (/datum/bounty_kingpin_crew): a 0.6 s telegraph where nobody fires ("the goons - * reach for their guns"), then first shots spread over half a second, at most two goons on one - * hunter in cover. He never leaves the sofa and fires a revolver after a visible 0.5 s aim. Goons - * stay within 7 tiles of the sofa, never shoot anyone who is down, and half of those left flee once - * he falls. The barkeep shouts, the turret ignores both sides, and hunters - * fighting his crew take no outpost property strikes (one marked hook in outpost.dm). His crew - * lives as long as he does. - * - His body is P2's (/mob/living/basic/bounty_criminal), with or without a posting: downed at 25%, - * up again after 60 s unless cuffed, and P2's capture rules and P5's pad apply. He surrenders only - * once his crew is down. - * - Goons (/mob/living/basic/bounty_kingpin_goon) are not bounties: 150 health, no loot, no real - * guns, and their bodies fade after 60 s. + * reach for their guns", and they really draw them), then first shots spread over half a second, + * at most two goons on one hunter in cover. Every shot is a real trigger pull of the real gun in a + * hand, through that body's AI controller (ai_interact(), a player's click), and every reload is + * the player's reload from the spares they carry. Nobody in his crew fires through someone they + * aren't fighting: a bystander, a guest or one of their own in the line holds the shot. He never + * leaves the sofa and fires his revolver after a visible 0.5 s aim that a hit spoils. Goons stay + * within 7 tiles of the sofa, never shoot anyone who is down, and half of those left walk out once + * he falls. The barkeep shouts, the turret ignores both sides, and hunters fighting his crew take + * no outpost property strikes (one marked hook in outpost.dm). His crew lives as long as he does. + * - His body is P2's (/mob/living/carbon/human/npc/bounty_criminal), a real human with or without a + * posting: tg's crit, stamina, cuffs and death, P2's capture rules and P5's pad. A boss (aimed crits), + * tougher by physiology (BOUNTY_KINGPIN_DAMAGE_TAKEN) and an armoured coat. He gives up (tg's + * *surrender) only once his crew is down and he is badly hurt. + * - Goons (/mob/living/carbon/human/npc/bounty_kingpin_goon) are not bounties: real humans in armour + * vests with real pistols, SMGs and shotguns and real spare magazines and shells. Their gear is + * locked to them (D3), so nothing they carry is loot; their bodies go with the human NPC corpse sweep. * * A var this file adds to a shared type carries a `kingpin_` prefix. */ @@ -182,65 +187,92 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // LOOKS // ========================================================================= -/// The kingpin: a charcoal suit, a red tie, a fedora and a cigar +/** + * The kingpin: a charcoal suit under an armoured trenchcoat, a red tie, a fedora and a cigar. His .357 + * and two speedloaders ride in the shoulder holster under the coat, a third speedloader and an + * epinephrine pen in his pockets. Locked to him (D3): none of it is loot. + */ /datum/outfit/bounty_kingpin name = "Bounty kingpin" uniform = /obj/item/clothing/under/suit/charcoal - suit = /obj/item/clothing/suit/toggle/lawyer/black + suit = /obj/item/clothing/suit/armor/hos/trenchcoat neck = /obj/item/clothing/neck/tie/red/tied gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup head = /obj/item/clothing/head/fedora mask = /obj/item/cigarette/cigar/havana - -/// The kingpin in a shootout: the same, with his revolver out (worn look only; he carries no real gun) -/datum/outfit/bounty_kingpin/armed - name = "Bounty kingpin: armed" - r_hand = BOUNTY_KINGPIN_REVOLVER - -/// A goon at ease: black suit, black tie, sunglasses + belt = /obj/item/storage/belt/holster/detective + belt_contents = list( + BOUNTY_KINGPIN_REVOLVER = 1, + BOUNTY_KINGPIN_SPEEDLOADER = 2, + ) + l_pocket = BOUNTY_KINGPIN_SPEEDLOADER + r_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A goon: black suit and tie under an armour vest, sunglasses. The subtypes carry the gun and its spares. /datum/outfit/bounty_kingpin_goon name = "Kingpin goon" uniform = /obj/item/clothing/under/suit/black - suit = /obj/item/clothing/suit/toggle/lawyer/black + suit = /obj/item/clothing/suit/armor/vest neck = /obj/item/clothing/neck/tie/black/tied glasses = /obj/item/clothing/glasses/sunglasses gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup -/// Goons with their guns out (worn looks only; goons carry no real guns and drop none) +/// A Stechkin in the holster under the vest, with two spare magazines beside it /datum/outfit/bounty_kingpin_goon/pistol name = "Kingpin goon: pistol" - r_hand = BOUNTY_GOON_PISTOL_GUN + belt = /obj/item/storage/belt/holster/detective + belt_contents = list( + BOUNTY_GOON_PISTOL_GUN = 1, + BOUNTY_GOON_PISTOL_MAG = 2, + ) +/// An Uzi on the vest, a spare magazine in each pocket /datum/outfit/bounty_kingpin_goon/smg name = "Kingpin goon: SMG" - r_hand = BOUNTY_GOON_SMG_GUN + suit_store = BOUNTY_GOON_SMG_GUN + l_pocket = BOUNTY_GOON_SMG_MAG + r_pocket = BOUNTY_GOON_SMG_MAG +/// A combat shotgun on the back, a bandolier of buckshot /datum/outfit/bounty_kingpin_goon/shotgun name = "Kingpin goon: shotgun" - r_hand = BOUNTY_GOON_SHOTGUN_GUN + back = BOUNTY_GOON_SHOTGUN_GUN + belt = /obj/item/storage/belt/bandolier + belt_contents = list(BOUNTY_GOON_SHOTGUN_AMMO = BOUNTY_GOON_SHOTGUN_SHELLS) + +/// The goons' combat shotgun: tg's, with its tube loaded with buckshot instead of rubber +/obj/item/gun/ballistic/shotgun/automatic/combat/bounty_kingpin + spawn_magazine_type = /obj/item/ammo_box/magazine/internal/shot/com/bounty_kingpin + +/obj/item/ammo_box/magazine/internal/shot/com/bounty_kingpin + ammo_type = BOUNTY_GOON_SHOTGUN_AMMO // ========================================================================= // THE KINGPIN // ========================================================================= /** - * The kingpin: P2's criminal body with his own numbers. He sits on the sofa and never leaves it, - * talks across the table, and in a shootout fires a revolver after a visible aim. His crew - * (kingpin_crew) runs the goons, the room and his business. He may be on a posting or on none. + * The kingpin: P2's criminal body, a real human. He sits on the sofa and never leaves it, talks across + * the table, and in a shootout draws his real revolver and fires it after a visible aim. His crew + * (kingpin_crew) runs the goons, the room, his shots and his business; his own AI controller only + * does what his body does by itself (soft crit, breaking free, giving up, reloading, his medipen). He + * may be on a posting or on none. */ -/mob/living/basic/bounty_criminal/kingpin +/mob/living/carbon/human/npc/bounty_criminal/kingpin name = "kingpin" desc = "Sharp suit, cold eyes. He runs this lounge, and his people are never far away." - maxHealth = BOUNTY_KINGPIN_HEALTH - health = BOUNTY_KINGPIN_HEALTH - // His crew drives him; no criminal AI (P3) is ever put on him. - ai_controller = null + ai_controller = /datum/ai_controller/basic_controller/human_npc/bounty_kingpin + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = BOUNTY_KINGPIN_DAMAGE_TAKEN + // A boss's toughness is his own, not his record's tier (bounty_criminal.dm body_toughness()) + body_tier_toughness = FALSE + npc_surrender = /datum/human_npc_surrender/bounty_kingpin + // Killed before any posting he is an ordinary corpse; on an open posting the sweep leaves him for the pad (npc_corpse_expired()) + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME /// His goons, the shootout, the room and the job he holds (/datum/bounty_kingpin_crew). He deletes it. var/datum/bounty_kingpin_crew/kingpin_crew - /// Rounds left in his revolver (a full cylinder at Initialize) - var/kingpin_rounds = 0 /// world.time he may start his next aim var/kingpin_next_aim_at = 0 /// The timer that fires the aimed shot, while he aims @@ -251,65 +283,51 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/datum/weakref/kingpin_aim_target /// Weakref of anyone who hit him -> how many times: he shoots whoever hurt him most var/list/kingpin_hits = list() - /// world.time of his next surrender roll - var/kingpin_next_surrender_roll = 0 - /// The timer fading his body out, when no posting holds it (kingpin_start_fade()) - var/kingpin_fade_timer /// Between two of his lines COOLDOWN_DECLARE(kingpin_say_cooldown) -/mob/living/basic/bounty_criminal/kingpin/Initialize(mapload) - . = ..() - kingpin_rounds = bounty_kingpin_revolver().magazine - -/mob/living/basic/bounty_criminal/kingpin/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/Destroy() kingpin_cancel_aim() - deltimer(kingpin_fade_timer) - kingpin_fade_timer = null QDEL_NULL(kingpin_crew) kingpin_hits.Cut() - // Deleted for any reason (the pad, a posting's cleanup, an admin): he is gone for the round + // Deleted for any reason (the pad, a posting's cleanup, the corpse sweep, an admin): he is gone for the round SSbounty_kingpin.kingpin_removed(src) return ..() -// Dead is gone for the round. On the board, his body is there to bring in; killed off it, there is nothing to claim and it fades. -/mob/living/basic/bounty_criminal/kingpin/death(gibbed) +// Dead is gone for the round. On the board, his body is there to bring in; killed off it, there is nothing to claim. +/mob/living/carbon/human/npc/bounty_criminal/kingpin/death(gibbed) . = ..() SSbounty_kingpin.kingpin_died(src) if(QDELETED(src)) return + kingpin_cancel_aim() // A chair holds a body upright: he slumps off his sofa if(istype(buckled, /obj/structure/chair)) buckled.unbuckle_mob(src, force = TRUE) - if(!kingpin_posting()) - kingpin_start_fade() - -// His numbers on P2's body -/mob/living/basic/bounty_criminal/kingpin/body_recovery_time() - return BOUNTY_KINGPIN_RECOVERY - -/mob/living/basic/bounty_criminal/kingpin/body_rally_time() - return BOUNTY_KINGPIN_RALLY -/mob/living/basic/bounty_criminal/kingpin/body_stamina_pool() - return BOUNTY_KINGPIN_STAMINA +// The corpse sweep never takes the body an open posting pays for; with no posting he is an ordinary corpse +/mob/living/carbon/human/npc/bounty_criminal/kingpin/npc_corpse_expired() + if(kingpin_posting()?.is_open()) + return FALSE + return ..() -/mob/living/basic/bounty_criminal/kingpin/bounty_outfit() - return kingpin_crew?.crew_armed ? /datum/outfit/bounty_kingpin/armed : /datum/outfit/bounty_kingpin +// He dresses as himself, whatever the zone: the outfit table asks (bounty_outfits.dm) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/bounty_build_outfit(zone) + return /datum/outfit/bounty_kingpin // He is always at the sofa; nothing else sets what he is doing. -/mob/living/basic/bounty_criminal/kingpin/start_activity(kind, atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/start_activity(kind, atom/anchor) return -// Shown a warrant, or someone reached for his wrists: his confront line, and nothing more (P6, P2) -/mob/living/basic/bounty_criminal/kingpin/on_confronted(mob/user) +// Shown a warrant: his confront line, and nothing more (P6, P2) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/on_confronted(mob/user) INVOKE_ASYNC(src, PROC_REF(kingpin_confronted), user) -/mob/living/basic/bounty_criminal/kingpin/identity_voice() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/identity_voice() return BOUNTY_ARCHETYPE_KINGPIN /// His lines come from his own dialogue file first; anything it has no lines for goes to P1's -/mob/living/basic/bounty_criminal/kingpin/bounty_say(context, list/values, force = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/bounty_say(context, list/values, force = FALSE) if(QDELETED(src) || stat == DEAD || !istext(context)) return FALSE if(!length(bounty_kingpin_lines("kingpin", context))) @@ -326,28 +344,46 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) say(line, forced = "bounty kingpin") return TRUE -/mob/living/basic/bounty_criminal/kingpin/examine(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/examine(mob/user) . = ..() if(kingpin_can_talk()) . += span_notice("[p_They()] look[p_s()] ready to talk business.") -// Every hit that gets through: with his crew down, he may give up -/mob/living/basic/bounty_criminal/kingpin/adjust_health(amount, updating_health = TRUE, forced = FALSE) - var/health_before = health - . = ..() - if(amount > 0 && !forced) - kingpin_consider_surrender(health_before) - // Hurt while his crew is at ease, by anything at all: they find out who - kingpin_crew?.member_hurt(src) +/** + * Someone reaches for his wrists. A man on his sofa doesn't step away: while he can still move he + * shoves them off a moment later (a real shove, which breaks their cuffing) and says his confront line. + * Paralysed after giving up, stamcritted, knocked down or in crit, he can't, and the cuffs go on. + */ +/mob/living/carbon/human/npc/bounty_criminal/kingpin/body_on_cuff_attempt(mob/living/user) + if(!kingpin_able()) + return + body_add_grudge(user) + addtimer(CALLBACK(src, PROC_REF(kingpin_answer_cuffs), WEAKREF(user)), BOUNTY_CUFF_REACTION_DELAY, TIMER_DELETE_ME) + +/// A moment after the reach: his line, and a shove for whoever is at his wrists, if he still can +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_answer_cuffs(datum/weakref/user_ref) + var/mob/living/user = user_ref?.resolve() + if(QDELETED(src) || QDELETED(user) || !kingpin_able() || !Adjacent(user)) + return + INVOKE_ASYNC(src, PROC_REF(kingpin_confronted), user) + bounty_kingpin_shove(src, user) + +// He gives up the way a person does (the human NPC base): his aim drops, his hands go up, then tg's *surrender +/mob/living/carbon/human/npc/bounty_criminal/kingpin/npc_surrender() + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_RESTRAINED) || npc_surrendering) + return FALSE + kingpin_cancel_aim() + visible_message(span_notice("[src] puts [p_their()] hands up."), vision_distance = BOUNTY_KINGPIN_SIGHT) + return ..() // An open hand in reach of him starts the talk -/mob/living/basic/bounty_criminal/kingpin/attack_hand(mob/living/carbon/human/user, list/modifiers) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/attack_hand(mob/living/carbon/human/user, list/modifiers) if(istype(user) && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK) && !is_restrained() && kingpin_can_talk()) INVOKE_ASYNC(src, PROC_REF(kingpin_open_talk), user) return TRUE return ..() -/mob/living/basic/bounty_criminal/kingpin/ranged_attack_hand(mob/living/user, list/modifiers) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/ranged_attack_hand(mob/living/user, list/modifiers) if(!kingpin_in_talk_range(user)) return FALSE if(!kingpin_can_talk()) @@ -357,72 +393,54 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return TRUE /// His posting, if he is on one (/datum/criminal_bounty/kingpin): none while he just sits in his lounge -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_posting() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_posting() RETURN_TYPE(/datum/criminal_bounty/kingpin) var/datum/criminal_bounty/kingpin/posting = posting() return istype(posting) ? posting : null /// Whether he is in his lounge: on a turf (not in a locker or a bag) within his goons' leash of his seat -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_on_leash() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_on_leash() var/turf/seat = kingpin_crew?.crew_seat var/turf/here = get_turf(src) if(!seat || !here || !isturf(loc)) return FALSE return here.z == seat.z && get_dist(here, seat) <= BOUNTY_GOON_LEASH -/// A posting let go of him in his lounge: his people see to him. The cuffs come off (onto the floor) and he stops giving up; a downed one gets up in his own time. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_back_to_business() - if(is_restrained()) - body_remove_cuffs() - REMOVE_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - -/// His body, with no posting to bring it in for, fades after BOUNTY_GOON_FADE as a goon's does (once) -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_start_fade() - if(kingpin_fade_timer || stat != DEAD || QDELETED(src)) - return - kingpin_fade_timer = addtimer(CALLBACK(src, PROC_REF(kingpin_fade_out)), BOUNTY_GOON_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// The body fades away, unless a posting holds it for the pad. Taken away, not destroyed: no proof of death. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_fade_out() - kingpin_fade_timer = null - if(QDELETED(src) || kingpin_posting()) - return - ADD_TRAIT(src, TRAIT_BOUNTY_REMOVED, BOUNTY_KINGPIN_TRAIT) - pulledby?.stop_pulling() - animate(src, alpha = 0, time = BOUNTY_GOON_FADE_OUT) - QDEL_IN(src, BOUNTY_GOON_FADE_OUT) +/// A posting let go of him in his lounge: his people see to him. The cuffs come off (onto the floor); anything else he is in, crit included, he gets over in his own time. +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_back_to_business() + if(handcuffed || legcuffed) + uncuff() /** * Takes him back to his sofa when he's been moved off it and is free again: a step at a time within - * his crew's leash, a walk from further off, and put straight back from another level or too far to - * walk, when nobody is watching him (L5). + * his crew's leash, a real walk from further off, and put straight back from another level or too + * far to walk only when no player is near enough to see it (human_npc_watched(), CB-19, L5). */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_go_home() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_go_home() var/turf/seat = kingpin_crew?.crew_seat var/turf/here = get_turf(src) if(!seat || !here || here == seat || buckled || !kingpin_able() || !isturf(loc) || pulledby) return FALSE if(here.z == seat.z && get_dist(here, seat) <= BOUNTY_GOON_LEASH) var/turf/next = get_step_to(src, seat) - if(next) + if(next && body_move_allowed(next)) Move(next, get_dir(src, next)) return TRUE if(here.z == seat.z && get_dist(here, seat) <= BOUNTY_KINGPIN_WALK_HOME_MAX) if(world.time >= kingpin_crew.crew_next_home_walk) kingpin_crew.crew_next_home_walk = world.time + BOUNTY_KINGPIN_WALK_HOME_GAP - GLOB.move_manager.jps_move(src, seat, delay = 3, timeout = BOUNTY_KINGPIN_WALK_HOME_GAP, repath_delay = 2 SECONDS, max_path_length = BOUNTY_KINGPIN_WALK_HOME_MAX * 2, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) + GLOB.move_manager.jps_move(src, seat, delay = max(3, cached_multiplicative_slowdown), timeout = BOUNTY_KINGPIN_WALK_HOME_GAP, repath_delay = 2 SECONDS, max_path_length = BOUNTY_KINGPIN_WALK_HOME_MAX * 2, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) return TRUE - for(var/mob/living/watcher in viewers(world.view, src)) - if(watcher.client && watcher != src) - return FALSE + if(human_npc_watched(src)) + return FALSE forceMove(seat) return kingpin_sit() /// Sits him on the sofa at his seat, facing the way it faces. TRUE if he is sitting there now. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_sit() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_sit() if(buckled) return istype(buckled, /obj/structure/chair) - if(stat != CONSCIOUS || downed || is_restrained() || body_is_stunned()) + if(stat != CONSCIOUS || is_downed() || is_restrained() || body_is_stunned()) return FALSE var/turf/seat = kingpin_crew?.crew_seat || get_turf(src) if(get_turf(src) != seat) @@ -438,30 +456,30 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== TALKING ===== /// Whether he'll talk now: awake, free, on his feet, and no shootout on -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_can_talk() - if(stat != CONSCIOUS || downed || is_restrained() || body_is_stunned()) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_can_talk() + if(stat != CONSCIOUS || is_downed() || is_restrained() || body_is_stunned() || npc_surrendering) return FALSE return kingpin_crew?.crew_state == BOUNTY_KINGPIN_CALM /// Whether `user` is close enough to talk to him: across the table, in sight, awake -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_in_talk_range(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_in_talk_range(mob/living/user) if(!isliving(user) || QDELETED(user) || user.stat != CONSCIOUS || user.z != z) return FALSE return get_dist(src, user) <= BOUNTY_KINGPIN_TALK_RANGE && can_see(user, src, BOUNTY_KINGPIN_TALK_RANGE) /// The radial's validity check -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_talk_check(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_talk_check(mob/living/user) return kingpin_can_talk() && kingpin_in_talk_range(user) /// Whether `user`'s crew has a live reason to confront him: his posting is up and open, and their ship took it from the board -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_confrontable(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_confrontable(mob/living/user) var/datum/criminal_bounty/kingpin/posting = kingpin_posting() if(!posting?.is_open()) return FALSE return posting.is_hunting(get_crew_ship(user)) /// The talk radial for `user`. "We're here for you" and "Walk away" only while their crew is hunting him; "I'll take it" only while he has offered their crew his job. Sleeps. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_open_talk(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_open_talk(mob/living/user) if(!kingpin_talk_check(user)) return var/list/options = list() @@ -487,7 +505,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * starts, his job is offered or taken, or nothing happens. Returns TRUE if he listened. Making a job * can sleep. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_talk(mob/living/user, option) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_talk(mob/living/user, option) if(!kingpin_can_talk() || QDELETED(user)) return FALSE bounty_kingpin_hunter_says(user, option) @@ -508,7 +526,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return TRUE /// Shown a warrant or reached for: his confront line, and no fight -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_confronted(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_confronted(mob/living/user) if(!kingpin_can_talk() || QDELETED(user)) return bounty_say("confront", force = TRUE) @@ -516,7 +534,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== HIS BUSINESS ===== /// The name he goes by on his jobs: his alias, else his name ("The Accountant") -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_job_name() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_job_name() return capitalize(record?.alias || record?.name || name) /** @@ -525,7 +543,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * fought his people, someone is already running his job, or he has none to give. May make his job, * which can sleep. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_work_refusal(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_work_refusal(mob/living/user) var/datum/bounty_kingpin_crew/crew = kingpin_crew if(!crew) return "no_work" @@ -546,7 +564,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return null /// "Got any work?": he offers `user`'s crew the job he holds, for BOUNTY_KINGPIN_OFFER_TIME, or says why not. Returns TRUE if he offered it. Sleeps. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_offer_work(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_offer_work(mob/living/user) var/refusal = kingpin_work_refusal(user) var/datum/bounty_kingpin_crew/crew = kingpin_crew if(QDELETED(src) || !crew) @@ -566,7 +584,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * (SSbounty_kingpin.kingpin_bind()): none of them can hunt him or turn him in. He makes another job * if the round's limit allows. Returns the job, or null. Sleeps. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_take_job(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_take_job(mob/living/user) var/datum/bounty_kingpin_crew/crew = kingpin_crew var/obj/structure/overmap/ship/ship = get_crew_ship(user) var/datum/mission/job = crew?.job() @@ -605,29 +623,32 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== HIS REVOLVER ===== -/// His revolver: the one in his hand, whose rounds and cylinder are his (bounty_real_weapon()) -/proc/bounty_kingpin_revolver() - return bounty_real_weapon(BOUNTY_KINGPIN_REVOLVER) - -/// Whether he can shoot now: awake, not down, not cuffed, not stunned, not given up -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_able() - if(stat != CONSCIOUS || downed || is_restrained() || body_is_stunned()) +/// Whether he can shoot now: awake, not in crit, not cuffed, not stunned, not lying there having given up, hands free +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_able() + if(stat != CONSCIOUS || is_downed() || is_restrained() || body_is_stunned() || npc_surrendering) return FALSE - return !HAS_TRAIT(src, TRAIT_INCAPACITATED) + return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_HANDS_BLOCKED) -/// He thumbs the hammer back and aims at `target`: a visible line for BOUNTY_KINGPIN_REVOLVER_AIM, then the shot -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_start_aim(mob/living/target) +/// He thumbs the hammer back and aims at `target`: a visible line for BOUNTY_KINGPIN_REVOLVER_AIM, then the shot. Needs a loaded gun in his hand. +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_start_aim(mob/living/target) kingpin_cancel_aim() + if(!human_npc_loaded_gun(src)) + return FALSE kingpin_next_aim_at = world.time + BOUNTY_KINGPIN_REVOLVER_COOLDOWN kingpin_aim_target = WEAKREF(target) var/atom/aim_at = kingpin_crew?.shot_target(target) || target + face_atom(aim_at) kingpin_aim_beam = Beam(aim_at, icon_state = "infrared", time = BOUNTY_KINGPIN_REVOLVER_AIM, beam_color = COLOR_RED) visible_message(span_danger("[src] thumbs the hammer back and aims at [target]!"), vision_distance = BOUNTY_KINGPIN_SIGHT) playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', 60, TRUE) kingpin_aim_timer = addtimer(CALLBACK(src, PROC_REF(kingpin_fire)), BOUNTY_KINGPIN_REVOLVER_AIM, TIMER_STOPPABLE | TIMER_DELETE_ME) + return TRUE -/// The aim is done: he fires if he still can and still sees them. A stun in the meantime spoils it. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_fire() +/** + * The aim is done: he pulls the trigger of the revolver in his hand, if he still can, still sees them + * and nobody he isn't fighting stands in the line. A stun in the meantime spoils it. + */ +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_fire() kingpin_aim_timer = null var/mob/living/target = kingpin_aim_target?.resolve() kingpin_aim_target = null @@ -641,32 +662,45 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return FALSE if(!crew.valid_target(target) || !bounty_kingpin_clear_shot(src, target)) return FALSE - kingpin_rounds-- - bounty_kingpin_shoot(src, crew.shot_target(target), bounty_kingpin_revolver(), crew) + var/atom/aim_at = crew.shot_target(target) + if(crew.line_blocked(src, aim_at)) + return FALSE + var/obj/item/gun/gun = human_npc_loaded_gun(src) + if(!gun) + return FALSE + return crew.pull_trigger(src, gun, aim_at) + +/// A hit while he aims: the shot is spoiled +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_fumble() + if(!kingpin_aim_timer) + return FALSE + kingpin_cancel_aim() + if(stat == CONSCIOUS) + visible_message(span_notice("[src] fumbles the shot."), vision_distance = COMBAT_MESSAGE_RANGE) return TRUE /// Drops his aim, if he has one -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_cancel_aim() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_cancel_aim() if(kingpin_aim_timer) deltimer(kingpin_aim_timer) kingpin_aim_timer = null kingpin_aim_target = null QDEL_NULL(kingpin_aim_beam) -/// He reloads: BOUNTY_KINGPIN_REVOLVER_RELOAD with nothing fired, a window for the hunters -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_reload() - kingpin_rounds = bounty_kingpin_revolver().magazine - kingpin_next_aim_at = world.time + BOUNTY_KINGPIN_REVOLVER_RELOAD +/// His reload starts (his AI's reload, the player's way: a speedloader into the cylinder): his line, and the room hears him ask for cover +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_reload_tell() + kingpin_cancel_aim() visible_message(span_notice("[src] breaks open [p_their()] revolver and reloads."), vision_distance = COMBAT_MESSAGE_RANGE) - playsound(src, 'sound/items/weapons/gun/revolver/load_bullet.ogg', 50, TRUE) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "reload") -/// He picks who to shoot among `visible`: whoever hit him most, else the nearest -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_choose_target(list/visible) +/// He picks who to shoot among `visible`: whoever hit him most, else the nearest, among those he has a clear line to +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_choose_target(list/visible) var/mob/living/best var/best_hits = -1 var/best_dist = INFINITY for(var/mob/living/candidate as anything in visible) + if(kingpin_crew?.line_blocked(src, kingpin_crew.shot_target(candidate))) + continue var/hits = kingpin_hits[candidate.weak_reference] || 0 var/dist = get_dist(src, candidate) if(hits > best_hits || (hits == best_hits && dist < best_dist)) @@ -676,48 +710,51 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return best /// Someone hit him: they count toward who he shoots first -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_note_hit(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_note_hit(mob/living/attacker) if(!isliving(attacker)) return var/datum/weakref/ref = WEAKREF(attacker) kingpin_hits[ref] = (kingpin_hits[ref] || 0) + 1 -// ===== GIVING UP ===== +// ===== GIVING UP (CB-14) ===== /** - * After a hit that got through: with his crew down and his health at or below - * BOUNTY_KINGPIN_SURRENDER_BELOW percent, he may give up. BOUNTY_KINGPIN_SURRENDER_FIRST on the hit - * that crosses the line, BOUNTY_KINGPIN_SURRENDER_LATER on later hits, one roll per - * BOUNTY_KINGPIN_SURRENDER_GAP. Never while rallying. Doesn't sleep. + * His surrender rules for the human NPC base's surrender subtree: only once his crew is down (no goon + * still in the fight) and he is at or below BOUNTY_KINGPIN_SURRENDER_BELOW percent of his health, with + * a hunter in sight. BOUNTY_KINGPIN_SURRENDER_FIRST on his first roll, BOUNTY_KINGPIN_SURRENDER_LATER + * after, one roll per BOUNTY_KINGPIN_SURRENDER_GAP. He holsters the revolver, says his line, puts his + * hands up and runs tg's *surrender; then tg's cuffs. He never runs from the sofa. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_consider_surrender(health_before) - if(stat != CONSCIOUS || downed || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED) || body_is_rallying()) - return FALSE - if(!kingpin_crew?.crew_down()) - return FALSE - var/line = maxHealth * BOUNTY_KINGPIN_SURRENDER_BELOW / 100 - if(health > line) - return FALSE - var/chance = BOUNTY_KINGPIN_SURRENDER_FIRST - if(health_before <= line) - if(world.time < kingpin_next_surrender_roll) - return FALSE - chance = BOUNTY_KINGPIN_SURRENDER_LATER - kingpin_next_surrender_roll = world.time + BOUNTY_KINGPIN_SURRENDER_GAP - if(!prob(chance)) +/datum/human_npc_surrender/bounty_kingpin + chance = BOUNTY_KINGPIN_SURRENDER_FIRST + retry_chance = BOUNTY_KINGPIN_SURRENDER_LATER + ally_penalty = 0 + health_share = BOUNTY_KINGPIN_SURRENDER_BELOW / 100 + hostiles_needed = 1 + range = BOUNTY_KINGPIN_SIGHT + retry_delay = BOUNTY_KINGPIN_SURRENDER_GAP + flees = FALSE + +/datum/human_npc_surrender/bounty_kingpin/hurt_enough(mob/living/carbon/human/npc/npc) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = npc + if(!istype(kingpin) || !kingpin.kingpin_crew?.crew_down()) return FALSE - kingpin_surrender() - return TRUE + return ..() -/// He gives up: P2 reads it as stunned, so he can be cuffed, and he fires no more -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_surrender() - if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) +// Who he gives up to: the hunters his crew is fighting that he can see +/datum/human_npc_surrender/bounty_kingpin/hostiles_near(mob/living/carbon/human/npc/npc) + . = list() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = npc + var/datum/bounty_kingpin_crew/crew = istype(kingpin) ? kingpin.kingpin_crew : null + if(!crew) return - kingpin_cancel_aim() - ADD_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - body_note_state() - visible_message(span_notice("[src] puts [p_their()] hands up."), vision_distance = BOUNTY_KINGPIN_SIGHT) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "surrender", null, TRUE) + for(var/mob/living/hunter as anything in crew.live_hunters()) + if(can_see(npc, hunter, range)) + . += hunter + +// His crew is down by the time he thinks about it: nobody's left to count +/datum/human_npc_surrender/bounty_kingpin/allies_near(mob/living/carbon/human/npc/npc) + return list() // ========================================================================= // THE GOONS @@ -726,42 +763,23 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * # Kingpin goon * - * One of the kingpin's crew: not a bounty, pays nothing, carries nothing. Before a shootout they - * idle at their posts (smoke, drink, murmur, watch the room); in one, their crew - * (/datum/bounty_kingpin_crew) moves them and fires their simulated guns. Turrets leave them alone - * (FACTION_TURRET). Their bodies fade after BOUNTY_GOON_FADE. + * One of the kingpin's crew: not a bounty, pays nothing. A real human in a suit and an armour vest, + * with a real gun and its spares carried on the body (the subtype picks which: three pistols, two + * shotguns and an SMG in the lounge). Their gear is locked to them (D3), so none of it is loot. Before + * a shootout they idle at their posts (smoke, drink, murmur, watch the room); in one, their crew + * (/datum/bounty_kingpin_crew) picks their targets, moves them, and pulls the triggers of the guns + * in their hands through their AI controllers; their own AI reloads, breaks free, crawls in crit and + * walks out. Turrets leave them alone (FACTION_TURRET). Their bodies go with the human NPC corpse sweep. */ -/mob/living/basic/bounty_kingpin_goon +/mob/living/carbon/human/npc/bounty_kingpin_goon name = "goon" - desc = "One of the kingpin's people. Good suit, bad attitude." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE - maxHealth = BOUNTY_GOON_HEALTH - health = BOUNTY_GOON_HEALTH - speed = 1 - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - mob_size = MOB_SIZE_HUMAN - sentience_type = SENTIENCE_BOSS + npc_name = "goon" faction = list(FACTION_BOUNTY_KINGPIN, FACTION_TURRET) - density = TRUE - move_resist = MOVE_FORCE_VERY_STRONG - // The body stays until it fades; nothing drops - basic_mob_flags = NONE - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - // P2's pattern: a pool of 100 and a coefficient, or the stamcrit ends the moment it starts (M1) - max_stamina = BOUNTY_GOON_MAX_STAMINA - stamina_crit_threshold = BOUNTY_GOON_STAMCRIT_AT - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - blood_volume = BLOOD_VOLUME_NORMAL - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - response_help_continuous = "taps" - response_help_simple = "tap" - ai_controller = null - /// BOUNTY_GOON_WEAPON_* + ai_controller = /datum/ai_controller/basic_controller/human_npc/bounty_kingpin_goon + npc_outfit_table = /datum/human_npc_outfit_table/bounty + // Like the criminals, his people never shoot someone who is down (defaults 1.13) + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + /// BOUNTY_GOON_WEAPON_*: what they carry var/goon_weapon = BOUNTY_GOON_WEAPON_PISTOL /// Where they stand, or sit, when there is no fight var/turf/goon_post @@ -771,9 +789,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/datum/weakref/goon_crew_ref /// Weakref to who they are shooting at var/datum/weakref/goon_target_ref - /// Rounds (or shells) left before a reload - var/goon_rounds = 0 - /// world.time they may fire next + /// world.time they may pull the trigger next var/goon_next_fire_at = 0 /// world.time a fumble ends var/goon_fumbled_until = 0 @@ -781,70 +797,59 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/goon_windup_timer /// world.time of their next step var/goon_next_move_at = 0 - /// Running away: he fell and they don't want to die for him + /// Running away: he fell and they don't want to die for him (they walk out, npc_walk_out()) var/goon_fleeing = FALSE - /// Walking out: their crew is gone - var/goon_leaving = FALSE - /// Their gun is out (their worn look) - var/goon_armed = FALSE - /// Which of the look pool's faces they have - var/goon_look_number = 1 - /// The timer fading their body out - var/goon_fade_timer /// Weakref to the chair at their post, if they sit var/datum/weakref/goon_chair_ref + /// Pistol shots in the current string, and a weakref to the magazine they came from (a new one starts the count again) + var/goon_string_shots = 0 + var/datum/weakref/goon_string_magazine + +/mob/living/carbon/human/npc/bounty_kingpin_goon/pistol + +/mob/living/carbon/human/npc/bounty_kingpin_goon/smg + goon_weapon = BOUNTY_GOON_WEAPON_SMG + +/mob/living/carbon/human/npc/bounty_kingpin_goon/shotgun + goon_weapon = BOUNTY_GOON_WEAPON_SHOTGUN -/mob/living/basic/bounty_kingpin_goon/Initialize(mapload) +/// The goon type that carries `weapon` (BOUNTY_GOON_WEAPON_*) +/proc/bounty_kingpin_goon_type(weapon) + switch(weapon) + if(BOUNTY_GOON_WEAPON_SMG) + return /mob/living/carbon/human/npc/bounty_kingpin_goon/smg + if(BOUNTY_GOON_WEAPON_SHOTGUN) + return /mob/living/carbon/human/npc/bounty_kingpin_goon/shotgun + return /mob/living/carbon/human/npc/bounty_kingpin_goon/pistol + +/mob/living/carbon/human/npc/bounty_kingpin_goon/Initialize(mapload, datum/human_npc_order/order) . = ..() - // Their own copy: 5 disabler hits (30 each) fill the pool of BOUNTY_GOON_MAX_STAMINA - damage_coeff = damage_coeff.Copy() - damage_coeff[STAMINA] = BOUNTY_GOON_MAX_STAMINA / BOUNTY_GOON_HEALTH - gender = prob(75) ? MALE : FEMALE - goon_look_number = random_outpost_npc_look_number() add_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP), BOUNTY_KINGPIN_TRAIT) - AddElement(/datum/element/relay_attackers) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) - INVOKE_ASYNC(src, PROC_REF(goon_dress)) -/mob/living/basic/bounty_kingpin_goon/Destroy() +/mob/living/carbon/human/npc/bounty_kingpin_goon/Destroy() goon_cancel_windup() - deltimer(goon_fade_timer) - goon_fade_timer = null goon_post = null goon_crew_ref = null goon_target_ref = null goon_chair_ref = null return ..() +// The outfit table asks what they wear: the suit and vest, and the gun of their post with its spares +/mob/living/carbon/human/npc/bounty_kingpin_goon/bounty_build_outfit(zone) + switch(goon_weapon) + if(BOUNTY_GOON_WEAPON_SMG) + return /datum/outfit/bounty_kingpin_goon/smg + if(BOUNTY_GOON_WEAPON_SHOTGUN) + return /datum/outfit/bounty_kingpin_goon/shotgun + return /datum/outfit/bounty_kingpin_goon/pistol + /// Their crew, if it still exists -/mob/living/basic/bounty_kingpin_goon/proc/goon_crew() +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_crew() var/datum/bounty_kingpin_crew/crew = goon_crew_ref?.resolve() return QDELETED(crew) ? null : crew -/// Puts their look on: at ease, or with their gun out. Sleeps. -/mob/living/basic/bounty_kingpin_goon/proc/goon_dress() - var/outfit = /datum/outfit/bounty_kingpin_goon - if(goon_armed) - switch(goon_weapon) - if(BOUNTY_GOON_WEAPON_SMG) - outfit = /datum/outfit/bounty_kingpin_goon/smg - if(BOUNTY_GOON_WEAPON_SHOTGUN) - outfit = /datum/outfit/bounty_kingpin_goon/shotgun - else - outfit = /datum/outfit/bounty_kingpin_goon/pistol - set_outpost_npc_look(src, outfit, gender, goon_look_number) - if(!QDELETED(src)) - update_appearance(UPDATE_OVERLAYS) - -/// Their gun comes out, or goes away -/mob/living/basic/bounty_kingpin_goon/proc/goon_arm(armed) - if(goon_armed == armed) - return - goon_armed = armed - INVOKE_ASYNC(src, PROC_REF(goon_dress)) - /// Says a line from the goons' dialogue, out loud or under their breath -/mob/living/basic/bounty_kingpin_goon/proc/goon_say(context, whisper = FALSE) +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_say(context, whisper = FALSE) if(stat != CONSCIOUS) return FALSE var/line = bounty_kingpin_line("goon", context) @@ -856,110 +861,241 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) say(line, forced = "bounty kingpin goon") return TRUE -/// Whether they can fight now: awake, on their feet, not stunned, not leaving -/mob/living/basic/bounty_kingpin_goon/proc/goon_able() - if(stat != CONSCIOUS || goon_fleeing || goon_leaving) +/// Whether they can fight now: awake, on their feet, hands free, not cuffed, not running, not leaving +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_able() + if(stat != CONSCIOUS || goon_fleeing || !isnull(npc_exit)) + return FALSE + if(HAS_TRAIT(src, TRAIT_RESTRAINED) || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return FALSE return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) +/// Counts a pistol shot just fired from `pistol` towards his string. TRUE when it ends one with rounds still in the gun. +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_string_shot(obj/item/gun/ballistic/pistol) + if(!istype(pistol) || !pistol.magazine) + return FALSE + goon_string_shots = goon_string_magazine?.resolve() == pistol.magazine ? goon_string_shots + 1 : 1 + goon_string_magazine = WEAKREF(pistol.magazine) + if(goon_string_shots < BOUNTY_PISTOL_STRING_SHOTS || bounty_gun_live_rounds(pistol) <= 0) + return FALSE + goon_string_shots = 0 + return TRUE + /// Drops a burst or a blast that was winding up -/mob/living/basic/bounty_kingpin_goon/proc/goon_cancel_windup() +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_cancel_windup() if(goon_windup_timer) deltimer(goon_windup_timer) goon_windup_timer = null -/// A goon's gun by BOUNTY_GOON_WEAPON_*: the one in his hand, loaded as it is (bounty_real_weapon()) -/proc/bounty_kingpin_goon_gun(weapon) - switch(weapon) - if(BOUNTY_GOON_WEAPON_SMG) - return bounty_real_weapon(BOUNTY_GOON_SMG_GUN) - if(BOUNTY_GOON_WEAPON_SHOTGUN) - return bounty_real_weapon(BOUNTY_GOON_SHOTGUN_GUN, BOUNTY_GOON_SHOTGUN_AMMO) - return bounty_real_weapon(BOUNTY_GOON_PISTOL_GUN) - -/// A full magazine (or tube) for their gun -/proc/bounty_kingpin_goon_mag(weapon) - var/datum/bounty_real_weapon/gun = bounty_kingpin_goon_gun(weapon) - return gun.magazine - -/// How long their gun takes to reload -/proc/bounty_kingpin_goon_reload(weapon) - switch(weapon) - if(BOUNTY_GOON_WEAPON_SMG) - return BOUNTY_GOON_SMG_RELOAD - if(BOUNTY_GOON_WEAPON_SHOTGUN) - return BOUNTY_GOON_SHOTGUN_RELOAD - return BOUNTY_GOON_PISTOL_RELOAD +/mob/living/carbon/human/npc/bounty_kingpin_goon/examine(mob/user) + . = ..() + if(stat == DEAD || human_npc_held_gun(src)) + return + . += span_notice("[p_They()] keep[p_s()] a hand near [p_their()] jacket.") -/mob/living/basic/bounty_kingpin_goon/examine(mob/user) +// Killed in a chair, they slump out of it +/mob/living/carbon/human/npc/bounty_kingpin_goon/death(gibbed) + goon_cancel_windup() . = ..() - if(stat == DEAD) + if(QDELETED(src) || stat != DEAD) return - if(goon_armed) - . += span_warning("[p_They()] [p_have()] a gun out.") - else - . += span_notice("[p_They()] keep[p_s()] a hand near [p_their()] jacket.") + if(istype(buckled, /obj/structure/chair)) + buckled.unbuckle_mob(src, force = TRUE) -// Hurt while their crew is at ease, by anything at all: the crew finds out who (H1) -/mob/living/basic/bounty_kingpin_goon/adjust_health(amount, updating_health = TRUE, forced = FALSE) +/** + * They broke someone's hold on them by resisting (the human NPC base's reactions): the grab answer + * (CB-22). A word, the grabber told, and a real shove if they're still in reach and have a hand free. + */ +/mob/living/carbon/human/npc/bounty_kingpin_goon/npc_check_pulled_free(atom/movable/holder, within = 0) + var/mob/living/grabber = npc_resisted_hold?.resolve() + var/freed = npc_resisted_at && world.time <= npc_resisted_at + within && (!holder || grabber == holder) . = ..() - if(amount > 0 && !forced) - goon_crew()?.member_hurt(src) + if(!freed || !isliving(grabber) || QDELETED(grabber) || stat != CONSCIOUS) + return + INVOKE_ASYNC(src, PROC_REF(goon_shove_off), grabber) -// Alive, they don't get dragged off anywhere; a body can be. -/mob/living/basic/bounty_kingpin_goon/can_be_pulled(user, force) - if(stat != DEAD) - if(ismob(user)) - balloon_alert(user, "[p_they()] shove[p_s()] you off!") +/// "Hands off.", and a shove for `grabber`. Sleeps. +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_shove_off(mob/living/grabber) + if(QDELETED(src) || QDELETED(grabber) || stat != CONSCIOUS) + return + balloon_alert(grabber, "[p_they()] shove[p_s()] you off!") + goon_say("shoved") + bounty_kingpin_shove(src, grabber) + +/** + * `shover` gives `target` a real shove: an empty hand, a right click on them, tg's shove (a step back, + * or a stagger against something solid). Nothing if they're out of reach or both hands are full. + * Returns TRUE if it was tried. + */ +/proc/bounty_kingpin_shove(mob/living/carbon/human/shover, mob/living/target) + if(QDELETED(shover) || QDELETED(target) || !shover.Adjacent(target) || !shover.ai_controller) return FALSE - return ..() + if(HAS_TRAIT(shover, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(shover, TRAIT_INCAPACITATED)) + return FALSE + if(!human_npc_ready_empty_hand(shover)) + return FALSE + INVOKE_ASYNC(shover.ai_controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), target, TRUE, list(RIGHT_CLICK = TRUE)) + return TRUE -// Out of a chair while down or dead, they lie flat as the criminals do -/mob/living/basic/bounty_kingpin_goon/set_lying_down(new_lying_angle) - . = ..() - bounty_body_lie_flat(src, new_lying_angle) +// ===== THEIR HANDS AND THEIR GUNS ===== -// A goon's body stays for BOUNTY_GOON_FADE, then fades. Nothing drops. -/mob/living/basic/bounty_kingpin_goon/death(gibbed) - goon_cancel_windup() - . = ..() - if(QDELETED(src)) - return - // A chair holds a body upright: they slump out of it - if(istype(buckled, /obj/structure/chair)) - buckled.unbuckle_mob(src, force = TRUE) - // A body can be dragged about - move_resist = MOVE_RESIST_DEFAULT - deltimer(goon_fade_timer) - goon_fade_timer = addtimer(CALLBACK(src, PROC_REF(goon_fade_out)), BOUNTY_GOON_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// Their body fades away -/mob/living/basic/bounty_kingpin_goon/proc/goon_fade_out() - goon_fade_timer = null - if(QDELETED(src)) +/// A gun `npc` carries on its body and can draw in a moment (holster, belt, suit storage, back, a pocket): a loaded one first +/proc/bounty_kingpin_carried_gun(mob/living/carbon/npc) + var/obj/item/gun/found + for(var/obj/item/gun/gun in npc.get_all_contents_type(/obj/item/gun)) + if(npc.is_holding(gun) || !human_npc_can_draw(npc, gun)) + continue + if(gun.can_shoot()) + return gun + found ||= gun + return found + +/// `npc` draws its gun into a free hand, as a person does, and readies it. Returns the gun in its hand, or null. +/proc/bounty_kingpin_draw_gun(mob/living/carbon/npc) + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(gun) + return gun + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(npc, TRAIT_INCAPACITATED)) + return null + gun = bounty_kingpin_carried_gun(npc) + if(!gun || !human_npc_take_in_hand(npc, gun)) + return null + human_npc_ready_weapon(npc, gun) + human_npc_ready_item(npc, gun) + return gun + +/// `npc` puts the guns in its hands away on its body (D3: never on the floor) +/proc/bounty_kingpin_holster_guns(mob/living/carbon/npc) + for(var/obj/item/gun/gun in npc.held_items) + gun.equip_to_best_slot(npc) + +/// Tells `member`'s AI whether its crew's guns are out, so a spent one gets reloaded (and a holstered one drawn again) +/proc/bounty_kingpin_set_armed(mob/living/member, armed) + var/datum/ai_controller/controller = member?.ai_controller + if(!controller) return - pulledby?.stop_pulling() - animate(src, alpha = 0, time = BOUNTY_GOON_FADE_OUT) - addtimer(CALLBACK(src, PROC_REF(goon_gone)), BOUNTY_GOON_FADE_OUT, TIMER_DELETE_ME) + if(armed) + controller.set_blackboard_key(BB_BOUNTY_KINGPIN_ARMED, TRUE) + else + controller.clear_blackboard_key(BB_BOUNTY_KINGPIN_ARMED) + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) -/mob/living/basic/bounty_kingpin_goon/proc/goon_gone() - qdel(src) +// ========================================================================= +// THEIR AI +// ========================================================================= + +/** + * The kingpin's own AI: what his body does by itself. His crew aims and fires for him. First the human + * NPC base's soft crit and breaking free (never out of his sofa: P2's bounty_escape_captivity leaves + * a chair he sat in alone), then giving up (his surrender rules), keeping his revolver loaded while the + * guns are out, and his medipen when he is hurt and nobody is on him. He doesn't wander. + */ +/datum/ai_controller/basic_controller/human_npc/bounty_kingpin + idle_behavior = null + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_surrender, + /datum/ai_planning_subtree/bounty_kingpin_crew_arms, + /datum/ai_planning_subtree/bounty_treat_self, + ) /** - * They leave: their crew is gone (the posting closed), or they ran. They walk toward `exit` and fade - * out after `time`. Nobody can hurt them on the way out. A body is left to fade on its own. + * A goon's own AI: soft crit, breaking free of cuffs, carriers and hard grabs (P2's), the grab answer + * for anyone else holding them (CB-22), walking out when they leave, and keeping their gun loaded while + * the guns are out. Their crew does the rest. They don't wander. */ -/mob/living/basic/bounty_kingpin_goon/proc/goon_leave(turf/exit, time = BOUNTY_GOON_LEAVE_TIME) - if(goon_leaving || stat == DEAD || QDELETED(src)) +/datum/ai_controller/basic_controller/human_npc/bounty_kingpin_goon + idle_behavior = null + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/bounty_kingpin_goon_grabbed, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/bounty_kingpin_crew_arms, + ) + +/// Held by anyone outside the crew, even a gentle pull: they resist, and break free with a word and a shove (npc_check_pulled_free()) +/datum/ai_planning_subtree/bounty_kingpin_goon_grabbed + +/datum/ai_planning_subtree/bounty_kingpin_goon_grabbed/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = controller.pawn + if(!istype(goon) || goon.stat != CONSCIOUS || HAS_TRAIT(goon, TRAIT_RESTRAINED)) return - goon_leaving = TRUE - goon_cancel_windup() - goon_target_ref = null - ADD_TRAIT(src, TRAIT_GODMODE, BOUNTY_KINGPIN_TRAIT) - buckled?.unbuckle_mob(src, force = TRUE) - if(exit && isturf(loc)) - GLOB.move_manager.jps_move(src, exit, delay = BOUNTY_GOON_STEP, timeout = time, repath_delay = 2 SECONDS, max_path_length = 60, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) - addtimer(CALLBACK(src, PROC_REF(goon_fade_out)), time, TIMER_DELETE_ME) + var/mob/living/grabber = goon.pulledby + if(!isliving(grabber) || goon.goon_crew()?.is_member(grabber)) + return + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * While the crew's guns are out (BB_BOUNTY_KINGPIN_ARMED): a spent gun in hand is reloaded the player's + * way from the spares carried, and an empty hand draws the gun again (after giving up, say). Nothing + * while at ease: the guns stay holstered. + */ +/datum/ai_planning_subtree/bounty_kingpin_crew_arms + +/datum/ai_planning_subtree/bounty_kingpin_crew_arms/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/pawn = controller.pawn + if(pawn.stat != CONSCIOUS || HAS_TRAIT(pawn, TRAIT_HANDS_BLOCKED) || LAZYLEN(pawn.do_afters)) + return + if(!controller.blackboard[BB_BOUNTY_KINGPIN_ARMED] || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return + if(ishumannpc(pawn)) + var/mob/living/carbon/human/npc/npc = pawn + if(npc.npc_surrendering) + return + var/obj/item/gun/gun = human_npc_held_gun(pawn) + if(gun?.can_shoot()) + return + if(gun) + if(!human_npc_can_reload(pawn, gun)) + return + controller.queue_behavior(/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew) + return SUBTREE_RETURN_FINISH_PLANNING + var/obj/item/gun/carried = bounty_kingpin_carried_gun(pawn) + if(!carried) + return + controller.set_blackboard_key(BB_HUMAN_NPC_DRAW_ITEM, carried) + controller.queue_behavior(/datum/ai_behavior/human_npc_draw, BB_HUMAN_NPC_DRAW_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * The human NPC base's reload (a spare from a pocket, belt or bag into the free hand, the gun clicked + * with it, racked, the empty dropped), with the crew's own line as it starts. A tube or cylinder is + * fed what it takes before it is racked, as a person feeds shells one by one. + */ +/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew + give_up_after = BOUNTY_KINGPIN_RELOAD_GIVE_UP + +// Its own start instead of the base's: the same checks and give-up time, with the kingpin's or the goon's line +/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew/setup(datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_UNTIL, world.time + give_up_after) + if(istype(npc, /mob/living/carbon/human/npc/bounty_criminal/kingpin)) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = npc + kingpin.kingpin_reload_tell() + else + npc.visible_message(span_notice("[npc] reloads."), vision_distance = COMBAT_MESSAGE_RANGE) + return TRUE + +/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + // Shell by shell into a tube with room left, while there's time to rack it after + if(istype(gun) && gun.internal_magazine && gun.magazine && world.time < controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL] - 3 SECONDS) + if(!human_npc_spent_ammo_in_hand(npc, gun) && human_npc_live_rounds(gun.magazine) < gun.magazine.max_ammo) + var/obj/item/ammo = human_npc_find_ammo(npc, gun) + if(ammo) + if(!npc.is_holding(ammo)) + if(human_npc_take_in_hand(npc, ammo)) + return AI_BEHAVIOR_DELAY + else if(human_npc_ready_item(npc, ammo)) + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + return ..() // ========================================================================= // HIS CREW: THE ROOM AND THE SHOOTOUT @@ -972,7 +1108,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * shootout, and the job he holds for a crew. Owned by the kingpin and deleted with * him: it lives as long as he does, posting or none. Holds its mobs by weakref. Processes on * SSfastprocess: at ease it keeps the room lively and watches for known hunters; in a shootout it - * moves the goons and fires their guns and his. + * picks targets, moves the goons and pulls the triggers of the real guns in their hands, and his. */ /datum/bounty_kingpin_crew /// Weakref to the kingpin @@ -981,6 +1117,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/turf/crew_seat /// The goon posts he sat down with, list(list(turf, BOUNTY_GOON_WEAPON_*), ...): an empty one is filled again when he goes on the board var/list/crew_posts = list() + /// Goons on their way to a post (human NPCs are built a body at a time): post turf -> /datum/human_npc_order + var/list/crew_pending = list() + /// Build new goons at once instead of queueing them (the unit tests) + var/crew_immediate = FALSE /// The job he holds for a crew (a BOUNTY_KINGPIN_JOB mission, not yet taken), or null var/datum/mission/crew_job /// Weakref of a ship he offered crew_job to -> world.time the offer lapses @@ -991,7 +1131,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/crew_facing = NORTH /// Where bystanders and leaving goons go: the lounge's refuge mark, out of the room and away from the lift var/turf/crew_refuge - /// Weakrefs to the goons (/mob/living/basic/bounty_kingpin_goon) + /// Weakrefs to the goons (/mob/living/carbon/human/npc/bounty_kingpin_goon) var/list/crew_goons = list() /// Weakrefs to everyone the crew is fighting: whoever confronted him, attacked any of them, or came with them var/list/crew_hunters = list() @@ -1032,20 +1172,20 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/crew_next_calm_at = 0 var/crew_next_home_walk = 0 var/crew_next_table_warning = 0 - /// Shots the crew has fired (the unit tests read it) + /// Trigger pulls the crew has made (the unit tests read it) var/crew_shots = 0 -/datum/bounty_kingpin_crew/New(mob/living/basic/bounty_criminal/kingpin/kingpin, turf/seat) +/datum/bounty_kingpin_crew/New(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin, turf/seat, immediate = FALSE) . = ..() kingpin_ref = WEAKREF(kingpin) crew_seat = seat || get_turf(kingpin) + crew_immediate = immediate var/obj/structure/chair/sofa = locate(/obj/structure/chair) in crew_seat if(sofa) crew_facing = sofa.dir GLOB.bounty_kingpin_crews += src - RegisterSignal(kingpin, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_member_attacked)) - RegisterSignal(kingpin, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_member_shot)) - RegisterSignals(kingpin, list(COMSIG_BOUNTY_CRIMINAL_DOWNED, COMSIG_LIVING_DEATH, SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED)), PROC_REF(on_boss_fell)) + watch_member(kingpin) + RegisterSignals(kingpin, list(COMSIG_BOUNTY_CRIMINAL_DOWNED, COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_SURRENDERED), PROC_REF(on_boss_fell)) RegisterSignal(kingpin, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, PROC_REF(on_boss_restrained)) crew_next_idle_at = world.time + rand(BOUNTY_GOON_IDLE_MIN, BOUNTY_GOON_IDLE_MAX) crew_next_boss_idle_at = world.time + rand(BOUNTY_KINGPIN_IDLE_MIN, BOUNTY_KINGPIN_IDLE_MAX) @@ -1054,14 +1194,22 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/Destroy() STOP_PROCESSING(SSfastprocess, src) GLOB.bounty_kingpin_crews -= src + // Goons still on their way never arrive + for(var/post in crew_pending) + qdel(crew_pending[post]) + crew_pending.Cut() // The job he held goes with him drop_job() restore_room(TRUE) - // Whoever is still standing leaves; bodies fade on their own - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons()) + // Whoever is still alive walks out, for real, as soon as they can; bodies are the corpse sweep's + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons()) goon.goon_crew_ref = null - if(goon.stat != DEAD) - goon.goon_leave(exit_turf(goon)) + goon.goon_cancel_windup() + if(goon.stat == DEAD) + continue + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) + goon_walk_out(goon) crew_goons.Cut() crew_hunters.Cut() crew_hostile_ships.Cut() @@ -1080,29 +1228,43 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_barkeep_ref = null return ..() +/// Listens to `member` (him or a goon) for attacks, shots from things that aren't people, and any damage at all +/datum/bounty_kingpin_crew/proc/watch_member(mob/living/member) + RegisterSignal(member, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_member_attacked)) + RegisterSignal(member, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_member_shot)) + RegisterSignal(member, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_member_damaged)) + RegisterSignals(member, list(COMSIG_LIVING_ADJUST_BRUTE_DAMAGE, COMSIG_LIVING_ADJUST_BURN_DAMAGE), PROC_REF(on_member_adjusted)) + // ===== WHO IS WHO ===== /// The kingpin, if he still exists /datum/bounty_kingpin_crew/proc/kingpin() - RETURN_TYPE(/mob/living/basic/bounty_criminal/kingpin) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() + RETURN_TYPE(/mob/living/carbon/human/npc/bounty_criminal/kingpin) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() return QDELETED(kingpin) ? null : kingpin /// The goons that still exist, dead or alive /datum/bounty_kingpin_crew/proc/goons() . = list() for(var/datum/weakref/ref as anything in crew_goons.Copy()) - var/mob/living/basic/bounty_kingpin_goon/goon = ref?.resolve() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = ref?.resolve() if(QDELETED(goon)) crew_goons -= ref continue . += goon -/// The goons still in the fight: alive, not running, not leaving +/// The goons still in the fight: not dead, in crit or cuffed (human_npc_out_of_fight()), not running, not leaving /datum/bounty_kingpin_crew/proc/standing_goons() . = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons()) - if(goon.stat != DEAD && !goon.goon_fleeing && !goon.goon_leaving) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons()) + if(!goon.goon_fleeing && isnull(goon.npc_exit) && !human_npc_out_of_fight(goon)) + . += goon + +/// The goons still at their posts, whatever state they are in: alive, not running, not leaving +/datum/bounty_kingpin_crew/proc/present_goons() + . = list() + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons()) + if(goon.stat != DEAD && !goon.goon_fleeing && isnull(goon.npc_exit)) . += goon /// His crew is down: no goon still in the fight @@ -1115,7 +1277,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return FALSE if(thing == kingpin()) return TRUE - var/mob/living/basic/bounty_kingpin_goon/goon = thing + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = thing return istype(goon) && goon.goon_crew_ref?.resolve() == src /// Whether the crew is fighting `who` @@ -1135,7 +1297,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/proc/add_hunter(mob/living/who) if(!isliving(who) || QDELETED(who) || is_member(who)) return FALSE - if(istype(who, /mob/living/basic/bounty_criminal) || istype(who, /mob/living/basic/bounty_companion)) + if(istype(who, /mob/living/carbon/human/npc/bounty_criminal) || istype(who, /mob/living/carbon/human/npc/bounty_companion)) return FALSE var/datum/weakref/ref = WEAKREF(who) if(ref in crew_hunters) @@ -1233,18 +1395,44 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== THE GOONS ===== -/// A goon with `weapon` at `post`, sitting if there is a chair there, facing the talk spot -/datum/bounty_kingpin_crew/proc/spawn_goon(turf/post, weapon = BOUNTY_GOON_WEAPON_PISTOL) +/** + * A goon with `weapon` (BOUNTY_GOON_WEAPON_*) for `post`, sitting if there is a chair there, facing + * the talk spot. A real human, so it goes through the human NPC spawn queue: `immediate` builds it now + * and returns it, else it is queued (one post at a time) and joins the crew when it is built, and this + * returns null. + */ +/datum/bounty_kingpin_crew/proc/spawn_goon(turf/post, weapon = BOUNTY_GOON_WEAPON_PISTOL, immediate = TRUE) if(!post) return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() var/datum/npc_spawn_context/context = new(kingpin || post, null, "ordinary", source_turf = post) - var/mob/living/basic/bounty_kingpin_goon/goon = spawn_npc_in_zone(/mob/living/basic/bounty_kingpin_goon, post, context, FALSE) + var/goon_type = bounty_kingpin_goon_type(weapon) + if(!immediate) + if(crew_pending[post]) + return null + crew_pending[post] = spawn_human_npc(goon_type, post, CALLBACK(src, PROC_REF(goon_arrived), post, weapon), context = context) + return null + var/datum/human_npc_order/order = spawn_human_npc(goon_type, post, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(goon)) + return null + goon_joined(goon, post, weapon) + return goon + +/// A queued goon for `post` has been built: he takes his post +/datum/bounty_kingpin_crew/proc/goon_arrived(turf/post, weapon, mob/living/carbon/human/npc/bounty_kingpin_goon/goon) + crew_pending -= post + if(QDELETED(goon)) + return + goon_joined(goon, post, weapon) + +/// `goon` takes `post` with `weapon`: in its chair, or facing the talk spot, one of the crew. A fight on already: he draws. +/datum/bounty_kingpin_crew/proc/goon_joined(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, turf/post, weapon) goon.goon_weapon = weapon goon.goon_post = post - goon.goon_rounds = bounty_kingpin_goon_mag(weapon) if(crew_seat && get_dist(post, crew_seat) <= 3) - goon.name = "bodyguard" + goon.fully_replace_character_name(goon.real_name, "bodyguard") var/obj/structure/chair/chair = locate(/obj/structure/chair) in post if(chair && !chair.has_buckled_mobs() && chair.buckle_mob(goon, force = TRUE)) goon.goon_chair_ref = WEAKREF(chair) @@ -1258,15 +1446,14 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) goon.goon_post_dir = facing || SOUTH goon.setDir(goon.goon_post_dir) add_goon(goon) - context.finalize() - return goon + if(is_fighting()) + goon_draw(goon) /// Makes `goon` one of the crew -/datum/bounty_kingpin_crew/proc/add_goon(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/add_goon(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) goon.goon_crew_ref = WEAKREF(src) crew_goons |= WEAKREF(goon) - RegisterSignal(goon, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_member_attacked)) - RegisterSignal(goon, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_member_shot)) + watch_member(goon) /// Where hunters stand to talk to him: two tiles out from the sofa, across the table /datum/bounty_kingpin_crew/proc/talk_spot() @@ -1275,12 +1462,23 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/turf/front = get_step(crew_seat, crew_facing) return front ? (get_step(front, crew_facing) || front) : crew_seat +/// `goon` walks out of the lounge for real (the human NPC base's npc_walk_out()): gone at the exit, or once nobody can see him +/datum/bounty_kingpin_crew/proc/goon_walk_out(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, turf/exit) + exit ||= exit_turf(goon) + if(!exit || goon.stat == DEAD) + return FALSE + goon.goon_cancel_windup() + goon.goon_target_ref = null + if(istype(goon.buckled, /obj/structure/chair)) + goon.buckled.unbuckle_mob(goon) + return goon.npc_walk_out(exit) + // ===== THE ROOM ===== /** * Finds the room around the sofa: the coffee table in front of it and the tables at the goons' posts * (breakable while he is here, and put back if missing), the barkeep who can see the - * sofa, and the refuge. + * sofa, and the refuge. Reads the posts, not the goons: they may still be on their way. */ /datum/bounty_kingpin_crew/proc/find_room() if(!crew_seat) @@ -1293,9 +1491,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(spot) coffee_spots += spot var/list/table_spots = coffee_spots.Copy() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons()) + for(var/list/post as anything in crew_posts) + var/turf/post_turf = post[1] for(var/direction in GLOB.cardinals) - var/turf/spot = get_step(goon.goon_post, direction) + var/turf/spot = get_step(post_turf, direction) if(spot && !(spot in table_spots)) table_spots += spot for(var/turf/spot as anything in table_spots) @@ -1377,11 +1576,14 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) else make_table_breakable(table) -/// Somewhere out of the lounge for `mover` to walk to: the refuge mark, never the hangar lift (M5) +/// Somewhere out of the lounge for `mover` to walk to: the refuge mark, never the hangar lift (M5). With no refuge (a test room): straight on out from the sofa past them. /datum/bounty_kingpin_crew/proc/exit_turf(atom/mover) if(crew_refuge) return crew_refuge - return null + var/turf/here = get_turf(mover) + if(!crew_seat || !here || here == crew_seat || here.z != crew_seat.z) + return null + return get_ranged_target_turf_direct(crew_seat, here, BOUNTY_KINGPIN_SIGHT + BOUNTY_GOON_LEASH) // ===== HIS BUSINESS ===== @@ -1426,7 +1628,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) qdel(job) crew_job_retry_at = world.time + BOUNTY_KINGPIN_JOB_RETRY return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) job.author = kingpin.kingpin_job_name() job.update_text() @@ -1494,20 +1696,21 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * He goes on the board with his crew at ease: every post he sat down with whose goon is dead or gone - * (fled, walked out) gets a new goon. Nothing mid-fight. Returns the goons it placed. + * (fled, walked out) gets a new goon, through the human NPC spawn queue (at once in the tests). + * Nothing mid-fight. Returns the goons it placed at once. */ /datum/bounty_kingpin_crew/proc/restaff() . = list() if(crew_state != BOUNTY_KINGPIN_CALM) return var/list/manned = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in present_goons()) manned |= goon.goon_post for(var/list/post as anything in crew_posts) var/turf/spot = post[1] - if(!spot || (spot in manned)) + if(!spot || (spot in manned) || crew_pending[spot]) continue - var/mob/living/basic/bounty_kingpin_goon/goon = spawn_goon(spot, post[2]) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = spawn_goon(spot, post[2], crew_immediate) if(goon) manned |= spot . += goon @@ -1521,9 +1724,9 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(!length(people)) return if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) - if((attack_flags & ATTACKER_SHOVING) && istype(source, /mob/living/basic/bounty_kingpin_goon)) - var/mob/living/basic/bounty_kingpin_goon/goon = source - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "shoved") + if((attack_flags & ATTACKER_SHOVING) && istype(source, /mob/living/carbon/human/npc/bounty_kingpin_goon)) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = source + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "shoved") return member_attacked(source, people) @@ -1540,6 +1743,18 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(length(people)) member_attacked(source, people) +/// Him or a goon took brute or burn through tg's apply_damage (a hit to a limb, a bullet, a blade) +/datum/bounty_kingpin_crew/proc/on_member_damaged(mob/living/source, damage, damagetype, def_zone, blocked) + SIGNAL_HANDLER + if(damage > 0 && (damagetype == BRUTE || damagetype == BURN)) + member_hurt(source) + +/// Him or a goon took brute or burn straight to the body (fire, a blast, a chemical) +/datum/bounty_kingpin_crew/proc/on_member_adjusted(mob/living/source, damagetype, amount, forced) + SIGNAL_HANDLER + if(amount > 0 && !forced) + member_hurt(source) + /// A hunter fired: at his crew, it's part of the fight (their stray shots take no outpost strike) /datum/bounty_kingpin_crew/proc/on_hunter_fired(mob/living/source, obj/projectile/shot, atom/fired_from, atom/original) SIGNAL_HANDLER @@ -1556,11 +1771,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * `member` (him or a goon) was attacked by `people`: they are hunters now, a goon drawing or - * winding up fumbles, and if all was calm the shootout starts. Someone the crew won't fight (another - * criminal) sets nothing off (L6). Doesn't sleep. + * winding up fumbles, his aim is spoiled, and if all was calm the shootout starts. Someone the crew + * won't fight (another criminal) sets nothing off (L6). Doesn't sleep. */ /datum/bounty_kingpin_crew/proc/member_attacked(atom/member, list/people) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() var/mob/living/first for(var/mob/living/person as anything in people) if(QDELETED(person) || is_member(person)) @@ -1573,8 +1788,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) kingpin.kingpin_note_hit(person) if(!first) return - if(istype(member, /mob/living/basic/bounty_kingpin_goon)) + if(istype(member, /mob/living/carbon/human/npc/bounty_kingpin_goon)) goon_hit(member) + else if(member == kingpin) + // A hit while he aims: "[src] fumbles the shot." + kingpin.kingpin_fumble() if(crew_state == BOUNTY_KINGPIN_CALM) start_shootout(first, FALSE) @@ -1596,7 +1814,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) for(var/mob/living/person in range(BOUNTY_GOON_LEASH, member)) if(person.stat != CONSCIOUS || !(person.mind || person.client) || is_member(person)) continue - if(istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(istype(person, /mob/living/carbon/human/npc/bounty_criminal) || istype(person, /mob/living/carbon/human/npc/bounty_companion)) continue if(SSbounty_kingpin.kingpin_mind_bound(person.mind) && !is_hunter(person)) continue @@ -1607,7 +1825,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(suspect) member_attacked(member, list(suspect)) -/// He fell (downed, gave up, died): his aim drops, and half the goons left run (once a fight) +/// He fell (in crit, gave up, died): his aim drops, and half the goons left run (once a fight) /datum/bounty_kingpin_crew/proc/on_boss_fell(datum/source) SIGNAL_HANDLER kingpin()?.kingpin_cancel_aim() @@ -1630,7 +1848,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/list/standing = sortTim(standing_goons(), GLOBAL_PROC_REF(cmp_bounty_kingpin_goon_health)) var/fleeing = floor(length(standing) * BOUNTY_GOON_FLEE_ON_BOSS / 100) for(var/i in 1 to fleeing) - var/mob/living/basic/bounty_kingpin_goon/goon = standing[i] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = standing[i] goon_flee(goon) . += goon @@ -1638,27 +1856,32 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /proc/cmp_bounty_kingpin_goon_health(mob/living/first, mob/living/second) return first.health - second.health -/// `goon` runs for it: away from the nearest hunter, and gone after BOUNTY_GOON_FLEE_TIME -/datum/bounty_kingpin_crew/proc/goon_flee(mob/living/basic/bounty_kingpin_goon/goon) +/** + * `goon` runs for it (CB-16): his gun goes away, his line, and he walks out for real (npc_walk_out()), + * to the refuge, else away from the nearest hunter. Shots still land on the way; he is gone only on + * the exit tile or once nobody can see him. + */ +/datum/bounty_kingpin_crew/proc/goon_flee(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) if(goon.goon_fleeing || goon.stat == DEAD) return goon.goon_fleeing = TRUE goon.goon_cancel_windup() goon.goon_target_ref = null - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "flee") - var/mob/living/nearest - for(var/mob/living/hunter as anything in live_hunters()) - if(!nearest || get_dist(goon, hunter) < get_dist(goon, nearest)) - nearest = hunter - if(nearest && isturf(goon.loc)) - GLOB.move_manager.move_away(goon, nearest, BOUNTY_KINGPIN_SIGHT + BOUNTY_GOON_LEASH, BOUNTY_GOON_STEP, BOUNTY_GOON_FLEE_TIME, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) - else - goon.goon_leave(exit_turf(goon), BOUNTY_GOON_FLEE_TIME) - return - addtimer(CALLBACK(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_leave), null, 1), BOUNTY_GOON_FLEE_TIME, TIMER_DELETE_ME) + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "flee") + var/turf/exit = crew_refuge + if(!exit) + var/mob/living/nearest + for(var/mob/living/hunter as anything in live_hunters()) + if(!nearest || get_dist(goon, hunter) < get_dist(goon, nearest)) + nearest = hunter + if(nearest && get_turf(nearest) != get_turf(goon)) + exit = get_ranged_target_turf_direct(goon, nearest, BOUNTY_KINGPIN_SIGHT + BOUNTY_GOON_LEASH, 180) + goon_walk_out(goon, exit) /// A goon was hit: drawing, he fumbles his first shot; winding up, he loses the burst or the blast -/datum/bounty_kingpin_crew/proc/goon_hit(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_hit(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) if(goon.stat != CONSCIOUS) return if(crew_state == BOUNTY_KINGPIN_DRAWING) @@ -1675,7 +1898,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/proc/can_fight() if(kingpin()?.kingpin_able()) return TRUE - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon.stat == CONSCIOUS) return TRUE return FALSE @@ -1683,10 +1906,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * The shootout starts, set off by `instigator` ("We're here for you" when `confronted`, else an * attack): the telegraph. For BOUNTY_KINGPIN_TELEGRAPH nobody in the crew fires: the goons reach - * for their guns, the table goons flip their tables, he kicks the coffee table over and the - * barkeep shouts. The instigator and their shipmates nearby are hunters now. It - * puts nobody on the board. Nothing starts when the crew won't fight the instigator (L6) or nobody - * can shoot. Doesn't sleep. Returns TRUE if it started. + * for their guns (and draw them), the table goons flip their tables, he draws his and kicks the + * coffee table over and the barkeep shouts. The instigator and their shipmates nearby are hunters + * now. It puts nobody on the board. Nothing starts when the crew won't fight the instigator (L6) or + * nobody can shoot. Doesn't sleep. Returns TRUE if it started. */ /datum/bounty_kingpin_crew/proc/start_shootout(mob/living/instigator, confronted = FALSE) add_hunter(instigator) @@ -1698,38 +1921,41 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_last_contact_at = world.time crew_lead = null crew_boss_fell = FALSE - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin?.stat == CONSCIOUS) kingpin.kingpin_cancel_aim() INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living, bounty_say), confronted ? "confront" : "attacked", null, TRUE) crew_seat?.visible_message(span_userdanger("The goons reach for their guns!"), vision_distance = BOUNTY_KINGPIN_SIGHT) crew_armed = TRUE if(kingpin) - INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, body_dress)) kingpin.kingpin_next_aim_at = crew_draw_ends_at - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + bounty_kingpin_set_armed(kingpin, TRUE) + if(kingpin.kingpin_able()) + bounty_kingpin_draw_gun(kingpin) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) goon_draw(goon) kick_table() clear_room() log_game("BOUNTY: [key_name(instigator)] started a shootout with [kingpin || "the kingpin's crew"] at [AREACOORD(crew_seat)] ([confronted ? "a confrontation" : "an attack"])") return TRUE -/// `goon` reaches for his gun: the telegraph box, the sound, out of his chair, his table over -/datum/bounty_kingpin_crew/proc/goon_draw(mob/living/basic/bounty_kingpin_goon/goon) - goon.goon_arm(TRUE) +/// `goon` reaches for his gun: the telegraph box, the sound, out of his chair, the real gun into his hand, his table over +/datum/bounty_kingpin_crew/proc/goon_draw(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) goon.goon_cancel_windup() goon.goon_target_ref = null - goon.goon_rounds = bounty_kingpin_goon_mag(goon.goon_weapon) goon.goon_next_fire_at = crew_draw_ends_at + bounty_kingpin_set_armed(goon, TRUE) var/turf/spot = get_turf(goon) if(spot) new /obj/effect/temp_visual/telegraphing/bounty_kingpin(spot) playsound(goon, 'sound/items/weapons/gun/pistol/rack.ogg', 50, TRUE) if(goon.buckled) goon.buckled.unbuckle_mob(goon, force = TRUE) + if(goon.goon_able()) + bounty_kingpin_draw_gun(goon) goon_flip_table(goon) if(prob(40)) - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "draw") + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "draw") /// Flips `table` toward `direction`: a table someone else knocked over the wrong way is set up first (H2) /datum/bounty_kingpin_crew/proc/flip_our_way(obj/structure/table/table, direction) @@ -1745,7 +1971,9 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return TRUE /// A goon at a table kicks it over for cover, away from himself. Never the coffee table: that's his. -/datum/bounty_kingpin_crew/proc/goon_flip_table(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_flip_table(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) + if(!goon.goon_able()) + return null for(var/direction in GLOB.cardinals) var/turf/spot = get_step(goon, direction) var/obj/structure/table/table = locate(/obj/structure/table) in spot @@ -1759,7 +1987,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// He kicks the coffee table over, toward the hunters: his shots go over it, theirs hit it /datum/bounty_kingpin_crew/proc/kick_table() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin?.kingpin_able()) return FALSE var/kicked = FALSE @@ -1795,7 +2023,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/list/goons = shuffle(standing_goons()) var/count = length(goons) for(var/i in 1 to count) - var/mob/living/basic/bounty_kingpin_goon/goon = goons[i] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goons[i] var/offset = count > 1 ? round(BOUNTY_KINGPIN_FIRST_SHOT_SPREAD * (i - 1) / (count - 1), 1) : 0 goon.goon_next_fire_at = max(crew_draw_ends_at + offset, goon.goon_fumbled_until) goon.goon_target_ref = null @@ -1806,26 +2034,28 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_lead = null crew_armed = FALSE crew_engaged.Cut() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) kingpin.kingpin_cancel_aim() - INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, body_dress)) + bounty_kingpin_set_armed(kingpin, FALSE) + bounty_kingpin_holster_guns(kingpin) if(kingpin.kingpin_able()) INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living, bounty_say), "stand_down", null, TRUE) - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in present_goons()) goon.goon_cancel_windup() goon.goon_target_ref = null - goon.goon_arm(FALSE) + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) restore_room() log_game("BOUNTY: [kingpin || "the kingpin's crew"] stood down at [AREACOORD(crew_seat)]") /** * With him gone (padded or deleted) or carried off his leash, there is nobody left to guard: the - * goons still standing walk out. Cuffed or dead on his sofa, they stay with him (M2). Returns TRUE if - * they left. + * goons still standing walk out, for real. Cuffed, in crit or dead on his sofa, they stay with him + * (M2). Returns TRUE if they left. */ /datum/bounty_kingpin_crew/proc/dismiss_if_boss_gone() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) var/turf/here = get_turf(kingpin) var/on_leash = here && crew_seat && here.z == crew_seat.z && get_dist(here, crew_seat) <= BOUNTY_GOON_LEASH @@ -1834,25 +2064,24 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/list/leaving = standing_goons() if(!length(leaving)) return FALSE - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in leaving) - goon.goon_leave(exit_turf(goon)) - return TRUE + . = FALSE + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in leaving) + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) + if(goon_walk_out(goon)) + . = TRUE // ===== THE TICK ===== /datum/bounty_kingpin_crew/process(seconds_per_tick) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin) return PROCESS_KILL - if(!kingpin.kingpin_posting()) - // With no posting to answer for him (L10): nowhere at all (nullspace) is gone for good - if(!get_turf(kingpin)) - log_game("BOUNTY: [kingpin] was cleared away from nowhere") - qdel(kingpin) - return PROCESS_KILL - // A body nobody can bring in (killed off the board, or its proof went up at a chasm's edge) fades - if(kingpin.stat == DEAD) - kingpin.kingpin_start_fade() + // With no posting to answer for him (L10): nowhere at all (nullspace) is gone for good + if(!kingpin.kingpin_posting() && !get_turf(kingpin)) + log_game("BOUNTY: [kingpin] was cleared away from nowhere") + qdel(kingpin) + return PROCESS_KILL // The pad is taking him, or a posting is clearing him away if(HAS_TRAIT(kingpin, TRAIT_BOUNTY_REMOVED)) return @@ -1871,10 +2100,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/proc/fight_tick() var/list/hunters = live_hunters() var/contact = FALSE - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon_tick(goon, hunters)) contact = TRUE - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin && boss_tick(kingpin, hunters)) contact = TRUE if(contact) @@ -1896,7 +2125,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_lead_at = world.time /// One goon's turn: pick a target, close in or shoot, or go looking. Returns TRUE if he sees a hunter. -/datum/bounty_kingpin_crew/proc/goon_tick(mob/living/basic/bounty_kingpin_goon/goon, list/hunters) +/datum/bounty_kingpin_crew/proc/goon_tick(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, list/hunters) if(goon.stat != CONSCIOUS) goon.goon_cancel_windup() return FALSE @@ -1921,10 +2150,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return length(visible) > 0 /// Whether `goon` is one of the BOUNTY_KINGPIN_MAX_ON_ONE goons nearest the lead, the ones who go looking -/datum/bounty_kingpin_crew/proc/goon_is_seeker(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_is_seeker(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) var/my_dist = get_dist(goon, crew_lead) var/closer = 0 - for(var/mob/living/basic/bounty_kingpin_goon/other as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/other as anything in standing_goons()) if(other == goon || !other.goon_able()) continue if(get_dist(other, crew_lead) < my_dist) @@ -1936,7 +2165,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * opening volley, and whenever a hunter is in cover, at most BOUNTY_KINGPIN_MAX_ON_ONE goons draw * on one hunter. */ -/datum/bounty_kingpin_crew/proc/goon_choose_target(mob/living/basic/bounty_kingpin_goon/goon, list/visible) +/datum/bounty_kingpin_crew/proc/goon_choose_target(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, list/visible) if(!length(visible)) return null var/opening = world.time < crew_draw_ends_at + BOUNTY_KINGPIN_FIRST_SHOT_SPREAD @@ -1955,22 +2184,25 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return best /// Whether enough other goons are drawing on `hunter` already that `goon` should pick someone else -/datum/bounty_kingpin_crew/proc/target_full(mob/living/hunter, mob/living/basic/bounty_kingpin_goon/goon, opening) +/datum/bounty_kingpin_crew/proc/target_full(mob/living/hunter, mob/living/carbon/human/npc/bounty_kingpin_goon/goon, opening) if(!opening && !in_cover(hunter)) return FALSE return goons_on(hunter, goon) >= BOUNTY_KINGPIN_MAX_ON_ONE /// How many goons other than `except` are drawing on `hunter` -/datum/bounty_kingpin_crew/proc/goons_on(mob/living/hunter, mob/living/basic/bounty_kingpin_goon/except) +/datum/bounty_kingpin_crew/proc/goons_on(mob/living/hunter, mob/living/carbon/human/npc/bounty_kingpin_goon/except) var/count = 0 - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon != except && goon.goon_target_ref?.resolve() == hunter) count++ return count -/// One step for `goon` toward `destination`, never past his leash from the sofa. Returns TRUE if he moved. -/datum/bounty_kingpin_crew/proc/goon_step_toward(mob/living/basic/bounty_kingpin_goon/goon, turf/destination) - if(!destination || !crew_seat || world.time < goon.goon_next_move_at || goon.buckled || !isturf(goon.loc)) +/** + * One real step for `goon` toward `destination`, never past his leash from the sofa, and never faster + * than his body walks (wounds, armour and stamina slow him as they slow anyone). Returns TRUE if he moved. + */ +/datum/bounty_kingpin_crew/proc/goon_step_toward(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, turf/destination) + if(!destination || !crew_seat || world.time < goon.goon_next_move_at || goon.buckled || !isturf(goon.loc) || goon.pulledby) return FALSE if(get_turf(goon) == destination) return FALSE @@ -1979,24 +2211,42 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return FALSE if(next.z != crew_seat.z || get_dist(next, crew_seat) > BOUNTY_GOON_LEASH) return FALSE - goon.goon_next_move_at = world.time + BOUNTY_GOON_STEP + goon.goon_next_move_at = world.time + max(BOUNTY_GOON_STEP, goon.cached_multiplicative_slowdown) return goon.Move(next, get_dir(goon, next)) +/// A sidestep for a clear line on `target`: an open tile next to `goon`, no further from them, from which nobody he isn't fighting is in the way +/datum/bounty_kingpin_crew/proc/goon_sidestep(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, atom/target) + var/turf/here = get_turf(goon) + var/turf/there = get_turf(target) + if(!here || !there) + return FALSE + var/list/spots = list() + for(var/direction in GLOB.alldirs) + var/turf/spot = get_step(here, direction) + if(!spot || spot == there || spot.is_blocked_turf(exclude_mobs = FALSE) || get_dist(spot, there) > get_dist(here, there)) + continue + if(line_blocked(spot, target)) + continue + spots += spot + if(!length(spots)) + return FALSE + return goon_step_toward(goon, pick(spots)) + /** - * `goon` tries to shoot `target`. Nobody fires while the crew is drawing. A pistol fires at once; an - * SMG spins up (BOUNTY_GOON_SMG_WINDUP) and a shotgun pumps (BOUNTY_GOON_SHOTGUN_WINDUP) first, and - * a hit in the meantime spoils it. An empty gun reloads. Returns TRUE if a shot or a wind-up started. + * `goon` tries to shoot `target`. Nobody fires while the crew is drawing, and only with a loaded gun + * in hand: a spent one is his AI's to reload. A pistol fires at once; an SMG spins up + * (BOUNTY_GOON_SMG_WINDUP) and a shotgun pumps (BOUNTY_GOON_SHOTGUN_WINDUP) first, and a hit in the + * meantime spoils it. Returns TRUE if a shot or a wind-up started. */ -/datum/bounty_kingpin_crew/proc/goon_try_attack(mob/living/basic/bounty_kingpin_goon/goon, mob/living/target) +/datum/bounty_kingpin_crew/proc/goon_try_attack(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, mob/living/target) if(crew_state != BOUNTY_KINGPIN_FIGHTING || !goon.goon_able() || !valid_target(target)) return FALSE if(goon.goon_windup_timer || world.time < goon.goon_next_fire_at || world.time < goon.goon_fumbled_until) return FALSE - if(goon.goon_rounds <= 0) - goon.goon_rounds = bounty_kingpin_goon_mag(goon.goon_weapon) - goon.goon_next_fire_at = world.time + bounty_kingpin_goon_reload(goon.goon_weapon) - goon.visible_message(span_notice("[goon] reloads."), vision_distance = COMBAT_MESSAGE_RANGE) - playsound(goon, 'sound/items/weapons/gun/general/magazine_insert_full.ogg', 40, TRUE) + if(!human_npc_loaded_gun(goon)) + return FALSE + if(line_blocked(goon, shot_target(target))) + goon_sidestep(goon, target) return FALSE switch(goon.goon_weapon) if(BOUNTY_GOON_WEAPON_SMG) @@ -2010,13 +2260,18 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) goon.visible_message(span_warning("[goon] pumps [goon.p_their()] shotgun."), vision_distance = COMBAT_MESSAGE_RANGE) goon.goon_windup_timer = addtimer(CALLBACK(src, PROC_REF(goon_finish_windup), WEAKREF(goon), WEAKREF(target)), BOUNTY_GOON_SHOTGUN_WINDUP, TIMER_STOPPABLE | TIMER_DELETE_ME) else + // Single shots off the Stechkin's own selector, in strings with a pause between them; an empty magazine reloads instead + var/obj/item/gun/ballistic/pistol = human_npc_loaded_gun(goon) + bounty_gun_single_fire(goon, pistol) goon.goon_next_fire_at = world.time + BOUNTY_GOON_PISTOL_COOLDOWN - goon_fire(goon, target) + var/rounds = bounty_gun_live_rounds(pistol) + if(goon_fire(goon, target) && bounty_gun_live_rounds(pistol) < rounds && goon.goon_string_shot(pistol)) + goon.goon_next_fire_at += BOUNTY_PISTOL_STRING_PAUSE return TRUE /// The wind-up is done: the SMG's burst or the shotgun's blast, if he still can and still sees them /datum/bounty_kingpin_crew/proc/goon_finish_windup(datum/weakref/goon_ref, datum/weakref/target_ref) - var/mob/living/basic/bounty_kingpin_goon/goon = goon_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goon_ref?.resolve() if(QDELETED(goon)) return goon.goon_windup_timer = null @@ -2027,27 +2282,70 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(get_dist(goon, target) <= BOUNTY_GOON_SHOTGUN_RANGE) goon_fire(goon, target) return - goon_fire(goon, target) - for(var/shot in 2 to BOUNTY_GOON_SMG_BURST) - addtimer(CALLBACK(src, PROC_REF(goon_burst_shot), goon_ref, target_ref), (shot - 1) * BOUNTY_GOON_SMG_BURST_GAP, TIMER_DELETE_ME) + if(!goon_fire(goon, target)) + return + for(var/pull in 2 to BOUNTY_GOON_SMG_BURST) + addtimer(CALLBACK(src, PROC_REF(goon_burst_shot), goon_ref, target_ref), (pull - 1) * BOUNTY_GOON_SMG_BURST_GAP, TIMER_DELETE_ME) -/// The rest of an SMG burst, one shot at a time +/// The rest of an SMG burst, a trigger pull at a time /datum/bounty_kingpin_crew/proc/goon_burst_shot(datum/weakref/goon_ref, datum/weakref/target_ref) - var/mob/living/basic/bounty_kingpin_goon/goon = goon_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goon_ref?.resolve() var/mob/living/target = target_ref?.resolve() if(QDELETED(goon) || crew_state != BOUNTY_KINGPIN_FIGHTING || !goon.goon_able() || !valid_target(target) || !bounty_kingpin_clear_shot(goon, target)) return goon_fire(goon, target) -/// One round from `goon`'s gun at `target` (at the mech they drive, if they drive one), if one is left: every pellet of a shell -/datum/bounty_kingpin_crew/proc/goon_fire(mob/living/basic/bounty_kingpin_goon/goon, mob/living/target) - if(goon.goon_rounds <= 0) - return null - goon.goon_rounds-- - return bounty_kingpin_shoot(goon, shot_target(target), bounty_kingpin_goon_gun(goon.goon_weapon), src) +/** + * One pull of the trigger of the loaded gun in `goon`'s hand at `target` (at the mech they drive, if + * they drive one), unless someone he isn't fighting is in the line: then he steps aside instead. + * Returns TRUE if he pulled it. + */ +/datum/bounty_kingpin_crew/proc/goon_fire(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, mob/living/target) + var/obj/item/gun/gun = human_npc_loaded_gun(goon) + if(!gun || QDELETED(target)) + return FALSE + var/atom/aim_at = shot_target(target) + if(line_blocked(goon, aim_at)) + goon_sidestep(goon, target) + return FALSE + return pull_trigger(goon, gun, aim_at) -/// The kingpin's turn: aim at whoever hurt him most, or reload. Never while drawing. Returns TRUE if he sees a hunter. -/datum/bounty_kingpin_crew/proc/boss_tick(mob/living/basic/bounty_criminal/kingpin/kingpin, list/hunters) +/** + * `shooter` pulls the trigger of `gun` (in their hand) at `aim_at`, the way a player does: the gun made + * the active hand, a body zone picked, and a click through the body's AI controller (ai_interact()), so + * the gun's own fire path runs (fire delay, burst, recoil, spread, casing, sound, wounds). No projectile + * is ever made here. Returns TRUE if the click was made. + */ +/datum/bounty_kingpin_crew/proc/pull_trigger(mob/living/carbon/human/shooter, obj/item/gun/gun, atom/aim_at) + var/datum/ai_controller/controller = shooter?.ai_controller + if(!controller || QDELETED(aim_at) || !human_npc_ready_item(shooter, gun)) + return FALSE + shooter.zone_selected = human_npc_pick_aim_zone() + INVOKE_ASYNC(controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), aim_at, TRUE) + crew_shots++ + return TRUE + +/** + * Whether someone the crew isn't fighting stands between `from` and `target`: a bystander, a guest, + * another criminal, or one of the crew itself. A shot from them would hit that person, so it isn't + * taken (CB-11, CB-13: no round passes through anyone). Shots go over anyone lying down. + */ +/datum/bounty_kingpin_crew/proc/line_blocked(atom/from, atom/target) + var/turf/start = get_turf(from) + var/turf/end = get_turf(target) + if(!start || !end || start == end) + return FALSE + for(var/turf/line_turf as anything in get_line(start, end)) + if(line_turf == start || line_turf == end) + continue + for(var/mob/living/other in line_turf) + if(other.stat == DEAD || other.body_position == LYING_DOWN || is_hunter(other)) + continue + return TRUE + return FALSE + +/// The kingpin's turn: aim at whoever hurt him most. Never while drawing; a spent revolver is his AI's to reload. Returns TRUE if he sees a hunter. +/datum/bounty_kingpin_crew/proc/boss_tick(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin, list/hunters) if(kingpin.stat != CONSCIOUS) kingpin.kingpin_cancel_aim() return FALSE @@ -2057,8 +2355,8 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return length(visible) > 0 if(crew_state != BOUNTY_KINGPIN_FIGHTING || kingpin.kingpin_aim_timer || world.time < kingpin.kingpin_next_aim_at) return length(visible) > 0 - if(kingpin.kingpin_rounds <= 0) - kingpin.kingpin_reload() + if(!human_npc_loaded_gun(kingpin)) + // Holstered (he gave up and thought better of it): his AI draws it again while the guns are out return length(visible) > 0 var/mob/living/target = kingpin.kingpin_choose_target(visible) if(target) @@ -2078,10 +2376,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return if(watch_for_trouble()) return - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin && !kingpin.buckled && kingpin.kingpin_able() && !kingpin.kingpin_sit()) kingpin.kingpin_go_home() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) goon_calm_tick(goon) right_the_tables() prune_job_offers() @@ -2099,7 +2397,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * started. */ /datum/bounty_kingpin_crew/proc/watch_for_trouble() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() var/mob/living/puller = kingpin?.pulledby if(isliving(puller) && !is_member(puller)) member_attacked(kingpin, list(puller)) @@ -2132,8 +2430,8 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/obj/structure/table/table = ref?.resolve() if(QDELETED(table) || !table.is_flipped) continue - var/mob/living/basic/bounty_kingpin_goon/nearest - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/nearest + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons) if(goon.goon_able() && (!nearest || get_dist(goon, table) < get_dist(nearest, table))) nearest = goon if(!nearest) @@ -2141,13 +2439,13 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) table.unflip_table() nearest.visible_message(span_notice("[nearest] sets [table] back on its legs."), vision_distance = COMBAT_MESSAGE_RANGE) righted = TRUE - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(righted && world.time >= crew_next_table_warning && kingpin?.kingpin_can_talk()) crew_next_table_warning = world.time + BOUNTY_KINGPIN_TABLE_WARN_GAP INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living, bounty_say), "furniture", null, TRUE) /// A goon at ease: back to his post and into his chair, and watching whoever is closest -/datum/bounty_kingpin_crew/proc/goon_calm_tick(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_calm_tick(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) if(!goon.goon_able()) return if(goon.goon_post && get_turf(goon) != goon.goon_post) @@ -2173,7 +2471,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// Someone came up to the table: a goon warns him, he says hello. Each visitor once per BOUNTY_KINGPIN_GREET_GAP. /datum/bounty_kingpin_crew/proc/greet_visitors() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin?.kingpin_can_talk() || !crew_seat) return for(var/mob/living/carbon/human/visitor in range(3, crew_seat)) @@ -2186,8 +2484,8 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_greeted[ref] = world.time var/list/goons = standing_goons() if(length(goons)) - var/mob/living/basic/bounty_kingpin_goon/goon = pick(goons) - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "company") + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = pick(goons) + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "company") var/context = "greet" if(is_hunter(visitor)) context = "greet_hostile" @@ -2199,14 +2497,14 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// One goon does something: a quiet word, a smoke, a drink, the cards /datum/bounty_kingpin_crew/proc/goon_idle() var/list/at_ease = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon.goon_able()) at_ease += goon if(!length(at_ease)) return - var/mob/living/basic/bounty_kingpin_goon/goon = pick(at_ease) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = pick(at_ease) if(prob(40)) - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "idle", TRUE) + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "idle", TRUE) return var/context = "actions" var/turf/spot = get_turf(goon) @@ -2225,7 +2523,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// He says something to nobody in particular, or puffs his cigar /datum/bounty_kingpin_crew/proc/boss_idle() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin?.kingpin_can_talk()) return if(prob(50)) @@ -2247,58 +2545,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) duration = BOUNTY_KINGPIN_TELEGRAPH /** - * # A shot from the kingpin's crew - * - * The real round of the gun that fires it (bounty_kingpin_shoot()). It passes through anyone the - * crew isn't fighting (bystanders, the crew itself, those who work for him): only a hunter it - * reaches can be hit. - */ -/obj/projectile/bullet/bounty_kingpin - name = "bullet" - /// Weakref to the crew whose shot it is (/datum/bounty_kingpin_crew) - var/datum/weakref/kingpin_crew_ref - -/obj/projectile/bullet/bounty_kingpin/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - if(isliving(target) && !direct_target) - var/datum/bounty_kingpin_crew/crew = kingpin_crew_ref?.resolve() - if(!crew?.is_hunter(target)) - return FALSE - return ..() - -/** - * `shooter` fires one round of `weapon` (a /datum/bounty_real_weapon) at `target` for `crew`: every - * pellet of a shell, each with the real round's numbers and spread. Returns the first projectile. - */ -/proc/bounty_kingpin_shoot(mob/living/shooter, atom/target, datum/bounty_real_weapon/weapon, datum/bounty_kingpin_crew/crew) - var/turf/start = get_turf(shooter) - if(!start || !target || !weapon) - return null - var/obj/projectile/bullet/bounty_kingpin/first - for(var/pellet in 1 to weapon.pellets) - var/obj/projectile/bullet/bounty_kingpin/bullet = new(start) - weapon.load(bullet) - bullet.kingpin_crew_ref = crew ? WEAKREF(crew) : null - bullet.starting = start - bullet.firer = shooter - bullet.fired_from = shooter - bullet.yo = target.y - start.y - bullet.xo = target.x - start.x - bullet.original = target - bullet.aim_projectile(target, shooter, null, weapon.shot_spread()) - if(QDELETED(bullet)) - continue - first ||= bullet - INVOKE_ASYNC(bullet, TYPE_PROC_REF(/obj/projectile, fire)) - if(weapon.fire_sound) - playsound(shooter, weapon.fire_sound, 60, TRUE) - if(crew) - crew.crew_shots++ - return first - -/** - * Whether a shot from `from` would reach `target`: in range, on the same level, and nothing in the - * way that stops a bullet (walls, windows, closed doors, machines; tables only when flipped toward - * the shot). People in the way don't count: the crew's shots pass through anyone but a hunter. + * Whether `from` has a line of sight and fire on `target` for the crew's choice of who to shoot: in + * range, on the same level, and nothing in the way that stops a bullet (walls, windows, closed doors, + * machines; tables only when flipped toward the shot, as tg's flipped tables let shots from behind + * through). People in the way are the trigger's business (line_blocked()): a goon doesn't fire + * through anyone he isn't fighting. */ /proc/bounty_kingpin_clear_shot(atom/from, atom/target, range = BOUNTY_KINGPIN_SIGHT) var/turf/start = get_turf(from) @@ -2340,7 +2591,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * # The kingpin's posting * - * P5's posting with his rules: "wanted dead or alive" (alive 100% however he got there, dead 80%), + * P5's posting with his rules: "wanted dead or alive" (alive 100% however he is brought in, dead 80%), * a clock that stops during a shootout, no relisting (he is only ever on his sofa), and no place * under the board's live-criminal cap. It never spawns him: it adopts the seated kingpin wherever he * is (bounty_kingpin_post()). Nobody who works for him (SSbounty_kingpin's bindings) can hunt him or @@ -2351,11 +2602,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// Every crew has been told he is wanted: once, when it went up (L7) var/kingpin_announced = FALSE -// Wanted dead or alive: restrained, stunned or downed is alive and pays in full; dead pays 80% -/datum/criminal_bounty/kingpin/board_share_for(state, worst_state) +// Wanted dead or alive (CB-28), on his condition at the pad: alive pays in full, cuffed, stunned or in crit alike; dead pays 80%. Free is refused. +/datum/criminal_bounty/kingpin/board_share_for(state) if(!bounty_state_share(state)) return 0 - if(state == BOUNTY_STATE_DEAD || worst_state == BOUNTY_STATE_DEAD) + if(state == BOUNTY_STATE_DEAD) return BOUNTY_KINGPIN_PAY_DEAD return BOUNTY_KINGPIN_PAY_ALIVE @@ -2369,7 +2620,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // The clock stops while his crew is fighting; a hunt doesn't stop it /datum/criminal_bounty/kingpin/board_clock_held(now = world.time) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = criminal() if(!kingpin) return !board_proof() && !board_relisting return istype(kingpin) && !!kingpin.kingpin_crew?.is_fighting() @@ -2427,7 +2678,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(reason == BOUNTY_CLOSE_CLAIMED) SSbounty_kingpin.kingpin_set_gone("claimed") else - var/mob/living/basic/bounty_criminal/kingpin/kingpin = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = criminal() if(kingpin_stays(kingpin)) kingpin_let_go(kingpin) . = ..() @@ -2435,13 +2686,13 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) SSbounty_kingpin.kingpin_resolved() /// Whether `kingpin` stays when it closes unclaimed: alive, with his crew, in his lounge (within his goons' leash of the sofa) -/datum/criminal_bounty/kingpin/proc/kingpin_stays(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/criminal_bounty/kingpin/proc/kingpin_stays(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) if(!istype(kingpin) || QDELETED(kingpin) || kingpin.stat == DEAD || !kingpin.kingpin_crew || HAS_TRAIT(kingpin, TRAIT_BOUNTY_REMOVED)) return FALSE return kingpin.kingpin_on_leash() /// Lets go of `kingpin`, who stays in his lounge: no longer its criminal, on no posting, and back to business -/datum/criminal_bounty/kingpin/proc/kingpin_let_go(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/criminal_bounty/kingpin/proc/kingpin_let_go(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) board_detach_criminal() kingpin.posting_ref = null REMOVE_TRAIT(kingpin, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) @@ -2468,24 +2719,30 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * The kingpin in `record`'s face at `seat` (on its sofa), on no posting, with a goon at each of * `posts` (list(list(turf, BOUNTY_GOON_WEAPON_*), ...)), and his crew knowing the room. Leashed to - * the trader outpost the seat is in, if it is in one. Returns him. + * the trader outpost the seat is in, if it is in one. He is built at once (one body); his goons come + * through the human NPC spawn queue a body at a time, unless `immediate_goons` (the unit tests). + * Returns him. */ -/proc/bounty_kingpin_setup(turf/seat, list/posts, datum/bounty_record/record) +/proc/bounty_kingpin_setup(turf/seat, list/posts, datum/bounty_record/record, immediate_goons = FALSE) if(!seat) return null var/datum/npc_spawn_context/context = new(seat, null, "boss") - var/mob/living/basic/bounty_criminal/kingpin/kingpin = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/kingpin, seat, context, FALSE) - kingpin.kingpin_crew = new /datum/bounty_kingpin_crew(kingpin, seat) + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/kingpin, seat, look = record, name = record?.name, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(kingpin)) + return null + kingpin.kingpin_crew = new /datum/bounty_kingpin_crew(kingpin, seat, immediate_goons) kingpin.body_setup(record, null) var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(seat) if(outpost) kingpin.body_set_site(outpost) kingpin.kingpin_sit() - context.finalize() for(var/list/post as anything in posts) kingpin.kingpin_crew.crew_posts += list(post.Copy()) - kingpin.kingpin_crew.spawn_goon(post[1], post[2]) kingpin.kingpin_crew.find_room() + for(var/list/post as anything in posts) + kingpin.kingpin_crew.spawn_goon(post[1], post[2], immediate_goons) return kingpin /// A new record for a kingpin: Most Wanted, his own archetype, a boss's alias and crime, a base value from his band @@ -2542,7 +2799,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * SSbounty_kingpin.kingpin_post() is the way in (the clock and the admin panel). Doesn't sleep. * Returns the posting, or null. */ -/proc/bounty_kingpin_post(mob/living/basic/bounty_criminal/kingpin/kingpin) +/proc/bounty_kingpin_post(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) if(!istype(kingpin) || QDELETED(kingpin) || HAS_TRAIT(kingpin, TRAIT_BOUNTY_REMOVED) || kingpin.kingpin_posting() || bounty_kingpin_open_posting()) return null var/obj/structure/overmap/trader_outpost/outpost = kingpin.kingpin_crew?.crew_outpost() @@ -2605,7 +2862,7 @@ SUBSYSTEM_DEF(bounty_kingpin) /// Whether it seats and posts him on its own. Off in tests, which seat and post their own. var/kingpin_auto_post = TRUE #endif - /// Weakref to the round's kingpin (/mob/living/basic/bounty_criminal/kingpin), once seated + /// Weakref to the round's kingpin (/mob/living/carbon/human/npc/bounty_criminal/kingpin), once seated var/datum/weakref/kingpin_ref /// He died, was deleted or was claimed at a pad: nobody seats or posts him again this round var/kingpin_gone = FALSE @@ -2631,12 +2888,12 @@ SUBSYSTEM_DEF(bounty_kingpin) /// This round's kingpin, if he is seated and his mob still exists (dead or alive) /datum/controller/subsystem/bounty_kingpin/proc/kingpin() - RETURN_TYPE(/mob/living/basic/bounty_criminal/kingpin) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() + RETURN_TYPE(/mob/living/carbon/human/npc/bounty_criminal/kingpin) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() return QDELETED(kingpin) ? null : kingpin /// Makes `kingpin` this round's kingpin -/datum/controller/subsystem/bounty_kingpin/proc/kingpin_track(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/controller/subsystem/bounty_kingpin/proc/kingpin_track(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) kingpin_ref = (istype(kingpin) && !QDELETED(kingpin)) ? WEAKREF(kingpin) : null /// Seats him at the black market once it has loaded, unless he sits there already or is gone for the round. Returns him, or null. @@ -2656,7 +2913,7 @@ SUBSYSTEM_DEF(bounty_kingpin) /datum/controller/subsystem/bounty_kingpin/proc/kingpin_seat(obj/structure/overmap/trader_outpost/outpost) if(kingpin_gone) return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) return kingpin for(var/datum/bounty_kingpin_crew/crew as anything in GLOB.bounty_kingpin_crews) @@ -2711,7 +2968,7 @@ SUBSYSTEM_DEF(bounty_kingpin) /datum/controller/subsystem/bounty_kingpin/proc/kingpin_post() if(kingpin_gone) return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin || kingpin.stat == DEAD) return null var/datum/criminal_bounty/kingpin/posting = bounty_kingpin_post(kingpin) @@ -2720,11 +2977,11 @@ SUBSYSTEM_DEF(bounty_kingpin) return posting /// `kingpin` died: he is gone for the round. A posting already up holds his body for the dead share; none goes up for it. -/datum/controller/subsystem/bounty_kingpin/proc/kingpin_died(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/controller/subsystem/bounty_kingpin/proc/kingpin_died(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) kingpin_set_gone(kingpin?.kingpin_posting() ? "died on the board" : "died off the board, nothing to claim") /// `kingpin`'s mob is being deleted: he is gone for the round -/datum/controller/subsystem/bounty_kingpin/proc/kingpin_removed(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/controller/subsystem/bounty_kingpin/proc/kingpin_removed(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) if(kingpin_ref && kingpin_ref == kingpin?.weak_reference) kingpin_ref = null kingpin_set_gone("deleted") @@ -2942,5 +3199,5 @@ SUBSYSTEM_DEF(bounty_kingpin) return "post the kingpin bounty on [posting.record?.name || "nobody"] at [posting.board_site_name || "the black market"]" /// His lounge faction stays neutral; his explicit boss encounter is still profiled. -/mob/living/basic/bounty_criminal/kingpin/is_npc_zone_exempt() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/is_npc_zone_exempt() return npc_zone_exempt || npc_spawn_context?.exempt || client || mind diff --git a/voidcrew/modules/bounties/bounty_lair.dm b/voidcrew/modules/bounties/bounty_lair.dm index d8173490d96..0cb5f3338b1 100644 --- a/voidcrew/modules/bounties/bounty_lair.dm +++ b/voidcrew/modules/bounties/bounty_lair.dm @@ -22,9 +22,10 @@ * rooms a crew clears stay cleared: the zone_mobs markers delete themselves after spawning and are never stamped * again. On its first load it indexes its own footprint once: it spawns the boss and the gatekeepers on their * landmarks, finds its gate poddoors (id BOUNTY_LAIR_GATE_ID), and keeps the boss, the gatekeepers and the goons to - * the rooms they started in. When the last gatekeeper dies, the gate opens. When the last boss dies (the mech, then - * the don who climbs out of it), the trophy drops where he fell, unless the boss's own death dropped it through - * bounty_lair_drop_trophy(). + * the rooms they started in. Gatekeepers that are human NPCs are queued (spawn_human_npc(), a body or two a tick); + * until each order has built its body, it counts as a gatekeeper standing, so the gate never opens on an empty back + * office. When the last gatekeeper dies, the gate opens. When the last boss dies (the mech, then the don who climbs + * out of it), the trophy drops where he fell, unless the boss's own death dropped it through bounty_lair_drop_trophy(). * * ## The lair clock (SSbounty_lairs) * @@ -88,8 +89,8 @@ close(BOUNTY_CLOSE_LAIR_GONE) return FALSE -/// Dead, and only dead: the trophy pays in full, anything else pays nothing -/datum/criminal_bounty/kill_only/board_share_for(state, worst_state) +/// Dead, and only dead: the trophy pays in full, anything else pays nothing (a boss brought in cuffed pays nothing: his trophy pays) +/datum/criminal_bounty/kill_only/board_share_for(state) return state == BOUNTY_STATE_DEAD ? 100 : 0 /// The pad takes its trophy only: the one it dropped, or any trophy bound to it, lying on the pad's own tile @@ -274,12 +275,33 @@ return FALSE return TRUE +/// Whether `candidate` is a lair boss: a /mob/living/basic/bounty_lair_boss (the don's mech), or the don on foot (a person) +/proc/bounty_is_lair_boss(mob/living/candidate) + return istype(candidate, /mob/living/basic/bounty_lair_boss) || istype(candidate, /mob/living/carbon/human/npc/bounty_mafia/don) + +/// The weakref to the posting a lair boss is wanted on, or null (not a lair boss, or an admin spawn) +/proc/bounty_lair_boss_posting_ref(mob/living/boss) + if(istype(boss, /mob/living/basic/bounty_lair_boss)) + var/mob/living/basic/bounty_lair_boss/machine = boss + return machine.posting_ref + if(istype(boss, /mob/living/carbon/human/npc/bounty_mafia/don)) + var/mob/living/carbon/human/npc/bounty_mafia/don/person = boss + return person.posting_ref + return null + +/// Makes `boss` wanted on the posting `posting_ref` points at. Does nothing to a mob that isn't a lair boss. +/proc/bounty_lair_set_boss_posting(mob/living/boss, datum/weakref/posting_ref) + if(istype(boss, /mob/living/basic/bounty_lair_boss)) + var/mob/living/basic/bounty_lair_boss/machine = boss + machine.posting_ref = posting_ref + else if(istype(boss, /mob/living/carbon/human/npc/bounty_mafia/don)) + var/mob/living/carbon/human/npc/bounty_mafia/don/person = boss + person.posting_ref = posting_ref + /// The open kill-only bounty a lair boss is wanted on (its posting_ref), or null /proc/bounty_kill_posting_of(mob/living/boss) - if(!istype(boss, /mob/living/basic/bounty_lair_boss)) - return null - var/mob/living/basic/bounty_lair_boss/lair_boss = boss - var/datum/criminal_bounty/kill_only/posting = lair_boss.posting_ref?.resolve() + var/datum/weakref/posting_ref = bounty_lair_boss_posting_ref(boss) + var/datum/criminal_bounty/kill_only/posting = posting_ref?.resolve() if(!istype(posting) || !posting.is_open()) return null return posting @@ -329,6 +351,11 @@ var/list/lair_gate_doors = list() /// Weakrefs to its living gatekeepers; the gate opens when the last one is gone var/list/lair_gatekeepers = list() + /// Gatekeepers still in the human NPC spawn queue: a key per order -> the order (/datum/human_npc_order). Each counts + /// as a gatekeeper standing until it has built its body (or failed to). + var/list/lair_pending_gatekeepers = list() + /// Makes the keys of lair_pending_gatekeepers + var/lair_spawn_serial = 0 /// Weakrefs to the bosses it is waiting on (the mech, then the don who climbs out of it) var/list/lair_bosses = list() /// The gate is open: it stays open, and the gatekeepers never come back @@ -357,6 +384,7 @@ /obj/structure/overmap/space_ruin/bounty_lair/Destroy() lair_unhook_mobs() + lair_cancel_pending_gatekeepers() lair_clear_timers() if(lair_release_timer) deltimer(lair_release_timer) @@ -550,9 +578,15 @@ log_mapping("BOUNTY LAIR: [name] has a boss landmark with no mob type at [AREACOORD(home)]") continue var/datum/npc_spawn_context/context = new(src, null, "boss") - var/mob/living/boss = spawn_npc_in_zone(boss_type, home, context, FALSE) + var/mob/living/boss + if(ispath(boss_type, /mob/living/carbon/human/npc)) + // One body, wanted before the fight can start: built now + var/datum/human_npc_order/order = spawn_human_npc(boss_type, home, context = context, immediate = TRUE) + boss = order?.spawned_ref?.resolve() + else + boss = spawn_npc_in_zone(boss_type, home, context, FALSE) + context.finalize() lair_adopt_boss(boss) - context.finalize() for(var/obj/effect/landmark/bounty_lair_gatekeeper/mark as anything in keeper_marks) if(QDELETED(mark)) @@ -566,17 +600,24 @@ log_mapping("BOUNTY LAIR: [name] has a gatekeeper landmark with no mob type at [AREACOORD(home)]") continue var/datum/npc_spawn_context/context = new(src, null, "boss") + if(ispath(keeper_type, /mob/living/carbon/human/npc)) + // People are queued, never a room of them in one tick: it holds the gate from now, and is watched once built + lair_spawn_serial++ + var/key = "[lair_spawn_serial]" + var/datum/human_npc_order/order = spawn_human_npc(keeper_type, home, CALLBACK(src, PROC_REF(lair_on_gatekeeper_built), key), context = context) + lair_pending_gatekeepers[key] = order + continue var/mob/living/keeper = spawn_npc_in_zone(keeper_type, home, context, FALSE) lair_add_gatekeeper(keeper) context.finalize() lair_leash_goons() - lair_goon_timer = addtimer(CALLBACK(src, PROC_REF(lair_leash_goons)), BOUNTY_LAIR_GOON_LEASH_DELAY, TIMER_STOPPABLE) + lair_goon_timer = addtimer(CALLBACK(src, PROC_REF(lair_settle)), BOUNTY_LAIR_GOON_LEASH_DELAY, TIMER_STOPPABLE) // Nobody left to hold the gate (none mapped, or it was opened before a reload): it stands open - if(lair_gate_open || !length(lair_gatekeepers)) + if(lair_gate_open || (!length(lair_gatekeepers) && !lair_pending_gatekeeper_count())) lair_open_gate(silent = TRUE) lair_link_done = TRUE - log_game("BOUNTY: [name] linked its interior: [length(lair_bosses)] boss(es), [length(lair_gatekeepers)] gatekeeper(s), [length(lair_gate_doors)] gate door(s)") + log_game("BOUNTY: [name] linked its interior: [length(lair_bosses)] boss(es), [length(lair_gatekeepers)] gatekeeper(s) and [lair_pending_gatekeeper_count()] on their way, [length(lair_gate_doors)] gate door(s)") /** * The interior is being given back while the bounty is open (nothing does this while it is mission-locked, so this is @@ -585,6 +626,7 @@ /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_on_unloading(datum/source) SIGNAL_HANDLER lair_unhook_mobs() + lair_cancel_pending_gatekeepers() lair_clear_timers() lair_gate_doors = list() lair_last_death_spot = null @@ -599,9 +641,7 @@ /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_adopt_boss(mob/living/boss) if(QDELETED(boss)) return FALSE - if(istype(boss, /mob/living/basic/bounty_lair_boss)) - var/mob/living/basic/bounty_lair_boss/lair_boss = boss - lair_boss.posting_ref = posting_ref + bounty_lair_set_boss_posting(boss, posting_ref) lair_bosses |= WEAKREF(boss) lair_watch(boss, PROC_REF(lair_on_boss_gone)) lair_leash(boss) @@ -637,7 +677,7 @@ if(!posting || lair_boss_killed) return var/standing = FALSE - for(var/mob/living/basic/bounty_lair_boss/boss as anything in lair_bosses_inside()) + for(var/mob/living/boss as anything in lair_bosses_inside()) if(boss.stat == DEAD) continue if(!boss.weak_reference || !(boss.weak_reference in lair_bosses)) @@ -673,13 +713,13 @@ log_game("BOUNTY: [name]'s boss is dead but there was no safe floor for its trophy; closing its bounty") posting.close(BOUNTY_CLOSE_LAIR_GONE) -/// Every lair boss standing in its footprint +/// Every lair boss standing in its footprint (bounty_is_lair_boss()) /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_bosses_inside() . = list() if(!footprint) return - for(var/mob/living/basic/bounty_lair_boss/boss in GLOB.mob_living_list) - if(!QDELETED(boss) && footprint.contains_turf(get_turf(boss))) + for(var/mob/living/boss as anything in GLOB.mob_living_list) + if(!QDELETED(boss) && bounty_is_lair_boss(boss) && footprint.contains_turf(get_turf(boss))) . += boss /// A trophy already bound to `posting` near where the boss fell (a boss's death that made its own) @@ -699,8 +739,8 @@ if(QDELETED(keeper)) return FALSE lair_gatekeepers |= WEAKREF(keeper) - // Troopers delete themselves on death and leave a corpse spawner: either signal counts, whichever comes first. - // An admin deleting one counts as gone too: a gate that never opens is worse. + // A person dies in place (COMSIG_LIVING_DEATH); a mob deleted on death, or by an admin, counts as gone too, whichever + // signal comes first: a gate that never opens is worse. lair_watch(keeper, PROC_REF(lair_on_gatekeeper_gone)) lair_leash(keeper) return TRUE @@ -713,7 +753,7 @@ lair_gatekeepers -= source.weak_reference lair_check_gate() -/// Opens the gate if no gatekeeper is left standing. Returns TRUE if it opened now. +/// Opens the gate if no gatekeeper is left standing, and none is still on its way. Returns TRUE if it opened now. /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_check_gate() if(lair_gate_open) return FALSE @@ -723,9 +763,48 @@ lair_gatekeepers -= ref continue return FALSE + if(lair_pending_gatekeeper_count()) + return FALSE lair_open_gate() return TRUE +/** + * How many queued gatekeepers are still to be built. An order that was cancelled, or built nothing, is dropped here; + * one that built its body has already moved to lair_gatekeepers (lair_on_gatekeeper_built()). + */ +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_pending_gatekeeper_count() + . = 0 + for(var/key in lair_pending_gatekeepers.Copy()) + var/datum/human_npc_order/order = lair_pending_gatekeepers[key] + if(QDELETED(order) || order.done) + lair_pending_gatekeepers -= key + continue + .++ + +/// A queued gatekeeper (`key`) was built: it holds the gate from now, kept to its room, unless the gate opened meanwhile +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_on_gatekeeper_built(key, mob/living/keeper) + if(QDELETED(src)) + return + var/known = !isnull(lair_pending_gatekeepers[key]) + lair_pending_gatekeepers -= key + if(QDELETED(keeper)) + lair_check_gate() + return + lair_leash(keeper) + if(!known || lair_gate_open || !lair_posting()) + return + lair_add_gatekeeper(keeper) + +/// Calls off every gatekeeper still in the spawn queue (the interior is going, or the lair is) +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_cancel_pending_gatekeepers() + var/list/orders = list() + for(var/key in lair_pending_gatekeepers) + orders += lair_pending_gatekeepers[key] + lair_pending_gatekeepers = list() + for(var/datum/human_npc_order/order as anything in orders) + if(!QDELETED(order) && !order.done) + qdel(order) + /// Opens its gate poddoors, the ones in its own footprint only, and tells everyone inside unless `silent` /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_open_gate(silent = FALSE) lair_gate_open = TRUE @@ -757,9 +836,15 @@ lair_mob.AddElement(/datum/element/bounty_lair_room_leash, home.type) return TRUE +/// Once every marker has spawned (the zone resolver retries for up to a minute, then the people are queued): the late goons +/// are kept to their rooms, and a gatekeeper order that never built a body stops holding the gate +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_settle() + lair_goon_timer = null + lair_leash_goons() + lair_check_gate() + /// Keeps every living goon of its kind in the room it stands in. Runs at the link, and again once every marker has spawned. /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_leash_goons() - lair_goon_timer = null var/goon_type = lair_kind?.goon_type if(!goon_type || !footprint || !loaded) return 0 @@ -774,13 +859,16 @@ /** * Keeps a lair mob in the room (area) it started in: it won't step out of it on its own, so it fights from the * doorway and a fight never drags the next room in (balance/lairs.md 9.2). Moves it doesn't make itself are never - * stopped (pulled, thrown, or dead), and once it is out of its room it walks freely, so it can never be stuck. + * stopped (pulled, carried or wheeled on what it is buckled to, thrown, shoved, or dead), and once it is out of its + * room it walks freely, so it can never be stuck. */ /datum/element/bounty_lair_room_leash element_flags = ELEMENT_BESPOKE argument_hash_start_idx = 2 /// The area type it is kept to. A type, not the area: a lair's areas are per-load instances, and no ref is held. var/home_area_type + /// REF() of each mob shoved -> the world.time of the shove: the push of a shove is someone else's move + var/list/shoved_at = list() /datum/element/bounty_lair_room_leash/Attach(datum/target, home_area_type) . = ..() @@ -788,14 +876,20 @@ return ELEMENT_INCOMPATIBLE src.home_area_type = home_area_type RegisterSignal(target, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move), override = TRUE) + RegisterSignal(target, COMSIG_LIVING_DISARM_HIT, PROC_REF(on_shoved), override = TRUE) /datum/element/bounty_lair_room_leash/Detach(datum/source) - UnregisterSignal(source, COMSIG_MOVABLE_PRE_MOVE) + UnregisterSignal(source, list(COMSIG_MOVABLE_PRE_MOVE, COMSIG_LIVING_DISARM_HIT)) + shoved_at -= REF(source) return ..() +/datum/element/bounty_lair_room_leash/proc/on_shoved(mob/living/source, mob/living/attacker, zone, obj/item/weapon) + SIGNAL_HANDLER + shoved_at[REF(source)] = world.time + /datum/element/bounty_lair_room_leash/proc/on_pre_move(mob/living/source, atom/new_location) SIGNAL_HANDLER - if(source.stat == DEAD || source.pulledby || source.throwing) + if(source.stat == DEAD || source.pulledby || source.buckled || source.throwing || shoved_at[REF(source)] == world.time) return NONE var/area/here = get_area(source) if(here?.type != home_area_type) @@ -916,7 +1010,7 @@ trophy_type = /obj/item/bounty_proof/trophy/mafia_don mugshot_icon = 'icons/mob/rideables/mecha.dmi' mugshot_state = "mauler" - goon_type = /mob/living/basic/trooper/russian/mafia + goon_type = /mob/living/carbon/human/npc/bounty_mafia gate_message = "The garage door grinds open." /** diff --git a/voidcrew/modules/bounties/bounty_lair_mafia.dm b/voidcrew/modules/bounties/bounty_lair_mafia.dm index 796e4eb45f9..4b45b10147b 100644 --- a/voidcrew/modules/bounties/bounty_lair_mafia.dm +++ b/voidcrew/modules/bounties/bounty_lair_mafia.dm @@ -8,42 +8,54 @@ * declared in bounty_lair_types.dm; this file sets their values and behaviour. Its numbers are in the P12 * section of voidcrew/_DEFINES/bounty_lairs.dm, its lines in strings/bounty_lair_mafia.json. * + * The goons, the lieutenants and the don are real human NPCs (voidcrew/modules/human_npcs/, phase D of the human NPC + * migration): a person's body, crit, wounds, cuffs and death, dressed in their Club Volga clothes, holding real guns + * with real spare magazines, reloading them by hand. Their kit is locked to them (owner decision D3), so a corpse + * leaves no gun. Only the don's mech is a mob of its own: it is a machine (owner call OC-5). + * * How the club fights: - * - **Goons** (/mob/living/basic/trooper/russian/mafia: knife, /pistol, /smg) keep to the room (area) they - * spawned in: they never walk out of it, and come to its door to get a line. A room is alerted by what its - * own goons see or what hits them: the one who spots a hunter shouts, the room's first shots follow a + * - **Goons** (/mob/living/carbon/human/npc/bounty_mafia: a kitchen knife, /pistol a Stechkin, /smg an Uzi) keep to + * the room (area) they spawned in: they never walk out of it, and come to its door to get a line. A room is alerted + * by what its own goons see or what hits them: the one who spots a hunter shouts, the room's first shots follow a * second later and spread over another second, and gunmen kick a wood table over for cover. At most * BOUNTY_MOBSTER_MAX_ON_ONE goons of a room go after one hunter standing in a doorway, however many are shot at. - * Grabbed, a goon breaks free and fights its grabber; dragged or thrown out of its room it still fights, and - * only walks back once there's nobody to fight. Nobody in the club targets a hunter who is down. Polymorph and - * type changes are refused. They leave a corpse in club clothes and never a gun. - * - **Lieutenants** (/lieutenant, /lieutenant/tommy, /lieutenant/brute) are tougher goons that hold the - * arena gate. The base type takes whichever role its room doesn't have yet, so two plain gatekeeper - * landmarks give one of each. They die like any trooper: COMSIG_LIVING_DEATH, then deletion. - * - **The mech** (/mob/living/basic/bounty_lair_boss/mafia_mech) is a mob that looks like a Mauler, not a tg - * mecha and not /mob/living/basic/boss. Its health follows the hunters engaged (P4's posse rule), counting - * exosuit pilots and harmless shots such as ion bolts. Anyone who hurts it is fair game at any range: it closes - * in as far as its arena lets it and answers from there, so it can't be sniped from past its sight. Every - * ability winds up where everyone can see it: the rocket volley puts a marker under each hunter it sees, - * then fires one non-breaching rocket per marker; the LMG shows a red cone out to its target that stays until - * the burst ends. An EMP stalls it (the ability it cut off is ready again as the stall ends) rather than - * killing it. With nobody left to fight for BOUNTY_MECH_RESET_AFTER it resets. It never leaves its arena. - * - **The don** (/mob/living/basic/bounty_lair_boss/mafia_don) climbs out when the mech breaks, next to a - * burnt-out wreck he uses for cover, holding the mech's grudges. He can't be cuffed and fights to the death; - * his death drops the gold signet ring (/obj/item/bounty_proof/trophy/mafia_don) with the posting on it, which - * P10 pays on. + * Grabbed, a goon resists the tg way and fights its grabber; dragged, carried, shoved or thrown out of its room it + * still fights, and only walks back once there's nobody to fight. Someone starting to cuff a goon who can still + * move sees it step away. Nobody in the club picks a hunter who is down as a target (only the choice: a round in + * flight hits whoever is in the way). Polymorph and type changes are refused. + * - **Lieutenants** (/lieutenant, /lieutenant/tommy, /lieutenant/brute) are elite goons in real armour that hold the + * arena gate. The base type takes whichever role its room doesn't have yet, so two plain gatekeeper landmarks give + * one of each. They die in place like anyone: P10 opens the gate on COMSIG_LIVING_DEATH. Tommy fires a Thompson; + * Brute punches, and a landed punch now and then knocks the hunter down (a tg Knockdown). + * - **The mech** (/mob/living/basic/bounty_lair_boss/mafia_mech) is a mob that looks like a Mauler, not a tg mecha + * and not /mob/living/basic/boss. Its health follows the hunters engaged (P4's posse rule), counting exosuit pilots + * and harmless shots such as ion bolts. Anyone who hurts it is fair game at any range: it closes in as far as its + * arena lets it and answers from there, so it can't be sniped from past its sight. Every ability winds up where + * everyone can see it: the rocket volley puts a marker under each hunter it sees, then fires one rocket per marker, + * whose burst is tg's light blast on everyone standing there, whatever their state, and never explosion(); the LMG + * shows a red cone out to its target that stays until the burst ends, and its rounds hit whoever is in the way. An + * EMP stalls it (the ability it cut off is ready again as the stall ends) rather than killing it. With nobody left + * to fight for BOUNTY_MECH_RESET_AFTER it repairs itself. It never leaves its arena. + * - **The don** (/mob/living/carbon/human/npc/bounty_mafia/don) climbs out when the mech breaks, next to a burnt-out + * wreck he uses for cover while he reloads, holding the mech's grudges. He never gives up: someone starting to cuff + * him while he can still move hears about it and sees him step away; cuffed, he works at the cuffs. A cuffed don + * still pays nothing: the lair is kill-only, and only his ring pays. His death drops the gold signet ring + * (/obj/item/bounty_proof/trophy/mafia_don) with the posting on it, once, which P10 pays on. + * + * For P10: a gatekeeper's death is COMSIG_LIVING_DEATH (watch COMSIG_QDELETING too, for an admin delete). The mech + * hands its posting_ref to the don and sends COMSIG_BOUNTY_MAFIA_DON_EJECTED with him before its own death; the don's + * death drops the ring through P10's bounty_lair_drop_trophy() and sends COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED with it. + * Everyone keeps to their room by bounty_mafia_leash_allows(), the same rule as P10's + * /datum/element/bounty_lair_room_leash, which a lair also puts on its mobs. * - * For P10: a gatekeeper's death is COMSIG_LIVING_DEATH (the lieutenant is deleted right after, so watch - * COMSIG_QDELETING too for an admin delete). The mech hands its posting_ref to the don and sends - * COMSIG_BOUNTY_MAFIA_DON_EJECTED with him before its own death; the don's death drops the ring through P10's - * bounty_lair_drop_trophy() and sends COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED with it. Everyone keeps to their room by - * bounty_mafia_leash_allows(), the same rule as P10's /datum/element/bounty_lair_room_leash. + * The club is built a body at a time (spawn_human_npc(), queued): its markers queue their goons, the lair queues the + * lieutenants, and only the don, one body the fight needs at once, is built on the spot. * * Nothing here scans turfs on a tick: targets come from SSmobs' list of players on the level, and every * telegraph and timer is tracked and cleaned up. */ -/// Trait source for a club mob standing still while it winds up, fires or reels +/// Trait source for the mech standing still while it winds up, fires or reels #define BOUNTY_MAFIA_BUSY_TRAIT "bounty_mafia_busy" // ===== SHARED ===== @@ -59,7 +71,10 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE return !isnull(candidate.mind) || !isnull(candidate.client) -/// Whether a club mob may shoot or hit `target` now: a hunter on their feet, never anyone already down (spec 12.3 C4) +/** + * Whether a club mob may pick `target` to go after: a hunter on their feet, never anyone already down (spec 12.3 C4). + * Only the choice of target: what is fired hits whoever it hits (defaults 1.11, CB-11). + */ /proc/bounty_mafia_may_hurt(mob/living/target) if(!bounty_mafia_is_hunter(target) || target.stat != CONSCIOUS) return FALSE @@ -89,9 +104,15 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/list/lines = GLOB.string_cache?[BOUNTY_MAFIA_STRINGS_FILE]?[context] return length(lines) ? pick(lines) : null -/// `speaker` says a line for `context`, off the caller's stack (safe from signal handlers). TRUE if there was one. +/** + * `speaker` says a line for `context`, off the caller's stack (safe from signal handlers). TRUE if there was one. + * A person speaks on their feet or whispers in soft crit (tg's say() does the whisper), never deeper down. + */ /proc/bounty_mafia_say(mob/living/speaker, context) - if(QDELETED(speaker) || speaker.stat != CONSCIOUS) + if(QDELETED(speaker)) + return FALSE + var/can_talk = ishuman(speaker) ? human_npc_can_talk(speaker) : (speaker.stat == CONSCIOUS) + if(!can_talk) return FALSE var/line = bounty_mafia_line(context) if(!line) @@ -99,46 +120,15 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), line) return TRUE -/** - * Fires one round of `weapon` (a /datum/bounty_real_weapon) from `shooter` at `target` (a mob or a turf) as - * `projectile_type`: every pellet of a shell, each with the real round's numbers and spread, plus `spread` degrees - * more (full width) if given, and no farther than `max_range` tiles if given. The club's own faction is never hit by - * it. Returns the first projectile still flying, or null. - */ -/proc/bounty_mafia_shoot(mob/living/shooter, projectile_type, atom/target, datum/bounty_real_weapon/weapon, spread = 0, max_range) - var/turf/start = get_turf(shooter) - var/turf/aim = get_turf(target) - if(!start || !aim || start == aim || !weapon) - return null - var/obj/projectile/first - for(var/pellet in 1 to weapon.pellets) - var/obj/projectile/bullet = new projectile_type(start) - weapon.load(bullet) - if(max_range) - bullet.range = max_range - bullet.maximum_range = max_range - bullet.firer = shooter - bullet.fired_from = shooter - bullet.ignored_factions = shooter.faction?.Copy() - bullet.spread = spread - bullet.aim_projectile(target, shooter, null, weapon.shot_spread()) - if(QDELETED(bullet)) - continue - bullet.fire() - if(!first && !QDELETED(bullet)) - first = bullet - if(weapon.fire_sound) - playsound(shooter, weapon.fire_sound, 60, TRUE) - return first - /** * Whether `mover`, kept to the room (area) `home`, may step onto `new_loc` on its own: anywhere in its room, never * out of it, so it fights from its doorway and a fight never drags the next room in (lairs.md 9.2). The same rule as * P10's /datum/element/bounty_lair_room_leash, which a lair also puts on its mobs at load, so the two always agree: - * moves it doesn't make itself (pulled, thrown, dead) are never stopped, and once out of its room it walks freely. + * moves it doesn't make itself (pulled, carried or wheeled on what it is buckled to, thrown, `shoved`, dead) are never + * stopped, and once out of its room it walks freely. */ -/proc/bounty_mafia_leash_allows(mob/living/mover, area/home, atom/new_loc) - if(!home || !new_loc || mover.stat == DEAD || mover.pulledby || mover.throwing) +/proc/bounty_mafia_leash_allows(mob/living/mover, area/home, atom/new_loc, shoved = FALSE) + if(!home || !new_loc || mover.stat == DEAD || mover.pulledby || mover.buckled || mover.throwing || shoved) return TRUE if(get_area(mover) != home) return TRUE @@ -167,19 +157,19 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) */ /proc/bounty_mafia_room_crew(area_type) var/type_text = "[area_type]" - var/static/marker = "/mafia_club/" + var/marker = "/mafia_club/" var/at = findtext(type_text, marker) if(!at) return null var/static/list/crews = list( - "foyer" = list(/mob/living/basic/trooper/russian/mafia = 2, /mob/living/basic/trooper/russian/mafia/pistol = 1), - "cloakroom" = list(/mob/living/basic/trooper/russian/mafia/pistol = 1, /mob/living/basic/trooper/russian/mafia = 1, /mob/living/basic/trooper/russian/mafia/smg = 1), - "casino" = list(/mob/living/basic/trooper/russian/mafia/pistol = 2, /mob/living/basic/trooper/russian/mafia = 2, /mob/living/basic/trooper/russian/mafia/smg = 1), - "bar" = list(/mob/living/basic/trooper/russian/mafia/pistol = 2, /mob/living/basic/trooper/russian/mafia/smg = 1, /mob/living/basic/trooper/russian/mafia = 1), - "kitchen" = list(/mob/living/basic/trooper/russian/mafia = 2, /mob/living/basic/trooper/russian/mafia/pistol = 1), - "vip" = list(/mob/living/basic/trooper/russian/mafia/smg = 1, /mob/living/basic/trooper/russian/mafia/pistol = 2, /mob/living/basic/trooper/russian/mafia = 1), - "counting" = list(/mob/living/basic/trooper/russian/mafia/pistol = 1, /mob/living/basic/trooper/russian/mafia = 1, /mob/living/basic/trooper/russian/mafia/smg = 1), - "back_office" = list(/mob/living/basic/trooper/russian/mafia/pistol = 1), + "foyer" = list(/mob/living/carbon/human/npc/bounty_mafia = 2, /mob/living/carbon/human/npc/bounty_mafia/pistol = 1), + "cloakroom" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 1, /mob/living/carbon/human/npc/bounty_mafia = 1, /mob/living/carbon/human/npc/bounty_mafia/smg = 1), + "casino" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 2, /mob/living/carbon/human/npc/bounty_mafia = 2, /mob/living/carbon/human/npc/bounty_mafia/smg = 1), + "bar" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 2, /mob/living/carbon/human/npc/bounty_mafia/smg = 1, /mob/living/carbon/human/npc/bounty_mafia = 1), + "kitchen" = list(/mob/living/carbon/human/npc/bounty_mafia = 2, /mob/living/carbon/human/npc/bounty_mafia/pistol = 1), + "vip" = list(/mob/living/carbon/human/npc/bounty_mafia/smg = 1, /mob/living/carbon/human/npc/bounty_mafia/pistol = 2, /mob/living/carbon/human/npc/bounty_mafia = 1), + "counting" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 1, /mob/living/carbon/human/npc/bounty_mafia = 1, /mob/living/carbon/human/npc/bounty_mafia/smg = 1), + "back_office" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 1), ) return crews[copytext(type_text, at + length(marker))] @@ -213,7 +203,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/room_key /// Its area var/datum/weakref/area_ref - /// Weakrefs to its goons (/mob/living/basic/trooper/russian/mafia) + /// Weakrefs to its goons (/mob/living/carbon/human/npc/bounty_mafia) var/list/members = list() /// Someone has seen or been hit by a hunter: the shout has gone up var/alerted = FALSE @@ -271,26 +261,26 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) break return room_exits -/datum/bounty_mafia_room/proc/join(mob/living/basic/trooper/russian/mafia/goon) +/datum/bounty_mafia_room/proc/join(mob/living/carbon/human/npc/bounty_mafia/goon) members |= WEAKREF(goon) -/datum/bounty_mafia_room/proc/leave(mob/living/basic/trooper/russian/mafia/goon) +/datum/bounty_mafia_room/proc/leave(mob/living/carbon/human/npc/bounty_mafia/goon) members -= WEAKREF(goon) if(!length(members)) qdel(src) -/// Its goons still standing +/// Its goons still alive /datum/bounty_mafia_room/proc/goons() . = list() for(var/datum/weakref/ref as anything in members) - var/mob/living/basic/trooper/russian/mafia/goon = ref.resolve() + var/mob/living/carbon/human/npc/bounty_mafia/goon = ref.resolve() if(!QDELETED(goon) && goon.stat != DEAD) . += goon /// How many of its goons, besides `except`, are going after `hunter` /datum/bounty_mafia_room/proc/attackers_on(mob/living/hunter, mob/living/except) . = 0 - for(var/mob/living/basic/trooper/russian/mafia/goon as anything in goons()) + for(var/mob/living/carbon/human/npc/bounty_mafia/goon as anything in goons()) if(goon != except && goon.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == hunter) .++ @@ -304,221 +294,28 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * BOUNTY_MOBSTER_ALERT, the others spread over the next BOUNTY_MOBSTER_FIRST_SHOT_SPREAD. Returns TRUE if this * alerted it. */ -/datum/bounty_mafia_room/proc/raise_alarm(mob/living/basic/trooper/russian/mafia/spotter, mob/living/hunter) +/datum/bounty_mafia_room/proc/raise_alarm(mob/living/carbon/human/npc/bounty_mafia/spotter, mob/living/hunter) if(hunter) hunter_ref = WEAKREF(hunter) if(alerted) return FALSE alerted = TRUE - for(var/mob/living/basic/trooper/russian/mafia/goon as anything in goons()) + for(var/mob/living/carbon/human/npc/bounty_mafia/goon as anything in goons()) goon.mafia_on_alarm(spotter, goon == spotter ? 0 : rand(0, BOUNTY_MOBSTER_FIRST_SHOT_SPREAD), hunter) return TRUE -// ===== GUNS ===== - -/** - * A club mob's gun, simulated: no item, nothing to drop, but every round is the real gun's - * (`gun_type`, bounty_real_weapon()): its damage, wounds, armour penetration, spread and magazine. - * try_fire() spins it up first if it has a wind-up, then fires a burst of real projectiles, and - * reloads when the magazine is spent. A hit during the wind-up can spoil it (fumble()). Its timers - * check `serial`, so a cancelled shot does nothing. - */ -/datum/bounty_mafia_gun - /// What it's called in messages - var/gun_name = "pistol" - /// The real gun: the one in its owner's hand - var/gun_type = BOUNTY_MOBSTER_PISTOL_GUN - var/projectile_type = /obj/projectile/bullet/bounty_mafia - /// Shots in one burst, and the time between them. A pistol fires a quick string of shots each time. - var/burst = BOUNTY_MOBSTER_PISTOL_BURST - var/burst_delay = BOUNTY_MOBSTER_PISTOL_BURST_GAP - /// From the start of one burst (or wind-up) to the next - var/cooldown = BOUNTY_MOBSTER_PISTOL_COOLDOWN - var/reload_time = BOUNTY_MOBSTER_PISTOL_RELOAD - /// The spin-up before a burst, if any - var/windup = 0 - /// The wind-up comes once per magazine (the don raises his pistol before each string), not before every burst - var/windup_once_per_magazine = FALSE - var/windup_sound - var/reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' - /// Dialogue context its owner may say on reloading, and the chance they do - var/reload_context = "goon_reload" - var/reload_bark_chance = 35 - /// A hit during the wind-up spoils it - var/fumbles = FALSE - /// The real gun's numbers (/datum/bounty_real_weapon) - var/datum/bounty_real_weapon/weapon - /// Rounds in a full magazine: the real gun's - var/magazine = 0 - /// Rounds left in the magazine - var/ammo - /// world.time it may start the next burst or wind-up - var/next_fire_at = 0 - /// world.time the reload is done - var/reloading_until = 0 - /// Spinning up now - var/winding = FALSE - /// Raised this magazine (windup_once_per_magazine) - var/raised = FALSE - /// Goes up with every burst or wind-up, and on a fumble, so stale timers do nothing - var/serial = 0 - /// Its owner (/mob/living) - var/datum/weakref/owner_ref - /// What it's aimed at - var/datum/weakref/aim_ref - -/datum/bounty_mafia_gun/New(mob/living/owner) - . = ..() - owner_ref = WEAKREF(owner) - weapon = bounty_real_weapon(gun_type) - magazine = max(1, weapon.magazine) - ammo = magazine - -/datum/bounty_mafia_gun/Destroy() - serial++ - owner_ref = null - aim_ref = null - weapon = null - return ..() - -/datum/bounty_mafia_gun/proc/owner() - var/mob/living/owner = owner_ref?.resolve() - return QDELETED(owner) ? null : owner - -/// Ready to start a burst or a wind-up -/datum/bounty_mafia_gun/proc/ready() - return !winding && world.time >= next_fire_at - -/datum/bounty_mafia_gun/proc/is_reloading() - return world.time < reloading_until - -/datum/bounty_mafia_gun/proc/owner_can_fire(mob/living/owner) - return owner && owner.stat == CONSCIOUS && isturf(owner.loc) && !HAS_TRAIT(owner, TRAIT_INCAPACITATED) - -/// Starts a burst at `target`, after the wind-up if it has one. Returns TRUE if it started. -/datum/bounty_mafia_gun/proc/try_fire(atom/target) - var/mob/living/owner = owner() - if(!owner_can_fire(owner) || !ready() || QDELETED(target)) - return FALSE - aim_ref = WEAKREF(target) - serial++ - owner.face_atom(target) - if(windup && !(windup_once_per_magazine && raised)) - winding = TRUE - next_fire_at = world.time + windup + cooldown - if(windup_sound) - playsound(owner, windup_sound, 50, TRUE) - owner.balloon_alert_to_viewers("aiming!") - addtimer(CALLBACK(src, PROC_REF(finish_windup), serial), windup, TIMER_DELETE_ME) - return TRUE - fire_burst() - return TRUE - -/// The wind-up under `fire_serial` is over: the burst goes, unless it was spoiled. Returns TRUE if it fired. -/datum/bounty_mafia_gun/proc/finish_windup(fire_serial) - if(fire_serial != serial || !winding) - return FALSE - winding = FALSE - raised = TRUE - if(!owner_can_fire(owner())) - return FALSE - fire_burst() - return TRUE - -/// A burst: as many of its shots as the magazine has rounds left, then a reload if it's spent -/datum/bounty_mafia_gun/proc/fire_burst() - var/shots = clamp(burst, 1, ammo) - ammo -= shots - next_fire_at = max(next_fire_at, world.time + cooldown) - var/fire_serial = serial - shoot_once(fire_serial) - for(var/i in 2 to shots) - addtimer(CALLBACK(src, PROC_REF(shoot_once), fire_serial), (i - 1) * burst_delay, TIMER_DELETE_ME) - if(ammo <= 0) - start_reload((shots - 1) * burst_delay) - -/// One shot of the burst under `fire_serial`. Never at someone who went down meanwhile. -/datum/bounty_mafia_gun/proc/shoot_once(fire_serial) - if(fire_serial != serial) - return null - var/mob/living/owner = owner() - var/atom/target = aim_ref?.resolve() - if(!owner_can_fire(owner) || QDELETED(target)) - return null - if(isliving(target) && !bounty_mafia_may_hurt(target)) - return null - owner.face_atom(target) - return bounty_mafia_shoot(owner, projectile_type, target, weapon) - -/// A fresh magazine, ready `delay` + reload_time from now -/datum/bounty_mafia_gun/proc/start_reload(delay = 0) - ammo = magazine - raised = FALSE - reloading_until = world.time + delay + reload_time - next_fire_at = max(next_fire_at, reloading_until) - var/mob/living/owner = owner() - if(!owner) - return - if(reload_sound) - playsound(owner, reload_sound, 40, TRUE) - if(reload_context && prob(reload_bark_chance)) - bounty_mafia_say(owner, reload_context) - -/// The wind-up is spoiled: nothing fires, and the next try waits `delay` -/datum/bounty_mafia_gun/proc/fumble(delay) - serial++ - winding = FALSE - next_fire_at = max(next_fire_at, world.time + delay) - -/datum/bounty_mafia_gun/smg - gun_name = "SMG" - gun_type = BOUNTY_MOBSTER_SMG_GUN - burst = BOUNTY_MOBSTER_SMG_BURST - burst_delay = BOUNTY_MOBSTER_SMG_BURST_GAP - cooldown = BOUNTY_MOBSTER_SMG_COOLDOWN - reload_time = BOUNTY_MOBSTER_SMG_RELOAD - windup = BOUNTY_MOBSTER_SMG_WINDUP - windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' - fumbles = TRUE - -/datum/bounty_mafia_gun/tommy - gun_name = "Tommy gun" - gun_type = BOUNTY_LIEUTENANT_TOMMY_GUN - burst = BOUNTY_LIEUTENANT_TOMMY_BURST - burst_delay = BOUNTY_LIEUTENANT_TOMMY_BURST_GAP - cooldown = BOUNTY_LIEUTENANT_TOMMY_COOLDOWN - reload_time = BOUNTY_LIEUTENANT_TOMMY_RELOAD - windup = BOUNTY_LIEUTENANT_TOMMY_WINDUP - windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' - -/datum/bounty_mafia_gun/don - gun_name = "gold pistol" - gun_type = BOUNTY_DON_GUN - cooldown = BOUNTY_DON_COOLDOWN - reload_time = BOUNTY_DON_RELOAD - windup = BOUNTY_DON_RAISE - windup_once_per_magazine = TRUE - windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' - reload_context = "don_reload" - reload_bark_chance = 50 - -// ===== PROJECTILES ===== - -/// A club bullet: the real round of the gun that fires it (bounty_mafia_shoot()) -/obj/projectile/bullet/bounty_mafia - name = "bullet" +// ===== THE MECH'S ROUNDS ===== +// The club's people fire the real guns in their hands; only the machine fires rounds of its own. /// The mech's machine gun: its real exosuit gun's rounds (BOUNTY_MECH_LMG_GUN), which barely scratch furniture -/obj/projectile/bullet/bounty_mafia/lmg +/obj/projectile/bullet/bounty_mafia_lmg name = "machine gun round" demolition_mod = BOUNTY_MECH_LMG_DEMOLITION /** * The mech's rocket: the cyberware buster's pattern, never tg's rocket and never explosion(). It flies through - * people to the marker it was fired at, and there does BOUNTY_MECH_ROCKET_DAMAGE brute and a knockdown to each - * hunter still standing on it, once a volley each. A player's exosuit in its way takes + * people to the marker it was fired at, and there it bursts: tg's light blast (ex_act(EXPLODE_LIGHT)) on everyone + * standing on it, whatever their state, once a volley each. A player's exosuit in its way takes * BOUNTY_MECH_ROCKET_EXOSUIT_DAMAGE, never the anti-structure multiplier. It bursts harmlessly on a wall, a pillar * or anything else in its way; it breaks tables, windows and doors only inside the club's garage. */ @@ -535,7 +332,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) projectile_phasing = PASSMOB /// The marker it flies to var/turf/rocket_target - /// REF() of each hunter this volley has hit -> TRUE, shared by the volley's rockets + /// REF() of each mob this volley has hit -> TRUE, shared by the volley's rockets var/list/rocket_volley /// The arena (/area), the only place it breaks things var/datum/weakref/rocket_area_ref @@ -590,7 +387,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE return get_area(spot) == arena -/// The bang, and nothing else: the fake explosion and its sound +/// The look and sound of the burst, and nothing else /obj/projectile/bullet/bounty_mafia_rocket/proc/rocket_burst(turf/spot) if(rocket_done || !spot) return FALSE @@ -599,53 +396,46 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) playsound(spot, 'sound/effects/explosion/explosion1.ogg', 60, TRUE) return TRUE -/// It reached its marker on `spot`: the bang, and the hit on each hunter standing there not yet hit this volley. Returns who it hit. +/** + * It reached its marker on `spot`: the burst, and tg's light blast on each mob standing there that this volley hasn't + * caught yet, whatever its state (CB-10: the blast is ex_act's, which spreads the damage, wounds, deafens and knocks + * down by the victim's bomb armour; no explosion(), so nothing is breached). Returns who it caught. + */ /obj/projectile/bullet/bounty_mafia_rocket/proc/rocket_detonate(turf/spot) . = list() if(rocket_done || !spot) return rocket_burst(spot) for(var/mob/living/victim in spot) - if(!bounty_mafia_may_hurt(victim)) + if(victim == firer) continue var/key = REF(victim) if(rocket_volley?[key]) continue if(rocket_volley) rocket_volley[key] = TRUE - var/blocked = victim.run_armor_check(BODY_ZONE_CHEST, BULLET, silent = TRUE) - victim.apply_damage(npc_zone_damage(damage, npc_zone_damage_factor, npc_zone_damage_scaled), BRUTE, BODY_ZONE_CHEST, blocked) - victim.Knockdown(BOUNTY_MECH_ROCKET_KNOCKDOWN) + victim.ex_act(EXPLODE_LIGHT, null, src) to_chat(victim, span_userdanger("A rocket bursts right at your feet!")) . += victim if(!QDELETED(src)) qdel(src) -// ===== GOONS ===== +// ===== THE CLUB'S PEOPLE ===== /** - * A club mobster. The base is the knife man; /pistol and /smg carry guns. Every blow and round is the real - * item's in its hand (`r_hand`, bounty_real_weapon()), at the club's own pace (lairs.md 3.1). + * A club mobster, a person. The base is the knife man; /pistol and /smg carry guns, /lieutenant holds the gate and + * /don runs the family. Each holds its real weapon from the start (they are on the clock in a mob club), with its + * spare magazines in its pockets, and fights with it through its hands (the AI's clicks). */ -/mob/living/basic/trooper/russian/mafia - name = "mobster" - desc = "A heavy in a tracksuit, working for the family that runs this club. He has a knife out." - maxHealth = BOUNTY_MOBSTER_KNIFE_HEALTH - health = BOUNTY_MOBSTER_KNIFE_HEALTH - speed = BOUNTY_MOBSTER_KNIFE_SPEED - melee_attack_cooldown = BOUNTY_MOBSTER_KNIFE_COOLDOWN +/mob/living/carbon/human/npc/bounty_mafia + npc_name = "mobster" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia + npc_outfit = /datum/outfit/bounty_mafia_goon/armed + npc_target_stat = HUMAN_NPC_SPARES_DOWNED faction = list(FACTION_RUSSIAN, BOUNTY_MAFIA_FACTION) - sentience_type = SENTIENCE_BOSS - // They talk to the hunters, so they speak Common - initial_language_holder = /datum/language_holder/atom_basic - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia - r_hand = BOUNTY_MOBSTER_KNIFE - loot = null ai_controller = /datum/ai_controller/basic_controller/bounty_mafia - /// The gun it fights with (a /datum/bounty_mafia_gun type), or null for a knife - var/mafia_gun_type - var/datum/bounty_mafia_gun/mafia_gun + /// A line on examine: who they are, never how to fight them + var/mafia_examine_line = "He works for the family that runs this club." /// Its room: the area it spawned in, which it never walks out of var/datum/weakref/mafia_home_ref /// Where it spawned, to walk back to if it's ever pushed out of its room @@ -658,46 +448,97 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/mafia_next_bark_at = 0 /// Prefix of its dialogue contexts; lines it has none of come from "goon" var/mafia_voice = "goon" + /// It chats about the club while nobody is fighting (the don doesn't) + var/mafia_idle_chatter = TRUE + /// Percent chance it says something when it's hurt + var/mafia_hurt_bark_chance = 20 /// world.time it first found itself out of its room with nobody to fight, or 0 var/mafia_away_since = 0 /// It has kicked a table over for cover this fight var/mafia_took_cover = FALSE - -/mob/living/basic/trooper/russian/mafia/Initialize(mapload) + /// world.time someone last shoved it: that tick's push is someone else's move, which its room never holds back + var/mafia_shoved_at = -1 + + // ----- how it fires its gun (a volley: the aim, then the pulls, then a pause) ----- + /// The aim before a volley ("aiming!"), or 0 for none + var/mafia_windup = 0 + /// The aim comes once per magazine (the don raises his pistol before the first string), not before every volley + var/mafia_windup_once = FALSE + var/mafia_windup_sound + /// A hit during the aim spoils it + var/mafia_fumbles = FALSE + /// Pulls of the trigger in a volley, and the time between them + var/mafia_volley = BOUNTY_MOBSTER_PISTOL_VOLLEY + var/mafia_volley_gap = BOUNTY_MOBSTER_PISTOL_VOLLEY_GAP + /// From the end of one volley to the start of the next + var/mafia_cooldown = BOUNTY_MOBSTER_PISTOL_STRING_PAUSE + /// Its gun is set to single shots with its own fire selector before it fires (a pistol: a string of single shots) + var/mafia_single_fire = FALSE + /// What it says as it reloads ("[voice]_[context]"), how often, and the sound of its hand going for a magazine + var/mafia_reload_context = "reload" + var/mafia_reload_chance = 35 + var/mafia_reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' + /// world.time the aim under way is done, or 0 + var/mafia_aim_until = 0 + /// A hit spoiled the aim under way + var/mafia_aim_spoiled = FALSE + /// Raised this magazine (mafia_windup_once) + var/mafia_raised = FALSE + /// Pulls left in the volley under way + var/mafia_volley_left = 0 + /// world.time of its next pull in the volley, and of its next volley + var/mafia_next_pull_at = 0 + var/mafia_next_volley_at = 0 + +/mob/living/carbon/human/npc/bounty_mafia/Initialize(mapload, datum/human_npc_order/order) . = ..() - // Its blows are the real item's in its hand (bare fists without one) - bounty_real_weapon(r_hand).apply_melee(src) - if(mafia_gun_type) - mafia_gun = new mafia_gun_type(src) - mafia_home_turf = get_turf(src) - var/area/home = get_area(src) - if(home) - mafia_home_ref = WEAKREF(home) - mafia_room_key = REF(home) - var/datum/bounty_mafia_room/room = bounty_mafia_room_for(home) - room.join(src) + mafia_settle_home() AddElement(/datum/element/relay_attackers) - RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(mafia_on_attacked)) - RegisterSignal(src, COMSIG_ATOM_BULLET_ACT, PROC_REF(mafia_on_bullet_act)) - RegisterSignal(src, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(mafia_on_pre_move)) - RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(mafia_on_damaged)) - // A polymorph or a type change deletes it without a death: a free kill, and for a lieutenant a free gate - RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(mafia_refuse_polymorph)) - RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(mafia_refuse_type_change)) + AddElement(/datum/element/bounty_mafia_body) -/mob/living/basic/trooper/russian/mafia/Destroy() +/mob/living/carbon/human/npc/bounty_mafia/Destroy() var/datum/bounty_mafia_room/room = mafia_room() room?.leave(src) - QDEL_NULL(mafia_gun) mafia_home_ref = null mafia_home_turf = null - // The shout's "!" (do_alert_animation) forgets itself on a timer that dies with us: let it go now, or it hard-deletes - update_on_z = null + // The shout's "!" (do_alert_animation) forgets itself on a timer that dies with us: let it go now + for(var/atom/movable/flick_visual/visual in update_on_z) + LAZYREMOVE(update_on_z, visual) return ..() -/mob/living/basic/trooper/russian/mafia/Life(seconds_per_tick = SSMOBS_DT, times_fired) +/// Takes the room it stands in as its home: its leash, its shout and its doorway cap +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_settle_home() + mafia_home_turf = get_turf(src) + var/area/home = get_area(src) + if(!home) + return + mafia_home_ref = WEAKREF(home) + mafia_room_key = REF(home) + bounty_mafia_room_for(home).join(src) + +// Club Volga's men: the face and hair of their look, always a man's build +/mob/living/carbon/human/npc/bounty_mafia/npc_apply_spawner_look(obj/effect/mob_spawn/spawner) + . = ..() + if(gender == MALE) + return + gender = MALE + physique = MALE + update_body(is_creating = TRUE) + +/mob/living/carbon/human/npc/bounty_mafia/examine(mob/user) + . = ..() + if(mafia_examine_line && stat != DEAD) + . += span_notice(mafia_examine_line) + +// OC-9: no deliberate executions are built this phase, so the club never executes anyone by accident +/mob/living/carbon/human/npc/bounty_mafia/hv_can_execute() + return !isnull(client) + +/mob/living/carbon/human/npc/bounty_mafia/Life(seconds_per_tick = SSMOBS_DT, times_fired) . = ..() - if(stat != CONSCIOUS || world.time < mafia_next_bark_at || !SPT_PROB(BOUNTY_MAFIA_IDLE_BARK_CHANCE, seconds_per_tick)) + if(!mafia_idle_chatter || stat != CONSCIOUS || npc_dormant()) + return + if(world.time < mafia_next_bark_at || !SPT_PROB(BOUNTY_MAFIA_IDLE_BARK_CHANCE, seconds_per_tick)) return var/datum/bounty_mafia_room/room = mafia_room() if(room?.alerted || !mafia_player_nearby()) @@ -705,7 +546,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) mafia_bark("idle") /// Whether a player is close enough to hear it chat -/mob/living/basic/trooper/russian/mafia/proc/mafia_player_nearby() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_player_nearby() var/turf/here = get_turf(src) if(!here || here.z > length(SSmobs.clients_by_zlevel)) return FALSE @@ -715,28 +556,112 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE /// Its room (/datum/bounty_mafia_room), or null -/mob/living/basic/trooper/russian/mafia/proc/mafia_room() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_room() return mafia_room_key ? GLOB.bounty_mafia_rooms[mafia_room_key] : null /// Says a line for "_" (or the plain goon one) unless it spoke recently or `force`. TRUE if it spoke. -/mob/living/basic/trooper/russian/mafia/proc/mafia_bark(context, force = FALSE) - if(stat != CONSCIOUS || (!force && world.time < mafia_next_bark_at)) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_bark(context, force = FALSE) + if(!human_npc_can_talk(src) || (!force && world.time < mafia_next_bark_at)) return FALSE if(!bounty_mafia_say(src, "[mafia_voice]_[context]") && (mafia_voice == "goon" || !bounty_mafia_say(src, "goon_[context]"))) return FALSE mafia_next_bark_at = world.time + BOUNTY_MAFIA_BARK_COOLDOWN return TRUE -/mob/living/basic/trooper/russian/mafia/proc/mafia_refuse_polymorph(datum/source, what_to_randomize) - SIGNAL_HANDLER - return STOP_WABBAJACK +/** + * The base reactions (human_npc_barks.dm) speak the club's lines: going down is a hurt line, breaking a grab is + * "Hands off." Everything else they react to (cuffs on and off, working at them, death) is left to tg's messages. + */ +/mob/living/carbon/human/npc/bounty_mafia/npc_bark(kind, force = FALSE) + switch(kind) + if(HUMAN_NPC_BARK_DOWNED) + return mafia_bark("hurt", force = TRUE) + if(HUMAN_NPC_BARK_PULLED_FREE) + return mafia_bark("shoved", force = TRUE) + return FALSE -/mob/living/basic/trooper/russian/mafia/proc/mafia_refuse_type_change(datum/source) - SIGNAL_HANDLER - return COMPONENT_BLOCK_MOB_CHANGE +// It broke a hunter's grab by resisting: the hunter feels it shove them off +/mob/living/carbon/human/npc/bounty_mafia/npc_check_pulled_free(atom/movable/holder, within = 0) + var/broke_free = npc_resisted_at && world.time <= npc_resisted_at + within + var/mob/living/resisted = npc_resisted_hold?.resolve() + . = ..() + if(!broke_free || !isliving(resisted) || (holder && resisted != holder)) + return + balloon_alert(resisted, "[p_they()] shove[p_s()] you off!") + +// ----- what happens to it (/datum/element/bounty_mafia_body relays these) ----- + +/** + * `attacker` (a player, or an exosuit's pilots) went for it: its room is alerted, and it turns on them if it has no + * one else, and only while the doorway cap has room (so shooting a goon held back never pulls it in). + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_answer_attack(atom/attacker) + if(stat == DEAD || !attacker) + return + for(var/mob/living/hunter as anything in bounty_mafia_attackers(attacker)) + mafia_raise_alarm(hunter) + if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && mafia_target_ok(hunter) && mafia_room_has_space(hunter)) + ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + +/** + * A hunter has hold of it (a grab, a carry, the cuffs coming out): that's an attack. The room is alerted and the + * hunter is its target, doorway cap or not. Its AI resists the hold the tg way (escape_captivity). TRUE if it answered. + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_answer_grab(mob/living/grabber) + if(stat == DEAD || !bounty_mafia_is_hunter(grabber) || faction_check_atom(grabber)) + return FALSE + mafia_raise_alarm(grabber) + if(ai_controller && ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] != grabber && mafia_target_ok(grabber)) + ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, grabber) + return TRUE + +/// Whether it can still answer cuffs on its own feet: up, awake, free, and nobody holding it +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_can_answer_cuffs() + if(stat != CONSCIOUS || !isturf(loc) || buckled || pulledby || body_position != STANDING_UP) + return FALSE + return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) && !HAS_TRAIT(src, TRAIT_IMMOBILIZED) && !HAS_TRAIT(src, TRAIT_RESTRAINED) + +/** + * tg's attempt_to_cuff() has begun on it (COMSIG_CARBON_CUFF_ATTEMPTED, defaults 1.4, CB-15). The cuffer is its target, + * and if it can still move it says so and, a moment into the cuffer's four seconds, steps away: a real step, which + * breaks their do_after. Down, paralysed or held, it can't, and the cuffs go on. Never COMSIG_CARBON_CUFF_PREVENT. + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_cuff_attempt(mob/living/user) + if(stat == DEAD || !isliving(user)) + return + mafia_answer_grab(user) + if(!mafia_can_answer_cuffs()) + return + mafia_bark("cuffed", force = TRUE) + addtimer(CALLBACK(src, PROC_REF(mafia_dodge_cuffs), WEAKREF(user)), BOUNTY_MAFIA_CUFF_REACTION, TIMER_DELETE_ME) + +/// One step away from `user_ref`'s cuffer, if it still can. TRUE if it stepped. +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_dodge_cuffs(datum/weakref/user_ref) + var/mob/living/user = user_ref?.resolve() + if(QDELETED(user) || !mafia_can_answer_cuffs() || !Adjacent(user)) + return FALSE + var/turf/here = get_turf(src) + var/away = get_dir(user, src) || pick(GLOB.cardinals) + for(var/direction in list(away, turn(away, 45), turn(away, -45), turn(away, 90), turn(away, -90))) + var/turf/spot = get_step(here, direction) + if(!spot || spot.is_blocked_turf()) + continue + // A real step: its room's leash and everything else on a move apply + if(Move(spot, direction) && get_turf(src) == spot) + return TRUE + return FALSE + +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_damaged(damage, damagetype) + if(damage <= 0 || stat != CONSCIOUS) + return + if(mafia_fumbles && mafia_aim_until && !mafia_aim_spoiled) + mafia_aim_spoiled = TRUE + visible_message(span_notice("[src] flinches and loses [p_their()] aim.")) + if(prob(mafia_hurt_bark_chance)) + mafia_bark("hurt") /// Its room was alerted by `spotter` over `hunter`; it may attack `delay` after the shout, and a gunman takes cover -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_alarm(mob/living/basic/trooper/russian/mafia/spotter, delay = 0, mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_alarm(mob/living/carbon/human/npc/bounty_mafia/spotter, delay = 0, mob/living/hunter) mafia_ready_at = world.time + BOUNTY_MOBSTER_ALERT + delay if(spotter == src) do_alert_animation() @@ -748,8 +673,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * A gunman kicks over a wood table beside it, on its side toward `hunter`, once a fight (lairs.md 3.1). The table's * top faces the hunter, so their shots hit it and the gunman's pass over it. Returns the table, or null. */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_take_cover(mob/living/hunter) - if(mafia_took_cover || !mafia_gun || stat != CONSCIOUS || QDELETED(hunter)) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_take_cover(mob/living/hunter) + if(mafia_took_cover || !human_npc_held_gun(src) || !mafia_can_act() || QDELETED(hunter)) return null var/turf/here = get_turf(src) var/toward = here ? get_dir(here, hunter) : NONE @@ -777,7 +702,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return cover /// Raises its room's alarm over `hunter`, or just its own with no room. TRUE if this raised it. -/mob/living/basic/trooper/russian/mafia/proc/mafia_raise_alarm(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_raise_alarm(mob/living/hunter) var/datum/bounty_mafia_room/room = mafia_room() if(room?.raise_alarm(src, hunter)) return TRUE @@ -787,34 +712,28 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) mafia_on_alarm(room ? null : src, room ? rand(0, BOUNTY_MOBSTER_FIRST_SHOT_SPREAD) : 0) return TRUE -/// Whether it can do anything at all -/mob/living/basic/trooper/russian/mafia/proc/mafia_can_act() +/// Whether it can do anything at all on its feet +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_can_act() return !QDELETED(src) && stat == CONSCIOUS && isturf(loc) && !HAS_TRAIT(src, TRAIT_INCAPACITATED) // ----- its room ----- /// Whether it stands in its own room -/mob/living/basic/trooper/russian/mafia/proc/mafia_at_home() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_at_home() var/area/home = mafia_home_ref?.resolve() return !home || get_area(src) == home /// Whether it may step onto `spot` on its own: anywhere in its room, never out of it (bounty_mafia_leash_allows()) -/mob/living/basic/trooper/russian/mafia/proc/mafia_leash_allows(turf/spot) - return bounty_mafia_leash_allows(src, mafia_home_ref?.resolve(), spot) - -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_pre_move(datum/source, atom/new_loc) - SIGNAL_HANDLER - if(!mafia_leash_allows(get_turf(new_loc))) - return COMPONENT_MOVABLE_BLOCK_PRE_MOVE - return NONE +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_leash_allows(turf/spot) + return bounty_mafia_leash_allows(src, mafia_home_ref?.resolve(), spot, mafia_shoved_at == world.time) /// Whether its next step toward `target` stays in its room -/mob/living/basic/trooper/russian/mafia/proc/mafia_step_ok(atom/target) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_step_ok(atom/target) var/turf/next = get_step_towards(src, target) return next && mafia_leash_allows(next) /// Its room's door tile nearest `target`: where it goes to get a line on someone outside, when a straight step won't do -/mob/living/basic/trooper/russian/mafia/proc/mafia_exit_toward(atom/target) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_exit_toward(atom/target) var/datum/bounty_mafia_room/room = mafia_room() if(!room || !target) return null @@ -831,7 +750,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * Out of its room with nobody to fight: it walks back. If it hasn't made it in BOUNTY_MOBSTER_ROOM_GIVE_UP (another * leash keeps it where it is), the room it stands in becomes its room. Returns the turf it walks to, or null. */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_head_home() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_head_home() if(mafia_at_home()) mafia_away_since = 0 return null @@ -843,7 +762,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return mafia_home_turf /// `new_home` is its room from now on: its leash, its shout and its doorway cap -/mob/living/basic/trooper/russian/mafia/proc/mafia_adopt_room(area/new_home) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_adopt_room(area/new_home) if(!new_home) return var/datum/bounty_mafia_room/old_room = mafia_room() @@ -857,14 +776,16 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) // ----- targets ----- /// Whether it may go after `target`: a hunter on their feet, on its level and near -/mob/living/basic/trooper/russian/mafia/proc/mafia_target_ok(mob/living/target) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_target_ok(mob/living/target) if(!bounty_mafia_may_hurt(target) || faction_check_atom(target)) return FALSE + if(cyberware_camo_hides(target, src)) // optical camo (cyberware_stealth.dm) + return FALSE var/turf/spot = get_turf(target) return spot && spot.z == z && get_dist(src, spot) <= BOUNTY_MOBSTER_CHASE /// Whether `hunter` is in a doorway as its room sees it: outside the room, or on a door tile -/mob/living/basic/trooper/russian/mafia/proc/mafia_in_doorway(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_in_doorway(mob/living/hunter) var/turf/spot = get_turf(hunter) if(!spot) return FALSE @@ -874,7 +795,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return !isnull(locate(/obj/machinery/door) in spot) /// Whether it may take `hunter` on: fewer than BOUNTY_MOBSTER_MAX_ON_ONE of its room already on a hunter in a doorway -/mob/living/basic/trooper/russian/mafia/proc/mafia_room_has_space(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_room_has_space(mob/living/hunter) if(!mafia_in_doorway(hunter)) return TRUE var/datum/bounty_mafia_room/room = mafia_room() @@ -885,7 +806,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * room's alarm), else the one its alerted room knows about, to get a line on from inside the room. Only * players on its level with a client are looked at, from SSmobs' list. */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_find_target() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_find_target() var/datum/ai_controller/controller = ai_controller if(!controller) return null @@ -921,130 +842,114 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, best) return best -/** - * A hunter is pulling or grabbing it: that's an attack. The room is alerted and the grabber is its target, doorway cap - * or not. Returns TRUE while it is held, so it resists before anything else. - */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_answer_grab() - var/mob/living/grabber = pulledby - if(!bounty_mafia_is_hunter(grabber) || faction_check_atom(grabber)) - return FALSE - mafia_raise_alarm(grabber) - if(ai_controller && ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] != grabber && mafia_target_ok(grabber)) - ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, grabber) - return TRUE - /// Whether it's past the shout and may attack -/mob/living/basic/trooper/russian/mafia/proc/mafia_is_ready() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_is_ready() var/datum/bounty_mafia_room/room = mafia_room() if(room && !room.alerted) return FALSE return mafia_ready_at && world.time >= mafia_ready_at -/// One attack on `target`: a burst if it has a gun and a line, a stab if it's next to them. TRUE if it attacked. -/mob/living/basic/trooper/russian/mafia/proc/mafia_attack(mob/living/target) - if(!mafia_can_act() || !mafia_is_ready() || !mafia_target_ok(target)) +// ----- fighting, through its hands ----- + +/** + * Queues its next move against `target` on `controller`: a reload from its pockets when its gun is dry, a volley + * when it has a loaded gun and a line, its knife or fists when it's next to them. TRUE if it queued one (or is + * between volleys, standing its ground); FALSE if it has to move to get at them. + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_plan_attack(datum/ai_controller/controller, mob/living/target) + if(HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return FALSE - face_atom(target) - if(mafia_gun) - if(!can_see(src, target, BOUNTY_MOBSTER_SIGHT)) + var/obj/item/gun/gun = human_npc_held_gun(src) + if(gun && !gun.can_shoot() && !LAZYLEN(do_afters) && world.time >= controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] && human_npc_can_reload(src, gun)) + controller.queue_behavior(/datum/ai_behavior/human_npc_reload/bounty_mafia) + return TRUE + if(human_npc_loaded_gun(src)) + if(!can_see(src, target, mafia_sight())) return FALSE - return mafia_gun.try_fire(target) - if(!Adjacent(target) || world.time < next_move) + controller.queue_behavior(/datum/ai_behavior/bounty_mafia_fire, BB_BASIC_MOB_CURRENT_TARGET) + return TRUE + if(!Adjacent(target)) return FALSE - return melee_attack(target) - -// ----- what happens to it ----- - -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_attacked(datum/source, atom/attacker, attack_flags) - SIGNAL_HANDLER - if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) - return - mafia_answer_attack(attacker) - -// Every shot that hits it, the ones relay_attackers skips too: an exosuit's guns fire as the suit -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_bullet_act(datum/source, obj/projectile/shot, def_zone, piercing_hit, blocked) - SIGNAL_HANDLER - mafia_answer_attack(shot?.firer) + controller.queue_behavior(mafia_melee_behavior(), BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return TRUE -/** - * `attacker` (a player, or an exosuit's pilots) went for it: its room is alerted, and it turns on them if it has no - * one else, and only while the doorway cap has room (so shooting a goon held back never pulls it in). - */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_answer_attack(atom/attacker) - if(stat == DEAD || !attacker) - return - for(var/mob/living/hunter as anything in bounty_mafia_attackers(attacker)) - mafia_raise_alarm(hunter) - if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && mafia_target_ok(hunter) && mafia_room_has_space(hunter)) - ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) +/// How far it sees and shoots +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_sight() + return BOUNTY_MOBSTER_SIGHT -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_damaged(datum/source, damage, damagetype) - SIGNAL_HANDLER - if(damage <= 0 || stat != CONSCIOUS) - return - if(mafia_gun?.fumbles && mafia_gun.winding) - mafia_gun.fumble(BOUNTY_MOBSTER_FUMBLE) - visible_message(span_notice("[src] flinches and loses [p_their()] aim.")) - if(prob(20)) - mafia_bark("hurt") +/// The behaviour it hits with up close: tg's melee, with the best thing it holds or its fist +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_melee_behavior() + return /datum/ai_behavior/basic_melee_attack/human_npc -/// A gunman with a pistol -/mob/living/basic/trooper/russian/mafia/pistol - name = "mobster gunman" - desc = "A heavy in a balaclava and a leather jacket, working for the family that runs this club. He has a pistol." - maxHealth = BOUNTY_MOBSTER_PISTOL_HEALTH - health = BOUNTY_MOBSTER_PISTOL_HEALTH - speed = BOUNTY_MOBSTER_PISTOL_SPEED - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/pistol - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/pistol - r_hand = BOUNTY_MOBSTER_PISTOL_GUN - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = /datum/bounty_mafia_gun - -/// An enforcer with an SMG that spins up before each burst -/mob/living/basic/trooper/russian/mafia/smg - name = "mobster enforcer" - desc = "A heavy in a balaclava and a leather jacket, working for the family that runs this club. He has an SMG." - maxHealth = BOUNTY_MOBSTER_SMG_HEALTH - health = BOUNTY_MOBSTER_SMG_HEALTH - speed = BOUNTY_MOBSTER_SMG_SPEED - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/smg - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/smg - r_hand = BOUNTY_MOBSTER_SMG_GUN - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = /datum/bounty_mafia_gun/smg +/// It starts aiming at `target`: the "aiming!" telegraph, its gun's sound, and nothing fires until mafia_aim_until +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_start_aim(atom/target) + mafia_aim_until = world.time + mafia_windup + mafia_aim_spoiled = FALSE + face_atom(target) + if(mafia_windup_sound) + playsound(src, mafia_windup_sound, 50, TRUE) + balloon_alert_to_viewers("aiming!") + +/// It reaches for a fresh magazine (its AI's reload, human_npc_reload): the sound, maybe a line, and the next magazine needs raising again +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_reload() + mafia_raised = FALSE + if(mafia_reload_sound) + playsound(src, mafia_reload_sound, 40, TRUE) + if(mafia_reload_context && prob(mafia_reload_chance)) + mafia_bark(mafia_reload_context) + +/// A gunman with a Stechkin, fired a single shot at a time +/mob/living/carbon/human/npc/bounty_mafia/pistol + npc_name = "mobster gunman" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/pistol + npc_outfit = /datum/outfit/bounty_mafia_goon/pistol/armed + mafia_single_fire = TRUE + +/// An enforcer with an Uzi that spins up before each volley +/mob/living/carbon/human/npc/bounty_mafia/smg + npc_name = "mobster enforcer" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/smg + npc_outfit = /datum/outfit/bounty_mafia_goon/smg/armed + mafia_windup = BOUNTY_MOBSTER_SMG_WINDUP + mafia_windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' + mafia_fumbles = TRUE + mafia_volley = BOUNTY_MOBSTER_SMG_VOLLEY + mafia_volley_gap = BOUNTY_MOBSTER_SMG_VOLLEY_GAP + mafia_cooldown = BOUNTY_MOBSTER_SMG_COOLDOWN + mafia_reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' // ===== LIEUTENANTS ===== /** - * The two who hold the arena gate: "Tommy" with a Tommy gun and "Brute" with his fists. Both take - * BOUNTY_LIEUTENANT_DAMAGE_MOD of any brute or burn. The base type takes the role its room doesn't have yet, - * so the first is Tommy and the second Brute; /tommy and /brute fix it. P10 opens the gate on their deaths - * (COMSIG_LIVING_DEATH; they are deleted right after, as troopers are). + * The two who hold the arena gate: "Tommy" with a Thompson and "Brute" with his fists. Elites (aimed crits, fire + * from the floor), in real armour, and they take BOUNTY_LIEUTENANT_DAMAGE_TAKEN of any brute or burn through their + * bodies. The base type takes the role its room doesn't have yet, so the first is Tommy and the second Brute; + * /tommy and /brute fix it. P10 opens the gate on their deaths (COMSIG_LIVING_DEATH). */ -/mob/living/basic/trooper/russian/mafia/lieutenant - name = "lieutenant" - desc = "One of the don's lieutenants." - maxHealth = BOUNTY_LIEUTENANT_TOMMY_HEALTH - health = BOUNTY_LIEUTENANT_TOMMY_HEALTH +/mob/living/carbon/human/npc/bounty_mafia/lieutenant + npc_name = "lieutenant" + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = BOUNTY_LIEUTENANT_DAMAGE_TAKEN + mafia_examine_line = "One of the don's lieutenants." /// BOUNTY_LIEUTENANT_TOMMY or BOUNTY_LIEUTENANT_BRUTE; null picks one at Initialize var/mafia_lieutenant_role + /// Percent chance a punch of Brute's that lands knocks them down + var/mafia_punch_knockdown_chance = BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN_CHANCE -/mob/living/basic/trooper/russian/mafia/lieutenant/Initialize(mapload) +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/Initialize(mapload, datum/human_npc_order/order) if(!mafia_lieutenant_role) mafia_lieutenant_role = mafia_pick_lieutenant_role() mafia_apply_lieutenant_role() return ..() /// The role its room doesn't have yet: Tommy first, then Brute -/mob/living/basic/trooper/russian/mafia/lieutenant/proc/mafia_pick_lieutenant_role() +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/proc/mafia_pick_lieutenant_role() var/area/home = get_area(loc) var/datum/bounty_mafia_room/room = home ? GLOB.bounty_mafia_rooms[REF(home)] : null var/has_tommy = FALSE var/has_brute = FALSE if(room) - for(var/mob/living/basic/trooper/russian/mafia/lieutenant/other in room.goons()) + for(var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/other in room.goons()) if(other.mafia_lieutenant_role == BOUNTY_LIEUTENANT_TOMMY) has_tommy = TRUE else if(other.mafia_lieutenant_role == BOUNTY_LIEUTENANT_BRUTE) @@ -1057,94 +962,187 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return pick(BOUNTY_LIEUTENANT_TOMMY, BOUNTY_LIEUTENANT_BRUTE) return BOUNTY_LIEUTENANT_TOMMY -/// Its role's body, gun and look. Before the parent Initialize, which dresses it and makes its AI. -/mob/living/basic/trooper/russian/mafia/lieutenant/proc/mafia_apply_lieutenant_role() - damage_coeff = list(BRUTE = BOUNTY_LIEUTENANT_DAMAGE_MOD, BURN = BOUNTY_LIEUTENANT_DAMAGE_MOD, TOX = 1, STAMINA = 1, OXY = 1) +/// Its role's look, clothes, gun and voice. Before the parent Initialize, which dresses it and makes its AI. +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/proc/mafia_apply_lieutenant_role() if(mafia_lieutenant_role == BOUNTY_LIEUTENANT_BRUTE) - name = "Brute" - desc = "A mountain of a man in a tracksuit and a bear-pelt hat, knuckles taped for work. The family's muscle." - maxHealth = BOUNTY_LIEUTENANT_BRUTE_HEALTH - health = BOUNTY_LIEUTENANT_BRUTE_HEALTH - speed = BOUNTY_LIEUTENANT_BRUTE_SPEED - melee_attack_cooldown = BOUNTY_LIEUTENANT_BRUTE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = null - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/brute - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/brute - r_hand = null + npc_name = "Brute" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/brute + npc_outfit = /datum/outfit/bounty_mafia_brute/armed + mafia_examine_line = "One of the don's lieutenants: the family's muscle, knuckles taped for work." mafia_voice = "brute" return mafia_lieutenant_role = BOUNTY_LIEUTENANT_TOMMY - name = "Tommy" - desc = "A lieutenant in a long coat and a fedora, with a drum-fed Tommy gun. He runs the back office." - maxHealth = BOUNTY_LIEUTENANT_TOMMY_HEALTH - health = BOUNTY_LIEUTENANT_TOMMY_HEALTH - speed = BOUNTY_LIEUTENANT_TOMMY_SPEED - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = /datum/bounty_mafia_gun/tommy - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/tommy - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/tommy - r_hand = BOUNTY_LIEUTENANT_TOMMY_GUN + npc_name = "Tommy" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/tommy + npc_outfit = /datum/outfit/bounty_mafia_tommy/armed + mafia_examine_line = "One of the don's lieutenants. He runs the back office." mafia_voice = "tommy" + mafia_windup = BOUNTY_LIEUTENANT_TOMMY_WINDUP + mafia_windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' + mafia_volley = BOUNTY_LIEUTENANT_TOMMY_VOLLEY + mafia_volley_gap = BOUNTY_LIEUTENANT_TOMMY_VOLLEY_GAP + mafia_cooldown = BOUNTY_LIEUTENANT_TOMMY_COOLDOWN + mafia_reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' + +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/mafia_melee_behavior() + if(mafia_lieutenant_role == BOUNTY_LIEUTENANT_BRUTE) + return /datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute + return ..() -// Brute's blows knock people down now and then -/mob/living/basic/trooper/russian/mafia/lieutenant/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - . = ..() - if(!. || mafia_lieutenant_role != BOUNTY_LIEUTENANT_BRUTE || !isliving(target)) +/** + * Brute's punch landed on `victim` (COMSIG_HUMAN_GOT_PUNCHED, which his melee listens to on its target for the length + * of the blow): now and then it knocks them off their feet. A designed weapon effect, through tg's Knockdown. + */ +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/proc/mafia_on_punch_landed(mob/living/carbon/human/victim, mob/living/attacker, damage, attack_type, obj/item/bodypart/affecting, final_armor_block, kicking, limb_sharpness) + SIGNAL_HANDLER + if(attacker != src || mafia_lieutenant_role != BOUNTY_LIEUTENANT_BRUTE || QDELETED(victim) || victim.stat == DEAD) return - if(!prob(BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN_CHANCE)) + if(!prob(mafia_punch_knockdown_chance)) return - var/mob/living/victim = target victim.Knockdown(BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN) victim.visible_message(span_danger("[src]'s punch knocks [victim] off [victim.p_their()] feet!"), span_userdanger("[src]'s punch knocks you off your feet!")) -/mob/living/basic/trooper/russian/mafia/lieutenant/tommy - name = "Tommy" +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/tommy + npc_name = "Tommy" mafia_lieutenant_role = BOUNTY_LIEUTENANT_TOMMY -/mob/living/basic/trooper/russian/mafia/lieutenant/brute - name = "Brute" - maxHealth = BOUNTY_LIEUTENANT_BRUTE_HEALTH - health = BOUNTY_LIEUTENANT_BRUTE_HEALTH +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/brute + npc_name = "Brute" mafia_lieutenant_role = BOUNTY_LIEUTENANT_BRUTE +/** + * Listens to a club member's body for it: hits, shots, steps, damage, grabs, cuffs, shoves, and the polymorph and + * type changes it refuses. An element, so nothing else registering the same signals on the mob replaces these. + */ +/datum/element/bounty_mafia_body + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/bounty_mafia_body/Attach(datum/target) + . = ..() + if(!istype(target, /mob/living/carbon/human/npc/bounty_mafia)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) + RegisterSignal(target, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_bullet_act)) + RegisterSignal(target, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move)) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) + RegisterSignal(target, COMSIG_LIVING_GET_PULLED, PROC_REF(on_grabbed)) + RegisterSignal(target, COMSIG_CARBON_CUFF_ATTEMPTED, PROC_REF(on_cuff_attempt)) + RegisterSignal(target, COMSIG_LIVING_DISARM_HIT, PROC_REF(on_shoved)) + // A polymorph or a type change deletes it without a death: a free kill, and for a lieutenant a free gate + RegisterSignal(target, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) + RegisterSignal(target, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) + +/datum/element/bounty_mafia_body/Detach(datum/source) + UnregisterSignal(source, list( + COMSIG_ATOM_WAS_ATTACKED, + COMSIG_ATOM_BULLET_ACT, + COMSIG_MOVABLE_PRE_MOVE, + COMSIG_MOB_AFTER_APPLY_DAMAGE, + COMSIG_LIVING_GET_PULLED, + COMSIG_CARBON_CUFF_ATTEMPTED, + COMSIG_LIVING_DISARM_HIT, + COMSIG_LIVING_PRE_WABBAJACKED, + COMSIG_PRE_MOB_CHANGED_TYPE, + )) + return ..() + +/datum/element/bounty_mafia_body/proc/on_attacked(mob/living/carbon/human/npc/bounty_mafia/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK)) + source.mafia_answer_attack(attacker) + +// Every shot that hits it, the ones relay_attackers skips too: an exosuit's guns fire as the suit +/datum/element/bounty_mafia_body/proc/on_bullet_act(mob/living/carbon/human/npc/bounty_mafia/source, obj/projectile/shot, def_zone, piercing_hit, blocked) + SIGNAL_HANDLER + source.mafia_answer_attack(shot?.firer) + +/datum/element/bounty_mafia_body/proc/on_pre_move(mob/living/carbon/human/npc/bounty_mafia/source, atom/new_loc) + SIGNAL_HANDLER + if(!source.mafia_leash_allows(get_turf(new_loc))) + return COMPONENT_MOVABLE_BLOCK_PRE_MOVE + return NONE + +/datum/element/bounty_mafia_body/proc/on_damaged(mob/living/carbon/human/npc/bounty_mafia/source, damage, damagetype) + SIGNAL_HANDLER + source.mafia_on_damaged(damage, damagetype) + +/datum/element/bounty_mafia_body/proc/on_grabbed(mob/living/carbon/human/npc/bounty_mafia/source, mob/living/puller) + SIGNAL_HANDLER + source.mafia_answer_grab(puller) + +/datum/element/bounty_mafia_body/proc/on_cuff_attempt(mob/living/carbon/human/npc/bounty_mafia/source, mob/living/user) + SIGNAL_HANDLER + source.mafia_on_cuff_attempt(user) + return NONE + +/datum/element/bounty_mafia_body/proc/on_shoved(mob/living/carbon/human/npc/bounty_mafia/source, mob/living/attacker, zone, obj/item/weapon) + SIGNAL_HANDLER + source.mafia_shoved_at = world.time + +/datum/element/bounty_mafia_body/proc/refuse_polymorph(datum/source, what_to_randomize) + SIGNAL_HANDLER + return STOP_WABBAJACK + +/datum/element/bounty_mafia_body/proc/refuse_type_change(datum/source) + SIGNAL_HANDLER + return COMPONENT_BLOCK_MOB_CHANGE + // ===== THE ROOM SPAWNER ===== /** * The club's goons. On P11's map every room has one of these; its room (the marker's area) fixes the crew - * (bounty_mafia_room_crew()). Each marker rolls 1-2 from its room's weighted roster. Goons - * only spawn on tiles of the marker's own room. + * (bounty_mafia_room_crew()). Each marker rolls 1-2 from its room's weighted roster, anywhere else 1-2 from its zone + * table. Goons only spawn on tiles of the marker's own room. They are people, so they are queued + * (spawn_human_npc()): a club of two dozen is built a body at a time, never in one tick. */ /obj/effect/zone_mobs/mafia name = "zone mob spawner (mafia)" scatter_radius = 3 mobs_green = list( - /mob/living/basic/trooper/russian/mafia = 4, - /mob/living/basic/trooper/russian/mafia/pistol = 3, - /mob/living/basic/trooper/russian/mafia/smg = 1, + /mob/living/carbon/human/npc/bounty_mafia = 4, + /mob/living/carbon/human/npc/bounty_mafia/pistol = 3, + /mob/living/carbon/human/npc/bounty_mafia/smg = 1, ) mobs_yellow = list( - /mob/living/basic/trooper/russian/mafia = 3, - /mob/living/basic/trooper/russian/mafia/pistol = 4, - /mob/living/basic/trooper/russian/mafia/smg = 2, + /mob/living/carbon/human/npc/bounty_mafia = 3, + /mob/living/carbon/human/npc/bounty_mafia/pistol = 4, + /mob/living/carbon/human/npc/bounty_mafia/smg = 2, ) mobs_red = list( - /mob/living/basic/trooper/russian/mafia = 2, - /mob/living/basic/trooper/russian/mafia/pistol = 4, - /mob/living/basic/trooper/russian/mafia/smg = 3, + /mob/living/carbon/human/npc/bounty_mafia = 2, + /mob/living/carbon/human/npc/bounty_mafia/pistol = 4, + /mob/living/carbon/human/npc/bounty_mafia/smg = 3, ) +/// Queues its goons. Returns their orders (/datum/human_npc_order). /obj/effect/zone_mobs/mafia/do_spawn(zone_type) + . = list() var/area/room = get_area(src) var/list/crew = room ? bounty_mafia_room_crew(room.type) : null - if(!crew) - return ..() + var/amount + if(crew) + amount = rand(1, 2) + else + var/list/count_range = count_green + crew = mobs_green + switch(zone_type) + if(ZONE_YELLOW) + crew = mobs_yellow || crew + count_range = count_yellow + if(ZONE_RED) + crew = mobs_red || mobs_yellow || crew + count_range = count_red + amount = rand(count_range[1], count_range[2]) + if(!length(crew) || amount < 1) + return var/list/open_turfs = get_scatter_turfs() - for(var/i in 1 to rand(1, 2)) + for(var/i in 1 to amount) var/mob_path = pick_weight(crew) + if(!ispath(mob_path, /mob/living/carbon/human/npc)) + continue var/turf/spot = length(open_turfs) ? pick_n_take(open_turfs) : get_turf(src) var/datum/npc_spawn_context/context = new(src, zone_type, "ordinary") - spawn_npc_in_zone(mob_path, spot, context) + . += spawn_human_npc(mob_path, spot, context = context) /obj/effect/zone_mobs/mafia/get_scatter_turfs() var/area/room = get_area(src) @@ -1153,9 +1151,10 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) if(get_area(spot) == room) . += spot -// ===== LOOKS AND CORPSES ===== -// The goons wear what the corpse leaves behind: club clothes, and never a gun (the gun in the living mob's -// hand is only a picture). +// ===== CLOTHES AND LOOKS ===== +// The club's clothes, as their corpses used to wear them; the /armed outfits are what the living wear: the same +// clothes, their real gun or knife in hand and spare magazines in their pockets, and the lieutenants' real armour. +// All of it is locked to them (D3): none of it is loot. /// A Ratnik tracksuit and an ushanka: the doormen and knife men /datum/outfit/bounty_mafia_goon @@ -1165,12 +1164,22 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/sneakers/black +/datum/outfit/bounty_mafia_goon/armed + name = "Club Volga mobster (armed)" + r_hand = BOUNTY_MOBSTER_KNIFE + /// The gunmen hide their faces under the ushanka and keep a leather jacket over the tracksuit /datum/outfit/bounty_mafia_goon/pistol name = "Club Volga gunman" suit = /obj/item/clothing/suit/jacket/leather mask = /obj/item/clothing/mask/russian_balaclava +/datum/outfit/bounty_mafia_goon/pistol/armed + name = "Club Volga gunman (armed)" + r_hand = BOUNTY_MOBSTER_PISTOL_GUN + l_pocket = BOUNTY_MOBSTER_PISTOL_MAGAZINE + r_pocket = BOUNTY_MOBSTER_PISTOL_MAGAZINE + /// The enforcers: a military tracksuit, a black balaclava, heavy boots /datum/outfit/bounty_mafia_goon/smg name = "Club Volga enforcer" @@ -1180,6 +1189,12 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) suit = /obj/item/clothing/suit/jacket/leather shoes = /obj/item/clothing/shoes/russian +/datum/outfit/bounty_mafia_goon/smg/armed + name = "Club Volga enforcer (armed)" + r_hand = BOUNTY_MOBSTER_SMG_GUN + l_pocket = BOUNTY_MOBSTER_SMG_MAGAZINE + r_pocket = BOUNTY_MOBSTER_SMG_MAGAZINE + /// Tommy dresses like the old films: a black suit, a long coat, a fedora /datum/outfit/bounty_mafia_tommy name = "Club Volga lieutenant (Tommy)" @@ -1189,6 +1204,14 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup +/// His long coat is a lined noir trenchcoat (real armour), the Thompson in his hands and two drums in his pockets +/datum/outfit/bounty_mafia_tommy/armed + name = "Club Volga lieutenant (Tommy, armed)" + suit = /obj/item/clothing/suit/toggle/jacket/det_trench/noir + r_hand = BOUNTY_LIEUTENANT_TOMMY_GUN + l_pocket = BOUNTY_LIEUTENANT_TOMMY_MAGAZINE + r_pocket = BOUNTY_LIEUTENANT_TOMMY_MAGAZINE + /// Brute: a tracksuit, a bear-pelt hat and taped knuckles /datum/outfit/bounty_mafia_brute name = "Club Volga lieutenant (Brute)" @@ -1197,6 +1220,11 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/russian +/// And a bulletproof vest over the tracksuit (real armour) +/datum/outfit/bounty_mafia_brute/armed + name = "Club Volga lieutenant (Brute, armed)" + suit = /obj/item/clothing/suit/armor/vest/russian + /// The don: a black suit, a fur coat, a gold chain and dark glasses /datum/outfit/bounty_mafia_don name = "Club Volga don" @@ -1207,6 +1235,13 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup +/datum/outfit/bounty_mafia_don/armed + name = "Club Volga don (armed)" + r_hand = BOUNTY_DON_GUN + l_pocket = BOUNTY_DON_MAGAZINE + r_pocket = BOUNTY_DON_MAGAZINE + +// The club's looks: hair and faces for the people (npc_look), and the plain clothes outfits above /obj/effect/mob_spawn/corpse/human/bounty_mafia name = "Club Volga mobster" outfit = /datum/outfit/bounty_mafia_goon @@ -1295,7 +1330,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /// What its volley fires: never tg's rocket (explosion()), always this var/mech_rocket_type = /obj/projectile/bullet/bounty_mafia_rocket /// What its machine gun fires - var/mech_lmg_type = /obj/projectile/bullet/bounty_mafia/lmg + var/mech_lmg_type = /obj/projectile/bullet/bounty_mafia_lmg /// What keeps it still with its AI quiet ("rockets", "lmg_windup", "lmg_fire", "stall"), or null var/mech_busy /// Goes up each time it becomes busy, so a stale timer does nothing @@ -1399,7 +1434,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return COMPONENT_MOVABLE_BLOCK_PRE_MOVE return NONE -/// Dragged or thrown out of its arena somehow: it's put back where it started +/// Dragged or thrown out of its arena somehow: the machine is put back where it started (OC-5) /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_check_home() var/area/arena = mech_home_ref?.resolve() if(!arena || !mech_home_turf || get_area(src) == arena) @@ -1575,7 +1610,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /** * After BOUNTY_MECH_RESET_AFTER with nobody in its arena, in sight or shooting at it, a fight it was in resets: * full health, the one-hunter table and an empty posse, so a second crew never finishes a wounded mech (lairs.md - * 9.5). Its Life() calls this. TRUE if it reset. + * 9.5). A machine with repair arms (OC-5, CB-18). Its Life() calls this. TRUE if it reset. */ /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_check_reset() if(stat == DEAD) @@ -1695,9 +1730,10 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE /** - * The rocket volley's lock-on: a red marker under each of `targets` (by default each hunter it can see), a - * rising tone, and BOUNTY_MECH_ROCKET_LOCK later one rocket per marker (mech_fire_rockets()). Nothing is hurt - * before then. Returns the serial the launch checks, or 0 if it didn't start. + * The rocket volley's lock-on: a red marker under each of `targets` (by default each hunter it can see; it never + * picks one who is down), a rising tone, and BOUNTY_MECH_ROCKET_LOCK later one rocket per marker + * (mech_fire_rockets()). Nothing is hurt before then, and the burst then catches whoever stands on the marker. + * Returns the serial the launch checks, or 0 if it didn't start. */ /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_start_rockets(list/targets) if(!mech_can_start_ability()) @@ -1754,7 +1790,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) rocket.rocket_area_ref = mech_home_ref rocket.firer = src rocket.fired_from = src - rocket.ignored_factions = faction.Copy() + rocket.npc_shot_hits_allies = TRUE rocket.range = get_dist(start, spot) + 3 rocket.maximum_range = rocket.range rocket.aim_projectile(spot, src) @@ -1805,7 +1841,39 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_lmg_round(serial) if(!mech_still("lmg_fire", serial) || !mech_lmg_aim) return null - return bounty_mafia_shoot(src, mech_lmg_type, mech_lmg_aim, bounty_real_weapon(BOUNTY_MECH_LMG_GUN), BOUNTY_MECH_LMG_ARC, mech_lmg_range + 1) + return mech_shoot_round(mech_lmg_aim, bounty_real_weapon(BOUNTY_MECH_LMG_GUN), BOUNTY_MECH_LMG_ARC, mech_lmg_range + 1) + +/** + * The machine fires one round of `weapon` (a /datum/bounty_real_weapon, its exosuit gun) at `target` (a turf): + * every pellet with the real round's numbers and spread, `spread` degrees more (full width), no farther than + * `max_range` tiles. A real round from a machine: it hits whoever is in its way, the club's own people included. + * Returns the first projectile still flying, or null. + */ +/mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_shoot_round(atom/target, datum/bounty_real_weapon/weapon, spread = 0, max_range) + var/turf/start = get_turf(src) + var/turf/aim = get_turf(target) + if(!start || !aim || start == aim || !weapon) + return null + var/obj/projectile/first + for(var/pellet in 1 to weapon.pellets) + var/obj/projectile/bullet = new mech_lmg_type(start) + weapon.load(bullet) + if(max_range) + bullet.range = max_range + bullet.maximum_range = max_range + bullet.firer = src + bullet.fired_from = src + bullet.npc_shot_hits_allies = TRUE + bullet.spread = spread + bullet.aim_projectile(target, src, null, weapon.shot_spread()) + if(QDELETED(bullet)) + continue + bullet.fire() + if(!first && !QDELETED(bullet)) + first = bullet + if(weapon.fire_sound) + playsound(src, weapon.fire_sound, 60, TRUE) + return first /** * A stomp on someone right next to it, which throws them (their exosuit and all) BOUNTY_MECH_STOMP_THROW tiles @@ -1862,7 +1930,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /** * The mech breaks: a burnt-out wreck where it stood (decoration and cover, nothing to salvage) and the don - * climbing out beside it, on the side away from the hunters, with the mech's posting. Returns the don. + * climbing out beside it, on the side away from the hunters, with the mech's posting. He is one body the fight + * needs at once, so he is built now (spawn_human_npc(immediate = TRUE)). Returns the don. */ /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_eject() if(mech_ejected) @@ -1877,22 +1946,21 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/obj/structure/bounty_mafia_wreck/wreck = new(spot) wreck.setDir(dir) var/turf/exit = mech_exit_turf(spot) + var/datum/criminal_bounty/posting = posting_ref?.resolve() var/datum/npc_spawn_context/context = new(src, null, "boss") - var/mob/living/basic/bounty_lair_boss/mafia_don/don = spawn_npc_in_zone(/mob/living/basic/bounty_lair_boss/mafia_don, exit || spot, context, FALSE) + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_mafia/don, exit || spot, name = posting?.record?.name, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = order?.spawned_ref?.resolve() + if(QDELETED(don)) + return null don.posting_ref = posting_ref don.don_wreck_ref = WEAKREF(wreck) // Whoever broke his machine, he knows don.don_grudge = mech_grudge.Copy() if(mech_home_ref) - don.don_home_ref = mech_home_ref - don.don_home_turf = mech_home_turf - var/datum/criminal_bounty/posting = posting_ref?.resolve() - if(posting?.record?.name) - don.name = posting.record.name - don.real_name = posting.record.name + don.mafia_home_ref = mech_home_ref + don.mafia_home_turf = mech_home_turf SEND_SIGNAL(src, COMSIG_BOUNTY_MAFIA_DON_EJECTED, don) don.don_on_eject() - context.finalize() return don /// The open tile beside `wreck_spot`, in its arena, farthest from the hunters @@ -1933,155 +2001,78 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) // ===== THE DON ===== /** - * The don on foot (phase two), out of his wreck. A pistol, the wreck for cover while he reloads, and no - * surrender: cuffs are refused and he fights until he dies. His death drops the gold signet ring with his - * posting on it and leaves a corpse in his clothes. + * The don on foot (phase two), out of his wreck: a person, the boss of the club. His gold Desert Eagle and two + * spare magazines, the wreck for cover while he reloads, and no surrender: he fights until he dies, and resists + * cuffs the tg way. Dragged out of his garage he can be taken anywhere; on his feet with nobody to fight he walks + * back. His death drops the gold signet ring with his posting on it, once, and leaves his body in his clothes. */ -/mob/living/basic/bounty_lair_boss/mafia_don - name = BOUNTY_DON_NAME - desc = "The don himself, head of the family that runs this club: a fur coat, a gold chain, and a gold pistol in his hand. He looks furious." - icon = 'icons/mob/simple/simple_human.dmi' - gender = MALE - // Nobody drags the don out of his garage - move_resist = MOVE_FORCE_STRONG - mob_biotypes = MOB_ORGANIC|MOB_HUMANOID - maxHealth = BOUNTY_DON_HEALTH - health = BOUNTY_DON_HEALTH - speed = BOUNTY_DON_SPEED - melee_attack_cooldown = 1.2 SECONDS - attack_vis_effect = ATTACK_EFFECT_PUNCH - combat_mode = TRUE - faction = list(FACTION_RUSSIAN, BOUNTY_MAFIA_FACTION) - sentience_type = SENTIENCE_BOSS - basic_mob_flags = DEL_ON_DEATH - initial_language_holder = /datum/language_holder/atom_basic - unsuitable_atmos_damage = 7.5 - unsuitable_cold_damage = 7.5 - unsuitable_heat_damage = 7.5 - ai_controller = /datum/ai_controller/basic_controller/bounty_mafia_don - /// His gold pistol (/datum/bounty_mafia_gun/don) - var/datum/bounty_mafia_gun/don/don_gun - /// His arena, which he never leaves, and where he's put back if dragged out - var/datum/weakref/don_home_ref - var/turf/don_home_turf +/mob/living/carbon/human/npc/bounty_mafia/don + npc_name = BOUNTY_DON_NAME + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/don + npc_outfit = /datum/outfit/bounty_mafia_don/armed + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = BOUNTY_DON_DAMAGE_TAKEN + ai_controller = /datum/ai_controller/basic_controller/bounty_mafia/don + mafia_examine_line = "The don himself, head of the family that runs this club. He looks furious." + mafia_voice = "don" + mafia_idle_chatter = FALSE + mafia_hurt_bark_chance = 15 + mafia_windup = BOUNTY_DON_RAISE + mafia_windup_once = TRUE + mafia_windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' + mafia_volley = BOUNTY_DON_VOLLEY + mafia_volley_gap = BOUNTY_DON_VOLLEY_GAP + mafia_cooldown = BOUNTY_DON_STRING_PAUSE + mafia_single_fire = TRUE + mafia_reload_chance = 50 /// The wreck he hides behind (/obj/structure/bounty_mafia_wreck) var/datum/weakref/don_wreck_ref - /// What keeps him still with his AI quiet ("stagger"), or null - var/don_busy - var/don_busy_serial = 0 - var/don_busy_timer - /// The ring has dropped - var/don_trophy_dropped = FALSE - /// world.time he may say another line - var/don_next_bark_at = 0 /// Weakrefs to the hunters who have hurt him or his mech: fair game at any range, seen or not var/list/don_grudge = list() + /// world.time he has his bearings after climbing out of the wreck + var/don_dazed_until = 0 + /// The ring has dropped + var/don_trophy_dropped = FALSE -/mob/living/basic/bounty_lair_boss/mafia_don/Initialize(mapload) - . = ..() - don_gun = new(src) - // Up close he pistol-whips with it, as hard as the real thing - bounty_real_weapon(BOUNTY_DON_GUN).apply_melee(src) - apply_dynamic_human_appearance(src, mob_spawn_path = /obj/effect/mob_spawn/corpse/human/bounty_mafia/don, r_hand = BOUNTY_DON_GUN) - AddElement(/datum/element/death_drops, string_list(list(/obj/effect/mob_spawn/corpse/human/bounty_mafia/don))) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) - AddElement(/datum/element/relay_attackers) - RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(don_on_attacked)) - RegisterSignal(src, COMSIG_ATOM_BULLET_ACT, PROC_REF(don_on_bullet_act)) - RegisterSignal(src, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(don_on_pre_move)) - RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(don_on_damaged)) - RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(don_refuse_polymorph)) - RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(don_refuse_type_change)) - ban_from_containment() - don_home_turf = get_turf(src) - var/area/home = get_area(src) - if(home) - don_home_ref = WEAKREF(home) - -/mob/living/basic/bounty_lair_boss/mafia_don/Destroy() - QDEL_NULL(don_gun) - deltimer(don_busy_timer) - don_busy_timer = null - don_home_ref = null - don_home_turf = null +/mob/living/carbon/human/npc/bounty_mafia/don/Destroy() don_wreck_ref = null don_grudge = null return ..() -/mob/living/basic/bounty_lair_boss/mafia_don/Life(seconds_per_tick = SSMOBS_DT, times_fired) - . = ..() - if(stat == DEAD) - return - var/area/arena = don_home_ref?.resolve() - if(arena && don_home_turf && get_area(src) != arena) - forceMove(don_home_turf) - -/mob/living/basic/bounty_lair_boss/mafia_don/death(gibbed) - if(stat != DEAD) - don_clear_busy() - don_drop_trophy() - return ..() +// His garage is his arena: no room, no shout, no doorway cap +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_settle_home() + mafia_home_turf = get_turf(src) + var/area/home = get_area(src) + if(home) + mafia_home_ref = WEAKREF(home) -// He won't be taken alive (kill-only, spec 14.1) -/mob/living/basic/bounty_lair_boss/mafia_don/item_interaction(mob/living/user, obj/item/tool, list/modifiers) - if(istype(tool, /obj/item/restraints)) - if(stat != DEAD) - balloon_alert(user, "he won't be taken alive!") - return ITEM_INTERACT_BLOCKING - return ..() +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_is_ready() + return TRUE -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_refuse_polymorph(datum/source, what_to_randomize) - SIGNAL_HANDLER - return STOP_WABBAJACK +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_raise_alarm(mob/living/hunter) + return FALSE -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_refuse_type_change(datum/source) - SIGNAL_HANDLER - return COMPONENT_BLOCK_MOB_CHANGE +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_sight() + return BOUNTY_MECH_SIGHT -/// Says a line for "don_" unless he spoke recently or `force` -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_bark(context, force = FALSE) - if(stat != CONSCIOUS || (!force && world.time < don_next_bark_at)) - return FALSE - if(!bounty_mafia_say(src, "don_[context]")) - return FALSE - don_next_bark_at = world.time + BOUNTY_MAFIA_BARK_COOLDOWN - return TRUE +/mob/living/carbon/human/npc/bounty_mafia/don/death(gibbed) + var/was_alive = stat != DEAD + . = ..() + if(was_alive && stat == DEAD) + don_drop_trophy() -/// Climbing out of the wreck: dazed for BOUNTY_DON_EJECT_STAGGER, and a word about his machine -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_eject() - don_set_busy("stagger", BOUNTY_DON_EJECT_STAGGER) +/// Climbing out of the wreck: he takes BOUNTY_DON_EJECT_STAGGER to get his bearings, and has a word about his machine +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_on_eject() + don_dazed_until = world.time + BOUNTY_DON_EJECT_STAGGER Shake(2, 1, BOUNTY_DON_EJECT_STAGGER) visible_message(span_danger("The canopy blows off the wreck and [src] climbs out, coughing!")) - don_bark("eject", force = TRUE) - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_set_busy(state, duration) - don_busy = state - don_busy_serial++ - deltimer(don_busy_timer) - don_busy_timer = null - ADD_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_MAFIA_BUSY_TRAIT) - ai_controller?.CancelActions() - if(duration) - don_busy_timer = addtimer(CALLBACK(src, PROC_REF(don_clear_busy), don_busy_serial), duration, TIMER_STOPPABLE | TIMER_DELETE_ME) - return don_busy_serial - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_clear_busy(serial) - if(serial && serial != don_busy_serial) - return FALSE - don_busy = null - deltimer(don_busy_timer) - don_busy_timer = null - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_MAFIA_BUSY_TRAIT) - return TRUE - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_can_act() - return !QDELETED(src) && stat == CONSCIOUS && isturf(loc) && !HAS_TRAIT(src, TRAIT_INCAPACITATED) + mafia_bark("eject", force = TRUE) /** - * The trophy: the gold signet ring, where he fell, with his posting and its record on it. Once only. - * Returns the ring, or null. + * The trophy: the gold signet ring, where he fell, with his posting and its record on it. Once only, however often + * he dies. A designed death drop: it is not his gear. Returns the ring, or null. */ -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_drop_trophy() +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_drop_trophy() if(don_trophy_dropped) return null var/turf/spot = get_turf(src) @@ -2102,39 +2093,23 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) SEND_SIGNAL(src, COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED, ring) return ring -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_pre_move(datum/source, atom/new_loc) - SIGNAL_HANDLER - if(!bounty_mafia_leash_allows(src, don_home_ref?.resolve(), new_loc)) - return COMPONENT_MOVABLE_BLOCK_PRE_MOVE - return NONE - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_attacked(datum/source, atom/attacker, attack_flags) - SIGNAL_HANDLER - if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) - return - don_answer_attack(attacker) - -// Every shot that hits him, the ones relay_attackers skips too (an exosuit's guns fire as the suit) -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_bullet_act(datum/source, obj/projectile/shot, def_zone, piercing_hit, blocked) - SIGNAL_HANDLER - don_answer_attack(shot?.firer) - -/// `attacker` (a player, or an exosuit's pilots) went for him, from any range: a grudge, and his target if he has none -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_answer_attack(atom/attacker) +// He holds a grudge against whoever hurts him, from any range, and takes them on if he has nobody else +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_answer_attack(atom/attacker) if(stat == DEAD || !attacker) return for(var/mob/living/hunter as anything in bounty_mafia_attackers(attacker)) don_grudge |= WEAKREF(hunter) - if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && don_target_ok(hunter)) + if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && mafia_target_ok(hunter)) ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_damaged(datum/source, damage, damagetype) - SIGNAL_HANDLER - if(damage > 0 && stat == CONSCIOUS && prob(15)) - don_bark("hurt") +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_answer_grab(mob/living/grabber) + if(stat == DEAD || !bounty_mafia_is_hunter(grabber) || faction_check_atom(grabber)) + return FALSE + don_grudge |= WEAKREF(grabber) + return ..() /// Whether he may go after `target`: a hunter on their feet in his arena, in his sight, or who has hurt him or his mech (any range up to BOUNTY_MECH_GRUDGE_RANGE) -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_target_ok(mob/living/target) +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_target_ok(mob/living/target) if(!bounty_mafia_may_hurt(target) || faction_check_atom(target)) return FALSE var/turf/spot = get_turf(target) @@ -2145,16 +2120,16 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return TRUE if(distance > BOUNTY_MECH_SIGHT) return FALSE - var/area/arena = don_home_ref?.resolve() + var/area/arena = mafia_home_ref?.resolve() return (arena && get_area(spot) == arena) || can_see(src, target, BOUNTY_MECH_SIGHT) -/// His target: the one he has while they're fair game, else the nearest he can see, else the nearest in his arena -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_find_target() +/// His target: the one he has while they're fair game, else the nearest he can see, else the nearest in his arena or holding his grudge +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_find_target() var/datum/ai_controller/controller = ai_controller if(!controller) return null var/mob/living/current = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - if(don_target_ok(current)) + if(mafia_target_ok(current)) return current current = null var/turf/here = get_turf(src) @@ -2167,7 +2142,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) if(grudged) candidates |= grudged for(var/mob/living/person in candidates) - if(!don_target_ok(person)) + if(!mafia_target_ok(person)) continue var/seen = can_see(src, person, BOUNTY_MECH_SIGHT) var/distance = get_dist(src, person) @@ -2182,7 +2157,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return current /// The tile behind his wreck from `threat`, if it's open and in his arena -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_cover_turf(atom/threat) +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_cover_turf(atom/threat) var/obj/structure/bounty_mafia_wreck/wreck = don_wreck_ref?.resolve() if(QDELETED(wreck) || !threat) return null @@ -2190,7 +2165,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/away = get_dir(threat, wreck_spot) if(!wreck_spot || !away) return null - var/area/arena = don_home_ref?.resolve() + var/area/arena = mafia_home_ref?.resolve() for(var/direction in list(away, turn(away, 45), turn(away, -45))) var/turf/spot = get_step(wreck_spot, direction) if(!spot || isclosedturf(spot) || spot.is_blocked_turf(exclude_mobs = TRUE)) @@ -2200,16 +2175,11 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return spot return null -/// One attack on `target`: a shot if he has a line, the pistol butt if he's next to them. TRUE if he attacked. -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_attack(mob/living/target) - if(!don_can_act() || don_busy || !don_target_ok(target)) - return FALSE - face_atom(target) - if(don_gun.ready() && can_see(src, target, BOUNTY_MECH_SIGHT) && don_gun.try_fire(target)) - return TRUE - if(Adjacent(target) && world.time >= next_move && !don_gun.winding) - return melee_attack(target) - return FALSE +/// Out of his garage on his own feet with nobody to fight: he walks back to where he climbed out. Returns that turf, or null. +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_head_home() + if(mafia_at_home() || !mafia_home_turf) + return null + return mafia_home_turf // ===== THE TROPHY ===== @@ -2245,30 +2215,166 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) . = ..() controller.clear_blackboard_key(target_key) +/** + * A club gunman's volley at its target, through its hands: the aim first if its gun has one (mafia_start_aim(), + * "aiming!"), then mafia_volley pulls of the trigger (controller.ai_interact(), the gun's own fire path, burst and + * fire rate) mafia_volley_gap apart, then mafia_cooldown before the next. A hit during the aim spoils it for a gun + * that fumbles. It checks its line of fire before each pull and steps aside rather than shoot through a friend. + */ +/datum/ai_behavior/bounty_mafia_fire + action_cooldown = 0.1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/bounty_mafia_fire/setup(datum/ai_controller/controller, target_key) + . = ..() + var/mob/living/carbon/human/npc/bounty_mafia/shooter = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(!istype(shooter) || QDELETED(target) || isnull(human_npc_loaded_gun(shooter))) + return FALSE + if(world.time < shooter.mafia_next_volley_at) + return FALSE + shooter.mafia_aim_until = 0 + shooter.mafia_aim_spoiled = FALSE + shooter.mafia_volley_left = 0 + shooter.mafia_next_pull_at = 0 + if(shooter.mafia_windup && !(shooter.mafia_windup_once && shooter.mafia_raised)) + shooter.mafia_start_aim(target) + else + shooter.mafia_volley_left = shooter.mafia_volley + return TRUE + +/datum/ai_behavior/bounty_mafia_fire/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/human/npc/bounty_mafia/shooter = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target) || !shooter.mafia_can_act() || HAS_TRAIT(shooter, TRAIT_HANDS_BLOCKED)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + shooter.face_atom(target) + if(shooter.mafia_aim_until) + if(shooter.mafia_aim_spoiled) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(world.time < shooter.mafia_aim_until) + return AI_BEHAVIOR_DELAY + shooter.mafia_aim_until = 0 + shooter.mafia_raised = TRUE + shooter.mafia_volley_left = shooter.mafia_volley + var/obj/item/gun/gun = human_npc_loaded_gun(shooter) + if(isnull(gun) || shooter.mafia_volley_left <= 0) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time < shooter.mafia_next_pull_at) + return AI_BEHAVIOR_DELAY + if(human_npc_ally_in_line(shooter, target)) + human_npc_sidestep(shooter, target) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(shooter, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(shooter.mafia_single_fire) + bounty_gun_single_fire(shooter, gun) + shooter.zone_selected = human_npc_pick_aim_zone() + controller.ai_interact(target = target, combat_mode = TRUE) + shooter.mafia_volley_left-- + shooter.mafia_next_pull_at = world.time + shooter.mafia_volley_gap + if(shooter.mafia_volley_left <= 0) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_mafia_fire/finish_action(datum/ai_controller/controller, succeeded, target_key) + . = ..() + var/mob/living/carbon/human/npc/bounty_mafia/shooter = controller.pawn + if(!istype(shooter)) + return + var/pause = shooter.mafia_aim_spoiled ? BOUNTY_MOBSTER_FUMBLE : shooter.mafia_cooldown + shooter.mafia_next_volley_at = max(shooter.mafia_next_volley_at, world.time + pause) + shooter.mafia_aim_until = 0 + shooter.mafia_aim_spoiled = FALSE + shooter.mafia_volley_left = 0 + +/// The player's reload (human_npc_reload), with the club's sound and lines as the hand goes for the magazine +/datum/ai_behavior/human_npc_reload/bounty_mafia + +/datum/ai_behavior/human_npc_reload/bounty_mafia/setup(datum/ai_controller/controller) + . = ..() + if(!.) + return + var/mob/living/carbon/human/npc/bounty_mafia/member = controller.pawn + if(istype(member)) + member.mafia_on_reload() + +/// Brute's fists: tg's melee, listening for his punch landing on the target for the length of the blow +/datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute + +/datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/brute = controller.pawn + var/mob/living/carbon/human/victim = controller.blackboard[target_key] + if(!istype(brute) || !ishuman(victim) || QDELETED(victim)) + return ..() + brute.RegisterSignal(victim, COMSIG_HUMAN_GOT_PUNCHED, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_mafia/lieutenant, mafia_on_punch_landed), override = TRUE) + . = ..() + brute.UnregisterSignal(victim, COMSIG_HUMAN_GOT_PUNCHED) + +/** + * The bounty AI's breaking free (bounty_escape_captivity: cuffs, a locker, a hard grab, a carry), and any hold a + * hunter has on a club member, a passive grab included: grabbed, a goon breaks free. It resists the tg way (resist()). + */ +/datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia + +/datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + . = ..() + if(. == SUBTREE_RETURN_FINISH_PLANNING) + return + var/mob/living/carbon/human/npc/bounty_mafia/member = controller.pawn + if(!istype(member) || member.stat != CONSCIOUS) + return + var/mob/living/holder = member.pulledby || (ismob(member.buckled) ? member.buckled : null) + if(!holder || !member.mafia_answer_grab(holder)) + return + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + // ----- goons ----- +/** + * The club's people on human bodies: soft crit first (crawl, plead, a boss fires wild from the floor), then breaking + * free of whatever holds them, then the family's own mind. + */ /datum/ai_controller/basic_controller/bounty_mafia - blackboard = list() + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + BB_AGGRO_RANGE = BOUNTY_MOBSTER_SIGHT, + BB_HUMAN_NPC_SHOT_DELAY = BOUNTY_MOBSTER_PISTOL_COOLDOWN, + BB_HUMAN_NPC_FIRE_RANGE = BOUNTY_MOBSTER_SIGHT, + ) ai_movement = /datum/ai_movement/basic_avoidance idle_behavior = null planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia, /datum/ai_planning_subtree/bounty_mafia_goon, ) +/datum/ai_controller/basic_controller/bounty_mafia/TryPossessPawn(atom/new_pawn) + if(!ishumannpc(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + return ..() + +// Cuffs and soft crit don't stop a person's AI (human_npc_soft_crit.dm) +/datum/ai_controller/basic_controller/bounty_mafia/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/bounty_mafia/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + /** - * A goon's whole mind: back into its room if it's out; its target; nothing until its room's shout is over; - * then shoot with a line (or stab when next to them), else close in as far as its room lets it. + * A goon's whole mind: its target; nothing until its room's shout is over; then reload, shoot with a line (or stab + * or punch when next to them), else close in as far as its room lets it; with nobody to fight, back into its room. */ /datum/ai_planning_subtree/bounty_mafia_goon /datum/ai_planning_subtree/bounty_mafia_goon/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/trooper/russian/mafia/goon = controller.pawn + var/mob/living/carbon/human/npc/bounty_mafia/goon = controller.pawn if(!istype(goon) || goon.stat != CONSCIOUS) return SUBTREE_RETURN_FINISH_PLANNING - // Grabbed by a hunter: that's a fight. He's the target, and it breaks free first. - if(goon.mafia_answer_grab()) - controller.queue_behavior(/datum/ai_behavior/resist) - return SUBTREE_RETURN_FINISH_PLANNING if(!goon.mafia_can_act()) return SUBTREE_RETURN_FINISH_PLANNING var/at_home = goon.mafia_at_home() @@ -2278,10 +2384,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) goon.face_atom(target) return SUBTREE_RETURN_FINISH_PLANNING if(target) - var/has_line = goon.mafia_gun ? can_see(goon, target, BOUNTY_MOBSTER_SIGHT) : goon.Adjacent(target) - if(has_line) + if(goon.mafia_plan_attack(controller, target)) goon.mafia_away_since = 0 - controller.queue_behavior(/datum/ai_behavior/bounty_mafia_goon_attack, BB_BASIC_MOB_CURRENT_TARGET) return SUBTREE_RETURN_FINISH_PLANNING if(at_home) if(goon.mafia_step_ok(target)) @@ -2293,22 +2397,13 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, exit) controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING + // Nobody it can get at from here: out of its room, it walks back var/turf/home = goon.mafia_head_home() if(home) controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, home) controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING -/datum/ai_behavior/bounty_mafia_goon_attack - -/datum/ai_behavior/bounty_mafia_goon_attack/perform(seconds_per_tick, datum/ai_controller/controller, target_key) - var/mob/living/basic/trooper/russian/mafia/goon = controller.pawn - var/mob/living/target = controller.blackboard[target_key] - if(!istype(goon) || QDELETED(target)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - goon.mafia_attack(target) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED - // ----- the mech ----- /datum/ai_controller/basic_controller/bounty_mafia_mech @@ -2358,44 +2453,50 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) // ----- the don ----- -/datum/ai_controller/basic_controller/bounty_mafia_don - blackboard = list() - ai_movement = /datum/ai_movement/basic_avoidance - idle_behavior = null +/datum/ai_controller/basic_controller/bounty_mafia/don + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + BB_AGGRO_RANGE = BOUNTY_MECH_SIGHT, + BB_HUMAN_NPC_SHOT_DELAY = BOUNTY_DON_COOLDOWN, + BB_HUMAN_NPC_FIRE_RANGE = BOUNTY_MECH_SIGHT, + ) planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia, /datum/ai_planning_subtree/bounty_mafia_don, ) -/// The don's mind: nothing while he reels; his target; behind the wreck while he reloads; shoot with a line; else closer +/** + * The don's mind: nothing while he gets his bearings; his target; behind the wreck to reload; shoot with a line, or + * his fists up close; else closer. With nobody to fight, back into his garage if he was taken out of it. + */ /datum/ai_planning_subtree/bounty_mafia_don /datum/ai_planning_subtree/bounty_mafia_don/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_lair_boss/mafia_don/don = controller.pawn - if(!istype(don) || don.don_busy || !don.don_can_act()) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = controller.pawn + if(!istype(don) || don.stat != CONSCIOUS) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time < don.don_dazed_until || !don.mafia_can_act()) return SUBTREE_RETURN_FINISH_PLANNING - var/mob/living/target = don.don_find_target() + var/mob/living/target = don.mafia_find_target() if(!target) + var/turf/home = don.don_head_home() + if(home) + controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, home) + controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING - if(don.don_gun.is_reloading()) + var/obj/item/gun/gun = human_npc_held_gun(don) + if(gun && !gun.can_shoot() && !LAZYLEN(don.do_afters) && world.time >= controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] && human_npc_can_reload(don, gun)) + // Behind the wreck first, then the fresh magazine var/turf/cover = don.don_cover_turf(target) if(cover && get_turf(don) != cover) controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, cover) controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING - if(don.Adjacent(target) || can_see(don, target, BOUNTY_MECH_SIGHT)) - controller.queue_behavior(/datum/ai_behavior/bounty_mafia_don_attack, BB_BASIC_MOB_CURRENT_TARGET) - else - controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach, BB_BASIC_MOB_CURRENT_TARGET) + if(don.mafia_plan_attack(controller, target)) + return SUBTREE_RETURN_FINISH_PLANNING + controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach, BB_BASIC_MOB_CURRENT_TARGET) return SUBTREE_RETURN_FINISH_PLANNING -/datum/ai_behavior/bounty_mafia_don_attack - -/datum/ai_behavior/bounty_mafia_don_attack/perform(seconds_per_tick, datum/ai_controller/controller, target_key) - var/mob/living/basic/bounty_lair_boss/mafia_don/don = controller.pawn - var/mob/living/target = controller.blackboard[target_key] - if(!istype(don) || QDELETED(target)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - don.don_attack(target) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED - #undef BOUNTY_MAFIA_BUSY_TRAIT diff --git a/voidcrew/modules/bounties/bounty_lair_types.dm b/voidcrew/modules/bounties/bounty_lair_types.dm index 3abadc197f3..f4406c2c0b5 100644 --- a/voidcrew/modules/bounties/bounty_lair_types.dm +++ b/voidcrew/modules/bounties/bounty_lair_types.dm @@ -19,30 +19,32 @@ /// Where a lair's boss appears when the interior loads. Placed on the map (P11); P10 spawns boss_type there. /obj/effect/landmark/bounty_lair_boss - /// The boss to spawn here (a /mob/living/basic/bounty_lair_boss path), set on the map + /// The boss to spawn here, set on the map: a /mob/living/basic/bounty_lair_boss path, or a human NPC boss (built at once, it is one body) var/boss_type /// A lair's gatekeeper spot. P10 spawns gatekeeper_type here at load; when every gatekeeper in the lair is dead, P10 opens the lair's gate poddoors (mapped with id "bounty_lair_gate"). /obj/effect/landmark/bounty_lair_gatekeeper - /// The mob to spawn here as a gatekeeper, set on the map (the mafia lieutenant for the club) + /// The mob to spawn here as a gatekeeper, set on the map (the mafia lieutenant for the club). A human NPC is queued (spawn_human_npc()). var/gatekeeper_type -/// The base of lair bosses: never under /mob/living/basic/boss (megafauna are banned from ruins and outposts). P12 sets the mafia ones. +/// The base of lair bosses that aren't people: never under /mob/living/basic/boss (megafauna are banned from ruins and outposts). P12 sets the mafia one. /mob/living/basic/bounty_lair_boss /// Weakref to the posting (/datum/criminal_bounty), or null for an admin spawn var/datum/weakref/posting_ref -/// The don in his mech (phase one). P12. +/// The don in his mech (phase one): a machine, so it stays a mob of its own (owner call OC-5). P12. /mob/living/basic/bounty_lair_boss/mafia_mech -/// The don on foot after the mech breaks (phase two). His death drops the trophy. P12. -/mob/living/basic/bounty_lair_boss/mafia_don -/// Mafia goons for the club. P12 sets them (lootless, club outfits); P11 places their markers. -/mob/living/basic/trooper/russian/mafia -/mob/living/basic/trooper/russian/mafia/pistol -/mob/living/basic/trooper/russian/mafia/smg +/// The club's people: real human NPCs. The base is the knife man; P12 sets them (club outfits, real guns); P11 places their markers. +/mob/living/carbon/human/npc/bounty_mafia +/mob/living/carbon/human/npc/bounty_mafia/pistol +/mob/living/carbon/human/npc/bounty_mafia/smg /// Tougher; their deaths open the arena (P10's gate rule). P12. -/mob/living/basic/trooper/russian/mafia/lieutenant +/mob/living/carbon/human/npc/bounty_mafia/lieutenant +/// The don on foot after the mech breaks (phase two), a person. His death drops the trophy. P12. +/mob/living/carbon/human/npc/bounty_mafia/don + /// Weakref to the posting (/datum/criminal_bounty) he is wanted on, or null for an admin spawn + var/datum/weakref/posting_ref /// Room spawner for the club's goons. P12 sets its tables; P11 places it on the map (the loot test requires every zone_mobs subtype to appear in a ruin map). /obj/effect/zone_mobs/mafia diff --git a/voidcrew/modules/bounties/bounty_outfits.dm b/voidcrew/modules/bounties/bounty_outfits.dm new file mode 100644 index 00000000000..0005b1fe614 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_outfits.dm @@ -0,0 +1,140 @@ +/** + * # Bounty hunting: what criminals and their people wear + * + * Owner: P2 body (the table, the base clothes and the tier armour). The families dress themselves by + * overriding bounty_build_outfit(): P3 meek and normal criminals and companions, P4 the mini-boss kits, + * P6 decoys, P9 the kingpin and his goons, P12 the mafia club. + * + * Toughness comes from what they wear (defaults 1.5, CR-12): petty criminals in plain clothes, Wanted in + * an armour vest, Most Wanted in a vest and a helmet, mini-bosses in their kit's armour. It is real + * armour on real slots, locked to the body (D3), so it protects by tg's armour rules and never drops. + * Their weapons are carried, not held, until the fight starts, with real spare ammunition; their + * self-treatment items are real (N6). The human NPC base adds a sealed suit and air in vacuum, a mask in + * bad air and warm clothes in the cold, on top of whatever is picked here (human_npc_outfits.dm). In + * vacuum the suit it seals up in is its own family's, in the armour class of what it wears in good air + * (bounty_suits.dm). + */ + +/** + * The outfit table of every bounty human: it asks the NPC being dressed (bounty_build_outfit()), so + * the choice can read its record, its tier, its style and its kit. Set as npc_outfit_table. Then the + * clothes for its site go on (bounty_site_clothes(), companions), and its own sealed suit if it is about + * to be sealed up for vacuum (bounty_seal_outfit()); the plain EVA layer is only the fallback. + */ +/datum/human_npc_outfit_table/bounty + vacuum_layer = /datum/human_npc_layer/eva + +/datum/human_npc_outfit_table/bounty/pick_outfit(mob/living/carbon/human/npc/npc, zone) + if(!ishumannpc(npc)) + return null + var/outfit = npc.bounty_build_outfit(zone || ZONE_GREEN) + outfit = npc.bounty_site_clothes(outfit) + // Chrome carriers dress light, so their worn chrome shows (C0, bounty_chrome.dm). A mini-boss wears its kit. + if(length(npc.npc_cyberware) && npc.bounty_dresses_to_show_chrome()) + outfit = bounty_chrome_expose_outfit(outfit, npc.npc_cyberware) + return bounty_seal_outfit(npc, outfit, src) + +/** + * What a bounty human wears in `zone` (ZONE_*): a /datum/outfit typepath, or an instance made for it + * (bounty_outfit_instance()). Null leaves it in whatever its look brings. Every bounty family overrides it. + */ +/mob/living/carbon/human/npc/proc/bounty_build_outfit(zone) + return null + +/** + * `outfit` (from bounty_build_outfit()) in the clothes it wears for where it is, or as it is. Companions + * swap their plain clothes for rough ones picked by their criminal's site (bounty_companions.dm). + */ +/mob/living/carbon/human/npc/proc/bounty_site_clothes(outfit) + return outfit + +/// A fresh outfit of `base_type`, to add weapons, armour and kit to before it is worn +/proc/bounty_outfit_instance(base_type) + if(!ispath(base_type, /datum/outfit)) + return null + return new base_type() + +/** + * Puts the armour of `tier` (BOUNTY_TIER_*) on `outfit`, an instance: Wanted an armour vest, Most Wanted + * a vest and a helmet, Petty none. Anything the old suit carried in its storage moves to the backpack. + */ +/proc/bounty_add_tier_armour(datum/outfit/outfit, tier) + if(!istype(outfit) || tier == BOUNTY_TIER_PETTY || isnull(tier)) + return outfit + if(outfit.suit_store) + if(outfit.back) + LAZYADDASSOC(outfit.backpack_contents, outfit.suit_store, 1) + outfit.suit_store = null + outfit.suit = /obj/item/clothing/suit/armor/vest + if(tier == BOUNTY_TIER_MOST_WANTED) + outfit.head = /obj/item/clothing/head/helmet + return outfit + +/// The plain clothes a criminal of `archetype` (BOUNTY_ARCHETYPE_*) wears in `zone` +/proc/bounty_plain_clothes(archetype, zone) + var/meek = archetype == BOUNTY_ARCHETYPE_MEEK + switch(zone) + if(ZONE_RED) + return meek ? /datum/outfit/bounty_criminal/drifter : /datum/outfit/bounty_criminal/tough + if(ZONE_YELLOW) + return meek ? /datum/outfit/bounty_criminal/spacer : /datum/outfit/bounty_criminal/hauler + return meek ? /datum/outfit/bounty_criminal : /datum/outfit/bounty_criminal/local + +// A criminal with no family of its own to dress it: plain clothes for its archetype and zone, and its tier's armour. +/mob/living/carbon/human/npc/bounty_criminal/bounty_build_outfit(zone) + var/datum/outfit/outfit = bounty_outfit_instance(bounty_plain_clothes(record?.archetype, zone)) + return bounty_add_tier_armour(outfit, record?.tier) + +/// ZONE_GREEN, ZONE_YELLOW or ZONE_RED for where its site is (or where it stands), green if unknown +/mob/living/carbon/human/npc/bounty_criminal/proc/body_zone() + var/atom/site = body_site_ref?.resolve() + var/zone = site ? SSovermap_zones?.get_zone_type(get_turf(site)) : SSovermap_zones?.get_zone_type_anywhere(get_turf(src)) + return zone || ZONE_GREEN + +// ===== PLAIN CLOTHES ===== + +/// Plain clothes for a meek criminal in green space +/datum/outfit/bounty_criminal + name = "Bounty criminal: civilian" + uniform = /obj/item/clothing/under/pants/jeans + suit = /obj/item/clothing/suit/toggle/jacket/sweater + shoes = /obj/item/clothing/shoes/sneakers/brown + +/// A meek criminal in yellow space +/datum/outfit/bounty_criminal/spacer + name = "Bounty criminal: spacer" + uniform = /obj/item/clothing/under/color/grey + suit = /obj/item/clothing/suit/jacket/bomber + shoes = /obj/item/clothing/shoes/workboots + head = /obj/item/clothing/head/beanie + +/// A meek criminal in red space +/datum/outfit/bounty_criminal/drifter + name = "Bounty criminal: drifter" + uniform = /obj/item/clothing/under/color/black + suit = /obj/item/clothing/suit/jacket/leather + shoes = /obj/item/clothing/shoes/jackboots + head = /obj/item/clothing/head/soft/grey + +/// A normal criminal in green space +/datum/outfit/bounty_criminal/local + name = "Bounty criminal: local" + uniform = /obj/item/clothing/under/costume/buttondown/slacks + suit = /obj/item/clothing/suit/jacket/letterman + shoes = /obj/item/clothing/shoes/sneakers/black + +/// A normal criminal in yellow space. Overalls, not bare jeans: a Wanted criminal's vest goes over a shirt. +/datum/outfit/bounty_criminal/hauler + name = "Bounty criminal: hauler" + uniform = /obj/item/clothing/under/misc/overalls + suit = /obj/item/clothing/suit/jacket/miljacket + gloves = /obj/item/clothing/gloves/fingerless + shoes = /obj/item/clothing/shoes/workboots + +/// A normal criminal in red space, and the fallback for a mini-boss +/datum/outfit/bounty_criminal/tough + name = "Bounty criminal: tough" + uniform = /obj/item/clothing/under/color/black + suit = /obj/item/clothing/suit/jacket/leather/biker + gloves = /obj/item/clothing/gloves/fingerless + shoes = /obj/item/clothing/shoes/jackboots diff --git a/voidcrew/modules/bounties/bounty_outpost.dm b/voidcrew/modules/bounties/bounty_outpost.dm index a60b8834fbf..9ac8cecbc28 100644 --- a/voidcrew/modules/bounties/bounty_outpost.dm +++ b/voidcrew/modules/bounties/bounty_outpost.dm @@ -7,23 +7,29 @@ * tgui/packages/tgui/interfaces/WantedBoard.tsx and the tests in * voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm. Do not edit this file outside P6. * - * What it owns: identifying the fugitive (decision 16, spec section 7, abuse review section C): + * What it owns: identifying the fugitive (decision 16, spec section 7, abuse review section C). + * The fugitive and its decoys are real humans (/mob/living/carbon/human/npc, voidcrew/modules/human_npcs/): * - Blending in. The fugitive and BOUNTY_DECOY_COUNT decoys wear the zone's patron clothes and go * by the same generic name ("patron", "dockhand", "spacer"); the alias is never shown. While - * they blend in nobody can pull them, turrets leave them alone and stray shots pass through them - * (/datum/component/bounty_outpost_blend). - * - Decoys (/mob/living/basic/bounty_criminal/decoy): look-alikes with the same body and routine - * as the fugitive. Showing one the warrant, or hitting one on purpose, raises the posting's - * alert; a hit is also an outpost strike for the hitter. + * they blend in the turrets leave them alone and a hit on one is judged by who it was meant for + * (/datum/component/bounty_outpost_blend). They are people like any other: they can be grabbed, + * shot, knocked down and cuffed the way anyone can, and what they do about it is their AI's. + * - Decoys (/mob/living/carbon/human/npc/bounty_criminal/decoy): look-alikes with the fugitive's + * face (a record that shares one or two of its features), the zone's clothes, its bag and belt + * (outpost_match_kit(), emptied), and its toughness and pace (body_match()). Showing one the + * warrant, hitting one on purpose or reaching for its wrists raises the posting's alert; a hit is + * also an outpost strike for the hitter. * - Traders' clues: any trader, the barkeep included, gives a ship hunting a fugitive there one * true clue, a different one per trader. * - The warrant (/obj/item/paper/bounty_warrant): shown to the fugitive it exposes them (P3's * on_confronted()); shown to anyone else they protest. A ship can show one every * BOUNTY_WARRANT_COOLDOWN. - * - The alert: at BOUNTY_OUTPOST_ALERT_SLIP the fugitive slips away and the bounty relists (P5's - * relist()). - * - The walk out: when the posting closes or the fugitive slips away, the decoys and an uncaught - * fugitive walk to the hangar lift and fade out (/datum/component/bounty_outpost_exit). + * - The alert: at BOUNTY_OUTPOST_ALERT_SLIP the decoys leave and the fugitive walks out to the + * hangar lift; the bounty relists at another trader outpost once it has gone (P5's relist()). A + * fugitive caught on the way is still the posting's. + * - The walk out: when the posting closes, the decoys and an uncaught fugitive walk to the hangar + * lift through the human NPC base's npc_walk_out(). They can be shot, downed and cuffed on the way + * and are deleted only on the lift or out of every player's sight (bounty_outpost_walk_out()). * - The wanted board (/obj/structure/bounty_wanted_board): a locked board on each concourse * listing the public bounties. * @@ -127,24 +133,28 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/list/outpost_clues_told = list() /// How many of the fugitive's features each decoy was made to share, in spawn order var/list/outpost_decoy_shares = list() + /// The decoys' human NPC orders (/datum/human_npc_order), kept so the ones not built yet can be cancelled + var/list/outpost_decoy_orders = list() /** * Sets a trader-outpost bounty up to blend in, once P5 has spawned the criminal at the outpost * (site_ref and criminal_ref set): * - the record gets its old look, so the listing's mugshot shows how they used to look (AR-C1); * - the fugitive wears the zone's clothes and the zone's generic name, and blends in; - * - BOUNTY_DECOY_COUNT decoys spawn out of sight, each sharing one or two of its features; + * - BOUNTY_DECOY_COUNT decoys spawn out of sight, each sharing one or two of its features. They are + * human NPCs, so they wait their turn in the spawn queue and join `decoys` as they are built; with + * `immediate` they are built now (tests, and callers that need them at once); * - the alert and the traders' clues start over. * * Refuses (FALSE) a mini-boss (AR-C9), an outpost that already has BOUNTY_MAX_FUGITIVES_PER_OUTPOST, * and a posting whose site is not a loaded trader outpost. Returns TRUE when set up. */ -/datum/criminal_bounty/proc/setup_outpost_blend() +/datum/criminal_bounty/proc/setup_outpost_blend(immediate = FALSE) var/obj/structure/overmap/trader_outpost/outpost = site_ref?.resolve() - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() if(!istype(outpost) || !outpost.template_bottom_left || !outpost.outpost_template || !fugitive || !record) return FALSE - if(istype(fugitive, /mob/living/basic/bounty_criminal/boss) || record.archetype == BOUNTY_ARCHETYPE_BOSS) + if(istype(fugitive, /mob/living/carbon/human/npc/bounty_criminal/boss) || record.archetype == BOUNTY_ARCHETYPE_BOSS) log_game("BOUNTY: a mini-boss ([record.name]) was refused at [outpost]. Mini-bosses never go to trader outposts.") return FALSE if(!bounty_outpost_has_room(outpost, src)) @@ -189,6 +199,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( if(!fugitive.site_bounds) fugitive.site_bounds = bounty_outpost_bounds(outpost) + // Dressed in the zone's clothes by the component, which also gives it the generic name fugitive.AddComponent(/datum/component/bounty_outpost_blend, src, FALSE, display_name, display_desc, outfit) // Everyone blending in has the same even chance of heading for a bar stool, the fugitive included var/list/free_stools = bounty_outpost_free_stools(public_floor) @@ -196,7 +207,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/feature_count = length(record.features) for(var/turf/spot as anything in spots) - if(length(decoys) >= BOUNTY_DECOY_COUNT) + if(length(outpost_decoy_orders) >= BOUNTY_DECOY_COUNT) break // One or two of its features each, and never all of them (make_decoy_record() holds to that too) var/shared = rand(1, 2) @@ -207,16 +218,19 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( // If a line ever names a crime, it names the same one for everybody if(decoy_record) decoy_record.crime = record.crime - var/datum/npc_spawn_context/context = fugitive.bounty_profile_context() - var/mob/living/basic/bounty_criminal/decoy/decoy = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/decoy, spot, context, FALSE) var/atom/anchor = (length(free_stools) && prob(50)) ? pick_n_take(free_stools) : null - decoy.outpost_become(fugitive, decoy_record, src, outpost, display_name, display_desc, outfit, anchor) - decoys += WEAKREF(decoy) + var/datum/human_npc_order/order = bounty_outpost_spawn_decoy(fugitive, decoy_record, spot, src, outpost, display_name, display_desc, outfit, anchor, CALLBACK(src, PROC_REF(outpost_decoy_built)), immediate) + outpost_decoy_orders += order RegisterSignal(src, COMSIG_BOUNTY_POSTING_CLOSED, PROC_REF(outpost_on_closed), override = TRUE) - log_game("BOUNTY: [record.name] is blending in at [outpost] as a [display_name], with [length(decoys)] decoys.") + log_game("BOUNTY: [record.name] is blending in at [outpost] as a [display_name], with [length(outpost_decoy_orders)] decoys on the way.") return TRUE +/// One of this posting's decoys has been built and made a look-alike: it is the posting's from now on +/datum/criminal_bounty/proc/outpost_decoy_built(mob/living/carbon/human/npc/bounty_criminal/decoy/decoy) + if(!QDELETED(decoy)) + decoys += WEAKREF(decoy) + /** * Raises the fugitive's alert for `reason` (BOUNTY_ALERT_*): a wrong warrant, or a hit on a decoy, * whoever did it. At BOUNTY_OUTPOST_ALERT_SLIP a fugitive still blending in slips away: it walks @@ -233,32 +247,58 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return TRUE /** - * The fugitive has noticed too much fuss: while it is still blending in and free, it and its - * decoys leave, and the bounty relists. An exposed fugitive is already running or fighting and - * stays. Returns TRUE if it slipped away. + * The fugitive has noticed too much fuss: while it is still blending in and free, the decoys leave + * and it walks out to the hangar lift for real (bounty_outpost_walk_out()). The bounty relists at + * another trader outpost once it has gone (outpost_fugitive_left()); until then it is still this + * posting's, so a fugitive downed or cuffed on the way is as good a catch as any. An exposed + * fugitive is already running or fighting and stays. Returns TRUE if it set off. */ /datum/criminal_bounty/proc/outpost_slip_away() - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() if(!fugitive || !fugitive.blended || fugitive.capture_state() != BOUNTY_STATE_FREE) return FALSE + var/datum/component/bounty_outpost_blend/blend = fugitive.GetComponent(/datum/component/bounty_outpost_blend) + if(blend?.leaving) + return FALSE var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() || site_ref?.resolve() var/where = outpost ? format_text(outpost.name) : "the outpost" - log_game("BOUNTY: [record?.name] slipped away from [where].") - // Detached first (outpost_release_site()), so relist() never deletes them mid-walk. P5 shows the - // reason as "WANTED: ." - outpost_release_site() - relist("slipped away from [where]") - return TRUE + log_game("BOUNTY: [record?.name] is slipping away from [where].") + outpost_dismiss_decoys() + // P5 shows the reason as "WANTED: ." + return bounty_outpost_walk_out(fugitive, outpost, CALLBACK(src, PROC_REF(outpost_fugitive_left), "slipped away from [where]")) -/// The decoys at the current site leave and fade out, and the posting forgets them -/datum/criminal_bounty/proc/outpost_dismiss_decoys(list/floor) - var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() - if(length(decoys) && outpost && isnull(floor)) - floor = bounty_outpost_public_floor(outpost) +/** + * The slipping fugitive reached the lift (or went where no player could see): the bounty relists. + * Its body goes with it, so the posting lets go of it first and its going is not read as a loss. + * Nothing happens if it stopped being the posting's meanwhile (the posting closed or relisted). + */ +/datum/criminal_bounty/proc/outpost_fugitive_left(reason, mob/living/carbon/human/npc/bounty_criminal/leaver) + if(!is_open() || board_relisting || QDELETED(leaver) || criminal() != leaver) + return + board_detach_criminal() + leaver.posting_ref = null + log_game("BOUNTY: [record?.name] slipped away.") + relist(reason) + +/** + * The decoys not built yet are cancelled and the built ones set walking out, and the posting forgets + * them all. The walk itself starts asynchronously: closing a posting must not wait on anyone's AI. + */ +/datum/criminal_bounty/proc/outpost_dismiss_decoys() + var/list/leaving = outpost_forget_decoys() + if(length(leaving)) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_outpost_walk_out_all), leaving, outpost_site?.resolve()) + +/// Cancels the decoys still waiting to be built and lets go of the built ones. Returns the built ones, living or dead. +/datum/criminal_bounty/proc/outpost_forget_decoys() + for(var/datum/human_npc_order/order as anything in outpost_decoy_orders) + qdel(order) + outpost_decoy_orders.Cut() + . = list() for(var/datum/weakref/decoy_ref as anything in decoys) - var/mob/living/basic/bounty_criminal/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoy_ref?.resolve() if(!QDELETED(decoy)) - bounty_outpost_walk_out(decoy, outpost, floor) + . += decoy decoys.Cut() /** @@ -270,15 +310,15 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( */ /datum/criminal_bounty/proc/outpost_release_site() var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() - var/list/floor = outpost ? bounty_outpost_public_floor(outpost) : null - outpost_dismiss_decoys(floor) - var/mob/living/basic/bounty_criminal/fugitive = criminal() - if(!fugitive || !outpost || get_trader_outpost_for_turf(get_turf(fugitive)) != outpost) - return - // P5's own detach, so its deletion watch comes off too and the fade isn't read as a loss - board_detach_criminal() - fugitive.posting_ref = null - bounty_outpost_walk_out(fugitive, outpost, floor) + var/list/leaving = outpost_forget_decoys() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() + if(fugitive && outpost && get_trader_outpost_for_turf(get_turf(fugitive)) == outpost) + // P5's own detach, so its deletion watch comes off too and the walk isn't read as a loss + board_detach_criminal() + fugitive.posting_ref = null + leaving += fugitive + if(length(leaving)) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_outpost_walk_out_all), leaving, outpost) /// The posting closed (P5's close()): the decoys and an uncaught fugitive walk out /datum/criminal_bounty/proc/outpost_on_closed(datum/source, reason, obj/structure/overmap/ship/winner) @@ -444,7 +484,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * `user` showed this posting's warrant to its fugitive: it stops blending in, remembers who, and * reacts as its kind does (P3's on_confronted(): a meek one bolts, a normal one fights). */ -/datum/criminal_bounty/proc/outpost_confront(mob/living/basic/bounty_criminal/fugitive, mob/living/user) +/datum/criminal_bounty/proc/outpost_confront(mob/living/carbon/human/npc/bounty_criminal/fugitive, mob/living/user) fugitive.body_add_grudge(user) var/datum/component/bounty_outpost_blend/blend = fugitive.GetComponent(/datum/component/bounty_outpost_blend) blend?.stop_blending() @@ -460,7 +500,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * The room notices an exposed fugitive: the nearest trader who can see them, within * BOUNTY_OUTPOST_REACT_RANGE, calls out. Returns the trader, or null. */ -/datum/criminal_bounty/proc/outpost_trader_reacts(mob/living/basic/bounty_criminal/fugitive) +/datum/criminal_bounty/proc/outpost_trader_reacts(mob/living/carbon/human/npc/bounty_criminal/fugitive) var/mob/living/basic/outpost_trader/nearest var/nearest_distance = INFINITY for(var/mob/living/basic/outpost_trader/trader in view(BOUNTY_OUTPOST_REACT_RANGE, fugitive)) @@ -478,7 +518,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * `user` showed this posting's warrant to the wrong person, `target`: they protest and show their * papers. At this posting's trader outpost, it raises the alert. */ -/datum/criminal_bounty/proc/outpost_wrong_accusation(mob/living/basic/bounty_criminal/target, mob/living/user) +/datum/criminal_bounty/proc/outpost_wrong_accusation(mob/living/carbon/human/npc/bounty_criminal/target, mob/living/user) target.outpost_protest(user) to_chat(user, span_notice("Their papers are in order. Not who you're after.")) var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() @@ -492,7 +532,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * Someone showed them a warrant that isn't theirs: they protest, and show a docking pass that * checks out. Nothing else happens to them. */ -/mob/living/basic/bounty_criminal/proc/outpost_protest(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/proc/outpost_protest(mob/user) if(stat != CONSCIOUS) return // Forced: a protest always comes out, whatever was said a moment ago (P1's third argument) @@ -505,99 +545,110 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( // DECOYS // ========================================================================= -// Built-in values, P6's. Everything that could tell a decoy from its fugitive is copied from the -// fugitive when the decoy is made (outpost_match_body()), whatever the fugitive's kind. -/mob/living/basic/bounty_criminal/decoy +// Built-in values, P6's. A decoy is a person like the fugitive: the same species, build and face but +// for the features it was made not to share, the zone's clothes, the fugitive's bag and belt +// (outpost_match_kit()), and its toughness and pace (body_match()). It carries no weapon and never fights. +/mob/living/carbon/human/npc/bounty_criminal/decoy name = "patron" desc = "Another customer passing through." - maxHealth = BOUNTY_MEEK_HEALTH - health = BOUNTY_MEEK_HEALTH + /// Its fixed toughness is its fugitive's (body_match()), never its own record's tier + body_tier_toughness = FALSE + /// Nobody's posting cleans a dead decoy up: the corpse sweep does + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME /// Barks when hit are rate-limited on their own, beside bounty_say()'s cooldown COOLDOWN_DECLARE(outpost_bark_cooldown) - /// How long its fugitive stays down (P2's body_recovery_time()), so they get up together; null before it is made one - var/outpost_recovery_time - /// Its fugitive's stamina pool (P2's body_stamina_pool()); null before it is made one - var/outpost_stamina_pool /** - * Makes this decoy a look-alike of `fugitive` wanted on `posting` at `outpost`: `decoy_record`'s - * face in the zone's `outfit`, the fugitive's body and routine, the zone's generic name, doing - * what the fugitive does (at `anchor`, a bar stool, or nothing). The decoy is NOT linked to the - * posting through posting_ref: it is not the criminal, and nothing the pad, the proof of death or - * the board does may treat it as one. The admin panel's decoys should come through here too. + * A decoy made any other way than bounty_outpost_spawn_decoy() still dresses as a customer of the zone + * it was made in, never as a criminal: no gun, no armour. + */ +/mob/living/carbon/human/npc/bounty_criminal/decoy/bounty_build_outfit(zone) + switch(zone) + if(ZONE_YELLOW) + return /datum/outfit/bounty_outpost_dockhand + if(ZONE_RED) + return /datum/outfit/bounty_outpost_spacer + return /datum/outfit/bounty_outpost_patron + +/** + * Builds a decoy for `fugitive` at `spot`: a human NPC with `decoy_record`'s face in the zone's + * `outfit` and generic name, in its fugitive's faction, in the spawn queue (SShuman_npcs) unless + * `immediate`. Once built it is made a look-alike (outpost_become()) and `on_built` is called with it. + * Returns the order; qdel() it to cancel a decoy not built yet. A decoy built for a posting that has + * closed, moved on or lost its fugitive in the meantime is deleted unseen. */ -/mob/living/basic/bounty_criminal/decoy/proc/outpost_become(mob/living/basic/bounty_criminal/fugitive, datum/bounty_record/decoy_record, datum/criminal_bounty/posting, obj/structure/overmap/trader_outpost/outpost, display_name, display_desc, outfit, atom/anchor) +/proc/bounty_outpost_spawn_decoy(mob/living/carbon/human/npc/bounty_criminal/fugitive, datum/bounty_record/decoy_record, turf/spot, datum/criminal_bounty/posting, obj/structure/overmap/trader_outpost/outpost, display_name, display_desc, outfit, atom/anchor, datum/callback/on_built, immediate = FALSE) + var/datum/npc_spawn_context/context = new(outpost || spot, null, "ordinary", source_turf = get_turf(outpost || spot)) + var/datum/callback/finish = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_outpost_finish_decoy), WEAKREF(fugitive), decoy_record, posting ? WEAKREF(posting) : null, outpost ? WEAKREF(outpost) : null, display_name, display_desc, outfit, anchor ? WEAKREF(anchor) : null, on_built) + return spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/decoy, spot, finish, look = decoy_record, outfit = outfit, name = display_name, faction = fugitive?.faction, context = context, immediate = immediate) + +/// A decoy has just been built (bounty_outpost_spawn_decoy()): checks it is still wanted, then makes it a look-alike +/proc/bounty_outpost_finish_decoy(datum/weakref/fugitive_ref, datum/bounty_record/decoy_record, datum/weakref/posting_ref, datum/weakref/outpost_ref, display_name, display_desc, outfit, datum/weakref/anchor_ref, datum/callback/on_built, mob/living/carbon/human/npc/bounty_criminal/decoy/decoy) + if(QDELETED(decoy)) + return + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = fugitive_ref?.resolve() + var/datum/criminal_bounty/posting = posting_ref?.resolve() + var/obj/structure/overmap/trader_outpost/outpost = outpost_ref?.resolve() + if(QDELETED(fugitive) || (posting_ref && (QDELETED(posting) || !posting.is_open() || posting.outpost_site?.resolve() != outpost))) + qdel(decoy) + return + decoy.outpost_become(fugitive, decoy_record, posting, outpost, display_name, display_desc, outfit, anchor_ref?.resolve()) + on_built?.Invoke(decoy) + +/** + * Makes this decoy, already built in the zone's clothes with its record's face, a look-alike of + * `fugitive` wanted on `posting` at `outpost`: the fugitive's toughness and pace (body_match()), the + * zone's generic name and description, blending in like the fugitive and doing what it does (at + * `anchor`, a bar stool, or nothing). The decoy is NOT linked to the posting through posting_ref: it + * is not the criminal, and nothing the pad, the proof of death or the board does may treat it as + * one. `outfit` is the zone's clothes: a decoy built another way has its own put right. + */ +/mob/living/carbon/human/npc/bounty_criminal/decoy/proc/outpost_become(mob/living/carbon/human/npc/bounty_criminal/fugitive, datum/bounty_record/decoy_record, datum/criminal_bounty/posting, obj/structure/overmap/trader_outpost/outpost, display_name, display_desc, outfit, atom/anchor) record = decoy_record - if(decoy_record?.gender) - gender = decoy_record.gender - site_bounds = bounty_outpost_bounds(outpost) - outpost_match_body(fugitive) - // The same routine as the fugitive's (AR-C6): the same AI controller type, so start_activity() - // sets the same blend-in routine on both. - var/datum/ai_controller/fugitive_controller = fugitive.ai_controller - if(istype(fugitive_controller)) - var/datum/ai_controller/current = ai_controller - if(!istype(current) || current.type != fugitive_controller.type) - if(istype(current)) - QDEL_NULL(ai_controller) - var/controller_type = fugitive_controller.type - new controller_type(src) + body_set_site(outpost) + // The concourse itself, whatever the site gave it + var/list/bounds = bounty_outpost_bounds(outpost) + if(bounds) + site_bounds = bounds + body_match(fugitive) + outpost_match_kit(fugitive) AddComponent(/datum/component/bounty_outpost_blend, posting, TRUE, display_name, display_desc, outfit) start_activity(BOUNTY_ACTIVITY_BLEND, anchor) /** - * Copies everything about `fugitive`'s body that a player could compare (AR-C6): P2's body_match() - * (health, pace, stamina and how it slows), how long it stays down, its size, how it breathes and - * talks, and how it reacts to a touch. + * Wears the same bag and belt as `fugitive` (a meek criminal's fanny pack, a holster, a satchel), which + * show at a glance and which the patron clothes leave on the fugitive. Each is a new one of its type, + * emptied: a decoy carries no weapon, so a gun slung on the fugitive's back has no match here. Locked + * to its body like the rest of its kit (D3). */ -/mob/living/basic/bounty_criminal/decoy/proc/outpost_match_body(mob/living/basic/bounty_criminal/fugitive) - outpost_recovery_time = fugitive.body_recovery_time() - outpost_stamina_pool = fugitive.body_stamina_pool() - body_match(fugitive) - damage_coeff = fugitive.damage_coeff.Copy() - max_stamina = fugitive.max_stamina - stamina_crit_threshold = fugitive.stamina_crit_threshold - stamina_recovery = fugitive.stamina_recovery - status_flags = fugitive.status_flags - mob_size = fugitive.mob_size - faction = LAZYCOPY(fugitive.faction) - habitable_atmos = fugitive.habitable_atmos?.Copy() - unsuitable_atmos_damage = fugitive.unsuitable_atmos_damage - minimum_survivable_temperature = fugitive.minimum_survivable_temperature - maximum_survivable_temperature = fugitive.maximum_survivable_temperature - unsuitable_cold_damage = fugitive.unsuitable_cold_damage - unsuitable_heat_damage = fugitive.unsuitable_heat_damage - speak_emote = fugitive.speak_emote?.Copy() - verb_say = fugitive.verb_say - verb_ask = fugitive.verb_ask - verb_exclaim = fugitive.verb_exclaim - verb_yell = fugitive.verb_yell - verb_whisper = fugitive.verb_whisper - bubble_icon = fugitive.bubble_icon - response_help_continuous = fugitive.response_help_continuous - response_help_simple = fugitive.response_help_simple - response_disarm_continuous = fugitive.response_disarm_continuous - response_disarm_simple = fugitive.response_disarm_simple - response_harm_continuous = fugitive.response_harm_continuous - response_harm_simple = fugitive.response_harm_simple - attacked_sound = fugitive.attacked_sound - -// Down as long as its fugitive would be (M2): a meek fugitive can't give itself away by getting up first. -/mob/living/basic/bounty_criminal/decoy/body_recovery_time() - return isnull(outpost_recovery_time) ? ..() : outpost_recovery_time - -/mob/living/basic/bounty_criminal/decoy/body_stamina_pool() - return isnull(outpost_stamina_pool) ? ..() : outpost_stamina_pool +/mob/living/carbon/human/npc/bounty_criminal/decoy/proc/outpost_match_kit(mob/living/carbon/human/npc/bounty_criminal/fugitive) + if(QDELETED(fugitive)) + return + for(var/slot in list(ITEM_SLOT_BACK, ITEM_SLOT_BELT)) + var/obj/item/theirs = fugitive.get_item_by_slot(slot) + var/obj/item/mine = get_item_by_slot(slot) + if(mine?.type == theirs?.type) + continue + if(mine) + temporarilyRemoveItemFromInventory(mine, force = TRUE) + qdel(mine) + if(!istype(theirs, /obj/item/storage)) + continue + var/obj/item/storage/copy = new theirs.type(src) + for(var/atom/movable/inside as anything in copy.contents) + qdel(inside) + if(equip_to_slot_or_del(copy, slot, initial = TRUE)) + npc_lock_item(copy) /// A decoy never fights anyone (P2's gate on attacks) -/mob/living/basic/bounty_criminal/decoy/may_attack(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/decoy/may_attack(atom/target) return FALSE /** - * Confronting a decoy, whoever does it (a wrong warrant, cuffs reached for, any other package): a - * wrong accusation, with its cost. It protests, and at its outpost the posting's alert rises (C1). + * Confronting a decoy, whoever does it (a wrong warrant, any other package): a wrong accusation, + * with its cost. It protests, and at its outpost the posting's alert rises (C1). */ -/mob/living/basic/bounty_criminal/decoy/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/decoy/on_confronted(mob/user) var/datum/component/bounty_outpost_blend/blend = GetComponent(/datum/component/bounty_outpost_blend) var/datum/criminal_bounty/posting = blend?.posting_ref?.resolve() if(blend?.leaving || QDELETED(posting) || !isliving(user) || !user.mind) @@ -605,15 +656,16 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return posting.outpost_wrong_accusation(src, user) -/// Someone hit it on purpose: a bark, then it backs off from them for a few seconds -/mob/living/basic/bounty_criminal/decoy/proc/outpost_react_to_hit(mob/living/attacker) +/// Someone hit it on purpose: a bark, then it backs off from them for a few seconds (not while it is walking out) +/mob/living/carbon/human/npc/bounty_criminal/decoy/proc/outpost_react_to_hit(mob/living/attacker) if(stat != CONSCIOUS || is_downed() || is_restrained()) return if(COOLDOWN_FINISHED(src, outpost_bark_cooldown)) COOLDOWN_START(src, outpost_bark_cooldown, 8 SECONDS) if(!bounty_say("hurt")) say(pick(GLOB.bounty_decoy_hurt_lines), forced = "bounty decoy") - if(QDELETED(attacker) || !isturf(loc) || HAS_TRAIT(src, TRAIT_IMMOBILIZED)) + var/datum/component/bounty_outpost_blend/blend = GetComponent(/datum/component/bounty_outpost_blend) + if(blend?.leaving || QDELETED(attacker) || !isturf(loc) || HAS_TRAIT(src, TRAIT_IMMOBILIZED)) return GLOB.move_manager.move_away(src, attacker, BOUNTY_DECOY_FLEE_DISTANCE, max(cached_multiplicative_slowdown, 1), BOUNTY_DECOY_FLEE_TIME, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) @@ -626,15 +678,19 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * * On the fugitive and on each decoy, so the two behave the same way (AR-C6, C7, C8): * - the zone's generic name and description (set once, and kept); - * - nobody can pull, drag, shove or stuff them anywhere; + * - the fugitive wears the zone's clothes (redress()); a decoy is built in them; * - the outpost's turrets leave them alone; - * - a shot not aimed at them passes through them; + * - they never step onto the hangar lift by themselves; * - a deliberate hit by a person counts: on a decoy it raises the alert and is an outpost strike, - * on the fugitive it ends the act. + * on the fugitive it ends the act. So does a reach for their wrists with cuffs. + * + * Nothing else is special about them: a hand on them, a shot, a shove or a thrown bottle goes through + * tg like it would for anyone, and they answer it the way their AI does. A shot hits whoever it hits; + * only one aimed at them counts as a deliberate hit. * * The fugitive stops blending in when exposed (the warrant, or a hit); a decoy never does. The - * component stays on an exposed fugitive for the rest of its time at the outpost: its own shots - * there pass through anyone not on its grudge list (AR-C8). + * component stays on an exposed fugitive for the rest of its time at the outpost: if it hurts a + * bystander there, the turrets are let loose on it (AR-C8). */ /datum/component/bounty_outpost_blend /// The posting they are part of, as a weakref (/datum/criminal_bounty) @@ -645,7 +701,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/decoy = FALSE /// Still blending in var/blending = TRUE - /// Walking out to the hangar (/datum/component/bounty_outpost_exit): hits and warrants no longer count + /// Walking out to the hangar (bounty_outpost_walk_out()): hits and warrants no longer count var/leaving = FALSE /// The zone's clothes they wear while blending in var/outfit @@ -657,21 +713,21 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( COOLDOWN_DECLARE(hit_grace) /datum/component/bounty_outpost_blend/Initialize(datum/criminal_bounty/posting, decoy = FALSE, display_name, display_desc, outfit) - if(!istype(parent, /mob/living/basic/bounty_criminal)) + if(!istype(parent, /mob/living/carbon/human/npc/bounty_criminal)) return COMPONENT_INCOMPATIBLE posting_ref = posting ? WEAKREF(posting) : null var/atom/outpost = posting?.outpost_site?.resolve() || posting?.site_ref?.resolve() outpost_ref = outpost ? WEAKREF(outpost) : null src.decoy = decoy src.outfit = outfit - var/mob/living/basic/bounty_criminal/owner = parent + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent owner.blended = TRUE if(display_name) owner.name = display_name owner.real_name = display_name + owner.dna?.real_name = display_name if(display_desc) owner.desc = display_desc - owner.move_resist = MOVE_FORCE_VERY_STRONG ADD_TRAIT(owner, TRAIT_NO_CONTAINMENT, BOUNTY_OUTPOST_TRAIT) ADD_TRAIT(owner, TRAIT_NO_STORAGE_INSERT, BOUNTY_OUTPOST_TRAIT) // Belt and braces beside P2's turret rule for criminals: turrets skip faction-mates before @@ -681,23 +737,18 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( new_faction += FACTION_TURRET owner.faction = new_faction added_turret_faction = TRUE + // A decoy was built in the zone's clothes already, which changes nothing; the fugitive was dressed as the criminal it is if(outfit) redress() - // P2 dresses a new criminal in its own clothes in the background; whichever look lands - // last would stick, so the zone's is put back on after it (from the cache: cheap). - addtimer(CALLBACK(src, PROC_REF(redress)), 3 SECONDS, TIMER_DELETE_ME) - addtimer(CALLBACK(src, PROC_REF(redress)), 10 SECONDS, TIMER_DELETE_ME) /datum/component/bounty_outpost_blend/Destroy(force) stop_blending() return ..() /datum/component/bounty_outpost_blend/RegisterWithParent() - RegisterSignal(parent, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(on_pull_attempt)) - RegisterSignal(parent, COMSIG_MOUSEDROP_ONTO, PROC_REF(on_dragged)) RegisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move)) RegisterSignal(parent, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_projectile_prehit)) - RegisterSignal(parent, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_item_interaction)) + RegisterSignal(parent, COMSIG_BOUNTY_CUFF_REACHED, PROC_REF(on_cuff_reached)) RegisterSignal(parent, COMSIG_ATOM_AFTER_ATTACKEDBY, PROC_REF(on_item_attack)) RegisterSignal(parent, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_hand_attack)) RegisterSignal(parent, COMSIG_ATOM_ATTACK_HAND_SECONDARY, PROC_REF(on_shoved)) @@ -707,15 +758,13 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( RegisterSignal(parent, COMSIG_ATOM_PREHITBY, PROC_REF(on_thrown_hit)) RegisterSignals(parent, list(COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_ANIMAL), PROC_REF(on_creature_attack)) RegisterSignal(parent, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_firing)) - RegisterSignal(parent, COMSIG_HOSTILE_POST_ATTACKINGTARGET, PROC_REF(on_melee_attack)) + RegisterSignals(parent, list(COMSIG_MOB_ITEM_ATTACK, COMSIG_LIVING_UNARMED_ATTACK), PROC_REF(on_own_melee_attack)) /datum/component/bounty_outpost_blend/UnregisterFromParent() UnregisterSignal(parent, list( - COMSIG_ATOM_CAN_BE_PULLED, - COMSIG_MOUSEDROP_ONTO, COMSIG_MOVABLE_PRE_MOVE, COMSIG_PROJECTILE_PREHIT, - COMSIG_ATOM_ITEM_INTERACTION, + COMSIG_BOUNTY_CUFF_REACHED, COMSIG_ATOM_AFTER_ATTACKEDBY, COMSIG_ATOM_ATTACK_HAND, COMSIG_ATOM_ATTACK_HAND_SECONDARY, @@ -726,27 +775,90 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_ANIMAL, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, - COMSIG_HOSTILE_POST_ATTACKINGTARGET, + COMSIG_MOB_ITEM_ATTACK, + COMSIG_LIVING_UNARMED_ATTACK, )) -/// Puts the zone's clothes back on, while still blending in and not hiding (a meek one's disguise stays) +/** + * Changes the fugitive into the zone's clothes while it blends in. It was dressed as the criminal it + * is; decoys are built in the zone's, so the two must end up looking the same. See + * outpost_wear_patron_clothes(). + */ /datum/component/bounty_outpost_blend/proc/redress() - var/mob/living/basic/bounty_criminal/owner = parent - if(!blending || leaving || !outfit || !owner.record || owner.hidden) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(!blending || leaving || !outfit || QDELETED(owner)) return - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(apply_bounty_look), owner, owner.record, outfit) + owner.outpost_wear_patron_clothes(outfit) + +/** + * Changes into `outfit_type`'s clothes: the uniform, the coat, the gloves and the shoes it names, and + * nothing on the head or the face (the zone's patrons wear no hats: hair is a clue). What it wore + * there is taken away and deleted. Everything else it carries stays where it is, pockets and belt + * included. The new clothes are locked to the body like the rest of its kit (D3). + * What its species can't live without stays on (a plasmaman's envirosuit, helmet and gloves, its + * species' outfit_important_for_life): its decoys, of its species, are dressed over the same. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/outpost_wear_patron_clothes(outfit_type) + var/datum/outfit/patron = new outfit_type() + var/list/swaps = list( + list(w_uniform, patron.uniform, ITEM_SLOT_ICLOTHING), + list(wear_suit, patron.suit, ITEM_SLOT_OCLOTHING), + list(gloves, patron.gloves, ITEM_SLOT_GLOVES), + list(shoes, patron.shoes, ITEM_SLOT_FEET), + list(head, patron.head, ITEM_SLOT_HEAD), + list(wear_mask, patron.mask, ITEM_SLOT_MASK), + ) + qdel(patron) + var/datum/outfit/life = dna?.species?.outfit_important_for_life + for(var/list/swap as anything in swaps) + var/obj/item/worn = swap[1] + var/new_type = swap[2] + if(worn && ispath(new_type) && worn.type == new_type) + continue + if(bounty_outpost_kept_for_life(worn, life, swap[3])) + continue + if(worn) + // idrop off: the pockets, belt and suit storage are meant to stay when the clothes over them are swapped + temporarilyRemoveItemFromInventory(worn, force = TRUE, idrop = FALSE) + qdel(worn) + if(!ispath(new_type)) + continue + var/obj/item/clothes = new new_type(src) + equip_to_slot_or_del(clothes, swap[3], initial = TRUE) + npc_lock_item(clothes) -/// Ends the act: pullable (by P2's rules) and containable again, and no longer in the turrets' faction. The name stays. +/// Whether `worn`, in `slot`, is what `life` (a species' outfit_important_for_life) puts there: kept on when blending in +/proc/bounty_outpost_kept_for_life(obj/item/worn, datum/outfit/life, slot) + if(isnull(worn) || !ispath(life, /datum/outfit)) + return FALSE + var/life_type + switch(slot) + if(ITEM_SLOT_ICLOTHING) + life_type = initial(life.uniform) + if(ITEM_SLOT_OCLOTHING) + life_type = initial(life.suit) + if(ITEM_SLOT_GLOVES) + life_type = initial(life.gloves) + if(ITEM_SLOT_FEET) + life_type = initial(life.shoes) + if(ITEM_SLOT_HEAD) + life_type = initial(life.head) + if(ITEM_SLOT_MASK) + life_type = initial(life.mask) + return ispath(life_type) && istype(worn, life_type) + +/// Ends the act: containable again, and no longer in the turrets' faction. The name stays. /datum/component/bounty_outpost_blend/proc/stop_blending() if(!blending) return blending = FALSE - var/mob/living/basic/bounty_criminal/owner = parent + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent if(QDELETED(owner)) return owner.blended = FALSE - if(!leaving) - owner.body_update_drag() + // Out of the act, the coat over its chrome comes off and the chrome shows (C0, bounty_chrome.dm). Decoys never drop the act. + if(!decoy && !leaving) + bounty_chrome_bare(owner) REMOVE_TRAIT(owner, TRAIT_NO_CONTAINMENT, BOUNTY_OUTPOST_TRAIT) REMOVE_TRAIT(owner, TRAIT_NO_STORAGE_INSERT, BOUNTY_OUTPOST_TRAIT) if(added_turret_faction) @@ -755,29 +867,6 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( owner.faction = new_faction added_turret_faction = FALSE -/** - * Nobody pulls someone blending in, or walking out. By P2's rule, though, anyone down, cuffed or dead - * can be dragged: a fugitive (or a decoy, the same, AR-C6) downed or cuffed while still blending in, - * by a blast say, is dragged like any other catch. - */ -/datum/component/bounty_outpost_blend/proc/on_pull_attempt(datum/source, mob/living/puller) - SIGNAL_HANDLER - if(leaving) - return COMSIG_ATOM_CANT_PULL - if(!blending) - return NONE - var/mob/living/basic/bounty_criminal/owner = parent - if(owner.stat == DEAD || owner.downed || owner.is_restrained()) - return NONE - return COMSIG_ATOM_CANT_PULL - -/// Drag-drops (onto a bed, into a crate or disposals) never check move_resist, so they are refused here -/datum/component/bounty_outpost_blend/proc/on_dragged(datum/source, atom/over, mob/user) - SIGNAL_HANDLER - if(blending || leaving) - return COMPONENT_CANCEL_MOUSEDROP_ONTO - return NONE - /** * They never step onto the hangar lift by themselves: the lift carries everyone standing on it, and * a patron riding it down would leave the outpost. Dragged or thrown, they go where they are taken @@ -794,37 +883,39 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return NONE /** - * A shot not aimed at someone blending in passes through them (AR-C7): nobody can use a decoy as a - * shield, or shoot "past" one to make a rival hit it. A shot aimed at them is a deliberate hit. + * A shot hits whoever it hits. One aimed at someone blending in is a deliberate hit by whoever fired + * it; a stray one that lands on them is not (nobody is framed with it). */ /datum/component/bounty_outpost_blend/proc/on_projectile_prehit(datum/source, obj/projectile/projectile) SIGNAL_HANDLER - if(leaving) - return PROJECTILE_INTERRUPT_HIT_PHASE - if(!blending) - return NONE - if(projectile.original != parent) - return PROJECTILE_INTERRUPT_HIT_PHASE - if(projectile.is_hostile_projectile() && isliving(projectile.firer)) + if(projectile.original == parent && projectile.is_hostile_projectile() && isliving(projectile.firer)) deliberate_hit(projectile.firer) return NONE /** - * Restraints reached for (C1): exactly the cost of a punch, on anyone blending in. A decoy's wrists - * are an outpost strike and the alert; the fugitive's end its act. Standing and free, they pull - * away, so P2's own refusal (and the confrontation it starts) never runs as a free test. Down or - * stunned, the cuffs go on as P2 puts them on anyone. + * Someone is putting restraints on them (COMSIG_BOUNTY_CUFF_REACHED, from the criminal body before its + * own reaction), exactly the cost of a punch (C1): + * - A decoy's wrists are an outpost strike and the alert, and it backs away (which breaks the cuffer's + * try). Handled here, so it isn't also taken for a wrong warrant. + * - The fugitive's end its act. The body's own reaction then steps it away and runs or fights, as for + * anyone who is reached for while free. + * Down, stunned or held, they can't do anything about it: the cuffs go on. Nobody without a mind + * counts. Walking out, the fugitive just takes it like any other free criminal; a decoy's wrists are + * still the hitter's strike (handle_hit()). */ -/datum/component/bounty_outpost_blend/proc/on_item_interaction(datum/source, mob/living/user, obj/item/tool, list/modifiers) +/datum/component/bounty_outpost_blend/proc/on_cuff_reached(datum/source, mob/living/user) SIGNAL_HANDLER - if(!blending || leaving || !istype(tool, /obj/item/restraints/handcuffs)) + if(!isliving(user) || !user.mind) return NONE - var/mob/living/basic/bounty_criminal/owner = parent - deliberate_hit(user) - if(owner.capture_state() != BOUNTY_STATE_FREE) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(decoy) + deliberate_hit(user) + return owner.body_can_answer_cuffs() ? COMPONENT_BOUNTY_CUFF_HANDLED : NONE + if(!blending || leaving) return NONE - owner.balloon_alert(user, "[owner.p_they()] pull[owner.p_s()] away!") - return ITEM_INTERACT_BLOCKING + owner.body_add_grudge(user) + stop_blending() + return NONE /datum/component/bounty_outpost_blend/proc/on_item_attack(datum/source, obj/item/weapon, mob/living/attacker, list/modifiers, list/attack_modifiers) SIGNAL_HANDLER @@ -873,22 +964,24 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( /// Someone hit them on purpose. Only a person counts: stray shots, stray throws, explosions and NPCs never do. /datum/component/bounty_outpost_blend/proc/deliberate_hit(mob/living/attacker) - if(leaving || !istype(attacker) || !attacker.mind || attacker == parent) + if(!istype(attacker) || !attacker.mind || attacker == parent) return INVOKE_ASYNC(src, PROC_REF(handle_hit), attacker) /** * A person hit them on purpose. - * - The fugitive: the act is over (it remembers who, and reacts as its kind does). + * - The fugitive: the act is over (it remembers who, and reacts as its kind does). Walking out, it + * just takes the blow. * - A decoy (AR-C5): one outpost strike for the hitter, as for hitting a trader, and the bounty's - * alert rises once per scuffle, whoever swung. The decoy barks and backs off. + * alert rises once per scuffle, whoever swung. The decoy barks and backs off. Walking out, the + * alert has gone up already, so only the strike and the bark are left. */ /datum/component/bounty_outpost_blend/proc/handle_hit(mob/living/attacker) - var/mob/living/basic/bounty_criminal/owner = parent - if(QDELETED(owner) || QDELETED(attacker) || leaving) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(QDELETED(owner) || QDELETED(attacker)) return if(!decoy) - if(!blending) + if(!blending || leaving) return owner.body_add_grudge(attacker) stop_blending() @@ -896,33 +989,33 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(get_turf(owner)) outpost?.register_aggression(attacker) - if(COOLDOWN_FINISHED(src, hit_grace)) + if(!leaving && COOLDOWN_FINISHED(src, hit_grace)) COOLDOWN_START(src, hit_grace, BOUNTY_OUTPOST_HIT_GRACE) var/datum/criminal_bounty/posting = posting_ref?.resolve() if(posting && !QDELETED(posting)) log_game("BOUNTY: [key_name(attacker)] hit a decoy of [posting.record?.name] at [AREACOORD(owner)].") posting.raise_alert(BOUNTY_ALERT_DECOY_HIT) // The alert may have sent everyone out already - var/mob/living/basic/bounty_criminal/decoy/decoy_mob = owner - if(!leaving && istype(decoy_mob)) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy_mob = owner + if(istype(decoy_mob)) decoy_mob.outpost_react_to_hit(attacker) -/// Its own fists landed on `target` -/datum/component/bounty_outpost_blend/proc/on_melee_attack(datum/source, atom/target, result) +/// Its own fists, or what it swung, landed on `target` +/datum/component/bounty_outpost_blend/proc/on_own_melee_attack(datum/source, atom/target) SIGNAL_HANDLER note_victim(target) /** * The fugitive hurt `victim`. At a trader outpost, anyone not on its grudge list is a bystander, and * hurting one ends the turrets' leave to let the hunters handle it (AR-C8). P2's may_attack() and - * the grudge-only shots below keep that from happening; a thrown attack that gets round them should - * call this too. + * its AI's line-of-fire check keep that from happening on purpose; a bullet that hits one anyway + * lands here too (on_firing()). */ /datum/component/bounty_outpost_blend/proc/note_victim(atom/victim) if(decoy || !isliving(victim) || victim == parent) return - var/mob/living/basic/bounty_criminal/owner = parent - if(owner.body_has_grudge(victim) || istype(victim, /mob/living/basic/bounty_criminal) || istype(victim, /mob/living/basic/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(owner.body_has_grudge(victim) || istype(victim, /mob/living/carbon/human/npc/bounty_criminal) || istype(victim, /mob/living/carbon/human/npc/bounty_companion)) return if(!is_trader_outpost_protected(owner)) return @@ -933,48 +1026,60 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( /** * Whether a trader outpost's turrets may fire on them (AR-C8): only a fugitive, exposed, that has * hurt someone who never crossed it. Blending in, or fighting only those who came for it, never. - * P2's line in is_hostile_creature() asks this for bounty criminals. + * Nor one the hunters already have: dead, in crit, cuffed or stunned (tg's own states). P2's line in + * is_hostile_creature() asks this for bounty criminals. */ -/mob/living/basic/bounty_criminal/proc/outpost_turret_target() +/mob/living/carbon/human/npc/bounty_criminal/proc/outpost_turret_target() + if(capture_state() != BOUNTY_STATE_FREE) + return FALSE var/datum/component/bounty_outpost_blend/blend = GetComponent(/datum/component/bounty_outpost_blend) return !!blend && !blend.blending && !blend.leaving && blend.hurt_bystander -/// The fugitive fired: at a trader outpost its shot passes through anyone not on its grudge list (AR-C8) +/// The fugitive fired: a shot of its that hits someone at a trader outpost is judged by who it hit (note_victim()) /datum/component/bounty_outpost_blend/proc/on_firing(datum/source, obj/projectile/projectile, atom/fired_from, atom/original) SIGNAL_HANDLER if(decoy || !is_trader_outpost_protected(parent)) return - RegisterSignal(projectile, COMSIG_PROJECTILE_SELF_PREHIT, PROC_REF(on_own_projectile_prehit)) + RegisterSignal(projectile, COMSIG_PROJECTILE_SELF_ON_HIT, PROC_REF(on_own_projectile_hit)) -/datum/component/bounty_outpost_blend/proc/on_own_projectile_prehit(obj/projectile/source, atom/target) +/datum/component/bounty_outpost_blend/proc/on_own_projectile_hit(obj/projectile/source, atom/movable/firer, atom/target, angle, hit_limb, blocked, pierce_hit) SIGNAL_HANDLER - if(!isliving(target) || target == parent) - return NONE - var/mob/living/basic/bounty_criminal/owner = parent - if(owner.body_has_grudge(target)) - return NONE - return PROJECTILE_INTERRUPT_HIT_PHASE + note_victim(target) // ========================================================================= // WALKING OUT // ========================================================================= +/// Sends each of `walkers` (human NPCs) out to `outpost`'s hangar lift: bounty_outpost_walk_out() +/proc/bounty_outpost_walk_out_all(list/walkers, obj/structure/overmap/trader_outpost/outpost) + for(var/mob/living/carbon/human/npc/walker as anything in walkers) + bounty_outpost_walk_out(walker, outpost) + /** - * `walker` leaves `outpost`: walks to the nearest hangar lift and fades out, or fades where they - * are if they can't walk there. `floor` is the outpost's public floor if the caller has it (one - * flood for everyone leaving at once); the lift is only walked to from a tile on it, so an - * unreachable lift is never pathed to. + * `walker` is done at `outpost`: it walks to the nearest hangar lift the way a person would + * (npc_walk_out(), human_npc_states.dm). It can be shot, downed, cuffed or grabbed on the way and then + * stays where it is; it is deleted only when it stands on the lift, or when no player can see it and + * it is on its feet and free. `on_left` is called with it just before that. A lift behind a counter it + * can't get round just means it goes when nobody is looking. Someone who is dead stays for the + * corpse sweep; someone sitting at a table or hiding gets up first. Returns TRUE if it set off. */ -/proc/bounty_outpost_walk_out(mob/living/walker, obj/structure/overmap/trader_outpost/outpost, list/floor) - if(QDELETED(walker) || walker.GetComponent(/datum/component/bounty_outpost_exit)) - return - var/turf/exit_turf = bounty_outpost_exit_turf(walker, outpost) - if(exit_turf) - if(isnull(floor)) - floor = bounty_outpost_public_floor(outpost) - if(!isturf(walker.loc) || !(walker.loc in floor) || !(exit_turf in floor)) - exit_turf = null - walker.AddComponent(/datum/component/bounty_outpost_exit, exit_turf) +/proc/bounty_outpost_walk_out(mob/living/carbon/human/npc/walker, obj/structure/overmap/trader_outpost/outpost, datum/callback/on_left) + if(QDELETED(walker) || walker.stat == DEAD || !isnull(walker.npc_exit)) + return FALSE + var/datum/component/bounty_outpost_blend/blend = walker.GetComponent(/datum/component/bounty_outpost_blend) + blend?.leaving = TRUE + var/mob/living/carbon/human/npc/bounty_criminal/criminal = walker + if(istype(criminal)) + // Out of the closet or from behind the plant, and up from its seat, its drink put down + if(criminal.hidden) + criminal.ai_unhide() + criminal.ai_end_activity() + criminal.ai_stand_up() + else if(walker.buckled && !ismob(walker.buckled) && !human_npc_out_of_fight(walker)) + walker.buckled.unbuckle_mob(walker, force = TRUE) + // The lift if there is one on its level; else where it stands, which it leaves once nobody can see it + var/turf/exit_turf = bounty_outpost_exit_turf(walker, outpost) || get_turf(walker) + return walker.npc_walk_out(exit_turf, on_left) /// The hangar lift alcove tile nearest `from`, on its level, or null /proc/bounty_outpost_exit_turf(atom/from, obj/structure/overmap/trader_outpost/outpost) @@ -992,114 +1097,6 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( nearest_distance = distance return nearest -/** - * # Walking out - * - * Someone P6 put at an outpost is done there: their AI stops, nobody can hurt or pull them, and - * they walk to the hangar lift and fade out. Anyone who can't walk (down, cuffed, hidden, dead), - * can't reach the lift, is taken off their feet on the way, or is still walking after - * BOUNTY_OUTPOST_EXIT_TIMEOUT fades where they are. The walk's move loop never outlives them. - */ -/datum/component/bounty_outpost_exit - /// The hangar lift tile they are walking to, or null - var/turf/destination - /// The walk to the lift, while it runs - var/datum/move_loop/walk_loop - /// Fading out now - var/fading = FALSE - -/datum/component/bounty_outpost_exit/Initialize(turf/destination) - if(!isliving(parent)) - return COMPONENT_INCOMPATIBLE - src.destination = destination - var/mob/living/walker = parent - var/datum/component/bounty_outpost_blend/blend = walker.GetComponent(/datum/component/bounty_outpost_blend) - if(blend) - blend.leaving = TRUE - ADD_TRAIT(walker, TRAIT_AI_PAUSED, BOUNTY_OUTPOST_TRAIT) - ADD_TRAIT(walker, TRAIT_GODMODE, BOUNTY_OUTPOST_TRAIT) - walker.move_resist = MOVE_FORCE_VERY_STRONG - walker.pulledby?.stop_pulling() - walker.stop_pulling() - addtimer(CALLBACK(src, PROC_REF(fade_out)), BOUNTY_OUTPOST_EXIT_TIMEOUT, TIMER_DELETE_ME) - if(!destination || !can_walk() || destination.z != walker.z) - fade_out() - return - RegisterSignal(walker, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) - walk_loop = GLOB.move_manager.jps_move(walker, destination, delay = BOUNTY_OUTPOST_EXIT_DELAY, timeout = BOUNTY_OUTPOST_EXIT_TIMEOUT, repath_delay = 2 SECONDS, max_path_length = 150, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) - if(!walk_loop) - fade_out() - return - RegisterSignal(walk_loop, COMSIG_MOVELOOP_PREPROCESS_CHECK, PROC_REF(before_step)) - RegisterSignals(walk_loop, list(COMSIG_MOVELOOP_STOP, COMSIG_QDELETING), PROC_REF(on_walk_ended)) - -/datum/component/bounty_outpost_exit/Destroy(force) - end_walk() - destination = null - return ..() - -/// Whether they can walk out on their own feet -/datum/component/bounty_outpost_exit/proc/can_walk() - var/mob/living/walker = parent - if(QDELETED(walker) || walker.stat != CONSCIOUS || !isturf(walker.loc) || walker.buckled) - return FALSE - if(HAS_TRAIT(walker, TRAIT_IMMOBILIZED) || HAS_TRAIT(walker, TRAIT_FLOORED) || HAS_TRAIT(walker, TRAIT_RESTRAINED)) - return FALSE - if(istype(walker, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = walker - if(criminal.hidden || criminal.is_downed() || criminal.is_restrained()) - return FALSE - return TRUE - -/// Before each step: someone off their feet or off the floor (stuffed somewhere, carried off) stops walking and fades, so the pathfinder is never asked to start from nowhere -/datum/component/bounty_outpost_exit/proc/before_step(datum/move_loop/source) - SIGNAL_HANDLER - if(can_walk()) - return NONE - addtimer(CALLBACK(src, PROC_REF(fade_out)), 0, TIMER_DELETE_ME) - return MOVELOOP_SKIP_STEP - -/// The walk stopped (timed out, or ended): whoever hasn't reached the lift fades where they are -/datum/component/bounty_outpost_exit/proc/on_walk_ended(datum/move_loop/source) - SIGNAL_HANDLER - UnregisterSignal(source, list(COMSIG_MOVELOOP_PREPROCESS_CHECK, COMSIG_MOVELOOP_STOP, COMSIG_QDELETING)) - if(walk_loop == source) - walk_loop = null - if(!fading) - addtimer(CALLBACK(src, PROC_REF(fade_out)), 0, TIMER_DELETE_ME) - -/// Stops the walk, if it is still going -/datum/component/bounty_outpost_exit/proc/end_walk() - if(!walk_loop) - return - var/datum/move_loop/ending = walk_loop - walk_loop = null - UnregisterSignal(ending, list(COMSIG_MOVELOOP_PREPROCESS_CHECK, COMSIG_MOVELOOP_STOP, COMSIG_QDELETING)) - if(!QDELETED(ending)) - qdel(ending) - -/datum/component/bounty_outpost_exit/proc/on_moved(datum/source, atom/old_loc, dir, forced, list/old_locs) - SIGNAL_HANDLER - // Faded after the step, never inside the move loop's own step - if(destination && get_dist(parent, destination) <= 1) - addtimer(CALLBACK(src, PROC_REF(fade_out)), 0, TIMER_DELETE_ME) - -/datum/component/bounty_outpost_exit/proc/fade_out() - if(fading) - return - fading = TRUE - end_walk() - var/mob/living/walker = parent - if(QDELETED(walker)) - return - UnregisterSignal(walker, COMSIG_MOVABLE_MOVED) - GLOB.move_manager.stop_looping(walker) - animate(walker, alpha = 0, time = BOUNTY_OUTPOST_FADE_TIME) - addtimer(CALLBACK(src, PROC_REF(vanish)), BOUNTY_OUTPOST_FADE_TIME, TIMER_DELETE_ME) - -/datum/component/bounty_outpost_exit/proc/vanish() - qdel(parent) - // ========================================================================= // ASKING TRADERS // ========================================================================= @@ -1132,7 +1129,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( if(!posting) return FALSE var/words - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() if(!fugitive || get_trader_outpost_for_turf(get_turf(fugitive)) != trader.outpost) words = "They're not here any more." // slipped away, or someone already dragged them off else @@ -1204,21 +1201,21 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() switch(kind[1]) if(BOUNTY_CLUE_SEEN) - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() return fugitive ? bounty_outpost_where(fugitive, outpost) : null if(BOUNTY_CLUE_RULED_OUT) var/index = kind[2] if(index < 1 || index > length(decoys)) return null var/datum/weakref/decoy_ref = decoys[index] - var/mob/living/basic/bounty_criminal/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy = decoy_ref?.resolve() if(QDELETED(decoy) || decoy.stat == DEAD) return null var/where = bounty_outpost_where(decoy, outpost) if(!where) return null // Only if the place picks out one person: never a hint that could fit the fugitive - for(var/mob/living/basic/bounty_criminal/other as anything in outpost_candidates()) + for(var/mob/living/carbon/human/npc/bounty_criminal/other as anything in outpost_candidates()) if(other != decoy && bounty_outpost_where(other, outpost) == where) return null return where @@ -1251,11 +1248,11 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( /// The fugitive and its decoys, whoever of them still exists /datum/criminal_bounty/proc/outpost_candidates() . = list() - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() if(fugitive) . += fugitive for(var/datum/weakref/decoy_ref as anything in decoys) - var/mob/living/basic/bounty_criminal/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy = decoy_ref?.resolve() if(!QDELETED(decoy)) . += decoy @@ -1429,7 +1426,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( if(QDELETED(posting) || posting.status != BOUNTY_POSTING_OPEN) to_chat(user, span_warning("This warrant is out of date.")) return - if(!istype(target, /mob/living/basic/bounty_criminal)) + if(!istype(target, /mob/living/carbon/human/npc/bounty_criminal)) user.visible_message(span_notice("[user] holds up a warrant for [target] to see."), span_notice("You hold up the warrant for [target] to see."), vision_distance = COMBAT_MESSAGE_RANGE) if(target.client) to_chat(target, span_notice("[user] shows you a bounty warrant. It isn't for you.")) @@ -1443,7 +1440,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return posting.outpost_start_warrant_cooldown(user) user.visible_message(span_notice("[user] holds up a warrant for [target] to see."), span_notice("You hold up the warrant for [target] to see."), vision_distance = COMBAT_MESSAGE_RANGE) - var/mob/living/basic/bounty_criminal/criminal = target + var/mob/living/carbon/human/npc/bounty_criminal/criminal = target if(criminal == posting.criminal()) posting.outpost_confront(criminal, user) else diff --git a/voidcrew/modules/bounties/bounty_placement.dm b/voidcrew/modules/bounties/bounty_placement.dm index f150143c2ce..0ca7fddbb2f 100644 --- a/voidcrew/modules/bounties/bounty_placement.dm +++ b/voidcrew/modules/bounties/bounty_placement.dm @@ -125,11 +125,11 @@ best += ruin return board_pick_tiered(best, good, any) -/// An NPC pirate ship with its crew aboard, not claimed, not abandoned, holding no other bounty +/// A plain NPC pirate ship with its crew aboard, not claimed, not abandoned, holding no other bounty. Never a themed crew's (bounty_ship_hides_criminals()): a criminal hides among ordinary pirates. /datum/controller/subsystem/criminal_bounties/proc/board_pick_npc_ship(zone, obj/structure/overmap/exclude) var/list/candidates = list() for(var/obj/structure/overmap/ship/npc/pirate/npc_ship in SSnpc_ships?.active_ships) - if(QDELETED(npc_ship) || npc_ship == exclude) + if(QDELETED(npc_ship) || npc_ship == exclude || !bounty_ship_hides_criminals(npc_ship)) continue if(npc_ship.player_controlled || npc_ship.abandoned || npc_ship.spawner_resolved || !npc_ship.crew_ever_spawned) continue @@ -277,7 +277,7 @@ SIGNAL_HANDLER if(!is_open() || board_relisting) return - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() // A criminal carried off the site (aboard a ship) is not the site's to take if(criminal && !board_site_contains(source, get_turf(criminal))) return @@ -285,11 +285,11 @@ /** * The site's interior went away with the criminal in it (spec 6: unload): the criminal, its - * companions and the marker go, the posting stays open, and the same record comes back at full - * health on the next load. Cuffs don't survive, but the worst state it reached does (12.1): it - * never pays more than it did before, and a dead one comes back as proof of death. + * companions and the marker go, the posting stays open, and the same record comes back unhurt on the + * next load, out of any cuffs. Pay is by its condition on the pad, so a living one owes nothing to how + * it was before; one that died comes back as proof of death. */ -/datum/criminal_bounty/proc/board_site_took_criminal(mob/living/basic/bounty_criminal/criminal) +/datum/criminal_bounty/proc/board_site_took_criminal(mob/living/carbon/human/npc/bounty_criminal/criminal) board_note_worst(criminal) board_detach_criminal() var/list/extras = list() @@ -307,13 +307,10 @@ board_wait_for_load(site()) log_game("BOUNTY: the [board_log_name()] waits for its site to load again") -/// Remembers the worst state `criminal` reached, for whatever body of it comes next (12.1) -/datum/criminal_bounty/proc/board_note_worst(mob/living/basic/bounty_criminal/criminal) - if(!criminal) - return - var/state = criminal.stat == DEAD ? BOUNTY_STATE_DEAD : criminal.worst_state - if(bounty_state_rank(state) < bounty_state_rank(board_worst_state)) - board_worst_state = state +/// Remembers whether `criminal` was dead when its site took it: the next body of it is then its proof of death +/datum/criminal_bounty/proc/board_note_worst(mob/living/carbon/human/npc/bounty_criminal/criminal) + if(criminal?.stat == DEAD) + board_worst_state = BOUNTY_STATE_DEAD /// The site is gone (a ruin respawning elsewhere, a pirate ship destroyed or despawned) /datum/criminal_bounty/proc/board_on_site_deleted(datum/source) @@ -409,7 +406,7 @@ return null if(board_worst_state == BOUNTY_STATE_DEAD) return board_place_proof(spot) - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(record, spot, src) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(record, spot, src) board_spawning = FALSE if(QDELETED(criminal)) return null @@ -441,7 +438,7 @@ * the blend at a trader outpost: P6's setup_outpost_blend()). Also used by tests and the admin panel * to adopt a criminal spawned by hand. */ -/datum/criminal_bounty/proc/board_adopt_criminal(mob/living/basic/bounty_criminal/criminal, obj/structure/overmap/where, activity, atom/anchor) +/datum/criminal_bounty/proc/board_adopt_criminal(mob/living/carbon/human/npc/bounty_criminal/criminal, obj/structure/overmap/where, activity, atom/anchor) if(QDELETED(criminal)) return FALSE board_detach_criminal() @@ -451,8 +448,6 @@ criminal.record = record RegisterSignal(criminal, COMSIG_QDELETING, PROC_REF(board_on_criminal_deleted)) board_spawn_failures = 0 - // Whatever an earlier body of theirs went through still counts (12.1). Only ever made worse. - criminal.body_restore_worst_state(board_worst_state) if(where) criminal.site_bounds = board_site_bounds(where) board_add_site_factions(criminal, where) @@ -480,7 +475,7 @@ /// Stops tracking the criminal without touching it /datum/criminal_bounty/proc/board_detach_criminal() - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(criminal) UnregisterSignal(criminal, COMSIG_QDELETING) criminal_ref = null @@ -496,26 +491,49 @@ return BOUNTY_ACTIVITY_BLEND return BOUNTY_ACTIVITY_EXPLORE -/// Companions for a normal criminal: 0 to BOUNTY_MAX_COMPANIONS in every zone (12.3 C9: the zone scales pay, never the fight), within the server's cap -/datum/criminal_bounty/proc/board_spawn_companions(mob/living/basic/bounty_criminal/criminal) +/** + * The company a normal criminal keeps at its site (bounty_roll_company(): a style and chrome a + * companion, at most one of them armed): 0 to BOUNTY_MAX_COMPANIONS in every zone (12.3 C9: the zone + * scales pay, never the fight). At most one aboard a pirate ship, whose crew are company enough, and + * nobody at a trader outpost or with any other kind of criminal. + */ +/datum/criminal_bounty/proc/board_roll_company() + if(record?.archetype != BOUNTY_ARCHETYPE_NORMAL || placement_kind == BOUNTY_PLACEMENT_TRADER_OUTPOST) + return list() var/count = rand(0, BOUNTY_MAX_COMPANIONS) - // The crew aboard a pirate ship are company enough if(placement_kind == BOUNTY_PLACEMENT_NPC_SHIP) count = min(count, 1) - count = min(count, BOUNTY_MAX_LIVE_COMPANIONS - SScriminal_bounties.board_live_companions()) + return bounty_roll_company(count, record?.tier) + +/** + * The company its criminal keeps at its site now, rolled the first time it is asked for at a site + * (board_roll_company()) and kept while the bounty stays there: the sighting names it, and the same + * company comes back each time the site loads. A new site rolls a new one. + */ +/datum/criminal_bounty/proc/board_company() + var/obj/structure/overmap/where = site() + if(where && board_company_site?.resolve() != where) + board_company = null + board_company_site = WEAKREF(where) + if(isnull(board_company)) + board_company = board_roll_company() + return board_company + +/// Spawns the company the posting rolled (board_company()) beside its criminal, within the server's cap on live companions +/datum/criminal_bounty/proc/board_spawn_companions(mob/living/carbon/human/npc/bounty_criminal/criminal) + var/list/company = board_company() + var/count = min(length(company), BOUNTY_MAX_LIVE_COMPANIONS - SScriminal_bounties.board_live_companions()) if(count <= 0) return list() - var/list/spawned = criminal.spawn_companions(count) - for(var/mob/living/companion in spawned) - companions |= WEAKREF(companion) - return spawned + // Built through the human NPC queue: each companion joins the posting's list as it joins its criminal (bounty_companions.dm). + criminal.spawn_companions(count, FALSE, company) /** * Makes the criminal a friend of the site's own, so it doesn't fight the hostiles native to where * it hides (spec 3): the crew's factions aboard a pirate ship, the planet's and ruins' hostiles. * The neutral faction every player has is never added. */ -/datum/criminal_bounty/proc/board_add_site_factions(mob/living/basic/bounty_criminal/criminal, obj/structure/overmap/where) +/datum/criminal_bounty/proc/board_add_site_factions(mob/living/carbon/human/npc/bounty_criminal/criminal, obj/structure/overmap/where) var/list/added = list(FACTION_HOSTILE) if(istype(where, /obj/structure/overmap/ship/npc)) var/obj/structure/overmap/ship/npc/npc_ship = where @@ -562,7 +580,7 @@ * let go and the bounty lists at a new site. Returns TRUE if it acted. */ /datum/criminal_bounty/proc/board_check_unreachable() - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(!criminal) return FALSE var/in_chasm = istype(criminal.loc, /obj/effect/abstract/chasm_storage) @@ -850,7 +868,7 @@ if(placement_kind == BOUNTY_PLACEMENT_TRADER_OUTPOST) board_drop_marker() return FALSE - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(!criminal) return FALSE if(!force && world.time < board_next_sighting) diff --git a/voidcrew/modules/bounties/bounty_posting.dm b/voidcrew/modules/bounties/bounty_posting.dm index 68f9bbe68a7..ea5b3445ca4 100644 --- a/voidcrew/modules/bounties/bounty_posting.dm +++ b/voidcrew/modules/bounties/bounty_posting.dm @@ -45,7 +45,7 @@ var/datum/weakref/board_proof_ref /// Weakrefs to GPS units (/datum/component/gps/item) its sighting beacon was uploaded to -> weakref of the ship that linked them var/list/board_gps_units = list() - /// The worst capture state (BOUNTY_STATE_*) any body of its criminal reached. A criminal that comes back when its site loads again starts from here: once downed, never full pay; once dead, only proof (12.1). + /// BOUNTY_STATE_DEAD once a body of its criminal was dead when its site unloaded: the next load places its proof of death, never a living body again. Otherwise BOUNTY_STATE_FREE. var/board_worst_state = BOUNTY_STATE_FREE /// The sighting beacon's tag on those units var/board_gps_tag @@ -57,6 +57,10 @@ var/list/board_printed_warrants = list() /// Timer deciding, a moment after its criminal was deleted, whether anything is left to turn in var/board_lost_timer + /// The company its criminal keeps at its site (board_company(): bounty_roll_company(), a style and chrome a companion), null until rolled + var/list/board_company + /// Weakref to the site board_company was rolled for: a new site rolls a new one + var/datum/weakref/board_company_site /datum/criminal_bounty/Destroy() // close() is the way out; this catches an admin qdel that skipped it @@ -188,7 +192,7 @@ if(record && !record.mugshot) bounty_queue_mugshot(record) board_push_waypoint(ship) - ship.ship_notify("WANTED: now hunting [record?.name]. [board_place_text()].", "MISSION CONTROL", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) + ship.ship_notify("WANTED: now hunting [record?.name]. [board_sighting_text()].", "MISSION CONTROL", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) log_game("BOUNTY: [ship.name] is hunting the [board_log_name()]") board_arm() return TRUE @@ -351,7 +355,7 @@ * so this never touches one being beamed out. */ /datum/criminal_bounty/proc/board_remove_mobs(reason, relisting = FALSE) - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() board_detach_criminal() var/list/extras = list() for(var/datum/weakref/ref as anything in companions + decoys) @@ -385,6 +389,12 @@ var/turf/spot = get_turf(leaving) if(spot && leaving.stat != DEAD) new /obj/effect/temp_visual/transporter_flash/departure(spot) + // Its locked kit goes with it (D3); whatever on it isn't its own (papers, a key, a crew's cuffs, a suit a + // crew put on it) stays behind, as at the pad (bounty_return_crew_items(), bounty_suits.dm). + if(ishumannpc(leaving) && spot) + var/mob/living/carbon/human/npc/body = leaving + body.npc_drop_not_gear() + bounty_return_crew_items(body, spot) qdel(leaving) // ===== RELISTING ===== @@ -448,7 +458,7 @@ SScriminal_bounties.board_changed() for(var/obj/structure/overmap/ship/hunter as anything in hunter_ships()) board_push_waypoint(hunter) - hunter.ship_notify("WANTED: new sighting of [record?.name]. [board_place_text()].", "MISSION CONTROL", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) + hunter.ship_notify("WANTED: new sighting of [record?.name]. [board_sighting_text()].", "MISSION CONTROL", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) log_game("BOUNTY: the [board_log_name()] relisted") board_arm() return TRUE @@ -486,19 +496,12 @@ return 0 /** - * Percent paid for a criminal in `state` at the pad that has at some point been as bad as - * `worst_state` (12.1: once downed, at most the downed share; once dead, the dead share). 0 for a - * free criminal, which the pad refuses. + * Percent paid for a criminal in `state` at the pad: its condition there, nothing it went through on the + * way (owner ruling 2026-11-08). 0 for a free criminal, which the pad refuses. The kingpin and kill-only + * postings override it. */ -/datum/criminal_bounty/proc/board_share_for(state, worst_state) - var/share = bounty_state_share(state) - if(!share) - return 0 - if(worst_state && worst_state != BOUNTY_STATE_FREE) - var/worst_share = bounty_state_share(worst_state) - if(worst_share) - share = min(share, worst_share) - return share +/datum/criminal_bounty/proc/board_share_for(state) + return bounty_state_share(state) /** * What a turn-in at `share` percent pays: list(credits, vouchers). Vouchers come only with the @@ -554,6 +557,32 @@ return "Seen at the [site_name]" return "Seen on [site_name]" +/// The sighting as the card and the notices give it: where they were seen, and who with ("Seen on Kepler IV with two friends, one armed") +/datum/criminal_bounty/proc/board_sighting_text() + var/company = board_company_text() + return company ? "[board_place_text()] [company]" : board_place_text() + +/// Who they were seen with (board_company()), or null for nobody: "with a friend", "with an armed friend", "with two friends", "with two friends, one armed" +/datum/criminal_bounty/proc/board_company_text() + if(board_relisting) + return null + var/list/company = board_company() + if(!length(company)) + return null + var/armed = 0 + for(var/list/member as anything in company) + if(bounty_style_is_gun(member[BOUNTY_COMPANY_STYLE])) + armed++ + if(length(company) == 1) + return armed ? "with an armed friend" : "with a friend" + var/friends = "with [length(company) == 2 ? "two" : length(company)] friends" + switch(armed) + if(0) + return friends + if(1) + return "[friends], one armed" + return "[friends], all armed" + /// How the poster says they're wanted: alive, dead or alive, or dead /datum/criminal_bounty/proc/board_terms() return "Wanted alive" @@ -595,7 +624,7 @@ "tier" = record?.tier || BOUNTY_TIER_PETTY, "terms" = board_terms(), "crime" = record?.crime, - "place" = board_place_text(), + "place" = board_sighting_text(), "zone" = board_zone_name(), "zone_color" = board_zone_color(), "mugshot_id" = record?.id, @@ -660,6 +689,9 @@ lines += "Wanted for: [record.crime].
" lines += "[board_terms()].
" lines += "[board_place_text()], [board_zone_name()].
" + var/company = board_company_text() + if(company) + lines += "[capitalize(company)].
" // One feature is left off, so what the traders say about them still tells a hunter something new var/list/features = record?.feature_lines() if(length(features)) diff --git a/voidcrew/modules/bounties/bounty_restraints.dm b/voidcrew/modules/bounties/bounty_restraints.dm index 736afda066f..a1eabc81d22 100644 --- a/voidcrew/modules/bounties/bounty_restraints.dm +++ b/voidcrew/modules/bounties/bounty_restraints.dm @@ -1,268 +1,137 @@ /** - * # Bounty hunting: restraints on a criminal + * # Bounty hunting: cuffs on a criminal * * Owner: P2 body. Do not edit this file outside P2. * - * What it owns: cuffing bounty criminals, a port of the prisoner's cuff pattern - * (outpost_prison_capture.dm) as procs on the criminal (spec sections 4 and 12.3, C1 and C2): - * - Any tg handcuffs, cable restraints or zipties, used on it by anyone with hands. Only on a criminal - * that is downed, stunned or has surrendered, over BOUNTY_CUFF_TIME. On one standing free the cuffs - * are refused, and the try alerts it (P3's on_confronted()). - * - Restrained, it is held still, throws no punches and fires no shots (may_attack()), plans nothing - * (TRAIT_BOUNTY_HELD and TRAIT_AI_PAUSED), and can be dragged. A downed one stays down until the - * cuffs come off. - * - Anyone takes them off with an empty hand over BOUNTY_UNCUFF_TIME: handcuffs to their hand, cable - * and zipties cut away. - * - Cable restraints slip after BOUNTY_CABLE_SLIP and zipties after BOUNTY_ZIPTIE_SLIP (a mini-boss in - * half the time, and it snaps them), with a visible struggle for the last BOUNTY_SLIP_WARNING. - * Handcuffs never slip. - * - The cuffs show as tg's handcuff overlay, stay in its contents, and drop when it dies or is deleted. - * Cuffing a criminal gives no claim on it: whoever gets it onto their own pad is paid (decision 18). + * A criminal is cuffed the way a person is: tg's attempt_to_cuff() (4 seconds, anyone with hands, any + * tg handcuffs, cable restraints or zipties), worn in tg's handcuff slot, and taken off the way anyone's + * are. A cuffed criminal can walk, as players can; it works at its cuffs through tg's resist (its AI's + * escape captivity): a minute for handcuffs, 45 s for zipties, 30 s for cable, and dragging it breaks the + * try (defaults 1.4). Its AI plans nothing else while cuffed. * - * The shared vars (`restraints` and the rest) are declared in bounty_types.dm and nowhere else; a - * var only P2 uses goes here, or in bounty_criminal.dm, with a `body_` prefix. + * What this file adds: + * - The reaction to a reach for its wrists (defaults 1.4, CR-7): while it can still move on its own it + * steps away a moment after the cuffing starts, which breaks the cuffer's do_after, and then runs or + * fights (on_confronted()). Paralysed after giving up, stamcritted, knocked down or in crit, it can't, + * and the cuffs go on. A subtype changes it in body_on_cuff_attempt(); the trader-outpost blend takes + * it over through COMSIG_BOUNTY_CUFF_REACHED. + * - COMSIG_BOUNTY_CRIMINAL_RESTRAINED and _UNRESTRAINED for the AI packages, from tg's TRAIT_RESTRAINED. + * - Fultons never go on (M2, owner question 10): the pad is the only way off its site bar the carry. + * Cuffing gives no claim on it: whoever gets it onto their own pad is paid (decision 18). */ -// ===== CUFFING ===== +/mob/living/carbon/human/npc/bounty_criminal + /// Weakref to whoever last reached for its wrists, and when + var/datum/weakref/body_cuffer_ref + var/body_cuffed_at = 0 + /// world.time it last started working at its cuffs (tg's cuff_resist()) + var/body_working_cuffs_at = 0 + +/// Hooks tg's cuffing on it. Called once from Initialize(). Cuffs going on and off come through tg's +/// own TRAIT_RESTRAINED handlers below: a second registration of that signal on itself would replace +/// tg's, and its hands would stay free in cuffs. +/mob/living/carbon/human/npc/bounty_criminal/proc/body_hook_cuffs() + RegisterSignal(src, COMSIG_CARBON_CUFF_ATTEMPTED, PROC_REF(body_on_cuff_signal)) + RegisterSignal(src, COMSIG_MOB_REMOVING_CUFFS, PROC_REF(body_on_working_cuffs)) + +// tg's own TRAIT_RESTRAINED handlers (code/modules/mob/living/init_signals.dm) block its hands. Registering +// the trait's signals again on itself would replace them, so the body hears the cuffs through them. +/mob/living/carbon/human/npc/bounty_criminal/on_restrained_trait_gain(datum/source) + . = ..() + body_on_restrained(source) + +/mob/living/carbon/human/npc/bounty_criminal/on_restrained_trait_loss(datum/source) + . = ..() + body_on_unrestrained(source) -// Restraints go on from the item interaction, ahead of anything else that item might do to it. A -// fulton never goes on: the pad is the only way off its site bar the carry (M2, owner question 10). -/mob/living/basic/bounty_criminal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) - if(istype(tool, /obj/item/restraints/handcuffs)) - return body_on_cuffs_used(user, tool) +// A fulton never goes on a criminal (M2). +/mob/living/carbon/human/npc/bounty_criminal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) if(istype(tool, /obj/item/extraction_pack)) balloon_alert(user, "won't hold [p_them()]!") return ITEM_INTERACT_BLOCKING return ..() -/// Why `user` can't put `cuffs` on it now, as a balloon alert, or null if they can -/mob/living/basic/bounty_criminal/proc/body_cuff_refusal(mob/living/user, obj/item/restraints/handcuffs/cuffs) - if(stat == DEAD) - return "can't cuff [p_them()]" - if(is_restrained()) - return "already cuffed" - if(istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/used) || istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/fake/used)) - return "used up" - if(user && !iscyborg(user) && user.usable_hands <= 0) - return "no hands free" - // Standing and free, it fights or runs; it has to be put down, stunned or talked into giving up first (C2). - if(capture_state() == BOUNTY_STATE_FREE) - return "[p_they()] pull[p_s()] away!" - return null +// ===== A REACH FOR ITS WRISTS ===== -/// Restraints used on it: refused with a balloon alert (and a free one is alerted), or put on in the background -/mob/living/basic/bounty_criminal/proc/body_on_cuffs_used(mob/living/user, obj/item/restraints/handcuffs/cuffs) - var/refusal = body_cuff_work ? "already busy" : body_cuff_refusal(user, cuffs) - if(refusal) - balloon_alert(user, refusal) - // Reaching for its wrists is a confrontation: a meek one bolts, a normal one fights (P3). Blending - // in, P6's blend component answers for it, the same for the fugitive and its decoys. - if(!body_cuff_work && !blended && capture_state() == BOUNTY_STATE_FREE) - body_add_grudge(user) - INVOKE_ASYNC(src, PROC_REF(on_confronted), user) - return ITEM_INTERACT_BLOCKING - INVOKE_ASYNC(src, PROC_REF(body_cuff_by), user, cuffs) - return ITEM_INTERACT_SUCCESS +/// tg's attempt_to_cuff() is about to start on it. Never refuses the cuffs: the body answers the way a person would. +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_cuff_signal(datum/source, mob/living/user) + SIGNAL_HANDLER + if(!isliving(user) || user == src || stat == DEAD) + return NONE + body_cuffer_ref = WEAKREF(user) + body_cuffed_at = world.time + if(SEND_SIGNAL(src, COMSIG_BOUNTY_CUFF_REACHED, user) & COMPONENT_BOUNTY_CUFF_HANDLED) + return NONE + body_on_cuff_attempt(user) + return NONE /** - * `user` puts `cuffs` on it over BOUNTY_CUFF_TIME, with tg's cuff sounds. Fails if it gets up or - * breaks free meanwhile, or moves away. Returns TRUE if it is restrained. Sleeps. + * `user` started cuffing it. While it can still move on its own it puts them on its grudge list and, + * a moment into their cuffing, steps away from them (breaking the try) and runs or fights. Override + * for a body that answers differently (a man on his sofa, a don who won't be taken alive). Inside the + * cuff signal's stack: must not sleep. */ -/mob/living/basic/bounty_criminal/proc/body_cuff_by(mob/living/user, obj/item/restraints/handcuffs/cuffs) - if(body_cuff_work || body_cuff_refusal(user, cuffs)) - return FALSE - body_cuff_work = TRUE - user.visible_message(span_danger("[user] is trying to put [cuffs] on [src]!"), span_danger("You try to put [cuffs] on [src].")) - playsound(src, cuffs.cuffsound, 30, TRUE, -2) - var/done = do_after(user, BOUNTY_CUFF_TIME, target = src, timed_action_flags = IGNORE_SLOWDOWNS) - body_cuff_work = FALSE - if(QDELETED(src)) - return FALSE - // A borg's cuffs are part of its module: it puts on a new pair of the same kind, as tg's cuffs do for borgs. - var/dispense = iscyborg(user) - if(!done || QDELETED(cuffs) || (cuffs.loc != user && !dispense) || body_cuff_refusal(user, cuffs)) - balloon_alert(user, "failed to cuff!") +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_cuff_attempt(mob/living/user) + if(!body_can_answer_cuffs()) + return + body_add_grudge(user) + addtimer(CALLBACK(src, PROC_REF(body_answer_cuffs), WEAKREF(user)), BOUNTY_CUFF_REACTION_DELAY, TIMER_DELETE_ME) + +/// Whether it can do anything about a reach for its wrists: awake, on its feet and free to move, not already cuffed, not held or hidden +/mob/living/carbon/human/npc/bounty_criminal/proc/body_can_answer_cuffs() + if(stat != CONSCIOUS || is_restrained() || buckled || hidden || !isturf(loc)) return FALSE - var/success_sound = cuffs.cuffsuccesssound - if(!body_apply_cuffs(cuffs, user, dispense)) - balloon_alert(user, "failed to cuff!") + if(HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_IMMOBILIZED) || HAS_TRAIT(src, TRAIT_FLOORED)) return FALSE - playsound(src, success_sound, 30, TRUE, -2) - user.visible_message(span_notice("[user] restrains [src] with [restraints]."), span_notice("You restrain [src] with [restraints].")) - log_combat(user, src, "restrained") - return TRUE + return !(pulledby && pulledby.grab_state >= GRAB_AGGRESSIVE) -/// What `cuffs` turn into once worn: zipties their used pair, fake zipties their fake used pair (tg gives both the real one), anything else stays itself (null) -/proc/bounty_worn_cuffs_type(obj/item/restraints/handcuffs/cuffs) - if(istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/fake) && !istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/fake/used)) - return /obj/item/restraints/handcuffs/cable/zipties/fake/used - return cuffs?.trashtype +/// A moment after the reach: a step away from `user_ref`'s owner, then its fight or flight (on_confronted()). +/mob/living/carbon/human/npc/bounty_criminal/proc/body_answer_cuffs(datum/weakref/user_ref) + var/mob/living/user = user_ref?.resolve() + if(QDELETED(src) || QDELETED(user) || !body_can_answer_cuffs()) + return + body_step_away_from(user) + on_confronted(user) -/** - * Puts `cuffs` on it at once; zipties become their used pair, as on people. `user`, if any, gives - * them up from their hands, unless `dispense` (a borg), which puts on a new pair of the same kind - * instead. Returns TRUE if it is restrained. - */ -/mob/living/basic/bounty_criminal/proc/body_apply_cuffs(obj/item/restraints/handcuffs/cuffs, mob/living/user, dispense = FALSE) - if(is_restrained() || QDELETED(cuffs) || stat == DEAD) +/// One real step directly or diagonally away from `threat`, onto ground its leash allows. Returns TRUE if it moved. +/mob/living/carbon/human/npc/bounty_criminal/proc/body_step_away_from(atom/threat) + var/turf/here = get_turf(src) + if(!here || !threat) return FALSE - var/obj/item/restraints/handcuffs/worn = cuffs - var/used_path = bounty_worn_cuffs_type(cuffs) - if(dispense) - var/dispensed_path = used_path || cuffs.type - worn = new dispensed_path(src) - else - if(user && !user.temporarilyRemoveItemFromInventory(cuffs)) - return FALSE - if(used_path) - worn = new used_path(src) - qdel(cuffs) - else - cuffs.forceMove(src) - if(QDELETED(worn) || worn.loc != src) - return FALSE - restraints = worn - ADD_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_BODY_CUFFS_TRAIT) - if(pulling) - stop_pulling() - ai_controller?.CancelActions() - body_update_held() + var/away = get_dir(threat, src) || pick(GLOB.alldirs) + for(var/direction in list(away, turn(away, 45), turn(away, -45), turn(away, 90), turn(away, -90))) + var/turf/step_turf = get_step(here, direction) + if(!step_turf || step_turf.is_blocked_turf(exclude_mobs = FALSE) || !body_move_allowed(step_turf)) + continue + if(Move(step_turf, direction)) + return TRUE + return FALSE + +// ===== CUFFS ON AND OFF ===== + +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_working_cuffs(datum/source, obj/item/cuffs) + SIGNAL_HANDLER + body_working_cuffs_at = world.time + +/// Cuffed: the AI packages hear who did it (whoever last reached for its wrists, if it was lately) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_restrained(datum/source) + SIGNAL_HANDLER + if(stat == DEAD || QDELING(src)) + return + var/mob/living/user = body_cuffer_ref?.resolve() + if(world.time > body_cuffed_at + 30 SECONDS) + user = null if(user) body_add_grudge(user) - body_start_slip() - update_appearance(UPDATE_OVERLAYS) body_state_changed() SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, user) - // Forced: the cuffs go on 2 s after a downing or a stun, well inside the cooldown of what they said then (BUG-1). - if(stat == CONSCIOUS) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "cuffed", null, TRUE) - return TRUE -// ===== UNCUFFING ===== - -// An empty, open hand on a restrained criminal starts taking the restraints off. -/mob/living/basic/bounty_criminal/attack_hand(mob/living/carbon/human/user, list/modifiers) - if(is_restrained() && stat != DEAD && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK)) - INVOKE_ASYNC(src, PROC_REF(body_uncuff_by), user) - return TRUE - return ..() - -/// `user` takes its restraints off over BOUNTY_UNCUFF_TIME. Returns TRUE if they came off. Sleeps. -/mob/living/basic/bounty_criminal/proc/body_uncuff_by(mob/living/user) - if(!is_restrained() || body_cuff_work) - return FALSE - body_cuff_work = TRUE - user.visible_message(span_notice("[user] starts taking [restraints] off [src]."), span_notice("You start taking [restraints] off [src].")) - var/done = do_after(user, BOUNTY_UNCUFF_TIME, target = src) - body_cuff_work = FALSE - if(!done || QDELETED(src) || !is_restrained()) - return FALSE - var/cut = istype(restraints, /obj/item/restraints/handcuffs/cable) - user.visible_message(span_notice("[user] [cut ? "cuts" : "takes"] [restraints] off [src]."), span_notice("You [cut ? "cut" : "take"] [restraints] off [src].")) - body_remove_cuffs(user) - return TRUE - -/** - * Takes its restraints off. With `user`, handcuffs go to their hands (or the floor) and cable and - * zipties are cut away; without, the restraints drop where it is. Returns the restraints still about, - * or null. - */ -/mob/living/basic/bounty_criminal/proc/body_remove_cuffs(mob/living/user) - var/obj/item/restraints/handcuffs/worn = restraints - if(!worn) - return null - // Cleared first, so Exited() leaves the rest to this proc. - restraints = null - var/atom/drop_spot = drop_location() - if(QDELETED(worn)) - worn = null - else if((user && istype(worn, /obj/item/restraints/handcuffs/cable)) || !drop_spot) - qdel(worn) - worn = null - else - worn.forceMove(drop_spot) - if(user) - user.put_in_hands(worn) - body_uncuffed(user) - return worn - -/// Its restraints are off, however they came off: it can move and act again, and a downed one whose recovery came due starts getting up -/mob/living/basic/bounty_criminal/proc/body_uncuffed(mob/living/user) - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_BODY_CUFFS_TRAIT) - body_stop_slip() - if(QDELETED(src)) +/// Out of its cuffs: the AI packages hear whether it got itself out (null) or someone took them off (src: who isn't known) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_unrestrained(datum/source) + SIGNAL_HANDLER + if(stat == DEAD || QDELING(src)) return - body_update_held() - update_appearance(UPDATE_OVERLAYS) + var/self_freed = body_working_cuffs_at && world.time <= body_working_cuffs_at + BOUNTY_SELF_FREED_WINDOW + body_working_cuffs_at = 0 body_state_changed() - SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, user) - if(downed && body_recovery_due && stat != DEAD) - body_recovery_due = FALSE - deltimer(body_recovery_timer) - body_stir() - -/mob/living/basic/bounty_criminal/Exited(atom/movable/gone, direction) - . = ..() - if(gone == restraints) - // Taken or destroyed some other way than body_remove_cuffs() - restraints = null - body_uncuffed(null) - -// ===== SLIPPING ===== - -/// How long its restraints hold before it slips them: cable, zipties or joke cuffs; a mini-boss in half the time; 0 for handcuffs, which never slip -/mob/living/basic/bounty_criminal/proc/body_slip_time() - if(!is_restrained()) - return 0 - var/time - if(istype(restraints, /obj/item/restraints/handcuffs/fake) || istype(restraints, /obj/item/restraints/handcuffs/cable/zipties/fake)) - time = BOUNTY_FAKE_CUFF_SLIP - else if(istype(restraints, /obj/item/restraints/handcuffs/cable/zipties)) - time = BOUNTY_ZIPTIE_SLIP - else if(istype(restraints, /obj/item/restraints/handcuffs/cable)) - time = BOUNTY_CABLE_SLIP - else - return 0 - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - time *= BOUNTY_BOSS_SLIP_MULT - return time - -/// Starts the clock on its restraints slipping, if they ever do -/mob/living/basic/bounty_criminal/proc/body_start_slip() - body_stop_slip() - var/time = body_slip_time() - if(time <= 0) - return - body_slip_warn_timer = addtimer(CALLBACK(src, PROC_REF(body_slip_warning)), max(0, time - BOUNTY_SLIP_WARNING), TIMER_STOPPABLE | TIMER_DELETE_ME) - body_slip_timer = addtimer(CALLBACK(src, PROC_REF(body_slip_cuffs)), time, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// Stops the slipping clock -/mob/living/basic/bounty_criminal/proc/body_stop_slip() - deltimer(body_slip_warn_timer) - deltimer(body_slip_timer) - body_slip_warn_timer = null - body_slip_timer = null - -/// The visible struggle before it slips its restraints -/mob/living/basic/bounty_criminal/proc/body_slip_warning() - body_slip_warn_timer = null - if(!is_restrained() || stat == DEAD) - return - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - visible_message(span_warning("[src] strains against [restraints].")) - else - visible_message(span_warning("[src] is working at [restraints].")) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "slipping_cuffs") - -/// Out of its restraints: a mini-boss snaps them, anyone else slips them and they drop. Returns TRUE if they came off. -/mob/living/basic/bounty_criminal/proc/body_slip_cuffs() - body_slip_timer = null - if(!is_restrained() || stat == DEAD) - return FALSE - var/snapped = istype(src, /mob/living/basic/bounty_criminal/boss) - var/message = snapped ? "[src] snaps [restraints]!" : "[src] slips out of [restraints]!" - visible_message(span_danger(message)) - var/obj/item/restraints/handcuffs/worn = body_remove_cuffs() - if(snapped && worn) - qdel(worn) - return TRUE + SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, self_freed ? null : src) diff --git a/voidcrew/modules/bounties/bounty_styles.dm b/voidcrew/modules/bounties/bounty_styles.dm new file mode 100644 index 00000000000..2ecc5690d8a --- /dev/null +++ b/voidcrew/modules/bounties/bounty_styles.dm @@ -0,0 +1,917 @@ +/** + * # Bounty hunting: fighting styles, with real weapons + * + * Owner: P3 AI. Do not edit this file outside P3. + * + * How a criminal or a companion fights (combat.md 6.2, defaults CR-13). Every style is a real item the + * fighter carries, puts in its hand when the fight starts, and uses the way a player does, through + * controller.ai_interact() (a click): + * - brawler: fists, and now and then a real shove (a right click); + * - knife: a knife from a pocket (Wanted and above: a combat knife). Its cuts and bleeding are the + * knife's own; + * - club: a baseball bat in hand (Wanted and above: the metal bat). It knocks people back as tg's bat does; + * - pistol: a Stechkin from a shoulder holster, set to single shots with its own fire selector, a spare + * magazine in a pocket, reloaded the player's way. Strings of BOUNTY_PISTOL_STRING_SHOTS, then a pause; + * - shotgun: a pump shotgun on the back with buckshot (Wanted and above: a semi-automatic combat + * shotgun), shells on a bandolier, loaded into the tube shell by shell; + * - SMG: a WT-550 worn on the belt at every tier, a spare magazine in a pocket. A burst is + * BOUNTY_SMG_BURST separate trigger pulls at the gun's own full-auto rate, so it stops the moment + * they can't shoot or the target is gone; + * - revolver: a .38 in a shoulder holster, a speedloader in a pocket. A dry cylinder is reloaded the + * player's way: the speedloader clicked into it pushes the spent casings out; + * - bottle: beer bottles from a satchel, thrown one at a time with a real throw (tg's bottle smashes on + * whoever it hits); out of bottles, they fight with their fists; + * - holdout: a meek criminal's little Makarov, a pocket gun, and a spare magazine in a fanny pack (the + * cornered volley in bounty_ai_meek.dm). + * Nothing is spawned for the fight, no projectile comes from the body, and nothing passes through + * anyone: before a shot or a throw the fighter checks its line and steps aside for anyone it may not + * hit (bounty_ai_line_blocked()), one step at a time at the body's own pace (bounty_ai_step()). The + * style sets only the timing: how often they pull the trigger or throw, the telegraph before it (a hit + * during it fumbles the attack), the reload, and the distance they keep. A gun fires at anyone it sees + * within BOUNTY_GUN_FIRE_RANGE while its holder walks in to BOUNTY_PISTOL_RANGE; the shotgun and the + * bottle keep to their own distance. A companion's gun is backup: shorter pistol strings and SMG bursts + * (companion_string, companion_burst). Gear is locked to the body (D3): it never drops and is never + * loot; bottles are consumables, so a thrown one leaves the hand and breaks. + * + * A gun that comes out of its holster, off their back or out of a bag at the start of a fight comes + * with a line and the holster's sound, and the draw is the telegraph for the first shot: at least + * BOUNTY_DRAW_TIME after it, fumbled by a hit in between (/datum/ai_behavior/human_npc_draw/bounty). + */ + +/// Every fighting style by key (BOUNTY_STYLE_*), one datum each +GLOBAL_LIST_INIT(bounty_styles, init_bounty_styles()) + +/proc/init_bounty_styles() + var/list/styles = list() + for(var/datum/bounty_style/style_type as anything in subtypesof(/datum/bounty_style)) + var/datum/bounty_style/style = new style_type + if(style.key) + styles[style.key] = style + return styles + +/// A fighter's style: from its controller's blackboard +/proc/bounty_ai_style(mob/living/fighter) + var/datum/ai_controller/controller = fighter?.ai_controller + if(QDELETED(controller)) + return null + return GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] + +/// Puts style `key` on `fighter`'s AI. Its weapon is whatever it was dressed with (bounty_build_outfit()). +/proc/bounty_ai_apply_style(mob/living/fighter, key) + var/datum/bounty_style/style = GLOB.bounty_styles[key] + var/datum/ai_controller/controller = fighter?.ai_controller + if(!style || QDELETED(controller)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_STYLE, key) + bounty_ai_clear_windup(controller) + controller.clear_blackboard_key(BB_BOUNTY_BURST_LEFT) + return TRUE + +/// Whether style `key` (BOUNTY_STYLE_*) fights with a gun +/proc/bounty_style_is_gun(key) + var/datum/bounty_style/style = GLOB.bounty_styles[key] + return !!style && ispath(style.weapon, /obj/item/gun) + +/// Whether a pawn is in a fight it can take part in: a criminal fighting, or a companion that has not broken +/proc/bounty_ai_fighting(mob/living/fighter) + if(istype(fighter, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = fighter + return criminal.ai_mode == BOUNTY_AI_FIGHTING && criminal.ai_can_act() + if(istype(fighter, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = fighter + return companion.ai_can_fight() + return FALSE + +/// Puts the weapons in `body`'s hands away on its body (the human NPC base's npc_stow_weapons()): a holster, a pocket, a back. What has nowhere to go stays in hand. +/proc/bounty_ai_holster(mob/living/carbon/human/npc/body) + if(!ishumannpc(body) || HAS_TRAIT(body, TRAIT_HANDS_BLOCKED)) + return + body.npc_stow_weapons() + +/// The first `item_type` that `body` carries and can take in hand in a moment (held, worn, in a bag or pouch it wears), or null +/proc/bounty_ai_carried(mob/living/carbon/body, item_type) + if(!ispath(item_type)) + return null + for(var/obj/item/thing as anything in body.get_all_contents_type(item_type)) + if(body.is_holding(thing) || human_npc_can_draw(body, thing)) + return thing + return null + +/// Whether `gun` fires on the next pull, or would after the pump its style's telegraph works (`pumps`) +/proc/bounty_ai_gun_ready(obj/item/gun/gun, pumps = FALSE) + if(!istype(gun)) + return FALSE + if(gun.can_shoot()) + return TRUE + var/obj/item/gun/ballistic/ballistic = gun + return pumps && istype(ballistic) && human_npc_gun_needs_rack(ballistic) + +/// Live rounds in a ballistic gun: its magazine (a revolver's cylinder holds its chamber) and a chambered round. Null for anything else. +/proc/bounty_gun_live_rounds(obj/item/gun/ballistic/gun) + if(!istype(gun)) + return null + . = gun.magazine ? human_npc_live_rounds(gun.magazine) : 0 + if(gun.chambered?.loaded_projectile && (!gun.magazine || !(gun.chambered in gun.magazine.stored_ammo))) + .++ + +/** + * Sets `gun` to single shots with its own fire selector, the way `user` would: their fire-mode button + * on the gun, or the selector itself while the button isn't theirs. A Stechkin fires three-round + * bursts until it is switched. Returns TRUE if the gun fires one round a pull now. + */ +/proc/bounty_gun_single_fire(mob/living/user, obj/item/gun/ballistic/gun) + if(!istype(gun)) + return FALSE + if(gun.burst_size <= 1) + return TRUE + // A gun with no selector only fires its burst + if(!gun.burst_fire_selection) + return FALSE + // The selector's click and its note go to whoever flips it, not to whoever's turn it is + var/mob/old_usr = usr + usr = user + for(var/datum/action/item_action/toggle_firemode/selector in gun.actions) + if(selector.owner == user) + selector.Trigger(user) + break + if(gun.burst_size > 1 && gun.burst_fire_selection) + gun.burst_select() + usr = old_usr + return gun.burst_size <= 1 + +/** + * Counts a shot just fired from `gun` towards a string of `string_length` on `controller`'s blackboard. A + * new magazine (a reload) starts the count again. TRUE when the shot ends a string with rounds still in + * the gun; an empty one is reloaded instead. + */ +/proc/bounty_ai_string_done(datum/ai_controller/controller, obj/item/gun/ballistic/gun, string_length) + if(!istype(gun) || string_length <= 0 || !gun.magazine) + return FALSE + var/shots = 1 + if(controller.blackboard[BB_BOUNTY_STRING_MAGAZINE] == gun.magazine) + shots += controller.blackboard[BB_BOUNTY_STRING_SHOTS] + else + controller.set_blackboard_key(BB_BOUNTY_STRING_MAGAZINE, gun.magazine) + if(shots >= string_length && bounty_gun_live_rounds(gun) > 0) + controller.set_blackboard_key(BB_BOUNTY_STRING_SHOTS, 0) + return TRUE + controller.set_blackboard_key(BB_BOUNTY_STRING_SHOTS, shots) + return FALSE + +/// Empties the hand that isn't holding `keep`, for a gun that needs both: puts what's there away, and lets go of a spent shell or a spent magazine +/proc/bounty_ai_free_other_hand(mob/living/carbon/body, obj/item/keep) + for(var/obj/item/held in body.held_items) + if(held == keep) + continue + if((isammocasing(held) || istype(held, /obj/item/ammo_box)) && !human_npc_live_rounds(held)) + body.dropItemToGround(held) + continue + if(!held.equip_to_best_slot(body)) + body.dropItemToGround(held) + return length(body.get_empty_held_indexes()) > 0 + +/** + * Whether someone `shooter` may not hit stands in its line of fire to `target`: one of its own gang, its + * own side of the site (the crew it is aboard with), or a bystander it has no quarrel with. Shots and + * throws pass over anyone lying down. This is the whole of friendly fire: nothing it fires passes + * through anyone. + */ +/proc/bounty_ai_line_blocked(mob/living/shooter, atom/target) + var/turf/start = get_turf(shooter) + var/turf/end = get_turf(target) + if(!start || !end) + return FALSE + for(var/turf/line_turf as anything in get_line(start, end)) + if(line_turf == start || line_turf == end) + continue + for(var/mob/living/other in line_turf) + if(other == shooter || other.stat == DEAD || other.body_position == LYING_DOWN) + continue + if(shooter.faction_check_atom(other) || !bounty_ai_may_hit(shooter, other)) + return TRUE + return FALSE + +/// One step sideways for a clear line to `target`, no further from them, on the leash, and no sooner than the body's own next step (a person can't dart about faster than they walk) +/proc/bounty_ai_sidestep(mob/living/fighter, atom/target) + var/turf/here = get_turf(fighter) + if(!here || !isturf(fighter.loc) || !bounty_ai_step_ready(fighter)) + return FALSE + var/list/options = list() + for(var/direction in GLOB.alldirs) + var/turf/next = get_step(here, direction) + if(!next || get_dist(next, target) > get_dist(here, target) || !next.can_cross_safely(fighter)) + continue + options += next + shuffle_inplace(options) + for(var/turf/next as anything in options) + if(bounty_ai_step(fighter, next, paced = TRUE)) + return TRUE + return FALSE + +/// A step straight back from `target`, now and then, on the leash, and no sooner than the body's own next step +/proc/bounty_ai_back_off(datum/ai_controller/controller, mob/living/fighter, atom/target) + if(world.time < controller.blackboard[BB_BOUNTY_BACKSTEP_AT] || !isturf(fighter.loc) || !bounty_ai_step_ready(fighter)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_BACKSTEP_AT, world.time + BOUNTY_BACKSTEP_GAP) + var/away_dir = get_dir(target, fighter) + var/turf/away = away_dir && get_step(fighter, away_dir) + if(!away || !bounty_ai_step(fighter, away, paced = TRUE)) + return FALSE + fighter.face_atom(target) + return TRUE + +/// Forgets a wind-up in progress +/proc/bounty_ai_clear_windup(datum/ai_controller/controller) + controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) + controller.clear_blackboard_key(BB_BOUNTY_WINDUP_FROM) + controller.clear_blackboard_key(BB_BOUNTY_WINDUP_STAMINA) + +// ===== THE STYLES ===== + +/** + * How someone fights. Criminals, companions and the meek's holdout share these. The style puts a real + * weapon (and its spares) on the outfit they are dressed in, and sets how they use it; what the weapon + * does is the weapon's. + */ +/datum/bounty_style + /// BOUNTY_STYLE_* + var/key + /// Fights at range: a gun, or something thrown + var/ranged = FALSE + /// What they carry, by tier: Petty, and Wanted or above (null for bare fists) + var/weapon + var/heavy_weapon + /// What counts as their style's weapon when they look for it on themselves + var/weapon_type + /// The behaviour that keeps this style's distance while it shoots or throws + var/ranged_behavior = /datum/ai_behavior/bounty_shoot + /// Anyone closer than this and they step back (0: never) + var/min_range = 0 + /// Between trigger pulls or throws + var/interval = 1 SECONDS + /// The telegraph before a shot or a throw, and what it says and sounds like + var/windup = 0 + var/windup_message + var/windup_sound + /// The telegraph works a pump: a spent shell in the chamber doesn't need a reload + var/pumps = FALSE + /// Only the first shot of a string needs the telegraph (the gun raised, the hammer back); the draw counts as one + var/windup_per_string = FALSE + /// Chance a blow is a shove instead + var/shove_chance = 0 + /// A gun kept in a holster, on the back or in a bag until a fight: what people see as it comes out (on_drawn()) + var/draw_message + /// The gun is set to single shots with its own fire selector before it fires + var/single_fire = FALSE + /// Trigger pulls in one burst, burst_gap apart (1: a pull at a time). Each pull is its own step of bounty_shoot. + var/burst = 1 + var/burst_gap = 0 + /// Shots in a string, then string_pause on top of the interval before the next (0: no strings) + var/string_shots = 0 + var/string_pause = 0 + /// Its weapon goes where nobody sees it while they pass as a customer: a pocket, a holster or a bag under + /// a coat (decoys wear the same holster or bag, outpost_match_kit()). A gun on the belt or the back, or + /// a bat in hand, gives them away, so a trader-outpost fugitive never fights this way (bounty_roll_style()). + var/concealable = TRUE + /// A companion's burst and string, shorter: their gun is backup (null: the same as burst and string_shots) + var/companion_burst + var/companion_string + +/** + * Puts this style's weapon and spares on `outfit` (an instance), the heavier weapon if `heavy`. The + * weapon is carried (a holster, a pocket, the back, a bag) unless it has nowhere to go on a body. + */ +/datum/bounty_style/proc/equip(datum/outfit/outfit, heavy = FALSE) + return + +/// The weapon this style carries at `heavy` +/datum/bounty_style/proc/weapon_for(heavy) + return (heavy && heavy_weapon) || weapon + +/// Trigger pulls in one burst for `fighter`: a companion's is companion_burst, if the style has one +/datum/bounty_style/proc/burst_length(mob/living/carbon/fighter) + if(!isnull(companion_burst) && istype(fighter, /mob/living/carbon/human/npc/bounty_companion)) + return companion_burst + return burst + +/// Shots in one string for `fighter`, 0 for none: a companion's is companion_string, if the style has one +/datum/bounty_style/proc/string_length(mob/living/carbon/fighter) + if(!isnull(companion_string) && istype(fighter, /mob/living/carbon/human/npc/bounty_companion)) + return companion_string + return string_shots + +/// Whether a burst is under way. One whose next pull is long overdue was cut off (the AI stopped meanwhile), and is forgotten. +/datum/bounty_style/proc/burst_under_way(datum/ai_controller/controller) + if(!controller.blackboard[BB_BOUNTY_BURST_LEFT]) + return FALSE + if(world.time > controller.blackboard[BB_BOUNTY_NEXT_SHOT] + BOUNTY_BURST_STALE) + end_burst(controller) + return FALSE + return TRUE + +/// Whatever is left of a burst never fires: the next one starts with its own telegraph +/datum/bounty_style/proc/end_burst(datum/ai_controller/controller) + controller.clear_blackboard_key(BB_BOUNTY_BURST_LEFT) + +/** + * `weapon`, this style's own, just came into `fighter`'s hand out of `drawn_from` (a holster, a bag, or + * the body itself for a slot). The selector is set, and a gun that was put away comes out with the + * line and the holster's sound: the draw is the telegraph for the first shot, BOUNTY_DRAW_TIME later, + * and a hit in between fumbles it. + */ +/datum/bounty_style/proc/on_drawn(mob/living/carbon/fighter, datum/ai_controller/controller, obj/item/weapon, atom/drawn_from) + if(single_fire) + bounty_gun_single_fire(fighter, weapon) + if(!draw_message) + return + var/now = world.time + end_burst(controller) + controller.set_blackboard_key(BB_BOUNTY_DRAWN_AT, now) + controller.set_blackboard_key(BB_BOUNTY_NEXT_SHOT, now + BOUNTY_DRAW_TIME) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_UNTIL, now + BOUNTY_DRAW_TIME) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_FROM, now) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_STAMINA, fighter.getStaminaLoss()) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(target) + fighter.face_atom(target) + fighter.visible_message(span_warning(replacetext(draw_message, "%USER", "[fighter]")), vision_distance = COMBAT_MESSAGE_RANGE) + var/obj/item/container = drawn_from + var/open_sound = istype(container) ? container.atom_storage?.open_sound : null + if(open_sound) + playsound(fighter, open_sound, 50, TRUE) + +/// The ranged weapon in `fighter`'s hand that is ready to use, or null: a loaded (or pumpable) gun, a bottle +/datum/bounty_style/proc/ready_in_hand(mob/living/carbon/fighter) + if(!ranged) + return null + for(var/obj/item/gun/gun in fighter.held_items) + if(bounty_ai_gun_ready(gun, pumps)) + return gun + return null + +/// A ranged weapon of this style `fighter` carries that is worth drawing (one that fires, or that it can load), or null +/datum/bounty_style/proc/carried_ranged(mob/living/carbon/fighter) + if(!ranged || !weapon_type) + return null + for(var/obj/item/gun/gun in fighter.get_all_contents_type(weapon_type)) + if(fighter.is_holding(gun) || !human_npc_can_draw(fighter, gun)) + continue + if(bounty_ai_gun_ready(gun, pumps) || human_npc_can_reload(fighter, gun)) + return gun + return null + +/// Whether this shot needs the telegraph first +/datum/bounty_style/proc/needs_windup(datum/ai_controller/controller) + if(windup <= 0) + return FALSE + if(!windup_per_string) + return TRUE + // Raised by the last shot, or by the draw, recently enough: the string goes on + var/raised_at = max(controller.blackboard[BB_BOUNTY_LAST_SHOT], controller.blackboard[BB_BOUNTY_DRAWN_AT]) + return !raised_at || world.time - raised_at > BOUNTY_PISTOL_STRING_GAP + +/// The telegraph: what they say, show and sound like before the shot +/datum/bounty_style/proc/telegraph(mob/living/carbon/fighter, atom/target, obj/item/weapon) + fighter.face_atom(target) + if(windup_message) + fighter.visible_message(span_warning(replacetext(windup_message, "%USER", "[fighter]"))) + if(windup_sound) + playsound(fighter, windup_sound, 50, TRUE) + +/** + * One step of a telegraphed attack. Returns TRUE when the attack goes now. Otherwise it is winding + * up (the telegraph is shown at the start), or the wind-up just ended in a fumble: any hit taken during + * it (damage, or more stamina loss) spoils the attack, and they try again a moment later. + */ +/datum/bounty_style/proc/windup_step(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + var/now = world.time + var/until = controller.blackboard[BB_BOUNTY_WINDUP_UNTIL] + if(until && now > until + BOUNTY_WINDUP_STALE) + // Left over from an interrupted fight: it has to be done again + bounty_ai_clear_windup(controller) + until = null + if(!until) + if(!needs_windup(controller)) + return TRUE + controller.set_blackboard_key(BB_BOUNTY_WINDUP_UNTIL, now + windup) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_FROM, now) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_STAMINA, fighter.getStaminaLoss()) + telegraph(fighter, target, weapon) + return FALSE + if(now < until) + fighter.face_atom(target) + return FALSE + var/hit_at = controller.blackboard[BB_BOUNTY_HIT_AT] + var/fumbled = (hit_at && hit_at >= controller.blackboard[BB_BOUNTY_WINDUP_FROM]) || fighter.getStaminaLoss() > controller.blackboard[BB_BOUNTY_WINDUP_STAMINA] + 1 + bounty_ai_clear_windup(controller) + if(fumbled) + fighter.visible_message(span_notice("[fighter] fumbles [weapon]!")) + controller.set_blackboard_key(BB_BOUNTY_NEXT_SHOT, now + BOUNTY_FUMBLE_PAUSE) + return FALSE + return TRUE + +/** + * The attack itself, with `weapon` in hand: a trigger pull at `target`, the player's way. Returns TRUE + * if it went. + */ +/datum/bounty_style/proc/attack(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + var/obj/item/gun/gun = weapon + if(!istype(gun)) + return FALSE + if(gun.weapon_weight == WEAPON_HEAVY) + bounty_ai_free_other_hand(fighter, gun) + if(!human_npc_ready_item(fighter, gun)) + return FALSE + if(single_fire) + bounty_gun_single_fire(fighter, gun) + fighter.zone_selected = human_npc_pick_aim_zone() + var/rounds = bounty_gun_live_rounds(gun) + if(!controller.ai_interact(target = target, combat_mode = TRUE)) + return FALSE + // Only a round that went counts: a click the gun refused (its fire delay, an empty chamber) is no shot + return isnull(rounds) || bounty_gun_live_rounds(gun) < rounds + +/** + * One pull of the trigger (or one throw) at `target`, then the clock for the next: the burst's next + * pull burst_gap away while one is under way, else the style's interval, with string_pause on top after + * the last shot of a string. Returns TRUE if a round went. + */ +/datum/bounty_style/proc/pull(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + var/fired = attack(fighter, controller, target, weapon) + var/now = world.time + if(fired) + controller.set_blackboard_key(BB_BOUNTY_LAST_SHOT, now) + var/pulls = burst_length(fighter) + if(fired && pulls > 1) + var/left = (controller.blackboard[BB_BOUNTY_BURST_LEFT] || pulls) - 1 + if(left > 0 && ready_in_hand(fighter)) + controller.set_blackboard_key(BB_BOUNTY_BURST_LEFT, left) + controller.set_blackboard_key(BB_BOUNTY_NEXT_SHOT, now + burst_gap) + return TRUE + end_burst(controller) + var/next = now + interval + if(fired && bounty_ai_string_done(controller, weapon, string_length(fighter))) + next += string_pause + controller.set_blackboard_key(BB_BOUNTY_NEXT_SHOT, next) + return fired + +/// Bare fists: a person's punch, and now and then a shove +/datum/bounty_style/brawler + key = BOUNTY_STYLE_BRAWLER + shove_chance = BOUNTY_BRAWLER_SHOVE_CHANCE + +/// A knife in a pocket. Its cuts bleed, as a real one's do. +/datum/bounty_style/knife + key = BOUNTY_STYLE_KNIFE + weapon = /obj/item/knife + heavy_weapon = /obj/item/knife/combat + weapon_type = /obj/item/knife + +/datum/bounty_style/knife/equip(datum/outfit/outfit, heavy = FALSE) + bounty_outfit_carry(outfit, weapon_for(heavy)) + +/// A baseball bat. A bat has no slot to go in on a body, so it is in hand from the start. +/datum/bounty_style/club + key = BOUNTY_STYLE_CLUB + weapon = /obj/item/melee/baseball_bat + heavy_weapon = /obj/item/melee/baseball_bat/ablative + weapon_type = /obj/item/melee/baseball_bat + concealable = FALSE + +/datum/bounty_style/club/equip(datum/outfit/outfit, heavy = FALSE) + outfit.r_hand = weapon_for(heavy) + +/// A Stechkin in a shoulder holster, a spare magazine in a pocket: single shots twice a second, in strings +/datum/bounty_style/pistol + key = BOUNTY_STYLE_PISTOL + ranged = TRUE + weapon = /obj/item/gun/ballistic/automatic/pistol/aps + weapon_type = /obj/item/gun/ballistic/automatic/pistol/aps + interval = BOUNTY_PISTOL_INTERVAL + min_range = BOUNTY_PISTOL_MIN_RANGE + windup = BOUNTY_PISTOL_WINDUP + windup_per_string = TRUE + windup_message = "%USER raises a pistol." + windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' + draw_message = "%USER pulls out a pistol!" + single_fire = TRUE + string_shots = BOUNTY_PISTOL_STRING_SHOTS + string_pause = BOUNTY_PISTOL_STRING_PAUSE + companion_string = BOUNTY_COMPANION_PISTOL_STRING + +/datum/bounty_style/pistol/equip(datum/outfit/outfit, heavy = FALSE) + var/obj/item/gun/ballistic/gun_type = weapon_for(heavy) + outfit.belt = /obj/item/storage/belt/holster + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, gun_type, 1) + bounty_outfit_carry(outfit, initial(gun_type.accepted_magazine_type)) + +/** + * A WT-550 worn on the belt, at every tier, and a spare magazine in a pocket. Brought up level before + * each burst; a burst is BOUNTY_SMG_BURST trigger pulls at the gun's own full-auto rate. + */ +/datum/bounty_style/smg + key = BOUNTY_STYLE_SMG + ranged = TRUE + weapon = /obj/item/gun/ballistic/automatic/wt550 + weapon_type = /obj/item/gun/ballistic/automatic/wt550 + interval = BOUNTY_SMG_INTERVAL + min_range = BOUNTY_SMG_MIN_RANGE + windup = BOUNTY_SMG_WINDUP + windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' + draw_message = "%USER pulls out an SMG!" + burst = BOUNTY_SMG_BURST + burst_gap = BOUNTY_SMG_BURST_GAP + concealable = FALSE + companion_burst = BOUNTY_COMPANION_SMG_BURST + +/datum/bounty_style/smg/equip(datum/outfit/outfit, heavy = FALSE) + var/obj/item/gun/ballistic/gun_type = weapon_for(heavy) + outfit.belt = gun_type + outfit.belt_contents = null + bounty_outfit_carry(outfit, initial(gun_type.accepted_magazine_type)) + +/// A .38 revolver in a shoulder holster, a speedloader in a pocket: one heavy shot a second +/datum/bounty_style/revolver + key = BOUNTY_STYLE_REVOLVER + ranged = TRUE + weapon = /obj/item/gun/ballistic/revolver/c38 + weapon_type = /obj/item/gun/ballistic/revolver/c38 + interval = BOUNTY_REVOLVER_INTERVAL + min_range = BOUNTY_REVOLVER_MIN_RANGE + windup = BOUNTY_REVOLVER_WINDUP + windup_per_string = TRUE + windup_message = "%USER thumbs back the hammer." + windup_sound = 'sound/items/weapons/gun/general/ballistic_click.ogg' + draw_message = "%USER pulls out a revolver!" + +/datum/bounty_style/revolver/equip(datum/outfit/outfit, heavy = FALSE) + outfit.belt = /obj/item/storage/belt/holster + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, weapon_for(heavy), 1) + bounty_outfit_carry(outfit, /obj/item/ammo_box/c38) + +/// Buckshot: a pump shotgun on the back, shells on a bandolier. The pump is the telegraph before each blast. +/datum/bounty_style/shotgun + key = BOUNTY_STYLE_SHOTGUN + ranged = TRUE + weapon = /obj/item/gun/ballistic/shotgun/lethal + heavy_weapon = /obj/item/gun/ballistic/shotgun/automatic/combat/bounty + weapon_type = /obj/item/gun/ballistic/shotgun + ranged_behavior = /datum/ai_behavior/bounty_shoot/close + interval = BOUNTY_SHOTGUN_INTERVAL + windup = BOUNTY_SHOTGUN_WINDUP + windup_message = "%USER pumps the shotgun." + windup_sound = 'sound/items/weapons/gun/shotgun/rack.ogg' + pumps = TRUE + draw_message = "%USER unslings a shotgun!" + concealable = FALSE + +/datum/bounty_style/shotgun/equip(datum/outfit/outfit, heavy = FALSE) + outfit.back = weapon_for(heavy) + outfit.belt = /obj/item/storage/belt/bandolier + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, /obj/item/ammo_casing/shotgun/buckshot, BOUNTY_SHOTGUN_SHELLS) + +/// Off the back, the pump is worked as it comes up if nothing is chambered: the draw is the first blast's telegraph +/datum/bounty_style/shotgun/on_drawn(mob/living/carbon/fighter, datum/ai_controller/controller, obj/item/weapon, atom/drawn_from) + . = ..() + var/obj/item/gun/ballistic/gun = weapon + if(istype(gun) && human_npc_gun_needs_rack(gun) && human_npc_ready_item(fighter, gun)) + gun.attack_self(fighter) + +/// The pump is real: a spent shell in the chamber is worked out by hand, which makes the rack's own sound +/datum/bounty_style/shotgun/telegraph(mob/living/carbon/fighter, atom/target, obj/item/weapon) + fighter.face_atom(target) + if(windup_message) + fighter.visible_message(span_warning(replacetext(windup_message, "%USER", "[fighter]"))) + var/obj/item/gun/ballistic/gun = weapon + if(istype(gun) && human_npc_gun_needs_rack(gun) && human_npc_ready_item(fighter, gun)) + gun.attack_self(fighter) + else if(windup_sound) + playsound(fighter, windup_sound, 50, TRUE) + +/// Beer bottles from a satchel, thrown one at a time +/datum/bounty_style/bottle + key = BOUNTY_STYLE_BOTTLE + ranged = TRUE + weapon = /obj/item/reagent_containers/cup/glass/bottle/beer + weapon_type = /obj/item/reagent_containers/cup/glass/bottle + ranged_behavior = /datum/ai_behavior/bounty_shoot/throw + interval = BOUNTY_BOTTLE_INTERVAL + min_range = BOUNTY_BOTTLE_MIN_RANGE + windup = BOUNTY_BOTTLE_WINDUP + windup_message = "%USER winds up to throw a bottle." + +/datum/bounty_style/bottle/equip(datum/outfit/outfit, heavy = FALSE) + if(!outfit.back) + outfit.back = /obj/item/storage/backpack/satchel + LAZYADDASSOC(outfit.backpack_contents, weapon, heavy ? BOUNTY_BOTTLES_WANTED : BOUNTY_BOTTLES_PETTY) + +/datum/bounty_style/bottle/ready_in_hand(mob/living/carbon/fighter) + for(var/obj/item/reagent_containers/cup/glass/bottle/bottle in fighter.held_items) + if(bottle.isGlass) + return bottle + return null + +/datum/bounty_style/bottle/carried_ranged(mob/living/carbon/fighter) + for(var/obj/item/reagent_containers/cup/glass/bottle/bottle in fighter.get_all_contents_type(weapon_type)) + if(bottle.isGlass && !fighter.is_holding(bottle) && human_npc_can_draw(fighter, bottle)) + return bottle + return null + +/// A real throw: the bottle leaves the hand and flies, as a player's does. tg's bottle breaks on whoever it hits. +/datum/bounty_style/bottle/attack(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + if(!human_npc_ready_item(fighter, weapon)) + return FALSE + fighter.zone_selected = human_npc_pick_aim_zone() + fighter.throw_mode_on(THROW_MODE_TOGGLE) + controller.ai_interact(target = target, combat_mode = TRUE) + // A click that didn't go through leaves throw mode on: the next click must not be a throw + if(fighter.throw_mode) + fighter.throw_mode_off(THROW_MODE_TOGGLE) + return FALSE + return !fighter.is_holding(weapon) + +/** + * The meek's little gun: a Makarov in a pocket and a spare magazine in a fanny pack. The volley is fired + * one trigger pull at a time by the meek AI (bounty_ai_meek.dm). + */ +/datum/bounty_style/holdout + key = BOUNTY_STYLE_HOLDOUT + ranged = TRUE + weapon = /obj/item/gun/ballistic/automatic/pistol + weapon_type = /obj/item/gun/ballistic/automatic/pistol + interval = BOUNTY_MEEK_PISTOL_GAP + +/datum/bounty_style/holdout/equip(datum/outfit/outfit, heavy = FALSE) + var/obj/item/gun/ballistic/gun_type = weapon + bounty_outfit_carry(outfit, gun_type) + outfit.belt = /obj/item/storage/belt/fannypack + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, initial(gun_type.accepted_magazine_type), 1) + +/** + * Puts `item_type` somewhere `outfit` carries it: a free pocket, else the belt's pouch, else the bag. + * Returns TRUE if it found a place. + */ +/proc/bounty_outfit_carry(datum/outfit/outfit, item_type) + if(!istype(outfit) || !ispath(item_type)) + return FALSE + if(!outfit.r_pocket) + outfit.r_pocket = item_type + return TRUE + if(!outfit.l_pocket) + outfit.l_pocket = item_type + return TRUE + if(outfit.belt && ispath(outfit.belt, /obj/item/storage)) + LAZYADDASSOC(outfit.belt_contents, item_type, 1) + return TRUE + if(outfit.back && ispath(outfit.back, /obj/item/storage)) + LAZYADDASSOC(outfit.backpack_contents, item_type, 1) + return TRUE + return FALSE + +/** + * The outfit a bounty fighter wears: plain clothes for `archetype` in `zone`, its tier's armour, its + * style's weapon and spares, and `treatment` (a bruise pack, a stimpack) in a pocket. + */ +/proc/bounty_fighter_outfit(archetype, zone, tier, style_key, heavy = FALSE, treatment) + var/datum/outfit/outfit = bounty_outfit_instance(bounty_plain_clothes(archetype, zone)) + if(!outfit) + return null + bounty_add_tier_armour(outfit, tier) + var/datum/bounty_style/style = GLOB.bounty_styles[style_key] + style?.equip(outfit, heavy) + if(treatment) + bounty_outfit_carry(outfit, treatment) + return outfit + +/// A Wanted criminal's combat shotgun, loaded with buckshot (tg's comes with rubber shot) +/obj/item/gun/ballistic/shotgun/automatic/combat/bounty + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/shot/com/bounty + +/obj/item/ammo_box/magazine/internal/shot/com/bounty + ammo_type = /obj/item/ammo_casing/shotgun/buckshot + +// ===== ARMING ===== + +/** + * In a fight with a target: keeps their style's weapon in hand. Draws it when the fight starts (a + * holster, a pocket, the back, a bag), reloads a gun the player's way when it runs dry (the human NPC + * base's reload; a shotgun shell by shell), or draws something else, or nothing and uses fists. + */ +/datum/ai_planning_subtree/bounty_arm + +/datum/ai_planning_subtree/bounty_arm/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/fighter = controller.pawn + if(!ishumannpc(fighter) || !bounty_ai_fighting(fighter) || !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + if(HAS_TRAIT(fighter, TRAIT_HANDS_BLOCKED) || LAZYLEN(fighter.do_afters) || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(!style) + return + var/obj/item/to_draw + if(style.ranged) + if(style.ready_in_hand(fighter)) + return + var/obj/item/gun/ballistic/gun = human_npc_held_gun(fighter) + if(istype(gun) && human_npc_can_reload(fighter, gun)) + controller.queue_behavior(gun.internal_magazine ? /datum/ai_behavior/human_npc_reload/bounty_tube : /datum/ai_behavior/human_npc_reload) + return SUBTREE_RETURN_FINISH_PLANNING + to_draw = style.carried_ranged(fighter) + else if(style.weapon_type) + if(human_npc_held_melee_weapon(fighter)) + return + to_draw = bounty_ai_carried(fighter, style.weapon_type) + if(fighter.is_holding(to_draw)) + return + else + return + if(!to_draw && !human_npc_held_melee_weapon(fighter)) + to_draw = human_npc_backup_weapon(fighter) + if(!to_draw) + return + controller.set_blackboard_key(BB_HUMAN_NPC_DRAW_ITEM, to_draw) + controller.queue_behavior(/datum/ai_behavior/human_npc_draw/bounty, BB_HUMAN_NPC_DRAW_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * The human NPC base's draw. When what comes into the hand is their style's own weapon, the style + * answers it (on_drawn()): a holstered or slung gun comes out with a line, and the first shot waits. + */ +/datum/ai_behavior/human_npc_draw/bounty + +/datum/ai_behavior/human_npc_draw/bounty/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/weapon = controller.blackboard[item_key] + var/atom/drawn_from = weapon?.loc + var/held_before = !QDELETED(weapon) && npc.is_holding(weapon) + . = ..() + if(held_before || QDELETED(weapon) || !npc.is_holding(weapon)) + return + var/datum/bounty_style/style = bounty_ai_style(npc) + if(style?.weapon_type && istype(weapon, style.weapon_type)) + style.on_drawn(npc, controller, weapon, drawn_from) + +/** + * The human NPC base's reload, for a gun loaded through its tube (a shotgun): every shell it can fit, + * one at a time, then the pump. + */ +/datum/ai_behavior/human_npc_reload/bounty_tube + give_up_after = 15 SECONDS + +/datum/ai_behavior/human_npc_reload/bounty_tube/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun) || !gun.internal_magazine || world.time >= controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL]) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/spent = human_npc_spent_ammo_in_hand(npc, gun) + if(spent) + npc.dropItemToGround(spent, force = TRUE) + return AI_BEHAVIOR_DELAY + var/obj/item/ammo_box/magazine/tube = gun.magazine + if(tube && tube.ammo_count(FALSE) < tube.max_ammo) + var/obj/item/ammo = human_npc_find_ammo(npc, gun) + if(ammo) + if(!npc.is_holding(ammo)) + if(!human_npc_take_in_hand(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + if(human_npc_gun_needs_rack(gun)) + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + if(gun.can_shoot()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + +// ===== SHOOTING AND THROWING ===== + +/// In a fight with a ranged style and its weapon ready in hand: shoots or throws, at the style's distance +/datum/ai_planning_subtree/bounty_ranged + +/datum/ai_planning_subtree/bounty_ranged/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/fighter = controller.pawn + if(!bounty_ai_fighting(fighter) || !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(!style?.ranged || HAS_TRAIT(fighter, TRAIT_HANDS_BLOCKED) || !style.ready_in_hand(fighter)) + return + controller.queue_behavior(style.ranged_behavior, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Shoots (or throws) at the target from the style's distance: walks in to required_distance, and fires + * at them on the way once it can see them within fire_range; steps back from anyone inside the style's + * min_range, steps aside for anyone it may not hit in its line, telegraphs, then pulls the trigger (or + * throws) once per the style's interval; a burst is that many pulls burst_gap apart, each one checked + * like the first. Planning carries on meanwhile, so a new target, a retreat or a call for help is picked + * up mid-fight. + */ +/datum/ai_behavior/bounty_shoot + // Often enough that a style fires at its own pace and its wind-up is never stretched + action_cooldown = 0.1 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + required_distance = BOUNTY_PISTOL_RANGE + /// How far off they shoot at someone they can see, walking in or not + var/fire_range = BOUNTY_GUN_FIRE_RANGE + +/datum/ai_behavior/bounty_shoot/setup(datum/ai_controller/controller, target_key, targeting_strategy_key) + . = ..() + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target)) + return FALSE + set_movement_target(controller, target) + +/datum/ai_behavior/bounty_shoot/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key) + var/mob/living/carbon/fighter = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(QDELETED(target) || !strategy?.can_attack(fighter, target, BOUNTY_FIGHT_VISION)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/datum/ai_controller/basic_controller/bounty/brain = controller + if(istype(brain) && !brain.may_shoot(target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/datum/bounty_style/style = bounty_ai_style(fighter) + var/obj/item/weapon = style?.ready_in_hand(fighter) + if(!weapon || HAS_TRAIT(fighter, TRAIT_HANDS_BLOCKED)) + // Dry or empty-handed: the arm subtree reloads or draws, or they fight with their fists + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + // Every round of a burst is a pull of its own, asked for again here: anything above that stops it stops the burst + var/bursting = style.burst_under_way(controller) + if(!can_see(fighter, target, fire_range)) + style.end_burst(controller) + return AI_BEHAVIOR_INSTANT + var/winding = !!controller.blackboard[BB_BOUNTY_WINDUP_UNTIL] + if(style.min_range && get_dist(fighter, target) < style.min_range && !winding && !bursting) + bounty_ai_back_off(controller, fighter, target) + if(bounty_ai_line_blocked(fighter, target)) + style.end_burst(controller) + bounty_ai_sidestep(fighter, target) + return AI_BEHAVIOR_DELAY + if(!winding && world.time < controller.blackboard[BB_BOUNTY_NEXT_SHOT]) + return AI_BEHAVIOR_DELAY + if(!bursting && !style.windup_step(fighter, controller, target, weapon)) + return AI_BEHAVIOR_DELAY + style.pull(fighter, controller, target, weapon) + return AI_BEHAVIOR_DELAY + +/// A wind-up or a burst cut short never carries over: the next shot telegraphs again +/datum/ai_behavior/bounty_shoot/finish_action(datum/ai_controller/controller, succeeded, target_key, targeting_strategy_key) + . = ..() + bounty_ai_clear_windup(controller) + controller.clear_blackboard_key(BB_BOUNTY_BURST_LEFT) + +/// The shotgun closes in to BOUNTY_SHOTGUN_RANGE, and fires from there: buckshot spreads too wide to hit much further off +/datum/ai_behavior/bounty_shoot/close + required_distance = BOUNTY_SHOTGUN_RANGE + fire_range = BOUNTY_SHOTGUN_RANGE + +/// The bottle thrower stays BOUNTY_BOTTLE_RANGE off, and throws from there +/datum/ai_behavior/bounty_shoot/throw + required_distance = BOUNTY_BOTTLE_RANGE + fire_range = BOUNTY_BOTTLE_RANGE + +// ===== MELEE ===== + +/** + * In a fight with a target and no ranged weapon ready: walks up and hits with the best melee weapon in + * hand, or a fist (the human NPC base's melee). A brawler now and then shoves instead. + */ +/datum/ai_planning_subtree/bounty_melee + +/datum/ai_planning_subtree/bounty_melee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/fighter = controller.pawn + if(!bounty_ai_fighting(fighter) || !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(style?.ranged && style.ready_in_hand(fighter)) + return + controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/human_npc/bounty, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The human NPC base's melee (tg's, through a click), with the brawler's shove: a real right click with an empty hand +/datum/ai_behavior/basic_melee_attack/human_npc/bounty + +/datum/ai_behavior/basic_melee_attack/human_npc/bounty/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/fighter = controller.pawn + var/mob/living/target = controller.blackboard[target_key] + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(!style?.shove_chance || !isliving(target) || !fighter.CanReach(target) || world.time < fighter.next_move) + return ..() + var/attack_at = controller.blackboard[BB_BASIC_MOB_MELEE_COOLDOWN_TIMER] + if(isnull(attack_at) || attack_at > world.time || !prob(style.shove_chance)) + return ..() + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(!strategy?.can_attack(fighter, target) || !human_npc_ready_empty_hand(fighter)) + return ..() + fighter.zone_selected = BODY_ZONE_CHEST + controller.ai_interact(target = target, combat_mode = TRUE, modifiers = list(RIGHT_CLICK = TRUE)) + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/bounties/bounty_suits.dm b/voidcrew/modules/bounties/bounty_suits.dm new file mode 100644 index 00000000000..bd53920b656 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_suits.dm @@ -0,0 +1,348 @@ +/** + * # Bounty hunting: suits where the air kills, and the crew's things at the pad + * + * Owner: A1 company and suits. Criminals and companions are real human NPCs, so they breathe, freeze and + * suffocate as anyone does (human_npc.dm); the human NPC base dresses them for the air where they are made + * (human_npc_outfits.dm). This file adds the bounty's rules on top: + * + * - Where they are dressed for. At a space ruin, vacuum is a door away, so a criminal there is always + * dressed for it; on a planet, so is one whose site has air that would hurt a person on any of a few + * tiles read at random, not only on its own (bounty_site_dress_for()). Companions are dressed for what + * their criminal was (bounty_companions.dm). + * - What they seal up in (bounty_vacuum_gear()): a suit in the armour class of what they wear in good air, + * so the vacuum neither strips nor gives armour. Petty and meek criminals and companions wear a soft + * suit; a Wanted criminal, who wears an armour vest, an armoured space suit; a mini-boss (every Most + * Wanted is one) a MODsuit of its kit's own theme (bounty_boss_kits.dm), put on and sealed by tg's own + * outfit path (quick_activation()) and running on a charged core. It is real gear, locked to them like + * the rest (D3, human_npc_gear.dm): it can't be stripped and never becomes loot, and that goes for a + * MODsuit's pieces, core, cell, modules and what its storage holds, all inside the body's contents. + * - Suiting up a catch. A hunter can put their own pressure suit or helmet on a criminal that is down, + * cuffed, stunned or dead, through the strip menu: it goes on in place of the locked piece in that slot, + * which is gone, taking as long as taking one off a person and putting the other on. It is the hunter's + * and can come off again like on anyone. An empty slot takes one the ordinary way. A MODsuit's helmet or + * chestplate is the whole suit's: the suit comes off as one and is gone, everything in it with it. + * - The pad gives the crew back everything they put on the body (bounty_return_crew_items()). + */ + +/// Soft suits, for those who wear no armour, each list(suit, helmet) +GLOBAL_LIST_INIT(bounty_soft_suits, list( + list(/obj/item/clothing/suit/space/eva, /obj/item/clothing/head/helmet/space/eva), + list(/obj/item/clothing/suit/space, /obj/item/clothing/head/helmet/space), + list(/obj/item/clothing/suit/space/nasavoid, /obj/item/clothing/head/helmet/space/nasavoid), +)) + +/// Armoured space suits, for those who wear an armour vest, each list(suit, helmet) +GLOBAL_LIST_INIT(bounty_armoured_suits, list( + list(/obj/item/clothing/suit/space/syndicate/black, /obj/item/clothing/head/helmet/space/syndicate/black), + list(/obj/item/clothing/suit/space/syndicate/black/green, /obj/item/clothing/head/helmet/space/syndicate/black/green), + list(/obj/item/clothing/suit/space/syndicate/black/blue, /obj/item/clothing/head/helmet/space/syndicate/black/blue), + list(/obj/item/clothing/suit/space/syndicate/black/orange, /obj/item/clothing/head/helmet/space/syndicate/black/orange), +)) + +// ===== WHERE THEY ARE DRESSED FOR ===== + +/** + * The BOUNTY_PLACEMENT_* kind of the site a spawn context was made for (the overmap object it names: + * spawn_bounty_criminal() makes the criminal's from its site, and companions inherit it), or null. + */ +/proc/bounty_site_kind_of(datum/npc_spawn_context/context) + var/source_type = context?.source_type + if(ispath(source_type, /obj/structure/overmap/planet)) + return BOUNTY_PLACEMENT_PLANET + if(ispath(source_type, /obj/structure/overmap/space_ruin)) + return BOUNTY_PLACEMENT_RUIN + if(ispath(source_type, /obj/structure/overmap/ship/npc/pirate)) + return BOUNTY_PLACEMENT_NPC_SHIP + if(ispath(source_type, /obj/structure/overmap/trader_outpost)) + return BOUNTY_PLACEMENT_TRADER_OUTPOST + return null + +/** + * HUMAN_NPC_SITE_* flags a bounty NPC made for `context`'s site is dressed for whatever the air on its own + * tile: vacuum at a space ruin, and on a planet whatever a few of its tiles' air asks of a person + * (bounty_planet_site()). The human NPC base adds the spawn tile's own. NONE anywhere else. + */ +/proc/bounty_site_dress_for(datum/npc_spawn_context/context) + switch(bounty_site_kind_of(context)) + if(BOUNTY_PLACEMENT_RUIN) + return HUMAN_NPC_SITE_VACUUM + if(BOUNTY_PLACEMENT_PLANET) + return bounty_planet_site(locate(context.source_id)) + return NONE + +/** + * What the air of `planet`'s site asks of a person (HUMAN_NPC_SITE_* flags), read on + * BOUNTY_SITE_AIR_SAMPLES open tiles picked at random within its footprint: a criminal in a sealed ruin + * on a planet whose air would kill it still has to go out in it. + */ +/proc/bounty_planet_site(obj/structure/overmap/planet/planet) + . = NONE + if(!istype(planet) || QDELETED(planet)) + return + var/list/bounds = bounty_site_bounds(planet) + if(length(bounds) < 5) + return + for(var/i in 1 to BOUNTY_SITE_AIR_SAMPLES) + var/turf/open/sample = locate(rand(bounds[1], bounds[3]), rand(bounds[2], bounds[4]), bounds[5]) + if(!istype(sample) || is_space_or_openspace(sample)) + continue + . |= human_npc_site_at(sample) + +/// Whether the human NPC base will seal `npc` up as it dresses it (npc_fit_outfit_to_site()): its site asks for it, and its species isn't proof against both vacuum and its cold +/proc/bounty_dressing_sealed(mob/living/carbon/human/npc/npc, datum/human_npc_outfit_table/table) + var/site = human_npc_site_at(get_turf(npc)) | npc.npc_always_site | table?.always_site + if(!(site & HUMAN_NPC_SITE_VACUUM)) + return FALSE + return !(HAS_TRAIT(npc, TRAIT_RESISTLOWPRESSURE) && HAS_TRAIT(npc, TRAIT_RESISTCOLD)) + +// ===== WHAT THEY SEAL UP IN ===== + +/mob/living/carbon/human/npc + /// The sealed suit and helmet it was given for vacuum, list(suit, helmet), once it has been dressed in them (bounty_vacuum_gear()) + var/list/suits_vacuum_gear + +/** + * The suit and helmet a bounty NPC seals up in where the air would kill it: list(suit, helmet) typepaths, + * a null helmet keeping its own (already sealed), or list(MODsuit control unit typepath). Null for the + * human NPC base's plain EVA layer. Each + * family overrides it; a random pick is fine, as it is asked once per body (bounty_vacuum_gear()). + */ +/mob/living/carbon/human/npc/proc/bounty_sealed_gear() + return null + +/** + * A mini-boss's MODsuit (bounty_sealed_gear()): a real tg suit of one theme, a BOUNTY_BOSS_MODSUIT_CELL in its + * standard core, and an expanded storage module for what its bag would hold, the suit taking the bag's place + * on its back. No other modules than the theme's own built-in ones. + */ +/obj/item/mod/control/pre_equipped/bounty_boss + applied_cell = BOUNTY_BOSS_MODSUIT_CELL + applied_modules = list(/obj/item/mod/module/storage/large_capacity) + +/// The Juggernaut's: a security suit, the nearest to its riot plates +/obj/item/mod/control/pre_equipped/bounty_boss/security + theme = /datum/mod_theme/security + +/// The Pyromaniac's: an atmospherics suit, fireproof like its fire suit +/obj/item/mod/control/pre_equipped/bounty_boss/atmospheric + theme = /datum/mod_theme/atmospheric + +/// The Demolitionist's: an engineering suit, as tough as its hard hat +/obj/item/mod/control/pre_equipped/bounty_boss/engineering + theme = /datum/mod_theme/engineering + +/// The Ghost's and the cyberpsycho's: a civilian suit, as light as what they wear in good air +/obj/item/mod/control/pre_equipped/bounty_boss/civilian + theme = /datum/mod_theme/civilian + +/// The Heavy's: a magnate suit, its energy armour that of the ablative coat it can't wear out there +/obj/item/mod/control/pre_equipped/bounty_boss/magnate + theme = /datum/mod_theme/magnate + +/// A soft suit or an armoured one for a criminal of `tier` (BOUNTY_TIER_*): armoured for those who wear an armour vest (bounty_add_tier_armour()) +/proc/bounty_tier_sealed_gear(tier) + if(isnull(tier) || tier == BOUNTY_TIER_PETTY) + return pick(GLOB.bounty_soft_suits) + return pick(GLOB.bounty_armoured_suits) + +/mob/living/carbon/human/npc/bounty_criminal/normal/bounty_sealed_gear() + return bounty_tier_sealed_gear(record?.tier) + +/mob/living/carbon/human/npc/bounty_criminal/meek/bounty_sealed_gear() + return bounty_tier_sealed_gear(record?.tier) + +// A mini-boss's kit sets its own MODsuit (bounty_boss_kits.dm); one with none (the cyberpsycho) wears plain clothes, so a civilian one +/mob/living/carbon/human/npc/bounty_criminal/boss/bounty_sealed_gear() + return list(/obj/item/mod/control/pre_equipped/bounty_boss/civilian) + +/** + * A MODsuit draws on its core while it runs (tg's MOD processing), and a dormant body is frozen in time + * (human_npc.dm): like a space suit's thermal regulator, the suit is paused while its wearer is dormant + * and runs again when it wakes, so its core doesn't run flat before anyone arrives. Performance only: a + * waking boss's suit is the same suit with the same charge, sealed all along. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/npc_set_suit_heating(on) + . = ..() + var/obj/item/mod/control/suit = back + if(!istype(suit)) + return + if(on) + START_PROCESSING(SSobj, suit) + else + STOP_PROCESSING(SSobj, suit) + +/mob/living/carbon/human/npc/bounty_companion/bounty_sealed_gear() + return pick(GLOB.bounty_soft_suits) + +/** + * The sealed suit and helmet `npc` wears where the air would kill it (bounty_sealed_gear()), the same + * every time it is asked: list(suit, helmet), or null for the plain EVA layer. Anything that changes a + * bounty NPC's clothes later (the chrome exposure, a blend) leaves these pieces alone. + */ +/proc/bounty_vacuum_gear(mob/living/carbon/human/npc/npc) + if(!ishumannpc(npc)) + return null + if(isnull(npc.suits_vacuum_gear)) + var/list/gear = npc.bounty_sealed_gear() + npc.suits_vacuum_gear = islist(gear) ? gear.Copy() : list() + return length(npc.suits_vacuum_gear) ? npc.suits_vacuum_gear : null + +/** + * `outfit` (a typepath or an instance) with `npc`'s own sealed suit and helmet on, if it is about to be + * sealed up for vacuum (bounty_dressing_sealed()): the human NPC base then finds it sealed, and only adds + * the air tank. Anything the old suit held moves to the backpack, as the base's layers do. A MODsuit goes + * on its back in place of its bag, what the bag held going in the suit's storage, and its suit and head + * slots are left for the chestplate and helmet it deploys (its gauntlets and boots go on over gloves and + * shoes). Returns the outfit unchanged when nothing is needed, else an instance (one passed in is changed + * in place). + */ +/proc/bounty_seal_outfit(mob/living/carbon/human/npc/npc, outfit, datum/human_npc_outfit_table/table) + if(isnull(outfit) || !bounty_dressing_sealed(npc, table)) + return outfit + var/list/gear = bounty_vacuum_gear(npc) + if(!length(gear)) + return outfit + var/modsuit = ispath(gear[1], /obj/item/mod/control) + if(!modsuit && (length(gear) < 2 || !ispath(gear[1], /obj/item/clothing/suit))) + return outfit + var/datum/outfit/dressed = ispath(outfit, /datum/outfit) ? new outfit() : outfit + if(!istype(dressed)) + return outfit + if(dressed.suit_store) + if(dressed.back || modsuit) + LAZYADDASSOC(dressed.backpack_contents, dressed.suit_store, 1) + dressed.suit_store = null + if(modsuit) + dressed.back = gear[1] + dressed.suit = null + dressed.head = null + return dressed + dressed.suit = gear[1] + var/obj/item/clothing/own_head = dressed.head + if(ispath(gear[2], /obj/item/clothing/head)) + dressed.head = gear[2] + else if(!ispath(own_head, /obj/item/clothing) || !(initial(own_head.clothing_flags) & STOPSPRESSUREDAMAGE) || !initial(own_head.min_cold_protection_temperature) || initial(own_head.min_cold_protection_temperature) > SPACE_HELM_MIN_TEMP_PROTECT) + dressed.head = /obj/item/clothing/head/helmet/space + return dressed + +// ===== SUITING UP A CATCH ===== + +/// Whether `garment` is a pressure suit or helmet `user` could put on someone in `slot` (ITEM_SLOT_OCLOTHING or ITEM_SLOT_HEAD) +/proc/bounty_pressure_garment_for(obj/item/clothing/garment, slot) + if(!istype(garment) || QDELETED(garment) || !(garment.clothing_flags & STOPSPRESSUREDAMAGE) || !(garment.slot_flags & slot)) + return FALSE + if(HAS_TRAIT(garment, TRAIT_NODROP) || (garment.item_flags & ABSTRACT)) + return FALSE + switch(slot) + if(ITEM_SLOT_OCLOTHING) + return istype(garment, /obj/item/clothing/suit) + if(ITEM_SLOT_HEAD) + return istype(garment, /obj/item/clothing/head) + return FALSE + +/** + * The pressure suit or helmet `user` holds ready to put on it in place of `worn`, one of its own locked + * pieces in the suit or head slot, or null when this isn't that (any other reach goes to tg's strip menu + * as it is). A hood belongs to its suit, and goes with it. + */ +/// The MODsuit on its back, if `worn` is one of that suit's pieces +/mob/living/carbon/human/npc/bounty_criminal/proc/suits_modsuit_of(obj/item/worn) + var/obj/item/mod/control/suit = back + if(istype(suit) && (worn in suit.get_parts(all = TRUE))) + return suit + return null + +/mob/living/carbon/human/npc/bounty_criminal/proc/suits_garment_offered(mob/user, obj/item/worn) + if(!ismob(user) || !istype(worn) || worn.loc != src || !human_npc_gear_locked(worn)) + return null + if(istype(worn, /obj/item/clothing/head/hooded)) + return null + var/slot = get_slot_by_item(worn) + if(slot != ITEM_SLOT_OCLOTHING && slot != ITEM_SLOT_HEAD) + return null + var/obj/item/clothing/garment = user.get_active_held_item() + return bounty_pressure_garment_for(garment, slot) ? garment : null + +/** + * The bounty_body component's strip hook (bounty_criminal.dm): `user` reaches for `worn` through the strip + * menu. With a pressure suit or helmet in hand for a slot its own locked piece fills, on a criminal that is + * down, cuffed, stunned or dead, the hunter starts putting theirs on (suits_put_on()); on one that is free, + * it pulls away. Returns COMPONENT_CANT_STRIP when it takes the reach over, so tg's strip doesn't go on to + * refuse the locked piece, else NONE. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/suits_strip_reach(mob/user, obj/item/worn) + var/obj/item/clothing/garment = suits_garment_offered(user, worn) + if(!garment) + return NONE + if(capture_state() == BOUNTY_STATE_FREE) + balloon_alert(user, "[p_they()] pull[p_s()] away!") + return COMPONENT_CANT_STRIP + INVOKE_ASYNC(src, PROC_REF(suits_put_on), user, worn, garment) + return COMPONENT_CANT_STRIP + +/** + * `user` puts `garment` on it in place of `worn`: as long as taking the one off a person and putting the + * other on (`worn`'s strip delay and `garment`'s equip delay), standing beside it. Sleeps. Returns TRUE if + * it went on. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/suits_put_on(mob/user, obj/item/worn, obj/item/clothing/garment) + garment.item_start_equip(src, garment, user) + if(!do_after(user, worn.strip_delay + garment.equip_delay_other, src, interaction_key = REF(garment))) + return FALSE + return suits_swap_now(user, worn, garment) + +/** + * Puts `garment`, from `user`'s hands, on it in place of `worn`, now: the locked piece is gone (it never + * leaves the body as loot), and the garment is the hunter's, an ordinary item that can come off again. + * A MODsuit's helmet or chestplate takes the whole suit with it: it comes off its back (tg's own clean + * up unseals it and pulls its pieces back in, the gloves and shoes it covered going back on) and is gone, + * its core, modules and storage with it. + * Only while it is still the same reach: `worn` in its slot, `garment` in the hunter's hand, the hunter + * beside it, and it down, cuffed, stunned or dead. Returns TRUE if it went on. Doesn't sleep. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/suits_swap_now(mob/user, obj/item/worn, obj/item/clothing/garment) + if(QDELETED(src) || QDELETED(user) || !user.Adjacent(src)) + return FALSE + if(suits_garment_offered(user, worn) != garment || capture_state() == BOUNTY_STATE_FREE) + return FALSE + var/slot = get_slot_by_item(worn) + // One that wouldn't fit them (their species, their shape) leaves their own piece on + if(!can_equip(garment, slot, disable_warning = TRUE, bypass_equip_delay_self = TRUE, ignore_equipped = TRUE)) + return FALSE + if(!user.temporarilyRemoveItemFromInventory(garment)) + return FALSE + var/obj/item/mod/control/modsuit = suits_modsuit_of(worn) + if(modsuit) + temporarilyRemoveItemFromInventory(modsuit, force = TRUE) + qdel(modsuit) + else + qdel(worn) + if(!equip_to_slot_if_possible(garment, slot, disable_warning = TRUE, bypass_equip_delay_self = TRUE)) + user.put_in_hands(garment) + return FALSE + finish_equip_mob(garment, src, user) + return TRUE + +// ===== THE CREW'S THINGS ===== + +/// Whether `item`, directly on a bounty NPC's body, is something the crew put there: not its own locked gear or consumables, and not part of the body (limbs, organs, implants). A hood goes with its suit. +/proc/bounty_crew_item(obj/item/item) + if(!istype(item) || QDELETED(item) || (item.item_flags & ABSTRACT)) + return FALSE + if(isbodypart(item) || isorgan(item) || istype(item, /obj/item/implant) || istype(item, /obj/item/clothing/head/hooded)) + return FALSE + return !HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) && !HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE) + +/** + * Puts down at `spot` everything the crew put on `body`: their cuffs, a suit and helmet, an air tank in its + * suit storage, whatever else. Its own gear goes with it (D3). Returns what was put down. + */ +/proc/bounty_return_crew_items(mob/living/carbon/human/npc/body, turf/spot) + . = list() + if(QDELETED(body) || !isturf(spot)) + return + for(var/obj/item/item in body.contents.Copy()) + if(item.loc != body || !bounty_crew_item(item)) + continue + if(body.transferItemToLoc(item, spot, force = TRUE)) + . += item diff --git a/voidcrew/modules/bounties/bounty_turn_in.dm b/voidcrew/modules/bounties/bounty_turn_in.dm index 463539bf9a0..2fa9980ba99 100644 --- a/voidcrew/modules/bounties/bounty_turn_in.dm +++ b/voidcrew/modules/bounties/bounty_turn_in.dm @@ -36,7 +36,7 @@ // ===== TURNING IN ===== /// Bind field work before the criminal or its proof can be carried to another band. -/datum/criminal_bounty/proc/board_note_work_zone(mob/living/basic/bounty_criminal/target) +/datum/criminal_bounty/proc/board_note_work_zone(mob/living/carbon/human/npc/bounty_criminal/target) if(!is_open() || !record || record.board_work_zone || target != criminal() || record.status != BOUNTY_RECORD_WANTED) return var/band = get_zone_type_for_player_turf(get_turf(target)) @@ -112,7 +112,7 @@ /datum/criminal_bounty/proc/board_target_on_pad(obj/machinery/mission_pad/pad) if(!pad || !isturf(pad.loc)) return null - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(criminal && criminal.loc == pad.loc) return criminal var/obj/item/bounty_proof/proof = board_proof() @@ -129,13 +129,13 @@ /datum/criminal_bounty/proc/board_target_refusal(obj/machinery/mission_pad/pad, list/found) var/atom/movable/target = board_target_on_pad(pad) if(!target) - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(criminal && get_turf(criminal) == get_turf(pad)) return "they're inside [criminal.loc]" return "nothing on the pad for this bounty" var/state = BOUNTY_STATE_DEAD - if(istype(target, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = target + if(istype(target, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = target if(criminal.client || criminal.mind) return "the pad won't take them" state = criminal.capture_state() @@ -147,9 +147,11 @@ /** * Pays `ship` for `target` (this bounty's criminal or its proof) in `state` at `pad`, and ends the - * bounty for everyone. No yield from start to end. The share is the worse of `state` and the worst - * state the criminal ever reached (12.1); a live catch gets its hurt, its captor and its preferred - * prison and goes to the prisoner pool. Returns list(credits, vouchers), or why not. + * bounty for everyone. No yield from start to end, and it pays once: the posting is closed before + * anything else can press. The share is the criminal's condition on the pad, read at the press (owner + * ruling 2026-11-08: dead 25%, in crit 60%, cuffed or stunned 100%; what it went through on the way + * doesn't count). A live catch gets its hurt, its captor and its preferred prison and goes to the + * prisoner pool. Returns list(credits, vouchers), or why not. */ /datum/criminal_bounty/proc/board_claim(obj/structure/overmap/ship/ship, obj/machinery/mission_pad/pad, atom/movable/target, state, mob/user) // Everything after this line runs once: a second press, or a second ship, finds it closed @@ -157,13 +159,13 @@ return "that bounty is closed" if(QDELETED(target) || QDELETED(ship)) return "nothing on the pad for this bounty" - var/mob/living/basic/bounty_criminal/criminal = istype(target, /mob/living/basic/bounty_criminal) ? target : null + var/mob/living/carbon/human/npc/bounty_criminal/criminal = istype(target, /mob/living/carbon/human/npc/bounty_criminal) ? target : null var/obj/item/bounty_proof/proof = istype(target, /obj/item/bounty_proof) ? target : null if(!criminal && !proof) return "nothing on the pad for this bounty" if(proof) state = BOUNTY_STATE_DEAD - var/share = board_share_for(state, criminal ? criminal.worst_state : BOUNTY_STATE_DEAD) + var/share = board_share_for(state) if(!share) return "they're standing free" var/list/pay = board_payout(share) @@ -266,8 +268,8 @@ var/state = null if(target) state = "proof" - if(istype(target, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = target + if(istype(target, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = target state = criminal.capture_state() var/refusal if(private_to && offered_to() != ship) @@ -316,8 +318,8 @@ /// What the pad says about `arrived` on it, or null if it says nothing (not a bounty target, or not this ship's to turn in) /obj/machinery/mission_pad/proc/bounty_pad_line(atom/movable/arrived) var/datum/criminal_bounty/posting - if(istype(arrived, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = arrived + if(istype(arrived, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = arrived posting = criminal.posting() else if(istype(arrived, /obj/item/bounty_proof)) var/obj/item/bounty_proof/proof = arrived @@ -343,18 +345,19 @@ // ===== THE BEAM ===== /** - * The pad takes them: held still, harmless and untouchable, and fading out in the prisoner's - * beam (outpost_prison_prisoner.dm beam_out()), then deleted. Never a teleport (AR-A1). Nothing - * they leave behind counts as proof of death. + * The pad takes them: held still and harmless, fading out in the prisoner's beam + * (outpost_prison_prisoner.dm beam_out()), then deleted. Never a teleport (AR-A1). The pay was settled + * before the beam starts, so nothing that happens to them during it changes it; there is no godmode + * (defaults 1.8, CR-18). Their locked kit goes with them (D3); anything on them that isn't kit (papers, + * a key, the crew's own cuffs) is left on the pad. Nothing they leave behind counts as proof of death. */ -/mob/living/basic/bounty_criminal/proc/board_beam_out() +/mob/living/carbon/human/npc/bounty_criminal/proc/board_beam_out() // Their last words as the pad takes them (P1's beamed_out lines), said before the beam holds them still if(stat == CONSCIOUS) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "beamed_out", null, TRUE) ADD_TRAIT(src, TRAIT_BOUNTY_REMOVED, BOUNTY_PAD_TRAIT) ADD_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_PAD_TRAIT) ADD_TRAIT(src, TRAIT_INCAPACITATED, BOUNTY_PAD_TRAIT) - ADD_TRAIT(src, TRAIT_GODMODE, BOUNTY_PAD_TRAIT) REMOVE_TRAIT(src, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) ai_controller?.set_ai_status(AI_STATUS_OFF) pulledby?.stop_pulling() @@ -368,8 +371,11 @@ transporter_dematerialise(src, BOUNTY_BEAM_TIME) addtimer(CALLBACK(src, PROC_REF(board_finish_beam_out)), BOUNTY_BEAM_TIME, TIMER_DELETE_ME) -/mob/living/basic/bounty_criminal/proc/board_finish_beam_out() +/mob/living/carbon/human/npc/bounty_criminal/proc/board_finish_beam_out() var/turf/spot = get_turf(src) + npc_drop_not_gear() + // What the crew put on them stays with the crew: their restraints, and any suit, helmet or tank they put on for the trip (bounty_suits.dm) + bounty_return_crew_items(src, spot) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) transporter_sparks(spot) @@ -445,13 +451,13 @@ * the criminal away (TRAIT_BOUNTY_REMOVED), when its bounty is closed or relisting, or when the * criminal went with its site's interior (it comes back at the next load). One proof per bounty. */ -/proc/bounty_drop_proof(mob/living/basic/bounty_criminal/criminal) +/proc/bounty_drop_proof(mob/living/carbon/human/npc/bounty_criminal/criminal) if(!criminal || HAS_TRAIT(criminal, TRAIT_BOUNTY_REMOVED)) return null var/datum/criminal_bounty/posting = criminal.posting_ref?.resolve() if(!posting || !posting.is_open() || posting.board_relisting) return null - var/mob/living/basic/bounty_criminal/current = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/current = posting.criminal() if(current && current != criminal) return null var/turf/spot = get_turf(criminal) @@ -526,13 +532,15 @@ /** * Called from the bluespace body bag's attempt_fold(): TRUE stops `folder` folding `bag`, because - * a living bounty criminal is inside (AR-E7). Tells `folder` why. + * a bounty criminal inside is alive and struggling (AR-E7, PL-20). One in crit or dead is a body, and + * folds like anyone's; the bag still never rides a transporter beam with a live one in it + * (bounty_blocks_transport()). Tells `folder` why. */ /proc/bounty_blocks_bag_fold(obj/structure/closet/body_bag/bag, mob/living/folder) if(!bag) return FALSE - for(var/mob/living/basic/bounty_criminal/criminal in bag.get_all_contents()) - if(criminal.stat == DEAD) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal in bag.get_all_contents()) + if(!bounty_body_bound(criminal) || criminal.is_downed()) continue if(folder) to_chat(folder, span_warning("[bag] won't fold with someone alive and struggling inside.")) @@ -555,12 +563,22 @@ if(QDELETED(thing)) return FALSE for(var/mob/living/passenger in thing.get_all_contents()) - if(passenger == thing || passenger.stat == DEAD) - continue - if(istype(passenger, /mob/living/basic/bounty_criminal) || istype(passenger, /mob/living/basic/bounty_companion)) + if(passenger != thing && bounty_body_bound(passenger)) return TRUE return FALSE +/** + * Whether `body` is a living bounty person the pad is the only way out for: a criminal still held to its + * site (its body's TRAIT_NO_TELEPORT; a convict a crew talked down loses it and is free to go), or a + * companion. Dead bodies are proof, and travel like any body. + */ +/proc/bounty_body_bound(mob/living/body) + if(!isliving(body) || QDELETED(body) || body.stat == DEAD) + return FALSE + if(istype(body, /mob/living/carbon/human/npc/bounty_criminal)) + return HAS_TRAIT_FROM(body, TRAIT_NO_TELEPORT, BOUNTY_BODY_TRAIT) + return istype(body, /mob/living/carbon/human/npc/bounty_companion) + /** * Called from the transporter pad's gather_payload(): TRUE keeps `thing` off the beam. A criminal * refuses teleports itself (P2); this stops a locker, a crate or a bag with a living one inside @@ -570,7 +588,6 @@ /proc/bounty_blocks_transport(atom/movable/thing) if(QDELETED(thing)) return FALSE - if(istype(thing, /mob/living/basic/bounty_criminal) || istype(thing, /mob/living/basic/bounty_companion)) - var/mob/living/rider = thing - return rider.stat != DEAD + if(ismob(thing)) + return bounty_body_bound(thing) return bounty_holds_living_criminal(thing) diff --git a/voidcrew/modules/bounties/bounty_types.dm b/voidcrew/modules/bounties/bounty_types.dm index 6af790269ea..19c63aee04c 100644 --- a/voidcrew/modules/bounties/bounty_types.dm +++ b/voidcrew/modules/bounties/bounty_types.dm @@ -68,6 +68,8 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/style /// BOUNTY_KIT_* for a mini-boss, else null var/kit + /// The chrome they carry (BOUNTY_CHROME_* keys), rolled once with the record, or null (bounty_chrome.dm). Real implants in their body; a prisoner's isn't installed. + var/list/chrome /// Distinguishing features, as P1's feature keys; feature_lines() turns them into examine lines var/list/features /// What carries into the prison beyond the tier: P1's trait keys, which P7's danger helpers read @@ -140,7 +142,7 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/placement_kind /// Weakref to where the criminal is: the overmap object of the planet, ruin, pirate ship or trader outpost var/datum/weakref/site_ref - /// Weakref to the criminal's mob while its site is loaded (/mob/living/basic/bounty_criminal) + /// Weakref to the criminal's mob while its site is loaded (/mob/living/carbon/human/npc/bounty_criminal) var/datum/weakref/criminal_ref /// Weakref to the ship a private offer is for; null for a public bounty var/datum/weakref/private_to @@ -152,9 +154,9 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/value = 0 /// world.time it comes off the board var/expires_at = 0 - /// Weakrefs to its decoys at a trader outpost (/mob/living/basic/bounty_criminal/decoy) + /// Weakrefs to its decoys at a trader outpost (/mob/living/carbon/human/npc/bounty_criminal/decoy) var/list/decoys = list() - /// Weakrefs to the criminal's companions (/mob/living/basic/bounty_companion) + /// Weakrefs to the criminal's companions (/mob/living/carbon/human/npc/bounty_companion) var/list/companions = list() /// The sighting marker hunters' GPS units point to var/obj/effect/bounty_sighting/marker @@ -167,29 +169,26 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) /// The criminal's mob, if its site is loaded and it still exists /datum/criminal_bounty/proc/criminal() - var/mob/living/basic/bounty_criminal/criminal = criminal_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal_ref?.resolve() return QDELETED(criminal) ? null : criminal // ===== THE CRIMINALS ===== /** - * A wanted criminal, while its site is loaded. Its own mob family: not an outpost prisoner (a - * caught criminal becomes a new prisoner, rebuilt from the record) and never under - * /mob/living/basic/boss (megafauna are banned from outposts). No AI controller until P3 gives it - * one. Built-in values are set by P2 in bounty_criminal.dm. + * A wanted criminal, while its site is loaded: a real human body (/mob/living/carbon/human/npc, the + * human NPC base) driven by the bounty AI. Its crit, stamina, cuffs, wounds, death and revival are + * tg's, as for a player; only its AI and the bounty rules on top are its own. Not an outpost + * prisoner: a caught criminal becomes a new prisoner, rebuilt from the record. Built-in values are + * set by P2 in bounty_criminal.dm. */ -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal /// Who they are (/datum/bounty_record) var/datum/bounty_record/record /// Weakref to the posting they are wanted on (/datum/criminal_bounty); null for an admin spawn with none var/datum/weakref/posting_ref - /// At or below the downed line: floored, harmless and draggable until they recover (P2) - var/downed = FALSE /// Hidden in a locker, a crate, as a potted plant or in the dark (P3) var/hidden = FALSE - /// The restraints on them, held in their contents (P2), or null - var/obj/item/restraints/handcuffs/restraints - /// Weakrefs to their companions (/mob/living/basic/bounty_companion) + /// Weakrefs to their companions (/mob/living/carbon/human/npc/bounty_companion) var/list/companions = list() /// Blending in at a trader outpost: the fugitive and its decoys idle, talk and move the same way (P6, P3) var/blended = FALSE @@ -197,42 +196,40 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/list/grudge = list() /// Their leash, list(min_x, min_y, max_x, max_y, z), or null for none (P2's leash_ok()) var/list/site_bounds - /// The worst capture state they have reached (BOUNTY_STATE_*): once downed or dead, the pay share never rises again. P2 writes it, P5 pays on it. - var/worst_state = BOUNTY_STATE_FREE /// The posting they are wanted on, if it still exists -/mob/living/basic/bounty_criminal/proc/posting() +/mob/living/carbon/human/npc/bounty_criminal/proc/posting() var/datum/criminal_bounty/posting = posting_ref?.resolve() return QDELETED(posting) ? null : posting /// A meek criminal: runs, hides, blends in. P2 sets its body, P3 its AI. -/mob/living/basic/bounty_criminal/meek +/mob/living/carbon/human/npc/bounty_criminal/meek /// A normal criminal: found doing something, fights back. P2 sets its body, P3 its AI. -/mob/living/basic/bounty_criminal/normal +/mob/living/carbon/human/npc/bounty_criminal/normal /// A mini-boss: the base of the five kits. P4 sets its body and AI in bounty_boss.dm. -/mob/living/basic/bounty_criminal/boss +/mob/living/carbon/human/npc/bounty_criminal/boss -/mob/living/basic/bounty_criminal/boss/juggernaut -/mob/living/basic/bounty_criminal/boss/pyromaniac -/mob/living/basic/bounty_criminal/boss/demolitionist -/mob/living/basic/bounty_criminal/boss/ghost -/mob/living/basic/bounty_criminal/boss/heavy +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy /** * A patron at a trader outpost who shares some of the fugitive's features. Same body, same * blended-in mode and same examine layout as the fugitive, so nothing about it gives the fugitive * away; only what a warrant does to it differs. P6 owns its behaviour (bounty_outpost.dm). */ -/mob/living/basic/bounty_criminal/decoy +/mob/living/carbon/human/npc/bounty_criminal/decoy /** * Someone with a normal criminal: fights beside them, is not a bounty and pays nothing. P3 owns it * (bounty_companions.dm) and sets its built-in values. */ -/mob/living/basic/bounty_companion - /// Weakref to the criminal they are with (/mob/living/basic/bounty_criminal) +/mob/living/carbon/human/npc/bounty_companion + /// Weakref to the criminal they are with (/mob/living/carbon/human/npc/bounty_criminal) var/datum/weakref/leader_ref // ===== OBJECTS ===== diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm new file mode 100644 index 00000000000..b4fbd38602a --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm @@ -0,0 +1,169 @@ +/** + * # Bounty chrome: the Cascade lattice + * + * Owner: C1. A criminal's Cascade lattice is the player's implant + * (/obj/item/organ/cyberimp/cyberware/cascade, ware_legend.dm): the surge, its eight seconds of + * speed, quicker blows, afterimages and dodge on the move, the six second crash, the minute's + * cooldown, and an EMP in the window crashing it, are all the implant's own. Nothing here changes + * them. This file is only how an NPC uses it (owner decision 5): + * - The tell (/datum/bounty_chrome_move/cascade, through C0's bounty_chrome.dm): a violet glow and + * a rising whine, standing still, for BOUNTY_CHROME_TELL_MIN; then the lattice's own Cascade Surge, + * Trigger()ed as a player's button press. It starts only with its target close and in sight, and + * never on a mini-boss whose kit fires the lattice itself (the Ghost's dash, bounty_boss_kits.dm): + * one lattice, one driver. + * - In the window its body has the lattice's speed, and its AI's own movement loop is paced by that + * body (the controller's movement_delay follows cached_multiplicative_slowdown), so a fighter who + * closes in runs a hunter down with no help from here. A gunman would stand and shoot, and the + * lattice only dodges on the move, so it strafes (strafe()): a step across its line to its target, + * never sooner than its body's own move delay allows, at most every BOUNTY_CASCADE_STRAFE_GAP, + * never while its AI is walking it and never off its leash (bounty_ai_step()). Each step leaves the + * lattice's afterimage, so the surge shows on a gunman too. + */ + +/// Furthest its target may be for it to surge, in tiles +#define BOUNTY_CASCADE_RANGE 7 +/// The glow through the tell +#define BOUNTY_CASCADE_COLOR "#c86bff" +/// How often the window's strafing looks for a step +#define BOUNTY_CASCADE_STRAFE_PULSE (0.1 SECONDS) +/// Shortest time between two strafing steps (its body's move delay may make it longer) +#define BOUNTY_CASCADE_STRAFE_GAP (0.4 SECONDS) + +/datum/bounty_chrome_move/cascade + piece_key = BOUNTY_CHROME_CASCADE + name = "Cascade surge" + action_type = /datum/action/cooldown/cyberware/cascade_surge + tell_time = BOUNTY_CHROME_TELL_MIN + tell_color = BOUNTY_CASCADE_COLOR + tell_sound = 'sound/effects/magic/charge.ogg' + tell_lines = list("Try and keep up.", "Watch this.", "Too slow.") + max_range = BOUNTY_CASCADE_RANGE + /// The window's strafing loop, while the lattice's window is open + var/strafe_timer + /// world.time its owner last took a step, by any means + var/last_step_at = 0 + /// world.time its next strafing step may come + var/next_strafe_at = 0 + /// The way its last strafing step went: the next one goes back the other way when it can, a zig-zag rather than a drift + var/last_strafe_dir = NONE + +/datum/bounty_chrome_move/cascade/Destroy() + stop_strafing() + return ..() + +/datum/bounty_chrome_move/cascade/detach() + stop_strafing() + return ..() + +/datum/bounty_chrome_move/cascade/can_start(atom/target) + if(kit_drives_lattice()) + return FALSE + return ..() + +/datum/bounty_chrome_move/cascade/tell_text(atom/target) + return "[owner]'s spine lights up with a rising whine." + +/datum/bounty_chrome_move/cascade/effect(atom/target) + . = ..() + if(.) + start_strafing() + +/datum/bounty_chrome_move/cascade/on_death() + stop_strafing() + +/// Whether its owner is a mini-boss whose kit fires this lattice itself (the Ghost's dash): the AI here never does too +/datum/bounty_chrome_move/cascade/proc/kit_drives_lattice() + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + return istype(boss) && (/obj/item/organ/cyberimp/cyberware/cascade in bounty_chrome_kit_organs(boss.boss_kit)) + +/// Whether the lattice's window is open on its owner now +/datum/bounty_chrome_move/cascade/proc/surging() + return !QDELETED(owner) && !!owner.has_status_effect(/datum/status_effect/cyberware_cascade_window) + +/// The window just opened: a fighter with a gun or bottles strafes until the window closes (a brawler or a mini-boss just closes in) +/datum/bounty_chrome_move/cascade/proc/start_strafing() + stop_strafing() + var/datum/bounty_style/style = bounty_ai_style(owner) + if(!style?.ranged || !surging()) + return + RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_owner_moved)) + last_step_at = world.time + next_strafe_at = world.time + BOUNTY_CASCADE_STRAFE_GAP + strafe_timer = addtimer(CALLBACK(src, PROC_REF(strafe_pulse)), BOUNTY_CASCADE_STRAFE_PULSE, TIMER_STOPPABLE | TIMER_DELETE_ME | TIMER_LOOP) + +/// Stops the strafing loop. Safe at any time. +/datum/bounty_chrome_move/cascade/proc/stop_strafing() + if(strafe_timer) + deltimer(strafe_timer) + strafe_timer = null + if(owner) + UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) + +/// One beat of the loop: a strafing step if one is due, and the loop ends with the window +/datum/bounty_chrome_move/cascade/proc/strafe_pulse() + if(!surging()) + stop_strafing() + return + strafe() + +/datum/bounty_chrome_move/cascade/proc/on_owner_moved(atom/movable/source, atom/old_loc, dir, forced) + SIGNAL_HANDLER + last_step_at = world.time + +/** + * A gunman in the lattice's window takes a step across its line to its target, keeping its distance and its + * sight of them, the way a player with a lattice keeps moving to keep the dodge: never sooner than its + * body's move delay after its last step, at most every BOUNTY_CASCADE_STRAFE_GAP, never while its AI + * is walking it, held still or not able to fight, and only on its leash (bounty_ai_step()). A fighter + * without a gun ready in hand closes in through its AI instead. Returns TRUE if it stepped. + */ +/datum/bounty_chrome_move/cascade/proc/strafe() + if(QDELETED(owner) || world.time < next_strafe_at || world.time < last_step_at + owner.cached_multiplicative_slowdown) + return FALSE + if(!surging() || !bounty_chrome_can_act(owner) || !bounty_chrome_in_fight(owner) || !bounty_ai_fighting(owner)) + return FALSE + if(owner.buckled || owner.pulledby || HAS_TRAIT(owner, TRAIT_IMMOBILIZED) || !(owner.mobility_flags & MOBILITY_MOVE)) + return FALSE + var/datum/ai_controller/controller = owner.ai_controller + if(QDELETED(controller) || controller.ai_movement?.moving_controllers[controller]) + return FALSE + var/datum/bounty_style/style = bounty_ai_style(owner) + if(!style?.ranged || !style.ready_in_hand(owner)) + return FALSE + var/atom/target = bounty_chrome_target(owner) + var/turf/here = get_turf(owner) + var/turf/aim = get_turf(target) + if(!here || !aim || aim.z != here.z) + return FALSE + var/distance = get_dist(here, aim) + if(distance < 2 || distance > BOUNTY_FIGHT_VISION) + return FALSE + var/list/sides = shuffle(strafe_dirs(here, aim)) + var/back = REVERSE_DIR(last_strafe_dir) + if(back in sides) + sides -= back + sides.Insert(1, back) + for(var/side in sides) + var/turf/spot = get_step(here, side) + if(!spot || isspaceturf(spot) || isopenspaceturf(spot) || get_dist(spot, aim) > distance || !can_see(spot, aim, BOUNTY_FIGHT_VISION)) + continue + // The glide its body's own pace gives a step, as its movement loop sets it + owner.set_glide_size(DELAY_TO_GLIDE_SIZE(owner.cached_multiplicative_slowdown)) + if(!bounty_ai_step(owner, spot)) + continue + next_strafe_at = world.time + BOUNTY_CASCADE_STRAFE_GAP + last_strafe_dir = side + owner.face_atom(target) + return TRUE + return FALSE + +/// The two cardinal steps across the line from `here` to `aim`: north and south of a target mostly east or west, east and west of one mostly north or south +/datum/bounty_chrome_move/cascade/proc/strafe_dirs(turf/here, turf/aim) + if(abs(aim.x - here.x) >= abs(aim.y - here.y)) + return list(NORTH, SOUTH) + return list(EAST, WEST) + +#undef BOUNTY_CASCADE_RANGE +#undef BOUNTY_CASCADE_COLOR +#undef BOUNTY_CASCADE_STRAFE_PULSE +#undef BOUNTY_CASCADE_STRAFE_GAP diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_deadeye.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_deadeye.dm new file mode 100644 index 00000000000..a78ba8add97 --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_deadeye.dm @@ -0,0 +1,194 @@ +/// How far it locks on: the implant's tag range (CYBERWARE_DEADEYE_TAG_RANGE, ware_pro_combat.dm) +#define BOUNTY_DEADEYE_RANGE 9 +/// The lock's tell: red eyes and a red laser line on the mark, before the tag lands +#define BOUNTY_DEADEYE_TELL (1 SECONDS) +/// After a fight starts, how soon it locks on: early, so the tell runs while it draws and the first homing rounds come while the fight is young +#define BOUNTY_DEADEYE_OPENING (0.5 SECONDS) +/// The same for an SMG, if that style exists (a homing burst that early outguns most hunters) +#define BOUNTY_DEADEYE_OPENING_SMG (2 SECONDS) +/// How often a held tag checks the mark is still in sight and on its feet +#define BOUNTY_DEADEYE_WATCH (0.5 SECONDS) +/// The red of the eyes, the line and the outline: the implant's reticle red +#define BOUNTY_DEADEYE_RED "#ff3300" +/// The eye-colour priority of the red eyes while it holds a lock (over a cultist's) +#define BOUNTY_DEADEYE_EYES_PRIORITY (EYE_COLOR_CULT_PRIORITY + 10) + +/** + * # Deadeye link on a bounty gunman + * + * The optics players buy (/obj/item/organ/eyes/robotic/cyberware/deadeye, ware_pro_combat.dm), used as a + * player would: tag whoever it is shooting at, then shoot. The tag, its reticle on the mark, its three + * homing rounds, its 15 seconds and its 25 second cooldown are all the implant's; the AI picks the moment, + * shows it coming, and lets go when the mark gets away. + * + * - The moment: in a fight with a gun style (bounty_chrome_style_shoots()), at someone it may shoot who + * is on their feet and in sight within the tag's range. The first lock comes BOUNTY_DEADEYE_OPENING + * into the fight (BOUNTY_DEADEYE_OPENING_SMG with an SMG), then whenever the implant is off cooldown. + * - The tell, BOUNTY_DEADEYE_TELL: its eyes go red, a red laser line runs from it to the mark, a red + * outline pulses on it and the scope whirs. It keeps drawing, moving and shooting meanwhile + * (tell_keeps_acting): the lock costs it no shots, which is what makes the optics worth a gunman's while. + * Out of sight when the tell ends, the implant itself refuses the tag. + * - Then the implant's Deadeye Tag, Trigger()ed on the mark as a player's click: its next three rounds + * home in. The eyes stay red while the tag holds. + * - It lets go (the implant's own end_tag(), with its "lost it" chirp) the moment the mark is out of its + * sight or down, and when it goes down itself or the fight ends: breaking line of sight is the answer, + * and a homing round never chases someone round a corner or into crit. Letting go only ever costs it. + */ +/datum/bounty_chrome_move/deadeye + piece_key = BOUNTY_CHROME_DEADEYE + name = "Deadeye tag" + action_type = /datum/action/cooldown/cyberware/deadeye_tag + tell_time = BOUNTY_DEADEYE_TELL + tell_holds_still = FALSE + tell_keeps_acting = TRUE + tell_color = BOUNTY_DEADEYE_RED + tell_sound = 'sound/items/weapons/scope.ogg' + max_range = BOUNTY_DEADEYE_RANGE + /// The opening lock's timer + var/opening_timer + /// The held tag's sight check + var/watch_timer + /// Weakref to who it tagged, while the tag holds + var/datum/weakref/mark_ref + +/datum/bounty_chrome_move/deadeye/detach() + stop() + return ..() + +/datum/bounty_chrome_move/deadeye/Destroy() + stop() + return ..() + +/// Its optics, if they are in its owner +/datum/bounty_chrome_move/deadeye/proc/optics() + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = owner?.get_organ_slot(ORGAN_SLOT_EYES) + return istype(optics) ? optics : null + +/datum/bounty_chrome_move/deadeye/can_start(atom/target) + if(!..()) + return FALSE + var/mob/living/mark = target + if(!isliving(mark) || mark.stat != CONSCIOUS) + return FALSE + var/datum/bounty_style/style = bounty_ai_style(owner) + if(!bounty_chrome_style_shoots(style?.key)) + return FALSE + // One lock at a time: a tag still holding is the implant's to spend + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = optics() + return !optics?.tagged_ref?.resolve() + +/datum/bounty_chrome_move/deadeye/tell_text(atom/target) + return "[owner]'s eyes flash red." + +/datum/bounty_chrome_move/deadeye/tell(atom/target) + ..() + if(QDELETED(owner) || QDELETED(target)) + return + bounty_chrome_red_eyes(owner, REF(src), BOUNTY_DEADEYE_EYES_PRIORITY, TRUE, BOUNTY_DEADEYE_RED) + add_mark(owner.Beam(target, icon_state = "r_beam", time = tell_time, maxdistance = BOUNTY_DEADEYE_RANGE + 1)) + +/datum/bounty_chrome_move/deadeye/effect(atom/target) + . = ..() + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = optics() + if(!. || QDELETED(target) || optics?.tagged_ref?.resolve() != target) + eyes_off() + return + mark_ref = WEAKREF(target) + deltimer(watch_timer) + watch_timer = addtimer(CALLBACK(src, PROC_REF(watch)), BOUNTY_DEADEYE_WATCH, TIMER_STOPPABLE | TIMER_LOOP | TIMER_DELETE_ME) + +/datum/bounty_chrome_move/deadeye/cancel(reason) + eyes_off() + +/datum/bounty_chrome_move/deadeye/on_fight_start(atom/target) + deltimer(opening_timer) + opening_timer = addtimer(CALLBACK(src, PROC_REF(open)), opening_delay(), TIMER_STOPPABLE | TIMER_DELETE_ME) + +/datum/bounty_chrome_move/deadeye/on_fight_end() + deltimer(opening_timer) + opening_timer = null + let_go() + +/datum/bounty_chrome_move/deadeye/on_downed() + deltimer(opening_timer) + opening_timer = null + let_go() + +/datum/bounty_chrome_move/deadeye/on_death() + stop() + +/// How soon into a fight its first lock comes +/datum/bounty_chrome_move/deadeye/proc/opening_delay() +#ifdef BOUNTY_STYLE_SMG + var/datum/bounty_style/style = bounty_ai_style(owner) + if(style?.key == BOUNTY_STYLE_SMG) + return BOUNTY_DEADEYE_OPENING_SMG +#endif + return BOUNTY_DEADEYE_OPENING + +/// The opening lock, early in the fight: its tell starts now if it can (the framework's opening gap is for the other moves) +/datum/bounty_chrome_move/deadeye/proc/open() + opening_timer = null + var/datum/component/bounty_chrome/chrome = owner?.GetComponent(/datum/component/bounty_chrome) + if(!chrome?.fighting || chrome.telling) + return + var/atom/target = bounty_chrome_target(owner) + if(can_start(target)) + chrome.start_move(src, target) + +/// The held tag's look: it lets go when the mark is out of sight or down, or it can't shoot anyway; it stops looking once the implant has spent or dropped the tag +/datum/bounty_chrome_move/deadeye/proc/watch() + var/mob/living/mark = mark_ref?.resolve() + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = optics() + if(!mark || QDELETED(owner) || optics?.tagged_ref?.resolve() != mark) + stop() + return + if(owner.stat != CONSCIOUS || mark.stat != CONSCIOUS || !bounty_ai_can_see(owner, mark, BOUNTY_DEADEYE_RANGE)) + let_go(lost = TRUE) + +/// Lets go of its tag through the implant's own end_tag(), if it holds one. `lost`: the mark got away, and the optics chirp. +/datum/bounty_chrome_move/deadeye/proc/let_go(lost = FALSE) + var/mob/living/mark = mark_ref?.resolve() + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = optics() + if(mark && optics?.tagged_ref?.resolve() == mark) + optics.end_tag(owner) + if(lost && owner.stat == CONSCIOUS) + playsound(owner, 'sound/machines/synth/synth_no.ogg', 30, TRUE) + stop() + +/// Stops watching a tag (whatever became of it) and its eyes go back to their own colour +/datum/bounty_chrome_move/deadeye/proc/stop() + deltimer(watch_timer) + watch_timer = null + mark_ref = null + eyes_off() + +/datum/bounty_chrome_move/deadeye/proc/eyes_off() + if(owner) + bounty_chrome_red_eyes(owner, REF(src), BOUNTY_DEADEYE_EYES_PRIORITY, FALSE) + +/** + * Turns `npc`'s eyes `color` (on) or back (off) for `source`, at eye-colour `priority` (tg's own + * add_eye_color(), as a cultist's go red), and gives examine its "eyes are glowing" line. A look only. + * Each source keeps its own priority, so two of them never clear each other's. Redline uses it too. + */ +/proc/bounty_chrome_red_eyes(mob/living/carbon/human/npc, source, priority, on, color = BOUNTY_DEADEYE_RED) + if(!ishuman(npc) || QDELETED(npc)) + return + var/had = !isnull(LAZYACCESS(npc.eye_color_left_overrides, "[priority]")) + if(on) + ADD_TRAIT(npc, TRAIT_UNNATURAL_RED_GLOWY_EYES, source) + if(!had) + npc.add_eye_color(color, priority) + return + REMOVE_TRAIT(npc, TRAIT_UNNATURAL_RED_GLOWY_EYES, source) + if(had) + npc.remove_eye_color(priority) + +#undef BOUNTY_DEADEYE_RANGE +#undef BOUNTY_DEADEYE_TELL +#undef BOUNTY_DEADEYE_OPENING +#undef BOUNTY_DEADEYE_OPENING_SMG +#undef BOUNTY_DEADEYE_WATCH +#undef BOUNTY_DEADEYE_RED +#undef BOUNTY_DEADEYE_EYES_PRIORITY diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_gorilla.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_gorilla.dm new file mode 100644 index 00000000000..30b56a46aa3 --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_gorilla.dm @@ -0,0 +1,816 @@ +/** + * # Bounty chrome: Gorilla Arms + * + * Owner: C2 (Mantis Blades and Gorilla Arms). Built on the chrome core, bounty_chrome.dm. + * + * The player's Gorilla Arms (voidcrew/modules/cyberware/ware_military_arms.dm), the real pair of myomer + * lattices, in a brawler, a brawler companion or a Juggernaut. Everything they do is the implant's: a punch + * from an arm whose own 4 second cooldown is over is its empowered punch (flat brute, wound bonus, a knock + * back a tile), the same punch on someone it holds in an aggressive grab hurls them across the room, and + * with both arms in it pries airlocks open by hand. The AI only chooses how to use them, with a tell of at + * least BOUNTY_CHROME_TELL_MIN before each big blow (owner decision 5): + * - Ordinary blows (/datum/ai_behavior/basic_melee_attack/human_npc/bounty/gorilla) are thrown with a fist + * whose punch isn't loaded, so they are plain punches. Its loaded fists wait for the two told moves. + * - The piston punch (/datum/bounty_chrome_move/gorilla_punch): it draws back a fist in an orange outline + * and the tile it aims at lights up, then it punches with a loaded arm: the implant's punch. Stepping off + * the lit tile makes it hit air. + * - The grab (/datum/bounty_chrome_move/gorilla_grab): with one fist just used and the other loaded, it + * reaches for someone beside it and takes hold, tg's own pull (the player's first ctrl-click), while an + * orange ring lights under them. A step away or Resist breaks that at once. If they stay, the grip + * tightens into tg's aggressive grab (the second ctrl-click); then two punches to the face with the used + * fist (tg's grapple pummel), and the loaded fist's punch, which on someone held fast is the implant's + * hurl. tg's grab rules hold throughout: Resist struggles free at tg's odds, a friend can pull them away, + * and a stun, a knockdown, cuffs or an EMP make it let go. It never grabs someone on the floor, held by + * someone else, or someone it just let go of (BOUNTY_GORILLA_GRAB_GUARD), and it never throws anyone + * toward a drop, lava or space. + * - Doors (/datum/bounty_chrome_move/gorilla_doors): when whoever it fights is behind a closed airlock + * near it, it puts a hand on the door out of combat mode, as the set bonus lets a player: an unpowered + * door gives at once, a powered one after the implant's slow pry, and bolts, welds and seals hold (the + * implant refuses them, and the AI leaves them alone). Only on its own ground (environment_damage_allowed()): + * never a ship, a hangar, an outpost, or a door with space or a big pressure difference behind it. + * + * A Juggernaut carrying the arms gets the grab as an ability in its own rotation (the core's adaptor); its + * own blows are its kit's melee, so the implant empowers them as it would a player's. + */ + +/// The piston punch's tell: the fist drawn back, long enough to step off the lit tile +#define BOUNTY_GORILLA_PUNCH_TELL (0.8 SECONDS) +/// The grab's tell, a hand on them: long enough to pull free before the grip tightens +#define BOUNTY_GORILLA_GRAB_TELL (0.8 SECONDS) +/// The first piston punch and the first grab of a fight come no sooner than these +#define BOUNTY_GORILLA_PUNCH_OPENING (1.5 SECONDS) +#define BOUNTY_GORILLA_GRAB_OPENING (3 SECONDS) +/// Between two grabs of one gorilla +#define BOUNTY_GORILLA_GRAB_COOLDOWN (12 SECONDS) +/// Someone it let go of isn't grabbed by it again for this long +#define BOUNTY_GORILLA_GRAB_GUARD (12 SECONDS) +/// Punches to the face before the finisher +#define BOUNTY_GORILLA_PUMMEL_PUNCHES 2 +/// It lets go after holding someone fast this long, whatever became of the punches +#define BOUNTY_GORILLA_HOLD_MAX (4 SECONDS) +/** + * A grab starts only while its used fist stays unloaded at least this long: the tell, the second + * ctrl-click and both face punches come a click apart (CLICK_CD_MELEE), so the face punches are plain + * ones and only the finisher is the implant's punch (its cooldown is per arm, CYBERWARE_GORILLA_PUNCH_COOLDOWN). + */ +#define BOUNTY_GORILLA_PUMMEL_FIST_LEFT (2.6 SECONDS) +/// How far the implant hurls someone held fast (CYBERWARE_GORILLA_HURL_RANGE), and knocks back anyone else +#define BOUNTY_GORILLA_HURL_RANGE 4 +#define BOUNTY_GORILLA_KNOCKBACK_RANGE 1 +/// Doors: how far one may be to go for, and how near its quarry has to be to it, out of sight, to be taken for behind it +#define BOUNTY_GORILLA_DOOR_REACH 2 +#define BOUNTY_GORILLA_BEHIND_RANGE 5 +/// Someone further off than this isn't worth breaking in for +#define BOUNTY_GORILLA_CHASE_RANGE 9 +/// Someone it lost sight of is still worth a door this long +#define BOUNTY_GORILLA_QUARRY_MEMORY (12 SECONDS) +/// A door it went for is left alone this long, so it doesn't paw at one that won't give +#define BOUNTY_GORILLA_DOOR_RETRY (10 SECONDS) +/// A door with more than this pressure (kPa) between its sides stays shut +#define BOUNTY_GORILLA_PRESSURE_GAP 20 +/// The arms' orange: the outline, and the ring under someone it reaches for (the mark art's red turned orange by a matrix) +#define BOUNTY_GORILLA_COLOR "#ff8a1f" +#define BOUNTY_GORILLA_MARK_MATRIX list(1, 0.54, 0.12, 0, 0, 0, 0, 0, 0) +/// Trait source for standing still while it holds someone +#define BOUNTY_GORILLA_TRAIT "bounty_chrome_gorilla" +/// What a mini-boss's boss_busy reads while it holds someone +#define BOUNTY_GORILLA_BOSS_BUSY "gorilla_hold" + +// ===== THE ARMS ===== + +/// The Gorilla Arms lattices in `npc` +/proc/bounty_gorilla_arms(mob/living/carbon/npc) + . = list() + if(QDELETED(npc)) + return + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in npc.organs) + . += arm + +/// Whether `arm`'s next punch is the implant's: its chrome running and its own cooldown over +/proc/bounty_gorilla_loaded(obj/item/organ/cyberimp/cyberware/gorilla_arms/arm) + var/datum/component/cyberware/chrome = arm?.GetComponent(/datum/component/cyberware) + if(!chrome?.is_running() || (arm.organ_flags & ORGAN_FAILING)) + return FALSE + return COOLDOWN_FINISHED(arm, slam_cooldown) + +/// How long until `arm`'s punch is loaded again: 0 if it is, INFINITY while its chrome is down (its punches are plain ones) +/proc/bounty_gorilla_reload_left(obj/item/organ/cyberimp/cyberware/gorilla_arms/arm) + var/datum/component/cyberware/chrome = arm?.GetComponent(/datum/component/cyberware) + if(!chrome?.is_running() || (arm.organ_flags & ORGAN_FAILING)) + return INFINITY + return COOLDOWN_TIMELEFT(arm, slam_cooldown) + +/// The free hand (a held index) on `arm`'s side of `npc`, or null +/proc/bounty_gorilla_hand(mob/living/carbon/npc, obj/item/organ/cyberimp/cyberware/gorilla_arms/arm) + return npc.get_empty_held_index_for_side(arm.zone == BODY_ZONE_R_ARM ? RIGHT_HANDS : LEFT_HANDS) + +/// The lattice in the arm of `npc`'s hand `index`, or null +/proc/bounty_gorilla_arm_of_hand(mob/living/carbon/npc, index) + var/zone = IS_RIGHT_INDEX(index) ? BODY_ZONE_R_ARM : BODY_ZONE_L_ARM + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in bounty_gorilla_arms(npc)) + if(arm.zone == zone) + return arm + return null + +/// An arm of `npc`'s whose punch is loaded and whose hand is free, or null +/proc/bounty_gorilla_loaded_arm(mob/living/carbon/npc) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in bounty_gorilla_arms(npc)) + if(bounty_gorilla_loaded(arm) && bounty_gorilla_hand(npc, arm)) + return arm + return null + +/** + * An arm of `npc`'s whose loaded punch would hurl someone it holds fast, or null. Only the real Gorilla + * Arms hurl (heavy_moves and a hurl_range: Brawler myomers never do), and the hurl shares the bearer's + * grab-move cooldown with the slam (hyperviolence_gorilla/gorilla_punch.dm, piston_hit()). + */ +/proc/bounty_gorilla_hurl_arm(mob/living/carbon/npc) + if(!TIMER_COOLDOWN_FINISHED(npc, COOLDOWN_GORILLA_GRAB_MOVE)) + return null + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in bounty_gorilla_arms(npc)) + if(arm.heavy_moves && arm.hurl_range > 0 && bounty_gorilla_loaded(arm) && bounty_gorilla_hand(npc, arm)) + return arm + return null + +/// A free hand of `npc`'s whose punch is a plain one (its arm's punch not loaded), the one furthest from loaded, or null +/proc/bounty_gorilla_plain_hand(mob/living/carbon/npc) + var/best + var/best_left = 0 + for(var/index in npc.get_empty_held_indexes()) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = bounty_gorilla_arm_of_hand(npc, index) + var/left = arm ? bounty_gorilla_reload_left(arm) : INFINITY + if(left > best_left) + best = index + best_left = left + return best + +/// Makes `npc`'s hand `index` the one it punches with. TRUE if it is. +/proc/bounty_gorilla_use_hand(mob/living/carbon/npc, index) + if(!index || npc.held_items[index]) + return FALSE + if(npc.active_hand_index != index) + npc.swap_hand(index, silent = TRUE) + return npc.active_hand_index == index + +/// Whether it has the two fists a grab needs: one loaded for the finisher that can hurl, and another that stays unloaded through the face punches +/proc/bounty_gorilla_fists_for_grab(mob/living/carbon/npc) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/finisher = bounty_gorilla_hurl_arm(npc) + if(!finisher) + return FALSE + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in bounty_gorilla_arms(npc)) + if(arm != finisher && bounty_gorilla_hand(npc, arm) && bounty_gorilla_reload_left(arm) >= BOUNTY_GORILLA_PUMMEL_FIST_LEFT) + return TRUE + return FALSE + +/// The way `npc` faces `victim` (face_atom()'s rule), which is the way the implant throws them +/proc/bounty_gorilla_facing(atom/npc, atom/victim) + var/dx = victim.x - npc.x + var/dy = victim.y - npc.y + if(abs(dx) < abs(dy)) + return dy > 0 ? NORTH : SOUTH + return dx > 0 ? EAST : WEST + +/// Whether nothing in the `tiles` tiles behind `victim`, seen from `npc`, is a drop, lava or space: where the implant would throw them +/proc/bounty_gorilla_safe_behind(atom/npc, mob/living/victim, tiles) + var/direction = bounty_gorilla_facing(npc, victim) + var/turf/spot = get_turf(victim) + for(var/i in 1 to tiles) + spot = get_step(spot, direction) + if(!spot || isclosedturf(spot)) + return TRUE + if(isspaceturf(spot) || isopenspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot)) + return FALSE + return TRUE + +// ===== ORDINARY BLOWS ===== + +/** + * A Gorilla Arms carrier's ordinary blows: the bounty brawler's melee (a punch, now and then a shove, + * bounty_styles.dm) thrown with a plain fist. Its loaded fists wait for the piston punch and the grab, + * which it tells first; with no plain fist free it stays on its target and throws nothing. A mech, or + * anything that isn't a person, takes whatever fist. + */ +/datum/ai_behavior/basic_melee_attack/human_npc/bounty/gorilla + +/datum/ai_behavior/basic_melee_attack/human_npc/bounty/gorilla/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/fighter = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(isliving(target) && !human_npc_held_melee_weapon(fighter)) + if(!bounty_gorilla_use_hand(fighter, bounty_gorilla_plain_hand(fighter))) + return AI_BEHAVIOR_DELAY + return ..() + +// ===== THE PISTON PUNCH ===== + +/** + * The piston punch: a fist drawn back in an orange outline over the lit tile for BOUNTY_GORILLA_PUNCH_TELL, + * then a loaded fist's punch at whoever still stands there, the implant's own. It also runs the gorilla's + * fighting each planning tick (plan()): the grab first when it can start, then this, else its ordinary blows. + */ +/datum/bounty_chrome_move/gorilla_punch + piece_key = BOUNTY_CHROME_GORILLA + name = "piston punch" + tell_time = BOUNTY_GORILLA_PUNCH_TELL + tell_color = BOUNTY_GORILLA_COLOR + tell_sound = 'sound/machines/piston/piston_raise.ogg' + max_range = 1 + // A mini-boss's own blows are the implant's punches already: the grab is the move it gets + boss_usable = FALSE + /// world.time its first piston punch of this fight may start + var/opens_at = 0 + /// world.time this fight started + var/fight_started_at = 0 + /// The tile the fist is aimed at, through the tell + var/turf/aimed_turf + +/datum/bounty_chrome_move/gorilla_punch/detach() + aimed_turf = null + return ..() + +/datum/bounty_chrome_move/gorilla_punch/on_fight_start(atom/target) + fight_started_at = world.time + opens_at = world.time + BOUNTY_GORILLA_PUNCH_OPENING + +/datum/bounty_chrome_move/gorilla_punch/can_start(atom/target) + var/mob/living/victim = target + if(!isliving(victim) || !isturf(victim.loc) || !owner.Adjacent(victim) || bounty_gorilla_held_by_another(victim, owner) || HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED)) + return FALSE + if(!..()) + return FALSE + if(!bounty_gorilla_loaded_arm(owner)) + return FALSE + return bounty_gorilla_safe_behind(owner, victim, BOUNTY_GORILLA_KNOCKBACK_RANGE) + +/datum/bounty_chrome_move/gorilla_punch/plan(datum/ai_controller/controller, atom/target) + var/datum/component/bounty_chrome/chrome = owner.GetComponent(/datum/component/bounty_chrome) + if(!chrome) + return FALSE + var/datum/bounty_chrome_move/gorilla_grab/grab = chrome.move_of(/datum/bounty_chrome_move/gorilla_grab) + if(grab) + if(grab.held_ref) + // It is holding someone: its grab's own clock throws the punches + return TRUE + if(grab.may_go_now() && grab.can_start(target) && chrome.start_move(grab, target)) + return TRUE + if(grab.pending(target)) + // The fist it just used is the one the face punches come from: it closes in for the grab and throws nothing meanwhile + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, 1) + return TRUE + if(bounty_melee_chrome_may_start(owner, opens_at, fight_started_at) && can_start(target) && chrome.start_move(src, target)) + return TRUE + // Another piece's move that plans its own behaviours, and comes after this one, plans first: ending the planning here would starve it + var/after = chrome.moves.Find(src) + for(var/index in after + 1 to length(chrome.moves)) + var/datum/bounty_chrome_move/later = chrome.moves[index] + if(later.piece_key != BOUNTY_CHROME_GORILLA && later.plan(controller, target)) + return TRUE + // Another piece's move is ready: the core starts it, so planning goes on to it + if(world.time >= chrome.next_move_at) + for(var/datum/bounty_chrome_move/other as anything in chrome.moves) + if(other.action_type && other.can_start(target)) + return FALSE + // Its ordinary blows, with a plain fist. Anything in its hands fights by the style's own melee. + if(!isliving(target) || human_npc_held_melee_weapon(owner)) + return FALSE + controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/human_npc/bounty/gorilla, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return TRUE + +/datum/bounty_chrome_move/gorilla_punch/tell_text(atom/target) + return "[owner] draws back a chrome fist!" + +/datum/bounty_chrome_move/gorilla_punch/tell(atom/target) + ..() + aimed_turf = get_turf(target) + if(aimed_turf) + add_mark(new /obj/effect/temp_visual/bounty_boss_mark(aimed_turf, tell_time + 1)) + +/datum/bounty_chrome_move/gorilla_punch/effect(atom/target) + var/turf/spot = aimed_turf + aimed_turf = null + var/mob/living/victim = target + if(!isliving(victim) || QDELETED(victim) || get_turf(victim) != spot || !owner.Adjacent(victim)) + // They stepped off the lit tile + if(spot && owner.Adjacent(spot)) + owner.face_atom(spot) + owner.do_attack_animation(spot) + playsound(owner, 'sound/items/weapons/punchmiss.ogg', 50, TRUE) + owner.visible_message(span_warning("[owner]'s fist swings through empty air.")) + return FALSE + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = bounty_gorilla_loaded_arm(owner) + if(!arm || !bounty_gorilla_safe_behind(owner, victim, BOUNTY_GORILLA_KNOCKBACK_RANGE)) + return FALSE + if(!bounty_gorilla_use_hand(owner, bounty_gorilla_hand(owner, arm))) + return FALSE + owner.zone_selected = BODY_ZONE_CHEST + return !!owner.ai_controller?.ai_interact(target = victim, combat_mode = TRUE) + +/// Whether someone other than `npc` holds `victim` fast: it doesn't swing at someone who can't step off the lit tile, or grab them from another +/proc/bounty_gorilla_held_by_another(mob/living/victim, mob/living/npc) + return !!victim.pulledby && victim.pulledby != npc && victim.pulledby.grab_state >= GRAB_AGGRESSIVE + +// ===== THE GRAB ===== + +/** + * The grab and pummel. Its tell is a hand on them (tg's pull) and an orange ring under them for + * BOUNTY_GORILLA_GRAB_TELL; then tg's aggressive grab, two face punches with a plain fist and the loaded + * fist's hurl, each a click apart, driven by its own clock (pummel()), not the AI's planning. A mini-boss + * gets it as an ability in its rotation, busy while it holds on. + */ +/datum/bounty_chrome_move/gorilla_grab + piece_key = BOUNTY_CHROME_GORILLA + name = "grab" + tell_time = BOUNTY_GORILLA_GRAB_TELL + tell_color = BOUNTY_GORILLA_COLOR + tell_sound = 'sound/machines/piston/piston_lower.ogg' + max_range = 1 + /// world.time its next grab may start (its opener, then BOUNTY_GORILLA_GRAB_COOLDOWN) + var/grab_ready_at = 0 + /// world.time this fight started + var/fight_started_at = 0 + /// Who its hand is on through the tell + var/datum/weakref/reach_ref + /// Who it holds fast now + var/datum/weakref/held_ref + /// Face punches thrown in this hold + var/punches = 0 + /// Goes up with every hold and every let-go, so a stale timer does nothing + var/hold_serial = 0 + /// The next click of the hold, and the hold's own clock + var/step_timer + var/hold_timer + /// A mini-boss's busy serial while it holds on + var/boss_serial = 0 + /// REF() of someone it let go of -> world.time it may grab them again + var/list/guard + +/datum/bounty_chrome_move/gorilla_grab/attach(mob/living/carbon/human/npc/npc) + . = ..() + // A mini-boss runs it through its own rotation; the core wraps only moves with an implant action, so this one is wrapped here + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + if(istype(boss) && !boss_ability) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/ability = new(boss) + ability.set_move(src) + ability.Grant(boss) + boss.boss_abilities += ability + +/datum/bounty_chrome_move/gorilla_grab/detach() + let_go() + reach_ref = null + guard = null + return ..() + +/datum/bounty_chrome_move/gorilla_grab/on_fight_start(atom/target) + fight_started_at = world.time + grab_ready_at = max(grab_ready_at, world.time + BOUNTY_GORILLA_GRAB_OPENING) + +/datum/bounty_chrome_move/gorilla_grab/on_fight_end() + let_go() + +/datum/bounty_chrome_move/gorilla_grab/on_downed() + let_go() + +/datum/bounty_chrome_move/gorilla_grab/on_death() + let_go() + +/datum/bounty_chrome_move/gorilla_grab/on_emp(severity) + // Its chrome glitching, it lets go + let_go() + +/datum/bounty_chrome_move/gorilla_grab/plan(datum/ai_controller/controller, atom/target) + // Holding someone, nothing else is planned: its own clock throws the punches + return !!held_ref + +/** + * Whether its other chrome lets the grab start now: past the gap after the last move fired, or straight + * after a piston punch, the fist that punch used being the one the face punches come from. + */ +/datum/bounty_chrome_move/gorilla_grab/proc/may_go_now() + var/datum/component/bounty_chrome/chrome = owner?.GetComponent(/datum/component/bounty_chrome) + if(!chrome || chrome.telling) + return FALSE + if(world.time >= chrome.next_move_at) + return TRUE + var/datum/bounty_chrome_move/gorilla_punch/punch = chrome.move_of(/datum/bounty_chrome_move/gorilla_punch) + if(!punch?.last_fired || punch.last_fired < fight_started_at) + return FALSE + return punch.last_fired > bounty_melee_chrome_last_move_at(owner, list(/datum/bounty_chrome_move/gorilla_punch)) + +/datum/bounty_chrome_move/gorilla_grab/can_start(atom/target) + var/mob/living/victim = target + if(held_ref || world.time < grab_ready_at || world.time < owner.next_move || HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED) || !may_grab(victim)) + return FALSE + if(!..()) + return FALSE + if(!bounty_gorilla_fists_for_grab(owner)) + return FALSE + return bounty_gorilla_safe_behind(owner, victim, BOUNTY_GORILLA_HURL_RANGE) + +/// Whether a grab at `target` could follow as soon as it is beside them: its fists and its other chrome allow one now, and they are someone it may grab +/datum/bounty_chrome_move/gorilla_grab/proc/pending(atom/target) + if(held_ref || world.time < grab_ready_at || !may_go_now() || !bounty_gorilla_fists_for_grab(owner)) + return FALSE + var/mob/living/victim = target + return may_grab(victim, need_adjacent = FALSE) && bounty_ai_dist(owner, victim) <= 2 && bounty_chrome_can_act(owner) + +/** + * Whether it may take hold of `victim`: someone beside it (unless `need_adjacent` is FALSE), awake and on + * their feet on a tile of their own, whom tg's grab can take hold of and tighten on, not held by anyone + * else, and not someone it let go of in the last BOUNTY_GORILLA_GRAB_GUARD. + */ +/datum/bounty_chrome_move/gorilla_grab/proc/may_grab(mob/living/victim, need_adjacent = TRUE) + if(!isliving(victim) || QDELETED(victim) || victim == owner || !isturf(victim.loc) || victim.buckled || victim.anchored) + return FALSE + if(victim.stat != CONSCIOUS || victim.body_position == LYING_DOWN || (need_adjacent && !owner.Adjacent(victim))) + return FALSE + // tg's grab never tightens on these (grabbedby()) + if(issilicon(victim) || !(victim.status_flags & CANPUSH) || HAS_TRAIT(victim, TRAIT_PUSHIMMUNE)) + return FALSE + if(victim.pulledby && victim.pulledby != owner) + return FALSE + if(LAZYACCESS(guard, REF(victim)) > world.time) + return FALSE + return victim.can_be_pulled(owner, owner.pull_force) + +/datum/bounty_chrome_move/gorilla_grab/tell_text(atom/target) + return "[owner] reaches for [target] with both chrome hands!" + +/datum/bounty_chrome_move/gorilla_grab/tell(atom/target) + ..() + grab_ready_at = world.time + BOUNTY_GORILLA_GRAB_COOLDOWN + var/turf/spot = get_turf(target) + if(spot) + add_mark(new /obj/effect/temp_visual/bounty_boss_mark(spot, tell_time + 1, BOUNTY_GORILLA_MARK_MATRIX, "target_circle")) + var/mob/living/victim = target + if(!isliving(victim)) + return + // Its hand on them: tg's pull, a player's first ctrl-click. It hurts nobody, and a step or Resist breaks it. + reach_ref = WEAKREF(victim) + grab_click(victim) + +/// A player's ctrl-click on `victim`: tg's grab, a pull the first time and the grip tightened the next +/datum/bounty_chrome_move/gorilla_grab/proc/grab_click(mob/living/victim) + return !!owner.ai_controller?.ai_interact(target = victim, modifiers = list(CTRL_CLICK = TRUE)) + +/datum/bounty_chrome_move/gorilla_grab/cancel(reason) + // Cut short with its hand on them: it lets go + var/mob/living/victim = reach_ref?.resolve() + reach_ref = null + if(victim && !QDELETED(owner) && owner.pulling == victim && !held_ref) + owner.stop_pulling() + +/datum/bounty_chrome_move/gorilla_grab/effect(atom/target) + var/mob/living/victim = reach_ref?.resolve() + reach_ref = null + if(!isliving(victim) || victim != target || owner.pulling != victim) + return FALSE + if(!may_hold(victim)) + owner.stop_pulling() + return FALSE + // They stayed: the grip tightens, the second ctrl-click + grab_click(victim) + if(!holding(victim)) + if(owner.pulling == victim) + owner.stop_pulling() + return FALSE + start_hold(victim) + return TRUE + +/// Whether it holds `victim` fast now: tg's aggressive grab, its own +/datum/bounty_chrome_move/gorilla_grab/proc/holding(mob/living/victim) + return !QDELETED(owner) && !QDELETED(victim) && owner.pulling == victim && owner.grab_state >= GRAB_AGGRESSIVE + +/// Whether `victim` is someone it goes on holding: awake, on their feet and beside it +/datum/bounty_chrome_move/gorilla_grab/proc/may_hold(mob/living/victim) + return !QDELETED(victim) && victim.stat == CONSCIOUS && victim.body_position != LYING_DOWN && isturf(victim.loc) && owner.Adjacent(victim) + +/// Whether the hold goes on: it holds them fast, can still act with its hands, isn't cut off as a mini-boss, and they are still someone it holds +/datum/bounty_chrome_move/gorilla_grab/proc/hold_ok(mob/living/victim) + if(!holding(victim) || !may_hold(victim)) + return FALSE + if(!bounty_chrome_can_act(owner) || HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED)) + return FALSE + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + if(istype(boss) && (boss.boss_busy != BOUNTY_GORILLA_BOSS_BUSY || boss.boss_busy_serial != boss_serial)) + return FALSE + return TRUE + +/// It holds `victim` fast: it stands still, and its clock throws the punches a click apart +/datum/bounty_chrome_move/gorilla_grab/proc/start_hold(mob/living/victim) + held_ref = WEAKREF(victim) + punches = 0 + hold_serial++ + // Struggled free, pulled away by a friend, thrown by the finisher: tg lets go, and it hears of it + RegisterSignal(owner, COMSIG_ATOM_NO_LONGER_PULLING, PROC_REF(on_no_longer_pulling)) + RegisterSignals(owner, list(SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED), SIGNAL_ADDTRAIT(TRAIT_HANDS_BLOCKED), SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_disabled)) + ADD_TRAIT(owner, TRAIT_IMMOBILIZED, BOUNTY_GORILLA_TRAIT) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + if(istype(boss)) + boss.boss_next_ability_at = max(boss.boss_next_ability_at, world.time + BOUNTY_GORILLA_HOLD_MAX) + boss_serial = boss.boss_set_busy(BOUNTY_GORILLA_BOSS_BUSY, BOUNTY_GORILLA_HOLD_MAX + 1 SECONDS) + hold_timer = addtimer(CALLBACK(src, PROC_REF(hold_expired), hold_serial), BOUNTY_GORILLA_HOLD_MAX, TIMER_STOPPABLE | TIMER_DELETE_ME) + next_click() + +/// Its next click of the hold, the moment its hand is free to click again +/datum/bounty_chrome_move/gorilla_grab/proc/next_click() + deltimer(step_timer) + step_timer = addtimer(CALLBACK(src, PROC_REF(pummel), hold_serial), max(owner.next_move - world.time, 1), TIMER_STOPPABLE | TIMER_DELETE_ME) + +/** + * One click of the hold under `serial`: a punch to the face with a plain fist while any are left, then + * the loaded fist's punch, the implant's hurl. With no loaded fist yet it holds on and waits, until the + * hold's own clock lets them go. If the hold broke meanwhile it lets go and nothing lands. Returns TRUE if + * it threw a punch. + */ +/datum/bounty_chrome_move/gorilla_grab/proc/pummel(serial) + step_timer = null + if(serial != hold_serial || !held_ref) + return FALSE + var/mob/living/victim = held_ref.resolve() + if(!hold_ok(victim)) + let_go() + return FALSE + if(world.time < owner.next_move) + next_click() + return FALSE + if(punches < BOUNTY_GORILLA_PUMMEL_PUNCHES) + var/plain = bounty_gorilla_plain_hand(owner) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/plain_arm = plain && bounty_gorilla_arm_of_hand(owner, plain) + if(plain && (!plain_arm || !bounty_gorilla_loaded(plain_arm))) + punches++ + punch(victim, plain) + if(serial == hold_serial && held_ref) + next_click() + return TRUE + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/finisher = bounty_gorilla_hurl_arm(owner) + if(!finisher) + // Its loaded fist isn't back yet: it holds on + step_timer = addtimer(CALLBACK(src, PROC_REF(pummel), hold_serial), 1, TIMER_STOPPABLE | TIMER_DELETE_ME) + return FALSE + if(!bounty_gorilla_safe_behind(owner, victim, BOUNTY_GORILLA_HURL_RANGE)) + let_go() + return FALSE + punch(victim, bounty_gorilla_hand(owner, finisher)) + // The fist lands a moment after the click (GORILLA_THRUST_LANDS), and only lands as the hurl while it still holds + // them. The hurl takes them out of its hands (tg ends the pull as they fly); if anything stopped it, it lets go then. + step_timer = addtimer(CALLBACK(src, PROC_REF(after_hurl), hold_serial), GORILLA_THRUST_LANDS + 1, TIMER_STOPPABLE | TIMER_DELETE_ME) + return TRUE + +/// The hurl's fist under `serial` has landed: if it still holds them, something stopped the hurl, and it lets go +/datum/bounty_chrome_move/gorilla_grab/proc/after_hurl(serial) + step_timer = null + if(serial == hold_serial && held_ref) + let_go() + +/// A punch to `victim`'s face with hand `index`: a player's click in combat mode, so tg's punch, or the implant's if that fist is loaded +/datum/bounty_chrome_move/gorilla_grab/proc/punch(mob/living/victim, index) + if(!bounty_gorilla_use_hand(owner, index)) + return FALSE + owner.zone_selected = BODY_ZONE_HEAD + return !!owner.ai_controller?.ai_interact(target = victim, combat_mode = TRUE) + +/// The hold's own clock under `serial` ran out: it lets go +/datum/bounty_chrome_move/gorilla_grab/proc/hold_expired(serial) + hold_timer = null + if(serial == hold_serial && held_ref) + let_go() + +/// tg let go of someone it pulled (inside stop_pulling()). If it was whoever it held, the hold is over. Must not sleep. +/datum/bounty_chrome_move/gorilla_grab/proc/on_no_longer_pulling(datum/source, atom/movable/was_pulling) + SIGNAL_HANDLER + if(was_pulling == held_ref?.resolve()) + end_hold() + +/// Stunned, knocked down, cuffed or its hands tied up mid-hold: it lets go. Inside a signal handler. +/datum/bounty_chrome_move/gorilla_grab/proc/on_disabled(datum/source) + SIGNAL_HANDLER + let_go() + +/// Lets go of whoever it holds, or has its hand on (tg's stop_pulling()). Safe at any time. +/datum/bounty_chrome_move/gorilla_grab/proc/let_go() + var/mob/living/victim = held_ref?.resolve() || reach_ref?.resolve() + end_hold() + reach_ref = null + if(victim && !QDELETED(owner) && owner.pulling == victim) + owner.stop_pulling() + +/// The hold is over: it may move again, a mini-boss is free, and whoever it held isn't grabbed by it again for a while +/datum/bounty_chrome_move/gorilla_grab/proc/end_hold() + if(!held_ref) + return + var/mob/living/victim = held_ref.resolve() + held_ref = null + punches = 0 + hold_serial++ + deltimer(step_timer) + step_timer = null + deltimer(hold_timer) + hold_timer = null + if(owner) + UnregisterSignal(owner, list(COMSIG_ATOM_NO_LONGER_PULLING, SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED), SIGNAL_ADDTRAIT(TRAIT_HANDS_BLOCKED), SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) + REMOVE_TRAIT(owner, TRAIT_IMMOBILIZED, BOUNTY_GORILLA_TRAIT) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + if(istype(boss) && boss_serial && boss.boss_busy == BOUNTY_GORILLA_BOSS_BUSY && boss.boss_busy_serial == boss_serial) + boss.boss_clear_busy(boss_serial) + boss_serial = 0 + if(!victim) + return + // Forget the guards that are over, then guard this one + for(var/key in guard) + if(guard[key] <= world.time) + guard -= key + LAZYSET(guard, REF(victim), world.time + BOUNTY_GORILLA_GRAB_GUARD) + +// ===== DOORS ===== + +/** + * Doors. Whoever it fights got behind a closed airlock near it: it goes to the door and puts a hand on it + * out of combat mode, which the arms' set bonus turns into the implant's own pry (tg's door_pryer). Looked + * at every Life tick (on_tick()), since the one it fought may no longer be its target once out of sight. + */ +/datum/bounty_chrome_move/gorilla_doors + piece_key = BOUNTY_CHROME_GORILLA + name = "door pry" + boss_usable = FALSE + /// Who it was last fighting, and when it last saw them + var/datum/weakref/quarry_ref + var/quarry_seen_at = 0 + /// REF() of a door it went for -> world.time it may go for it again + var/list/tried + +/datum/bounty_chrome_move/gorilla_doors/detach() + quarry_ref = null + tried = null + return ..() + +/datum/bounty_chrome_move/gorilla_doors/plan(datum/ai_controller/controller, atom/target) + note_quarry(target) + return FALSE + +/datum/bounty_chrome_move/gorilla_doors/on_fight_end() + quarry_ref = null + +/datum/bounty_chrome_move/gorilla_doors/on_tick(seconds_per_tick, fighting, atom/target) + if(!fighting) + quarry_ref = null + return + note_quarry(target) + try_door() + +/// Remembers `target` as who it fights while it can see them +/datum/bounty_chrome_move/gorilla_doors/proc/note_quarry(atom/target) + if(!QDELETED(target) && (isliving(target) || ismecha(target)) && bounty_ai_can_see(owner, target, BOUNTY_GORILLA_CHASE_RANGE)) + quarry_ref = WEAKREF(target) + quarry_seen_at = world.time + +/// Whether the arms' set bonus is working: both arms in and synced, Gorilla or Brawler, so it can force doors +/datum/bounty_chrome_move/gorilla_doors/proc/can_pry() + return gorilla_synced(owner) || brawler_synced(owner) + +/** + * Whoever it is after and can't get at, its target out of sight or behind a closed glass door, or someone + * it lost sight of in the last BOUNTY_GORILLA_QUARRY_MEMORY: if a closed airlock near it is in the way, it + * goes for it, a step to it if need be, then a hand on it. Returns TRUE if it did something. + */ +/datum/bounty_chrome_move/gorilla_doors/proc/try_door() + if(!bounty_chrome_can_act(owner) || QDELETED(owner.ai_controller) || LAZYLEN(owner.do_afters) || HAS_TRAIT(owner, TRAIT_IMMOBILIZED) || HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED) || !can_pry()) + return FALSE + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + if(istype(boss) && boss.boss_busy) + return FALSE + var/datum/component/bounty_chrome/chrome = owner.GetComponent(/datum/component/bounty_chrome) + var/datum/bounty_chrome_move/gorilla_grab/grab = chrome?.move_of(/datum/bounty_chrome_move/gorilla_grab) + if(chrome?.telling || grab?.held_ref) + return FALSE + var/atom/quarry = bounty_chrome_target(owner) + var/in_sight = FALSE + if(!QDELETED(quarry)) + in_sight = bounty_ai_can_see(owner, quarry, BOUNTY_GORILLA_CHASE_RANGE) + if(in_sight && owner.Adjacent(quarry)) + return FALSE + else + quarry = quarry_ref?.resolve() + if(QDELETED(quarry) || world.time > quarry_seen_at + BOUNTY_GORILLA_QUARRY_MEMORY) + return FALSE + var/turf/goal = get_turf(quarry) + if(!goal || goal.z != owner.z || get_dist(owner, goal) > BOUNTY_GORILLA_CHASE_RANGE || !bounty_ai_may_hit(owner, quarry)) + return FALSE + var/obj/machinery/door/airlock/door = find_door(goal, in_sight) + if(!door) + return FALSE + var/turf/here = get_turf(owner) + var/turf/door_turf = get_turf(door) + if(get_dist(here, door_turf) == 1 && (get_dir(here, door_turf) in GLOB.cardinals)) + LAZYSET(tried, REF(door), world.time + BOUNTY_GORILLA_DOOR_RETRY) + if(!human_npc_ready_empty_hand(owner)) + return FALSE + owner.face_atom(door) + // A hand on the door out of combat mode: the set bonus pries it (tg's door_pryer) + INVOKE_ASYNC(owner.ai_controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), door, FALSE) + return TRUE + return step_to_door(door) + +/** + * The closed airlock within BOUNTY_GORILLA_DOOR_REACH of it that stands between it and `goal`, nearest + * first: on the straight line to `goal` when it can see there (a glass door), or else nearer `goal` than + * it is and within BOUNTY_GORILLA_BEHIND_RANGE of it. Only one it may force (may_force()). + */ +/datum/bounty_chrome_move/gorilla_doors/proc/find_door(turf/goal, in_sight) + var/turf/here = get_turf(owner) + var/list/line = in_sight ? get_line(here, goal) : null + var/to_goal = get_dist(here, goal) + var/obj/machinery/door/airlock/best + var/best_score = INFINITY + for(var/turf/spot as anything in RANGE_TURFS(BOUNTY_GORILLA_DOOR_REACH, here)) + var/obj/machinery/door/airlock/door = locate() in spot + if(!door) + continue + if(in_sight) + if(!(spot in line)) + continue + else if(get_dist(spot, goal) >= to_goal || get_dist(spot, goal) > BOUNTY_GORILLA_BEHIND_RANGE) + continue + if(!may_force(door)) + continue + var/score = get_dist(here, spot) * 10 + get_dist(spot, goal) + if(score < best_score) + best = door + best_score = score + return best + +/** + * Whether it may go at `door`: shut and not bolted, welded or sealed (the implant won't force those), not + * one it went for a moment ago, on its side's own ground where it may break things + * (environment_damage_allowed(): never a ship, a hangar, an outpost or a trader outpost), and one that lets + * no air out when it opens. + */ +/datum/bounty_chrome_move/gorilla_doors/proc/may_force(obj/machinery/door/airlock/door) + if(QDELETED(door) || !door.density || door.operating || door.locked || door.welded || door.seal) + return FALSE + // A trader outpost's doors refuse the pry (hyperviolence_gorilla/gorilla_pry.dm) + if(gorilla_pry_refused(door)) + return FALSE + if(LAZYACCESS(tried, REF(door)) > world.time) + return FALSE + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(owner) + if(!side || !side.environment_damage_allowed(get_turf(door))) + return FALSE + return bounty_gorilla_door_holds_air(door) + +/// One step to a free side of `door` beside it, on its side's leash. Returns TRUE if it moved. +/datum/bounty_chrome_move/gorilla_doors/proc/step_to_door(obj/machinery/door/airlock/door) + var/turf/here = get_turf(owner) + var/turf/door_turf = get_turf(door) + for(var/direction in GLOB.cardinals) + var/turf/beside = get_step(door_turf, direction) + if(!beside || get_dist(here, beside) != 1) + continue + if(bounty_ai_step(owner, beside)) + owner.face_atom(door) + return TRUE + return FALSE + +/** + * Whether opening `door` lets no air out: none of its open sides is space, open space or a drop, and the + * pressures on them are within BOUNTY_GORILLA_PRESSURE_GAP of each other. + */ +/proc/bounty_gorilla_door_holds_air(obj/machinery/door/door) + var/turf/spot = get_turf(door) + if(!spot) + return FALSE + var/lowest = INFINITY + var/highest = 0 + var/sides = 0 + for(var/direction in GLOB.cardinals) + var/turf/beside = get_step(spot, direction) + if(!beside || isclosedturf(beside)) + continue + if(isspaceturf(beside) || isopenspaceturf(beside) || isgroundlessturf(beside)) + return FALSE + var/datum/gas_mixture/air = beside.return_air() + var/pressure = air ? air.return_pressure() : 0 + lowest = min(lowest, pressure) + highest = max(highest, pressure) + sides++ + return sides >= 2 && highest - lowest <= BOUNTY_GORILLA_PRESSURE_GAP + +#undef BOUNTY_GORILLA_PUNCH_TELL +#undef BOUNTY_GORILLA_GRAB_TELL +#undef BOUNTY_GORILLA_PUNCH_OPENING +#undef BOUNTY_GORILLA_GRAB_OPENING +#undef BOUNTY_GORILLA_GRAB_COOLDOWN +#undef BOUNTY_GORILLA_GRAB_GUARD +#undef BOUNTY_GORILLA_PUMMEL_PUNCHES +#undef BOUNTY_GORILLA_HOLD_MAX +#undef BOUNTY_GORILLA_PUMMEL_FIST_LEFT +#undef BOUNTY_GORILLA_HURL_RANGE +#undef BOUNTY_GORILLA_KNOCKBACK_RANGE +#undef BOUNTY_GORILLA_DOOR_REACH +#undef BOUNTY_GORILLA_BEHIND_RANGE +#undef BOUNTY_GORILLA_CHASE_RANGE +#undef BOUNTY_GORILLA_QUARRY_MEMORY +#undef BOUNTY_GORILLA_DOOR_RETRY +#undef BOUNTY_GORILLA_PRESSURE_GAP +#undef BOUNTY_GORILLA_COLOR +#undef BOUNTY_GORILLA_MARK_MATRIX +#undef BOUNTY_GORILLA_TRAIT +#undef BOUNTY_GORILLA_BOSS_BUSY diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_mantis.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_mantis.dm new file mode 100644 index 00000000000..d62e8810a72 --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_mantis.dm @@ -0,0 +1,354 @@ +/** + * # Bounty chrome: Mantis Blades + * + * Owner: C2 (Mantis Blades and Gorilla Arms). Built on the chrome core, bounty_chrome.dm. + * + * The player's Mantis Blades (voidcrew/modules/cyberware/ware_military_arms.dm), the real pair of forearm + * housings, in a criminal or companion who fights up close (the piece rolls only on brawlers, knife and + * club fighters). The NPC uses them the way a player does, through the housings' own buttons: + * - Blades out (/datum/bounty_chrome_move/mantis_blades). Once its fight starts it presses each housing's + * button, and the blade comes out into that hand with the implant's own sound and line. From then on the + * blades are its weapon: its melee swings the strongest thing it holds, and a hardlight blade's force, + * edge and wound bonus are the blade's own, so its cuts are tg's slash wounds and bleed as those do. A + * knife in that hand goes back in its pocket first. A bat has nowhere to go on a body, so that arm keeps + * the bat and the other arm's blade does the work. + * - The blades go back in when the fight is over, or it goes down, is cuffed or gives up: the button + * again, or the implant's own stow when it is past pressing buttons. An EMP stows them by itself (the + * implant), and they stay in through the reboot. + * - They never leave its body. While out a blade is NODROP and indestructible (the toolkit's Extend()), + * and it is locked gear like the rest of its kit (human_npc_gear.dm): it can't be disarmed, stripped or + * dropped, and one forced out of its hand is deleted. When it dies, or a housing is cut or taken out of + * it, the housing burns out to scrap and its blade goes with it (the human NPC base). + * - The lunge (/datum/bounty_chrome_move/mantis_lunge). With someone two to five tiles off in a clear + * line, it turns its blades on them in a blue outline and a blue line lights the floor up to them, then + * it presses a housing's own Mantis Lunge on them: the implant's telegraph, dash, cuts and knockdown and + * its 20 second cooldown, all as written. The dash runs down the line it showed (the implant's dash_aim), + * so a hunter who steps off it is missed. The AI only picks when: never at someone already on the floor, + * never down a line with anything solid or anyone standing on it (the dash would fall short), and never where the dash + * could carry it off its side's site or past a drop, lava or space (the implant moves its bearer by + * force, so the AI looks first). The first lunge may come a second into a fight. + */ + +/// The furthest any model's lunge reaches, in tiles. The housing's own lunge_range (ware_military_arms.dm) +/// decides the real reach (can_start()). +#define BOUNTY_MANTIS_LUNGE_RANGE 5 +/// The closest a target may be for a lunge: right beside them it is a swing, not a lunge +#define BOUNTY_MANTIS_LUNGE_MIN 2 +/// The tell before the lunge, the line lit on the floor; the implant's own telegraph follows it +#define BOUNTY_MANTIS_LUNGE_TELL (0.9 SECONDS) +/// The first lunge of a fight may start this long after the fight does +#define BOUNTY_MANTIS_OPENING (1 SECONDS) +/// A hand it couldn't empty for a blade is left alone this long before it tries again +#define BOUNTY_MANTIS_STOW_RETRY (10 SECONDS) +/// The tell's outline, in the hardlight blade's blue +#define BOUNTY_MANTIS_COLOR "#6cb8ff" +/// The line on the floor: the mark art is pure red, which a plain colour only darkens, so a matrix turns it the blade's blue +#define BOUNTY_MANTIS_MARK_MATRIX list(0.42, 0.72, 1, 0, 0, 0, 0, 0, 0) +/// How long the line stays lit once the lunge is pressed: the implant's 0.3 second wind-up and the run itself +#define BOUNTY_MANTIS_LINE_HOLD (0.5 SECONDS) + +// ===== HELPERS ===== + +/// The Mantis Blade housings in `npc`'s arms +/proc/bounty_mantis_housings(mob/living/carbon/npc) + . = list() + if(QDELETED(npc)) + return + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing in npc.organs) + . += housing + +/// Whether `housing`'s chrome is running, so its blade can come out +/proc/bounty_mantis_running(obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing) + var/datum/component/cyberware/chrome = housing?.GetComponent(/datum/component/cyberware) + return !!chrome?.is_running() + +/// `housing`'s own button, the one that puts its blade out and away +/proc/bounty_mantis_toggle(obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing) + return locate(/datum/action/item_action/organ_action/toggle/toolkit) in housing?.actions + +/// The hands (RIGHT_HANDS or LEFT_HANDS) `housing`'s blade comes out into +/proc/bounty_mantis_side(obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing) + return housing.zone == BODY_ZONE_R_ARM ? RIGHT_HANDS : LEFT_HANDS + +/// The blades `housing` keeps, out or in (the toolkit's items_list) +/proc/bounty_mantis_blades(obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing) + . = list() + for(var/datum/weakref/blade_ref as anything in housing?.items_list) + var/obj/item/blade = blade_ref?.resolve() + if(!QDELETED(blade)) + . += blade + +/** + * Puts every blade of `npc`'s that is out back in its housing: the housing's own button, or, when it is + * past pressing buttons (down in crit), the implant's own stow. Doesn't sleep. + */ +/proc/bounty_mantis_put_away(mob/living/carbon/npc) + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing as anything in bounty_mantis_housings(npc)) + if(!housing.blade_extended()) + continue + var/datum/action/toggle = bounty_mantis_toggle(housing) + if(toggle?.IsAvailable()) + toggle.Trigger() + if(housing.blade_extended()) + housing.Retract() + +/// Whether a dash must never cross `spot`: open space or a drop, lava, a chasm, space +/proc/bounty_mantis_drop(turf/spot) + return isspaceturf(spot) || isopenspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot) + +/// world.time `npc` last fired any chrome move, leaving out moves of `except_types`, or 0 if none has fired +/proc/bounty_melee_chrome_last_move_at(mob/living/carbon/human/npc/npc, list/except_types) + . = 0 + var/datum/component/bounty_chrome/chrome = npc?.GetComponent(/datum/component/bounty_chrome) + for(var/datum/bounty_chrome_move/move as anything in chrome?.moves) + if(except_types && is_type_in_list(move, except_types)) + continue + . = max(., move.last_fired) + +/** + * Whether `npc`'s other chrome lets a move whose own opener is at `opens_at` start now: past the gap the + * last move to fire left (the component's next_move_at), or, with none fired since the fight started at + * `fight_started_at`, past the move's own opener, which comes before the core's (BOUNTY_CHROME_OPENING). + */ +/proc/bounty_melee_chrome_may_start(mob/living/carbon/human/npc/npc, opens_at, fight_started_at) + if(world.time < opens_at) + return FALSE + var/datum/component/bounty_chrome/chrome = npc?.GetComponent(/datum/component/bounty_chrome) + if(!chrome || chrome.telling) + return FALSE + if(world.time >= chrome.next_move_at) + return TRUE + return bounty_melee_chrome_last_move_at(npc) < fight_started_at + +// ===== BLADES OUT ===== + +/** + * The blades, out for a fight and away after it. It plans nothing of its own: in a fight it presses a + * housing's button for each blade still in (plan(), and on_tick() for a mini-boss, which plans no chrome + * of its own), and out of it, down, cuffed or giving up it puts them away. + */ +/datum/bounty_chrome_move/mantis_blades + piece_key = BOUNTY_CHROME_MANTIS + name = "Mantis Blades" + // No tell: putting a blade out hurts nobody, and the implant shows and sounds it + boss_usable = FALSE + /// REF() of a housing -> world.time it may try again to empty that hand for its blade + var/list/stow_retry_at + +/datum/bounty_chrome_move/mantis_blades/attach(mob/living/carbon/human/npc/npc) + . = ..() + // A body dressed before its housings went in (a test, an admin's install) has its blades locked now, like its other gear + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing as anything in bounty_mantis_housings(npc)) + for(var/obj/item/blade as anything in bounty_mantis_blades(housing)) + npc.npc_lock_item(blade) + RegisterSignals(npc, list(SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), COMSIG_HUMAN_NPC_SURRENDERED), PROC_REF(on_out_of_it)) + +/datum/bounty_chrome_move/mantis_blades/detach() + if(owner) + UnregisterSignal(owner, list(SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), COMSIG_HUMAN_NPC_SURRENDERED)) + if(!QDELETED(owner)) + bounty_mantis_put_away(owner) + stow_retry_at = null + return ..() + +/datum/bounty_chrome_move/mantis_blades/plan(datum/ai_controller/controller, atom/target) + // In its fight and able to act: a blade out is this moment's click + if(!bounty_chrome_in_fight(owner) || !bounty_chrome_can_act(owner)) + return FALSE + return draw_one() + +/datum/bounty_chrome_move/mantis_blades/on_tick(seconds_per_tick, fighting, atom/target) + if(fighting && !blades_go_away()) + if(bounty_chrome_can_act(owner)) + draw_one() + return + bounty_mantis_put_away(owner) + +/datum/bounty_chrome_move/mantis_blades/on_fight_end() + bounty_mantis_put_away(owner) + +/datum/bounty_chrome_move/mantis_blades/on_downed() + bounty_mantis_put_away(owner) + +/datum/bounty_chrome_move/mantis_blades/on_death() + bounty_mantis_put_away(owner) + +/// Whether the blades go away for now: down in crit, cuffed, giving up or dead. A knockdown or a stun keeps them out, as it would a player's. +/datum/bounty_chrome_move/mantis_blades/proc/blades_go_away() + return QDELETED(owner) || owner.stat != CONSCIOUS || HAS_TRAIT(owner, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(owner, TRAIT_RESTRAINED) || owner.npc_surrendering + +/// Cuffed, or giving up: the blades go in. Inside a signal handler. +/datum/bounty_chrome_move/mantis_blades/proc/on_out_of_it(datum/source) + SIGNAL_HANDLER + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_mantis_put_away), owner) + +/** + * Puts out one blade that is still in, the way a player's button does: its housing's chrome running and + * its hand free. Returns TRUE if a blade came out. + */ +/datum/bounty_chrome_move/mantis_blades/proc/draw_one() + if(QDELETED(owner)) + return FALSE + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing as anything in bounty_mantis_housings(owner)) + if(housing.blade_extended() || !bounty_mantis_running(housing)) + continue + var/datum/action/toggle = bounty_mantis_toggle(housing) + if(!toggle?.IsAvailable() || !free_hand_for(housing)) + continue + toggle.Trigger() + if(housing.blade_extended()) + return TRUE + return FALSE + +/** + * Whether the hand `housing`'s blade comes out into is free, first putting away what it holds there if + * that has somewhere to go on its body (a knife into a pocket). Never drops anything. + */ +/datum/bounty_chrome_move/mantis_blades/proc/free_hand_for(obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing) + var/side = bounty_mantis_side(housing) + if(owner.get_empty_held_index_for_side(side)) + return TRUE + var/key = REF(housing) + if(world.time < LAZYACCESS(stow_retry_at, key)) + return FALSE + for(var/obj/item/held as anything in owner.get_held_items_for_side(side, all = TRUE)) + if(HAS_TRAIT(held, TRAIT_NODROP) || !held.equip_to_best_slot(owner)) + LAZYSET(stow_retry_at, key, world.time + BOUNTY_MANTIS_STOW_RETRY) + return FALSE + return !!owner.get_empty_held_index_for_side(side) + +// ===== THE LUNGE ===== + +/** + * The Mantis Lunge: the line lit on the floor and a blue outline for BOUNTY_MANTIS_LUNGE_TELL, then a + * housing's own Mantis Lunge pressed on the target, the housing with its blade out. + */ +/datum/bounty_chrome_move/mantis_lunge + piece_key = BOUNTY_CHROME_MANTIS + name = "Mantis Lunge" + action_type = /datum/action/cooldown/cyberware/mantis_lunge + tell_time = BOUNTY_MANTIS_LUNGE_TELL + tell_color = BOUNTY_MANTIS_COLOR + tell_lines = list("Hold still.", "You're mine.", "Got you now.") + max_range = BOUNTY_MANTIS_LUNGE_RANGE + min_range = BOUNTY_MANTIS_LUNGE_MIN + /// world.time its first lunge of this fight may start + var/opens_at = 0 + /// world.time this fight started + var/fight_started_at = 0 + /// The tile its tell's line ends on: the dash runs there, not to wherever the target has gone + var/turf/aimed_at + +/datum/bounty_chrome_move/mantis_lunge/on_fight_start(atom/target) + fight_started_at = world.time + opens_at = world.time + BOUNTY_MANTIS_OPENING + +/// A housing's Mantis Lunge it can press now: one whose blade is out, its chrome running and the lunge off cooldown +/datum/bounty_chrome_move/mantis_lunge/proc/lunge_action() + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing as anything in bounty_mantis_housings(owner)) + if(!housing.blade_extended()) + continue + var/datum/action/cooldown/cyberware/mantis_lunge/lunge = locate() in housing.actions + if(lunge?.owner == owner && lunge.IsAvailable()) + return lunge + return null + +/datum/bounty_chrome_move/mantis_lunge/ready() + return !!lunge_action() + +/datum/bounty_chrome_move/mantis_lunge/can_start(atom/target) + var/mob/living/victim = target + // The blades pass over someone on the floor: no lunge at them + if(!isliving(victim) || !isturf(victim.loc) || victim.body_position == LYING_DOWN) + return FALSE + if(!..()) + return FALSE + // Each model reaches as far as its own housing says + var/datum/action/cooldown/cyberware/mantis_lunge/lunge = lunge_action() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = lunge?.organ + if(!istype(housing) || get_dist(owner, victim) > housing.lunge_range) + return FALSE + return line_clear(victim) + +/** + * Whether the implant's dash at `victim` carries it only over its own ground: nothing solid on the way + * (or it falls short), every tile on its side's leash, and no drop, lava or space beside the line either, + * since they may step aside during the tell and the dash goes where they are then. + */ +/datum/bounty_chrome_move/mantis_lunge/proc/line_clear(mob/living/victim) + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(owner) + var/turf/start = get_turf(owner) + var/turf/aim = get_turf(victim) + // A companion whose criminal is gone has no ground of its own to dash over + if(!side || !start || !aim || start.z != aim.z) + return FALSE + for(var/turf/spot as anything in get_line(start, aim)) + if(spot == start) + continue + if(spot == aim) + break + // A standing body stops the dash as a wall does + if(spot.is_blocked_turf(source_atom = owner) || !side.leash_ok(spot)) + return FALSE + for(var/turf/beside as anything in RANGE_TURFS(1, spot)) + if(bounty_mantis_drop(beside)) + return FALSE + return TRUE + +/datum/bounty_chrome_move/mantis_lunge/plan(datum/ai_controller/controller, atom/target) + // Its own opener, a little before the core's: early enough to show inside a short fight + if(!bounty_melee_chrome_may_start(owner, opens_at, fight_started_at) || !can_start(target)) + return FALSE + var/datum/component/bounty_chrome/chrome = owner.GetComponent(/datum/component/bounty_chrome) + return !!chrome?.start_move(src, target) + +/datum/bounty_chrome_move/mantis_lunge/tell_text(atom/target) + return "[owner] turns [owner.p_their()] blades on [target]." + +/datum/bounty_chrome_move/mantis_lunge/tell(atom/target) + ..() + var/turf/start = get_turf(owner) + aimed_at = get_turf(target) + if(!start || !aimed_at) + return + // The way it will come, lit up to whoever it is aimed at + light_line(start, aimed_at, tell_time + 1, TRUE) + +/// Lights the floor from `start` to `aim` for `time`, kept as the tell's marks if `as_tell` +/datum/bounty_chrome_move/mantis_lunge/proc/light_line(turf/start, turf/aim, time, as_tell) + for(var/turf/spot as anything in get_line(start, aim)) + if(spot == start) + continue + var/obj/effect/mark = new /obj/effect/temp_visual/bounty_boss_mark(spot, time, BOUNTY_MANTIS_MARK_MATRIX, spot == aim ? "target_circle" : null) + if(as_tell) + add_mark(mark) + +/datum/bounty_chrome_move/mantis_lunge/effect(atom/target) + var/turf/aim = aimed_at + aimed_at = null + var/datum/action/cooldown/cyberware/mantis_lunge/lunge = lunge_action() + if(!lunge || !isliving(target) || QDELETED(target)) + return FALSE + // The dash goes down the line the tell showed + lunge.dash_aim = aim + if(!lunge.Trigger(target = target)) + lunge.dash_aim = null + return FALSE + // The line stays lit through the implant's own wind-up and the run + var/turf/start = get_turf(owner) + if(start && aim) + light_line(start, aim, BOUNTY_MANTIS_LINE_HOLD, FALSE) + return TRUE + +/datum/bounty_chrome_move/mantis_lunge/detach() + aimed_at = null + return ..() + +#undef BOUNTY_MANTIS_LUNGE_RANGE +#undef BOUNTY_MANTIS_LUNGE_MIN +#undef BOUNTY_MANTIS_LUNGE_TELL +#undef BOUNTY_MANTIS_OPENING +#undef BOUNTY_MANTIS_STOW_RETRY +#undef BOUNTY_MANTIS_COLOR +#undef BOUNTY_MANTIS_MARK_MATRIX +#undef BOUNTY_MANTIS_LINE_HOLD diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_piledriver.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_piledriver.dm new file mode 100644 index 00000000000..811e2cfb7b8 --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_piledriver.dm @@ -0,0 +1,214 @@ +/** + * # Bounty chrome: the Meteor Piledriver + * + * Owner: C1. A criminal's Meteor Piledriver is the player's leg frame + * (/obj/item/organ/cyberimp/cyberware/piledriver, ware_legend.dm): the leap, its reach, the warning + * shadow, the 0.8 s in the air, the crush on whoever is still under it, the shockwave around it, and + * the minute's cooldown are all the implant's own, and nothing here changes them. Once it is in the + * air it comes down, as a player's leap does: shot or stunned up there, it still lands. This file is + * only how an NPC uses it (owner decision 5): + * - When: in a fight, with its target BOUNTY_PILEDRIVER_MIN_RANGE to BOUNTY_PILEDRIVER_MAX_RANGE tiles + * off and in sight (anyone closer it fights hand to hand), and a landing it can make: the target's + * own tile, or the nearest free tile beside them, that the implant itself would take (in reach, in + * sight, an open arc, a surface to land on) and that the NPC would choose: on its leash (a mini-boss + * also where its abilities may land), never space, a chasm or lava. + * - Never onto people outside its fight (C0's bystander gate, over everything the landing reaches), + * and never onto anyone already down. Checked when it decides and again when it jumps. + * - The tell: it drops into a crouch with its pistons whining and an amber glow, and a red reticle + * sits where it means to land, following its target, for BOUNTY_PILEDRIVER_CROUCH. Then the + * implant's own Meteor Leap, Trigger()ed onto the landing tile as a player's click; the implant puts + * its own warning shadow down there for the time it is in the air. + * - If the landing is gone when the crouch ends (its target got behind a wall, a bystander walked in, + * its target went down), the pistons hiss and settle and nothing fires. + * A target in a mech isn't leapt at: the landing can't reach anyone inside one. + */ + +/// Nearest a target may be for a leap, in tiles: anyone closer is fought hand to hand +#define BOUNTY_PILEDRIVER_MIN_RANGE 3 +/// Furthest a target may be for a leap, in tiles: a tile beside them stays in the implant's reach +#define BOUNTY_PILEDRIVER_MAX_RANGE 7 +/// The implant's own reach (CYBERWARE_PILEDRIVER_RANGE, ware_legend.dm) +#define BOUNTY_PILEDRIVER_REACH 8 +/// How far around the landing the implant's shockwave reaches (CYBERWARE_PILEDRIVER_IMPACT_RANGE, ware_legend.dm) +#define BOUNTY_PILEDRIVER_IMPACT 2 +/// The crouch before the leap: its tell +#define BOUNTY_PILEDRIVER_CROUCH (1 SECONDS) +/// Pixels it sinks in the crouch +#define BOUNTY_PILEDRIVER_DIP 3 +/// The glow through the crouch, piston amber +#define BOUNTY_PILEDRIVER_COLOR "#ffb347" +/// Offset source for the crouch +#define BOUNTY_PILEDRIVER_SOURCE "bounty_chrome_piledriver" + +/datum/bounty_chrome_move/piledriver + piece_key = BOUNTY_CHROME_PILEDRIVER + name = "Meteor leap" + action_type = /datum/action/cooldown/cyberware/piledriver_leap + tell_time = BOUNTY_PILEDRIVER_CROUCH + tell_color = BOUNTY_PILEDRIVER_COLOR + tell_sound = 'sound/machines/piston/piston_lower.ogg' + tell_lines = list("Heads up!", "Stay right there.", "Look up.") + area_move = TRUE + min_range = BOUNTY_PILEDRIVER_MIN_RANGE + max_range = BOUNTY_PILEDRIVER_MAX_RANGE + // Its time in the air on top of the usual gap + recover_time = 5 SECONDS + /// What the crouch is aimed at, while the reticle follows it + var/atom/movable/tracked + +/datum/bounty_chrome_move/piledriver/can_start(atom/target) + if(ismecha(target) || !..()) + return FALSE + var/turf/spot = landing_for(target) + return !isnull(spot) && !downed_in(impact_turfs(spot)) + +/datum/bounty_chrome_move/piledriver/area_turfs(atom/target) + var/turf/spot = landing_for(target) + return spot ? impact_turfs(spot) : list() + +/datum/bounty_chrome_move/piledriver/tell(atom/target) + ..() + if(QDELETED(owner)) + return + owner.add_offsets(BOUNTY_PILEDRIVER_SOURCE, z_add = -BOUNTY_PILEDRIVER_DIP) + place_aim_mark(landing_for(target)) + stop_tracking() + if(ismovable(target)) + tracked = target + RegisterSignal(tracked, COMSIG_MOVABLE_MOVED, PROC_REF(on_tracked_moved)) + RegisterSignal(tracked, COMSIG_QDELETING, PROC_REF(stop_tracking)) + +/datum/bounty_chrome_move/piledriver/tell_text(atom/target) + return "[owner] drops into a crouch, [owner.p_their()] leg pistons whining." + +/datum/bounty_chrome_move/piledriver/clear_tell() + stop_tracking() + // Only its own crouch: offsets put back while it is up in the air would drop it out of its leap's rise. No animation: the rise starts from here a moment later. + if(!QDELETED(owner) && (BOUNTY_PILEDRIVER_SOURCE in owner.offsets?[PIXEL_Z_OFFSET])) + owner.remove_offsets(BOUNTY_PILEDRIVER_SOURCE, animate = FALSE) + return ..() + +/** + * The crouch is over: it jumps at where its target stands now, as a player clicks where they see + * them, if it still may. Otherwise the pistons settle and nothing fires. + */ +/datum/bounty_chrome_move/piledriver/effect(atom/target) + var/turf/spot + if(!QDELETED(target) && !ismecha(target) && bounty_ai_may_hit(owner, target)) + spot = landing_for(target) + if(!spot || !bounty_chrome_area_clear(owner, impact_turfs(spot), target) || downed_in(impact_turfs(spot))) + settle() + return FALSE + return bounty_chrome_trigger(owner, action_type, spot) + +/// The crouch came to nothing: the pistons hiss and settle +/datum/bounty_chrome_move/piledriver/proc/settle() + if(QDELETED(owner) || owner.stat != CONSCIOUS) + return + playsound(owner, 'sound/machines/steam_hiss.ogg', 40, TRUE) + owner.visible_message(span_notice("[owner]'s leg pistons hiss and settle.")) + +/// Everything a landing on `spot` reaches: the tile, and the shockwave's ring around it +/datum/bounty_chrome_move/piledriver/proc/impact_turfs(turf/spot) + return RANGE_TURFS(BOUNTY_PILEDRIVER_IMPACT, spot) + +/// Whether anyone in `turfs` is down and still alive: it never lands on a person who is down +/datum/bounty_chrome_move/piledriver/proc/downed_in(list/turfs) + for(var/turf/spot as anything in turfs) + for(var/mob/living/person in spot) + if(person != owner && person.stat != CONSCIOUS && person.stat != DEAD) + return TRUE + return FALSE + +/** + * The tile a leap at `target` comes down on now, or null if there is none: their own tile if it may + * land there, else the free tile beside them nearest to it, never one next to where it stands. + * Geometry only: who it may jump at is can_start()'s and effect()'s. + */ +/datum/bounty_chrome_move/piledriver/proc/landing_for(atom/target) + var/turf/here = get_turf(owner) + var/turf/aim = get_turf(target) + if(!here || !aim || here.z != aim.z || !isturf(owner.loc)) + return null + var/datum/action/cooldown/cyberware/piledriver_leap/leap = bounty_chrome_action(owner, action_type) + if(!leap) + return null + if(landing_ok(here, aim, leap)) + return aim + var/turf/best + var/best_distance = INFINITY + for(var/turf/beside as anything in RANGE_TURFS(1, aim)) + var/from_here = get_dist(here, beside) + if(beside == aim || from_here < 2 || from_here >= best_distance) + continue + if(!landing_ok(here, beside, leap)) + continue + best = beside + best_distance = from_here + return best + +/** + * Whether it may land on `spot` from `here`: what the implant itself takes (in reach and in sight, a + * surface to land on, an open arc, Activate() in ware_legend.dm) and what the NPC chooses: never + * space, a chasm or lava, and only on its own ground (on_its_ground()). + */ +/datum/bounty_chrome_move/piledriver/proc/landing_ok(turf/here, turf/spot, datum/action/cooldown/cyberware/piledriver_leap/leap) + if(!spot || spot == here || get_dist(here, spot) > BOUNTY_PILEDRIVER_REACH) + return FALSE + if(isspaceturf(spot) || isopenspaceturf(spot) || islava(spot) || ischasm(spot)) + return FALSE + if(spot.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + if(!can_see(owner, spot, BOUNTY_PILEDRIVER_REACH) || leap.arc_blocker(here, spot)) + return FALSE + return on_its_ground(spot) + +/// Whether `spot` is ground it would land on: its leash (a companion its criminal's), and for a mini-boss where its abilities may land +/datum/bounty_chrome_move/piledriver/proc/on_its_ground(turf/spot) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + if(istype(boss)) + return boss.boss_landing_ok(spot) + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(owner) + if(side) + return side.leash_ok(spot) + return spot.can_cross_safely(owner) + +/// Puts the reticle on `spot`, or takes it away for a null one +/datum/bounty_chrome_move/piledriver/proc/place_aim_mark(turf/spot) + // The reticle is the only mark the crouch puts down (add_mark()) + var/obj/effect/temp_visual/bounty_boss_mark/mark + for(var/datum/weakref/mark_ref as anything in marks) + var/obj/effect/temp_visual/bounty_boss_mark/live = mark_ref.resolve() + if(!QDELETED(live)) + mark = live + break + if(!spot) + if(mark) + qdel(mark) + return + if(!mark) + // The bounty bosses' red floor mark (bounty_boss.dm) as a reticle, like every boss mark and the implant's own shadow; deleted when the crouch ends, its duration only a backstop + add_mark(new /obj/effect/temp_visual/bounty_boss_mark(spot, BOUNTY_PILEDRIVER_CROUCH * 3, null, "target_circle")) + return + mark.abstract_move(spot) + +/// Its target moved in the crouch: the reticle follows to where it would land now +/datum/bounty_chrome_move/piledriver/proc/on_tracked_moved(atom/movable/source, atom/old_loc, dir, forced) + SIGNAL_HANDLER + place_aim_mark(landing_for(source)) + +/// Stops following its target. Safe at any time. +/datum/bounty_chrome_move/piledriver/proc/stop_tracking() + SIGNAL_HANDLER + if(tracked) + UnregisterSignal(tracked, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + tracked = null + +#undef BOUNTY_PILEDRIVER_MIN_RANGE +#undef BOUNTY_PILEDRIVER_MAX_RANGE +#undef BOUNTY_PILEDRIVER_REACH +#undef BOUNTY_PILEDRIVER_IMPACT +#undef BOUNTY_PILEDRIVER_CROUCH +#undef BOUNTY_PILEDRIVER_DIP +#undef BOUNTY_PILEDRIVER_COLOR +#undef BOUNTY_PILEDRIVER_SOURCE diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm new file mode 100644 index 00000000000..16806ed7d64 --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm @@ -0,0 +1,117 @@ +/// Percent of its health at or under which it burns: the AI's moment (the implant fires whenever its bearer presses it) +#define BOUNTY_REDLINE_HURT_BELOW 75 +/// Stamina lost at or over which it burns too: halfway to a stamina crit, so a baton or a disabler gets an answer before the floor +#define BOUNTY_REDLINE_WINDED_AT 50 +/// The tell: the chest speaker charging before the roar +#define BOUNTY_REDLINE_TELL (1 SECONDS) +/// How far the implant's roar reaches (CYBERWARE_REDLINE_ROAR_RANGE, ware_legend.dm): the bystander gate looks this far +#define BOUNTY_REDLINE_ROAR_RANGE 5 +/// The red of the tell's outline, its light and the eyes through the burn +#define BOUNTY_REDLINE_COLOR "#ff2a2a" +/// The eye-colour priority of the red eyes through the burn (over a cultist's) +#define BOUNTY_REDLINE_EYES_PRIORITY (EYE_COLOR_CULT_PRIORITY + 11) + +/** + * # Redline core on a bounty NPC + * + * The rage OS players buy (/obj/item/organ/cyberimp/cyberware/redline, ware_legend.dm), used the way a + * player would: once a fight has turned on them. The AI picks the moment and shows it coming; everything + * after the button press is the implant's own. + * + * - The moment: in a fight, hurt to BOUNTY_REDLINE_HURT_BELOW percent of its health or winded to + * BOUNTY_REDLINE_WINDED_AT stamina, and nobody outside the fight within the roar's reach (the bystander + * gate, owner decision 5): its own side and the site's people keep it shut. + * - The tell, BOUNTY_REDLINE_TELL: it stands still, a red outline pulses over it, the chest speaker + * crackles and glows red, and it says so. A stun, a knockdown, cuffs, crit or an EMP in the tell and + * nothing happens (the framework). + * - Then the implant's Redline Burn, Trigger()ed as a player's button: the roar (it staggers everyone in + * reach who isn't crew with the bearer, so the gate is checked again as it fires), 12 seconds of stun + * immunity, a red outline over it the whole time (the implant's, as on a player), 40% less brute and + * burn through its physiology, harder fists, 90 seconds of cooldown, then + * the crash: a hundred stamina, which floors it in stamina crit, and leaden legs. That collapse is the + * hunters' window. An EMP in the burn drops it straight into the crash, as it does a player. + * - Its eyes stay red through the burn (the implant's own lines say they flood red and drain), a look only. + */ +/datum/bounty_chrome_move/redline + piece_key = BOUNTY_CHROME_REDLINE + name = "Redline burn" + action_type = /datum/action/cooldown/cyberware/redline_burn + tell_time = BOUNTY_REDLINE_TELL + tell_color = BOUNTY_REDLINE_COLOR + tell_lines = list("That's it.", "Now you've done it.", "You want to see red?") + area_move = TRUE + +// Hurt first: it is cheap, and the bystander gate's look around (view()) only runs once it would burn +/datum/bounty_chrome_move/redline/can_start(atom/target) + return bounty_redline_hurt(owner) && ..() + +/// Whether `npc` is hurt or winded enough to burn its Redline +/proc/bounty_redline_hurt(mob/living/npc) + if(QDELETED(npc)) + return FALSE + return npc.health <= npc.maxHealth * BOUNTY_REDLINE_HURT_BELOW / 100 || npc.getStaminaLoss() >= BOUNTY_REDLINE_WINDED_AT + +// The implant's roar staggers only those outside its side (cyberware_is_ally()), so its own companions don't hold it back +/datum/bounty_chrome_move/redline/area_clear(atom/target) + return bounty_chrome_area_clear(owner, area_turfs(target), target, TRUE) + +/// The roar's reach: every tile it can see within BOUNTY_REDLINE_ROAR_RANGE, as the implant's roar looks (oview()) +/datum/bounty_chrome_move/redline/area_turfs(atom/target) + . = list() + if(QDELETED(owner)) + return + for(var/turf/spot in view(BOUNTY_REDLINE_ROAR_RANGE, owner)) + . += spot + +/datum/bounty_chrome_move/redline/tell_text(atom/target) + return "[owner]'s chest speaker crackles and starts to glow red." + +/datum/bounty_chrome_move/redline/tell(atom/target) + ..() + if(QDELETED(owner)) + return + add_mark(owner.mob_light(1.5, 1, BOUNTY_REDLINE_COLOR)) + owner.Shake(1, 0, tell_time) + +// Someone outside the fight stepped into the roar's reach during the tell: the button stays unpressed (the gate, owner decision 5) +/datum/bounty_chrome_move/redline/effect(atom/target) + if(!area_clear(target)) + return FALSE + . = ..() + if(. && owner.has_status_effect(/datum/status_effect/cyberware_redline_window)) + owner.apply_status_effect(/datum/status_effect/bounty_redline_eyes) + +/datum/bounty_chrome_move/redline/detach() + owner?.remove_status_effect(/datum/status_effect/bounty_redline_eyes) + return ..() + +/** + * Red eyes through a Redline burn on a bounty NPC: what the implant's roar and crash lines say happens + * ("eyes flood red", "the red drains out of their eyes"). A look only; it ends with the burn. + */ +/datum/status_effect/bounty_redline_eyes + id = "bounty_redline_eyes" + // Past the implant's 12 second window; it ends with the window, this is a backstop + duration = 15 SECONDS + tick_interval = 0.5 SECONDS + alert_type = null + +/datum/status_effect/bounty_redline_eyes/on_apply() + if(owner.stat == DEAD || !owner.has_status_effect(/datum/status_effect/cyberware_redline_window)) + return FALSE + bounty_chrome_red_eyes(owner, id, BOUNTY_REDLINE_EYES_PRIORITY, TRUE, BOUNTY_REDLINE_COLOR) + return TRUE + +/datum/status_effect/bounty_redline_eyes/tick(seconds_between_ticks) + if(owner.stat == DEAD || !owner.has_status_effect(/datum/status_effect/cyberware_redline_window)) + qdel(src) + +/datum/status_effect/bounty_redline_eyes/on_remove() + bounty_chrome_red_eyes(owner, id, BOUNTY_REDLINE_EYES_PRIORITY, FALSE) + +#undef BOUNTY_REDLINE_HURT_BELOW +#undef BOUNTY_REDLINE_WINDED_AT +#undef BOUNTY_REDLINE_TELL +#undef BOUNTY_REDLINE_ROAR_RANGE +#undef BOUNTY_REDLINE_COLOR +#undef BOUNTY_REDLINE_EYES_PRIORITY diff --git a/voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm b/voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm new file mode 100644 index 00000000000..6857a41546e --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm @@ -0,0 +1,348 @@ +/** + * # Bounty hunting: the cyberpsycho + * + * Owner: C6. A mini-boss kit (BOUNTY_KIT_CYBERPSYCHO) whose kit is its chrome, the rarest of the kits + * (bounty_roll_kit()). Everything but its fight is any mini-boss's: the Most Wanted posting and pay, its + * stimulants, a sealed suit where the air would kill it, capture, and in prison the Juggernaut's charge + * (its chrome stays out of prison, decision 7). + * + * Its chrome is the real implants (decisions 4 and 5), as many of the seven pieces as one body's organ + * slots take. Six go in, in this order (bounty_psycho_organs()): + * - the Redline core in the operating-system slot. The Cascade lattice needs the same slot; Redline is + * its phase two, so the lattice is the piece left out; + * - a Mantis Blade housing in its right arm and Gorilla Arms in its left, one of each pair; + * - the Meteor Piledriver in its legs, a Lazarus node by its heart, and Deadeye optics for eyes. + * Their load is 32 (10 + 3 + 3 + 8 + 5 + 3), over a body's 20. It is augmented beyond anyone else: its + * Redline core carries the Governor Delete's capacity (BOUNTY_PSYCHO_EXTRA_CAPACITY) through the real + * capacity hook, the cyberware component's capacity_bonus, which get_chrome_capacity(), the brownout + * monitor, the cradle and a chrome read all go by. It is set on the core as the core goes in, first, so + * nothing browns out on the way. Chrome taken out of it burns to scrap (the human NPC base), so no core + * with that headroom ever leaves its body. + * + * How it fights, through the boss rotation (bounty_boss.dm), one move at a time and each with its tell: + * - its blade is out of its right forearm while it fights, and its melee is that blade (Mantis Blades); + * - the Mantis Lunge down a lit line, the Meteor Leap under a reticle, and a piston punch with its Gorilla + * fist (C2's told punch, put in its rotation here, since its melee is the blade); + * - its Gorilla hand stays empty, so it can punch through glass it may break (boss_start_smash()); + * - the Redline is its phase two (psycho_burn_due()): hurt to BOUNTY_PSYCHO_BURN_HURT percent of its + * health, or winded by a stamina weapon (BOUNTY_PSYCHO_BURN_WINDED), it drops what it was winding up + * and burns: C3's tell, then the implant's stun-proof window, then its crash into stamina crit, which is + * the capture. Once a spawn. Until then it doesn't wear out and fall back; it burns instead. Its roar + * holds off for people outside its fight, never for its own companions (psycho_roar_clear()); + * - Lazarus stands it up out of crit by itself, the node's own jolt; its six minute recharge outlasts a + * fight; + * - the Deadeye optics are only its eyes: it carries no gun for the tag to steer. + * An EMP takes its chrome down on the real reboot timers, sparking (C0's malfunction look); one during the + * burn crashes it, and with its core down it tires like any other mini-boss. + */ + +/// Neural capacity its Redline core carries: the Governor Delete's (its chrome_capacity_bonus, ware_legend.dm) +#define BOUNTY_PSYCHO_EXTRA_CAPACITY 15 +/// Hurt to this percent of its health, it burns its Redline (C3's line for any carrier) +#define BOUNTY_PSYCHO_BURN_HURT 75 +/// Winded this far (stamina loss), it burns too: one disabler bolt or baton blow, so a stamina weapon gets an answer before the floor +#define BOUNTY_PSYCHO_BURN_WINDED 25 + +/// The cyberpsycho's chrome as organ typepaths, in the order they go in: the Redline core first, as it carries the headroom for the rest +/proc/bounty_psycho_organs() + return list( + /obj/item/organ/cyberimp/cyberware/redline, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + /obj/item/organ/cyberimp/cyberware/piledriver, + /obj/item/organ/cyberimp/cyberware/lazarus, + /obj/item/organ/eyes/robotic/cyberware/deadeye, + ) + +/// The pieces (BOUNTY_CHROME_*) its chrome AI drives: every one but the Cascade lattice +/proc/bounty_psycho_keys() + return list(BOUNTY_CHROME_REDLINE, BOUNTY_CHROME_MANTIS, BOUNTY_CHROME_GORILLA, BOUNTY_CHROME_PILEDRIVER, BOUNTY_CHROME_LAZARUS, BOUNTY_CHROME_DEADEYE) + +// ===== THE BOSS ===== + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho + name = "chromed criminal" + desc = "A twitching criminal with more chrome than skin." + boss_kit = BOUNTY_KIT_CYBERPSYCHO + /// Its Redline has burned once: its phase two is spent + var/psycho_burned = FALSE + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/Initialize(mapload, datum/human_npc_order/order) + // Its kit is its chrome: the base installs it once it is dressed + npc_cyberware = bounty_psycho_organs() + . = ..() + psycho_wire_chrome() + RegisterSignal(src, SIGNAL_ADDTRAIT(TRAIT_STUNIMMUNE), PROC_REF(psycho_burn_started)) + RegisterSignal(src, SIGNAL_REMOVETRAIT(TRAIT_STUNIMMUNE), PROC_REF(psycho_burn_ended)) + +// Its Redline core goes in first, with the headroom the rest of its chrome needs +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/npc_install_cyberware(ware_type) + var/obj/item/organ/ware = ..() + if(istype(ware, /obj/item/organ/cyberimp/cyberware/redline)) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + if(chrome && !chrome.capacity_bonus) + chrome.capacity_bonus = BOUNTY_PSYCHO_EXTRA_CAPACITY + cyberware_reevaluate_brownout(src) + return ware + +/** + * Its chrome's AI, once the implants are in (C0's bounty_chrome_equip()): every piece's moves, and its + * rotation set to what this body can do. Its own lines say what is coming (boss_bark()), not each move's. + * Its Redline is its own move (/datum/bounty_chrome_move/redline/psycho), its phase two, in place of the + * one every carrier gets, and its Gorilla fist gets the piston punch. The Gorilla grab needs two Gorilla + * fists and the Deadeye tag a gun, and it has neither, so they stay out of its rotation. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_wire_chrome() + var/datum/component/bounty_chrome/chrome = bounty_chrome_equip(src, bounty_psycho_keys()) + if(!chrome) + return + for(var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/unused in boss_abilities.Copy()) + if(istype(unused.move, /datum/bounty_chrome_move/redline) || istype(unused.move, /datum/bounty_chrome_move/gorilla_grab) || istype(unused.move, /datum/bounty_chrome_move/deadeye)) + boss_abilities -= unused + qdel(unused) + var/datum/bounty_chrome_move/redline/plain = chrome.move_of(/datum/bounty_chrome_move/redline) + if(plain) + chrome.moves -= plain + plain.detach() + qdel(plain) + psycho_add_move(chrome, /datum/bounty_chrome_move/redline/psycho) + psycho_add_move(chrome, /datum/bounty_chrome_move/gorilla_punch/psycho) + for(var/datum/bounty_chrome_move/move as anything in chrome.moves) + move.tell_lines = null + +/// Adds a move of `move_type` to its chrome AI `chrome`, as a move in its rotation (C0's adaptor). Returns the move. +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_add_move(datum/component/bounty_chrome/chrome, move_type) + var/datum/bounty_chrome_move/move = new move_type + chrome.moves += move + move.attach(src) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/ability = new(src) + ability.set_move(move) + ability.Grant(src) + boss_abilities += ability + return move + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/cyberpsycho)) + +// No bag over its spine rig: its stimulants and bruise pack ride in a bum bag, where it reaches them as any boss reaches its bag. The bag holds three, so its combat stimpack rides in a pocket. +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_supplies(datum/outfit/outfit) + if(!istype(outfit)) + return outfit + outfit.belt = /obj/item/storage/belt/fannypack/black + LAZYINITLIST(outfit.belt_contents) + outfit.belt_contents[BOUNTY_BOSS_STIMULANT] = BOUNTY_BOSS_STIMULANTS + outfit.belt_contents[/obj/item/stack/medical/bruise_pack] = 1 + if(isnull(outfit.l_pocket)) + outfit.l_pocket = BOUNTY_BOSS_COMBAT_STIMULANT + else if(isnull(outfit.r_pocket)) + outfit.r_pocket = BOUNTY_BOSS_COMBAT_STIMULANT + return outfit + +// Its chrome is its kit: it dresses to show it (C0's bounty_chrome_expose_outfit()) +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/bounty_dresses_to_show_chrome() + return TRUE + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_gear_text() + return "[p_They()] can't keep still, and there's more chrome on [p_them()] than skin." + +/// Shorts and boots: its arms, chest and legs are chrome, and they show +/datum/outfit/bounty_boss/cyberpsycho + name = "Bounty mini-boss: Cyberpsycho" + uniform = /obj/item/clothing/under/shorts/black + gloves = null + +// ----- its phase two ----- + +/// Its Redline core, if it is in it +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_core() + return locate(/obj/item/organ/cyberimp/cyberware/redline) in organs + +/// Its Redline in its rotation, if it has one +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_ability() + var/datum/component/bounty_chrome/chrome = GetComponent(/datum/component/bounty_chrome) + var/datum/bounty_chrome_move/redline/psycho/burn = chrome?.move_of(/datum/bounty_chrome_move/redline/psycho) + return burn?.boss_ability + +/// Whether its phase two is still to come: it has never burned, and its Redline core is in it and running (an EMP or a brownout puts that off) +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_pending() + if(psycho_burned) + return FALSE + var/obj/item/organ/core = psycho_core() + var/datum/component/cyberware/ware = core?.GetComponent(/datum/component/cyberware) + return !!ware?.is_running() + +/// Hurt or winded enough to burn +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_hurt_enough() + return health <= maxHealth * BOUNTY_PSYCHO_BURN_HURT / 100 || getStaminaLoss() >= BOUNTY_PSYCHO_BURN_WINDED + +/** + * Whether it burns its Redline now at `target` (or whoever it fights, for null): its phase two still to + * come, hurt or winded enough, in its fight, and a Redline move's own checks but its "hurt" reading (C3's + * line is any carrier's, this is its own): able to act, someone it may hit in sight, the implant ready, + * and nobody outside the fight within the roar's reach (psycho_roar_clear()). Never at someone it can't + * get at (boss_valid_target(): shot from behind glass it can't break, it takes cover instead, C5), so a + * crew can't spend its phase two from where it can't follow. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_due(atom/target) + if(!boss_hostile || !psycho_burn_pending() || !psycho_hurt_enough()) + return FALSE + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = psycho_burn_ability() + var/datum/bounty_chrome_move/redline/move = burn?.move + target ||= bounty_chrome_target(src) || boss_revenge_target() + if(!istype(move) || QDELETED(target) || !bounty_chrome_can_act(src) || !bounty_ai_may_hit(src, target) || !boss_valid_target(target)) + return FALSE + if(bounty_ai_dist(src, target) > move.max_range || !bounty_ai_can_see(src, target, move.max_range)) + return FALSE + return move.ready() && psycho_roar_clear(move.area_turfs(target), target) + +/** + * Whether its Redline's roar may go off over `turfs`: nobody stands there who is outside its fight and has + * a player behind them or is a person (C0's bystander gate, bounty_chrome_area_clear()), but its own + * companions don't count, and nor do creatures: a cyberpsycho doesn't mind staggering its friends. Its + * companions come in one for each extra hunter (OC-8), so C0's gate would keep its phase two shut in most + * fights against a crew. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_roar_clear(list/turfs, atom/aimed) + for(var/turf/spot as anything in turfs) + if(!isturf(spot)) + continue + var/list/people = list() + for(var/mob/living/person in spot) + people += person + for(var/obj/vehicle/sealed/mecha/mech in spot) + for(var/mob/living/pilot in mech.occupants) + people += pilot + for(var/mob/living/person as anything in people) + if(person == src || person.stat == DEAD || bounty_ai_same_gang(src, person)) + continue + if((person.mind || person.client || ishuman(person)) && !bounty_chrome_in_posse(src, person, aimed)) + return FALSE + return TRUE + +// Its phase two comes before any other move +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_choose_ability(atom/target) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = psycho_burn_ability() + if(burn && !QDELETED(target) && boss_can_use_ability() && burn.IsAvailable() && burn.worth_using(target)) + return burn + return ..() + +// A stamina weapon or a bad wound: it doesn't wait its turn to burn. A moment later, once the hit is done with. +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_took_damage(damage, damagetype) + if(!psycho_burned && psycho_hurt_enough()) + addtimer(CALLBACK(src, PROC_REF(psycho_burn_now)), 1, TIMER_UNIQUE | TIMER_DELETE_ME) + +/** + * Burns its Redline now if it should (psycho_burn_due()): whatever it was winding up, or the glass it was + * punching, it drops, and the burn's tell starts in the next move's place. Not while it is up in a leap, + * holding on to someone or already winding the burn up. Returns TRUE if the tell started. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_now() + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = psycho_burn_ability() + if(QDELETED(src) || !burn || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED) || !boss_can_act()) + return FALSE + var/atom/target = bounty_chrome_target(src) || boss_revenge_target() + if(!burn.worth_using(target)) + return FALSE + if(boss_busy) + // A wind-up or a smash ("smash", BOUNTY_BOSS_BUSY_SMASH) gives way; anything else it sees through + if(boss_winding() == burn || (boss_busy != "windup" && boss_busy != "smash")) + return FALSE + boss_interrupt() + boss_next_ability_at = min(boss_next_ability_at, world.time) + boss_fallback_until = 0 + // Its own click, as its AI's Trigger() makes it: a timer's callback carries the usr of whoever hit it, and Trigger() would click as them + return !!burn.InterceptClickOn(src, null, target) + +// Tiring is what sets its Redline off: until its phase two is spent it burns instead of falling back, and burning, it doesn't tire +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_is_worn_out() + if(psycho_burn_pending() || has_status_effect(/datum/status_effect/cyberware_redline_window)) + return FALSE + return ..() + +// The burn's tell gets its own lines, said over the cooldown on its talk +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_bark(suffix, fallback) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/winding = boss_winding() + if(suffix == "ability" && istype(winding) && istype(winding.move, /datum/bounty_chrome_move/redline/psycho)) + if(boss_kit && stat == CONSCIOUS) + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "[boss_kit]_redline", null, TRUE) + return + return ..() + +/// The implant's burn started (its window's stun immunity went on): its phase two is spent +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_started(datum/source) + SIGNAL_HANDLER + var/datum/status_effect/window = /datum/status_effect/cyberware_redline_window + if(HAS_TRAIT_FROM(src, TRAIT_STUNIMMUNE, initial(window.id))) + psycho_burned = TRUE + +/// The burn is over (its stun immunity went), and the crash comes: it says so +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_ended(datum/source) + SIGNAL_HANDLER + if(psycho_burned && boss_kit && stat == CONSCIOUS) + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "[boss_kit]_exhausted", null, TRUE) + +/** + * A player in its body (a brain transplant) loses its gear (the human NPC base's Login()), and the headroom + * only a cyberpsycho carries: with its core's capacity gone, its chrome browns out, all of it, as anyone's + * does over capacity. Admins driving it for an event keep it. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/Login() + . = ..() + if(!. || isnull(client) || client.holder) + return + var/obj/item/organ/core = psycho_core() + var/datum/component/cyberware/core_ware = core?.GetComponent(/datum/component/cyberware) + if(core_ware?.capacity_bonus) + core_ware.capacity_bonus = 0 + cyberware_reevaluate_brownout(src) + +// ===== ITS MOVES ===== + +/** + * The cyberpsycho's Redline, its phase two, in place of the one every carrier gets: C3's tell (the chest + * speaker glowing red, standing still) and the implant's burn, on its own rules (psycho_burn_due()) and its + * own roar gate (psycho_roar_clear()), checked again as the button is pressed. Wired by the cyberpsycho + * itself (psycho_wire_chrome()), so no other Redline carrier gets it. + */ +/datum/bounty_chrome_move/redline/psycho + piece_key = null + +/datum/bounty_chrome_move/redline/psycho/can_start(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = owner + return istype(psycho) && psycho.psycho_burn_due(target) + +// Someone outside the fight stepped into the roar's reach during the tell: the button stays unpressed +/datum/bounty_chrome_move/redline/psycho/effect(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = owner + if(!istype(psycho) || !psycho.psycho_roar_clear(area_turfs(target), target)) + return FALSE + . = bounty_chrome_trigger(owner, action_type, trigger_target(target)) + if(. && owner.has_status_effect(/datum/status_effect/cyberware_redline_window)) + owner.apply_status_effect(/datum/status_effect/bounty_redline_eyes) + +/** + * Its Gorilla fist's piston punch as a move in its rotation: C2's punch, a fist drawn back over the lit + * tile for its tell, then the arm's own empowered punch. C2 leaves a mini-boss's Gorilla punches to its + * melee; this one's melee is its blade. Wired by the cyberpsycho itself (psycho_wire_chrome()), so no other + * Gorilla Arms carrier gets it. + */ +/datum/bounty_chrome_move/gorilla_punch/psycho + piece_key = null + boss_usable = TRUE + +// Its rotation throws it; it plans nothing of its own +/datum/bounty_chrome_move/gorilla_punch/psycho/plan(datum/ai_controller/controller, atom/target) + return FALSE + +// ===== THE BOUNTY PANEL ===== + +// "All of it" is the cyberpsycho: its body is the only one that carries it all (C0's seam) +/datum/bounty_admin_panel/admin_chrome_all(datum/bounty_record/record, list/kit_organs) + record.archetype = BOUNTY_ARCHETYPE_BOSS + record.kit = BOUNTY_KIT_CYBERPSYCHO + record.style = null + return list() + +#undef BOUNTY_PSYCHO_EXTRA_CAPACITY +#undef BOUNTY_PSYCHO_BURN_HURT +#undef BOUNTY_PSYCHO_BURN_WINDED diff --git a/voidcrew/modules/bounties/strings/bounty_criminals.json b/voidcrew/modules/bounties/strings/bounty_criminals.json index e87678a2b73..2461850b930 100644 --- a/voidcrew/modules/bounties/strings/bounty_criminals.json +++ b/voidcrew/modules/bounties/strings/bounty_criminals.json @@ -75,7 +75,8 @@ "pyromaniac": ["Matches", "Torch", "Cinders", "Smoky", "Blaze"], "demolitionist": ["Fuse", "Boomer", "Sticks", "Two-Fingers", "Detonator"], "ghost": ["Shade", "Blink", "Needle", "Nobody", "Quiet"], - "heavy": ["Gunner", "Big Iron", "Tank", "Belt", "Rattle"] + "heavy": ["Gunner", "Big Iron", "Tank", "Belt", "Rattle"], + "cyberpsycho": ["Static", "Glitch", "Hardwire", "Burnout", "Scrap Saint", "Sparks"] }, "features": { "scar": [ @@ -539,6 +540,25 @@ "Light them up!" ] }, + "pyromaniac_smoke": { + "any": [ + "Hold your breath.", + "Lights out.", + "Bit smoky in here.", + "Where'd I go?", + "Breathe deep.", + "Mind your eyes." + ] + }, + "pyromaniac_reload": { + "any": [ + "Hang on, changing tanks.", + "Fresh tank. Don't go anywhere.", + "Stay right there.", + "Just need a top-up.", + "Hold that thought." + ] + }, "pyromaniac_exhausted": { "any": [ "Running low on fuel...", @@ -678,6 +698,116 @@ "More targets. Fine.", "Hope you all brought armor." ] + }, + "juggernaut_cover": { + "any": [ + "Hiding back there? I'll wait.", + "Come out and face me.", + "You can't stay in there forever.", + "Come out from behind there!", + "I'll be right here when you come out.", + "Shooting from back there? Real brave." + ] + }, + "pyromaniac_cover": { + "any": [ + "Hiding? I can wait.", + "You'll have to come out sometime.", + "Come out, come out. It's warm out here.", + "I've got all day, and plenty of fuel.", + "Stay in there, then. I'll wait.", + "Shooting from back there? Come closer." + ] + }, + "demolitionist_cover": { + "any": [ + "Come out from behind there!", + "Hiding? I've got time.", + "You can't hide in there forever.", + "Shoot from back there all you like. I'll wait.", + "I know where you are.", + "Sooner or later, you come out." + ] + }, + "ghost_cover": { + "any": [ + "You'll come out eventually.", + "I'll be right here.", + "Hiding won't help you.", + "Take your time. I'm not going anywhere.", + "Come and find me.", + "I can wait all day." + ] + }, + "heavy_cover": { + "any": [ + "Can't hide in there forever.", + "Come out from behind there!", + "Stay in there, then. I'll wait.", + "Peek out again. Go on.", + "I've got all day.", + "Shooting from back there? Cute." + ] + }, + "cyberpsycho_intro": { + "any": [ + "Get out of my head!", + "Can you hear it humming? Can you?", + "More meat. Come here.", + "Don't look at me like that!", + "It won't stop. Nothing stops.", + "You're not real. None of you are real." + ] + }, + "cyberpsycho_ability": { + "any": [ + "Hold still!", + "There you are.", + "Got you now!", + "Come here!", + "Stay right there!", + "Don't run, meat!" + ] + }, + "cyberpsycho_redline": { + "any": [ + "Make it stop!", + "LOUDER!", + "Not now. Not now!", + "Everything's red.", + "Shut up! Shut up!", + "It's too loud!" + ] + }, + "cyberpsycho_exhausted": { + "any": [ + "Why is it so quiet?", + "Something's burning. Is that me?", + "I can't feel my arms.", + "Keep running, keep running...", + "My hands won't stop shaking.", + "Just let me sit down." + ] + }, + "cyberpsycho_posse": { + "any": [ + "More of you? Good. More meat.", + "All of you. I'll take all of you.", + "Bring the whole crew.", + "So many voices...", + "Line up.", + "Doesn't matter how many." + ] + }, + "cyberpsycho_cover": { + "any": [ + "Come out! Come OUT!", + "I can hear you breathing.", + "Where'd you go? Where'd you GO?", + "You can't stay in there forever!", + "Hiding won't help. I'll find you.", + "Come out and play." + ] } } } diff --git a/voidcrew/modules/bounties/strings/bounty_lair_mafia.json b/voidcrew/modules/bounties/strings/bounty_lair_mafia.json index 1c4af5a59e4..d0966d79c2a 100644 --- a/voidcrew/modules/bounties/strings/bounty_lair_mafia.json +++ b/voidcrew/modules/bounties/strings/bounty_lair_mafia.json @@ -33,6 +33,16 @@ "I'm bleeding here!", "Somebody shoot them!" ], + "goon_shoved": [ + "Hands off.", + "Back up, pal.", + "Don't touch me." + ], + "goon_cuffed": [ + "Get off me!", + "Not a chance.", + "You're not putting those on me." + ], "tommy_alert": [ "Nobody goes through this door. Boss's orders.", "You picked the wrong club, friend.", @@ -83,5 +93,10 @@ "You'll pay for that.", "My family will find you.", "Not like this. Not in my own club." + ], + "don_cuffed": [ + "Nobody takes me alive.", + "You'll have to kill me first.", + "Cuffs? On me?" ] } diff --git a/voidcrew/modules/cargo/shipping/_shuttle_loan.dm b/voidcrew/modules/cargo/shipping/_shuttle_loan.dm index 49ee78bb71a..87b3d32db60 100644 --- a/voidcrew/modules/cargo/shipping/_shuttle_loan.dm +++ b/voidcrew/modules/cargo/shipping/_shuttle_loan.dm @@ -210,13 +210,14 @@ GLOBAL_LIST_INIT(voidcrew_shuttle_loans, list( qdel(item) return delivery -/// The cargo bay can be off-map; its host ship supplies the encounter band. +/// The cargo bay can be off-map; its host ship supplies the encounter band. People (human NPCs) are queued +/// and built a body a tick (spawn_npc_in_zone_or_queue()). /datum/voidcrew_shuttle_loan/proc/spawn_hostile(mob_type, turf/location, datum/voidcrew_cargo_shuttle/shuttle) if(!npc_source) var/turf/source_turf = get_turf(shuttle.target_ship) npc_source = new(shuttle, SSovermap_zones.get_zone_type_anywhere(source_turf) || ZONE_GREEN, source_turf = source_turf) var/datum/npc_spawn_context/context = new(shuttle, npc_source.zone_type, "ordinary", source_turf = npc_source.source_turf) - return spawn_npc_in_zone(mob_type, location, context) + return spawn_npc_in_zone_or_queue(mob_type, location, context) /// Department resupply - free supplies, no bonus /datum/voidcrew_shuttle_loan/department_resupply @@ -449,13 +450,13 @@ GLOBAL_LIST_INIT(voidcrew_shuttle_loans, list( for(var/i in 1 to 2) if(!length(cargo_turfs)) break - spawn_hostile(/mob/living/basic/trooper/syndicate/ranged/infiltrator, pick_n_take(cargo_turfs), shuttle) + spawn_hostile(/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/infiltrator, pick_n_take(cargo_turfs), shuttle) // Chance for more if(prob(75) && length(cargo_turfs)) - spawn_hostile(/mob/living/basic/trooper/syndicate/ranged/infiltrator, pick_n_take(cargo_turfs), shuttle) + spawn_hostile(/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/infiltrator, pick_n_take(cargo_turfs), shuttle) if(prob(50) && length(cargo_turfs)) - spawn_hostile(/mob/living/basic/trooper/syndicate/ranged/infiltrator, pick_n_take(cargo_turfs), shuttle) + spawn_hostile(/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/infiltrator, pick_n_take(cargo_turfs), shuttle) /// Lots of bees - Toxic bees and beekeeping equipment /datum/voidcrew_shuttle_loan/lots_of_bees @@ -580,15 +581,15 @@ GLOBAL_LIST_INIT(voidcrew_shuttle_loans, list( // The Russians if(length(cargo_turfs)) - spawn_hostile(/mob/living/basic/trooper/russian, pick_n_take(cargo_turfs), shuttle) + spawn_hostile(/mob/living/carbon/human/npc/ruin/trooper/russian, pick_n_take(cargo_turfs), shuttle) if(length(cargo_turfs)) - spawn_hostile(/mob/living/basic/trooper/russian/ranged, pick_n_take(cargo_turfs), shuttle) // This one drops a mateba + spawn_hostile(/mob/living/carbon/human/npc/ruin/trooper/russian/gunner, pick_n_take(cargo_turfs), shuttle) // This one drops a Nagant if(length(cargo_turfs)) spawn_hostile(/mob/living/basic/bear/russian, pick_n_take(cargo_turfs), shuttle) // Chance for more if(prob(75) && length(cargo_turfs)) - spawn_hostile(/mob/living/basic/trooper/russian, pick_n_take(cargo_turfs), shuttle) + spawn_hostile(/mob/living/carbon/human/npc/ruin/trooper/russian, pick_n_take(cargo_turfs), shuttle) if(prob(50) && length(cargo_turfs)) spawn_hostile(/mob/living/basic/bear/russian, pick_n_take(cargo_turfs), shuttle) diff --git a/voidcrew/modules/cyberware/chrome_cradle.dm b/voidcrew/modules/cyberware/chrome_cradle.dm index ef2dc109b0a..495bcccf1ad 100644 --- a/voidcrew/modules/cyberware/chrome_cradle.dm +++ b/voidcrew/modules/cyberware/chrome_cradle.dm @@ -94,8 +94,6 @@ /obj/machinery/chrome_cradle/examine(mob/user) . = ..() - . += span_notice("Drag yourself (or a patient) onto the slab to lie back on it. Whoever's on the slab runs their own install; the rig won't take orders from anyone else.") - . += span_notice("Tune-ups run [CYBERWARE_TUNEUP_FEE] cr: EMP-scrambled and damaged chrome comes back to spec. Load cash into the slab or pay by ID.") if(loaded_credits) . += span_notice("The slab's cash slot holds [loaded_credits] cr.") if(length(tray)) @@ -226,6 +224,32 @@ playsound(src, 'sound/machines/click.ogg', 40, TRUE) SStgui.update_uis(src) +// ---- Arm choice -------------------------------------------------------- + +/** + * Seats an uninstalled arm housing on the patient's free arm when its own arm + * already holds one of the same kind, so a second Widowline goes on the other + * arm instead of racking the first. Starts from the housing's own arm every + * time, so a housing the cradle moved once doesn't stay moved for the next + * patient. With both arms taken (or no free arm to take it), it stays on its + * own arm and replaces as before. Cradle only: surgery picks its arm by + * the surgeon's target zone (pre_surgical_insertion()). + */ +/obj/machinery/chrome_cradle/proc/pick_cradle_arm(obj/item/organ/ware, mob/living/carbon/patient) + if(!istype(ware, /obj/item/organ/cyberimp/arm/toolkit/cyberware) || ware.owner || !istype(patient)) + return + var/home_zone = initial(ware.zone) + var/other_zone = home_zone == BODY_ZONE_R_ARM ? BODY_ZONE_L_ARM : BODY_ZONE_R_ARM + if(!length(ware.valid_zones) || !ware.valid_zones[home_zone] || !ware.valid_zones[other_zone]) + return + var/target_zone = home_zone + var/obj/item/organ/home_incumbent = patient.get_organ_slot(ware.valid_zones[home_zone]) + if(home_incumbent && home_incumbent.type == ware.type \ + && !patient.get_organ_slot(ware.valid_zones[other_zone]) && patient.get_bodypart(other_zone)) + target_zone = other_zone + if(ware.zone != target_zone) + ware.swap_zone(target_zone) + // ---- Sequences --------------------------------------------------------- /** @@ -239,7 +263,10 @@ if(!istype(patient) || QDELETED(ware)) return var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) - if(!chrome || !cyberware_insert_check(ware, patient)) + if(!chrome) + return + pick_cradle_arm(ware, patient) + if(!cyberware_insert_check(ware, patient)) return if(ware.loc != src && !patient.transferItemToLoc(ware, src)) balloon_alert(patient, "[ware.name] is stuck to you!") @@ -261,6 +288,8 @@ /// on the marked stage a spark shower plus Splice's mid-line. /obj/machinery/chrome_cradle/proc/run_sequence_stage(mid_stage = FALSE) playsound(src, 'sound/effects/servostep.ogg', 60, TRUE) + // The rig's arms move on the beat (cyberware_anim2.dm). + rig_beat(mid_stage) var/mob/living/patient = occupant if(istype(patient)) patient.do_jitter_animation(60) @@ -286,6 +315,8 @@ var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) if(!chrome) return + // The arms may have changed during the sequence; choose again before the recheck. + pick_cradle_arm(ware, patient) // Recheck the final set after the sequence delay, while every incumbent is // still installed. An intervening install or lost Governor must not evict it. if(!cyberware_insert_check(ware, patient)) @@ -317,6 +348,7 @@ SStgui.update_uis(src) return do_sparks(3, FALSE, src) + rig_boot_fx(patient) patient.flash_act(visual = 1) if(chrome.tier >= CYBERWARE_TIER_4) visible_message(span_notice("The parlor's neon stutters as the rig pulls the current it needs.")) @@ -370,6 +402,7 @@ for(var/timer in stage_timers) deltimer(timer) stage_timers.Cut() + rig_stop() busy = FALSE busy_action = null busy_ware = null @@ -408,7 +441,7 @@ if(any_repairable) return null if(only_brownout && length(installed)) - return "That chrome is browned out, not broken. Shed some load instead." + return "That chrome is browned out, not broken." return "Nothing needs a tune-up." /obj/machinery/chrome_cradle/proc/try_tune_up(mob/living/carbon/patient) @@ -662,6 +695,8 @@ else projected_load += chrome.chrome_load projected_capacity += chrome.capacity_bonus + // Show the arm the install would actually use (an uninstalled housing only). + pick_cradle_arm(selected_ware, patient) for(var/obj/item/organ/incumbent in selected_ware.cyberware_get_incumbents(patient)) evicts += incumbent.name projected_load -= get_installed_capacity_cost(incumbent) @@ -778,6 +813,7 @@ if(!ware.GetComponent(/datum/component/cyberware)) balloon_alert(patient, "not chrome!") return TRUE + pick_cradle_arm(ware, patient) if(!cyberware_insert_check(ware, patient, feedback_to = patient)) return TRUE begin_install(ware) @@ -807,6 +843,13 @@ return TRUE +// ---- Ship saves --------------------------------------------------------- + +// A cradle belongs to a parlor. A saved hull must never rebuild one (an admin test kit can +// drop one on a ship), or every load would hand that ship a free operating slab. +/obj/machinery/chrome_cradle/checkpoint_type() + return null + // ---- Outpost aggression ------------------------------------------------ // Attacking outpost property is aggression, the cradle included. diff --git a/voidcrew/modules/cyberware/chrome_hud.dm b/voidcrew/modules/cyberware/chrome_hud.dm new file mode 100644 index 00000000000..c8c2a10b63c --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_hud.dm @@ -0,0 +1,198 @@ +/** + * # Chrome on the HUD + * + * Chrome abilities are fired from the chrome wheel (chrome_wheel.dm), so none + * of them gets an action button of its own. One "Chrome" button stands in for + * all of them: it shows the pick (what Use chrome fires) with its cooldown and + * offline state, and a click opens the wheel. + * + * A chrome ability is any action whose target is installed chrome (an organ + * carrying /datum/component/cyberware), plus the wheel's own Arm chrome. Every + * other action, printable tg implants included, keeps its button. + * + * Hiding is tg's own way to keep an action without a button (the ninja dash, + * energy_katana.dm): GiveAction() does nothing for it. The action is still + * granted, so it keeps its owner, its keydown signal, its check_flags signals, + * its cooldown and its click intercepts, and Trigger() works as before. It + * just never joins anyone's actions list (so the HUD, observers and riders + * never see it) and never gets a button. Code that used to look for a chrome + * ability in mob.actions asks get_chrome_wheel_actions() instead. + * + * The chips on the wheel and the Chrome button still wear each ability's own + * button look: chrome_ability_look() builds a button that never goes on a + * screen and copies it. + */ + +/// Whether this action is a chrome ability: fired from the chrome wheel, never +/// given a button of its own. +/datum/action/proc/is_chrome_ability() + var/obj/item/organ/ware = target + return istype(ware) && !!ware.GetComponent(/datum/component/cyberware) + +/datum/action/GiveAction(mob/viewer) + if(is_chrome_ability()) + return + return ..() + +// GiveAction() is the only way in, but a button must never be built for one by any other road either. +/datum/action/ShowTo(mob/viewer) + if(is_chrome_ability()) + return + return ..() + +/datum/action/Grant(mob/grant_to) + . = ..() + if(owner && is_chrome_ability()) + chrome_hud_sync(owner, gained = TRUE) + +/datum/action/Remove(mob/remove_from) + // Grant() hands over by setting the new owner first, then removing the old one. + var/mob/losing = remove_from || owner + . = ..() + if(losing && is_chrome_ability()) + chrome_hud_sync(losing) + +/// A chrome ability's button would have been redrawn (a cooldown ticking, an +/// ability armed, a stun): the Chrome button redraws instead if it shows this one. +/datum/action/build_all_button_icons(update_flags = ALL, force) + . = ..() + if(owner && is_chrome_ability()) + var/datum/component/chrome_wheel/wheel = owner.GetComponent(/datum/component/chrome_wheel) + wheel?.on_ability_changed(src) + +/** + * Brings the bearer's Chrome button and Arm chrome up to date with the chrome + * they carry. [gained] makes the wheel if the body has none yet. + */ +/proc/chrome_hud_sync(mob/living/carbon/bearer, gained = FALSE) + if(!iscarbon(bearer) || QDELETED(bearer)) + return + var/datum/component/chrome_wheel/wheel = gained ? get_chrome_wheel(bearer) : bearer.GetComponent(/datum/component/chrome_wheel) + wheel?.sync_abilities() + +// ---- Ability looks ----------------------------------------------------- + +/// The last look built for this action's chip or Chrome button, and what it was built from. +/datum/action/var/tmp/mutable_appearance/chrome_look +/datum/action/var/tmp/chrome_look_key + +/// What an ability's look is built from. A change here rebuilds it. +/proc/chrome_ability_look_key(datum/action/ability) + var/atom/thing = isatom(ability.target) ? ability.target : null + return "[ability.button_icon]|[ability.button_icon_state]|[ability.overlay_icon_state]|[ability.name]|[thing?.icon]|[thing?.icon_state]|[length(thing?.overlays)]" + +/** + * An ability's own button look, without its background, text, tint or + * position: built on a button that never goes on a screen, so it carries the + * same icon and item overlay a button would. A fresh copy each call. + */ +/proc/chrome_ability_look(datum/action/ability) + RETURN_TYPE(/mutable_appearance) + var/key = chrome_ability_look_key(ability) + if(!ability.chrome_look || ability.chrome_look_key != key) + var/atom/movable/screen/movable/action_button/button = ability.create_button() + var/mutable_appearance/look = new(button) + qdel(button) + look.underlays = list() + look.plane = FLOAT_PLANE + look.layer = FLOAT_LAYER + look.pixel_x = 0 + look.pixel_y = 0 + look.pixel_w = 0 + look.pixel_z = 0 + look.screen_loc = null + look.maptext = null + look.color = null + look.alpha = 255 + look.transform = matrix() + look.appearance_flags = NONE + ability.chrome_look = look + ability.chrome_look_key = key + return new /mutable_appearance(ability.chrome_look) + +// ---- The Chrome button ------------------------------------------------- + +/// The pick's look, frame and anything else the Chrome button laid over itself. +/atom/movable/screen/movable/action_button/var/list/chrome_overlays + +/** + * The one HUD button chrome gets. Shows the wheel's pick framed in cyan, or + * the wheel itself with no pick; red like the pick's own button when it can't + * be used, with its cooldown in seconds and a lit background while it is out + * or armed. A click opens the wheel, and a chip clicked there is picked and fired. + */ +/datum/action/chrome_button + name = "Chrome" + button_icon = CHROME_HUD_ICON + button_icon_state = "chrome" + background_icon_state = "bg_tech_blue" + default_button_position = SCRN_OBJ_INSERT_FIRST + +/datum/action/chrome_button/proc/get_wheel() + RETURN_TYPE(/datum/component/chrome_wheel) + var/datum/component/chrome_wheel/wheel = target + return istype(wheel) ? wheel : null + +/// The ability the button shows, or null. +/datum/action/chrome_button/proc/get_pick() + return get_wheel()?.get_button_pick() + +/datum/action/chrome_button/Trigger(mob/clicker, trigger_flags) + . = ..() + if(!.) + return FALSE + if(clicker && clicker != owner) + return FALSE + var/datum/component/chrome_wheel/wheel = get_wheel() + if(!wheel) + return FALSE + wheel.button_clicked() + return TRUE + +/datum/action/chrome_button/create_button() + var/atom/movable/screen/movable/action_button/button = ..() + // Placed like a cooldown button's count. + button.maptext_x = 4 + button.maptext_y = 2 + button.maptext_width = 32 + button.maptext_height = 16 + return button + +/datum/action/chrome_button/is_action_active(atom/movable/screen/movable/action_button/current_button) + var/datum/action/pick = get_pick() + return pick && chrome_wheel_ability_active(pick, owner) + +/datum/action/chrome_button/apply_button_background(atom/movable/screen/movable/action_button/current_button, force) + // Lit while the pick is out or armed. + background_icon_state = is_action_active(current_button) ? "bg_tech_blue_active" : "bg_tech_blue" + return ..() + +/datum/action/chrome_button/apply_button_icon(atom/movable/screen/movable/action_button/current_button, force) + // With a pick, the pick's own look is laid over an empty button instead. + current_button.icon = get_pick() ? null : button_icon + current_button.icon_state = button_icon_state + +/datum/action/chrome_button/apply_button_overlay(atom/movable/screen/movable/action_button/current_button, force) + . = ..() + if(current_button.chrome_overlays) + current_button.cut_overlay(current_button.chrome_overlays) + current_button.chrome_overlays = null + var/datum/action/pick = get_pick() + if(!pick) + return + // No RESET_COLOR: the button's red goes over the pick, as it did on the pick's own button. + var/list/laid = list(chrome_ability_look(pick), chrome_wheel_marker("module_selected")) + current_button.add_overlay(laid) + current_button.chrome_overlays = laid + +/datum/action/chrome_button/update_button_status(atom/movable/screen/movable/action_button/current_button, force) + . = ..() + current_button.maptext = "" + var/datum/action/pick = get_pick() + if(!pick) + return + var/cooldown_left = chrome_wheel_cooldown_left(pick) + if(cooldown_left) + current_button.maptext = MAPTEXT_TINY_UNICODE("[CEILING(cooldown_left / 10, 1)]") + if(chrome_wheel_offline(pick) || !pick.IsAvailable()) + current_button.color = pick.transparent_when_unavailable ? rgb(128, 0, 0, 128) : rgb(128, 0, 0) diff --git a/voidcrew/modules/cyberware/chrome_wheel.dm b/voidcrew/modules/cyberware/chrome_wheel.dm new file mode 100644 index 00000000000..0e4a86c3f8a --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_wheel.dm @@ -0,0 +1,1482 @@ +/** + * # Chrome wheel + * + * One wheel holding every ability the bearer's chrome carries, so nobody has + * to hunt a row of action buttons mid-fight. Three keybinds drive it + * (chrome_wheel_keybindings.dm): + * + * - Chrome wheel: hold it and the wheel opens at the centre of the view. Point + * at a chip and let go, or click one, to pick it. A quick tap leaves the + * wheel open for a click instead; pressing the key again shuts it. + * - Use chrome: fires the picked ability on key down. + * - Use OS chrome: fires the OS-slot ware's ability on key down, whatever is + * picked. + * + * A chip IS the bearer's existing /datum/action, fired through the same + * Trigger() a button click would use. Cooldowns, ORGAN_FAILING, check_flags + * and click-to-target modes are the ability's own, and nothing here copies or + * replaces one. Abilities are collected generically from every installed + * organ carrying /datum/component/cyberware, so new ware shows up without + * touching this file. Chrome abilities get no HUD button of their own + * (chrome_hud.dm): the wheel owns the one "Chrome" button that shows the pick + * and opens the wheel, and the Arm chrome ability (chrome_wheel_arms.dm) that + * deploys every arm at once. + * + * Looks: an outline of the bearer's body inside a ring cut into one arc per + * body region. Each ware sits in a socket (chrome_wheel_socket()), a run of + * fixed positions on its region's arc, with a leader line from the socket to + * the part of the body it lives in. A socket's chrome changing never moves + * another socket's chips, and an empty socket keeps a dim outline. Pointing at + * a chip grows it and lights its arc, its leader and its body part in its + * tier's colour, and a fixed readout under the ring names it. The pick wears + * the chrome read's corner brackets, and the Chrome button shows it in the + * MOD-style "selected" frame on the HUD. + * + * Pointing is by bearing from the ring's centre, so a flick outward picks the + * chip in that direction without landing on it. Inside the body nothing is + * pointed at. + * + * Sub-options: an ability can offer choices through + * /datum/action/proc/chrome_wheel_options(), empty by default. The wheel does + * not show them; a pick made with select(ability, option_id) makes Use chrome + * call the ability's use_chrome_wheel_option() with that id instead of Trigger(). + * + * Mouse handling: BYOND only reports the mouse over things it can hit, so the + * open wheel lays a full-view cursor catcher (tg's, as the scope uses) under + * its mouse-transparent pieces. Every move and click lands on the catcher, and + * the chip is worked out from where the mouse is relative to the centre. + */ + +// Readout panel under the ring, in pixels from the bottom left of the centre +// tile (y up). The panel's top edge, left edge and size. +#define CHROME_WHEEL_READOUT_TOP -108 +#define CHROME_WHEEL_READOUT_LEFT -66 +#define CHROME_WHEEL_READOUT_WIDTH 164 +#define CHROME_WHEEL_READOUT_HEIGHT 25 + +/// Organ slots that sort first inside their socket, in this order. Any other +/// slot follows them, alphabetically by slot name. +GLOBAL_LIST_INIT(chrome_wheel_slot_order, list( + ORGAN_SLOT_EYES, + ORGAN_SLOT_CYBERWARE_EARS, + ORGAN_SLOT_CYBERWARE_RIGGER, + ORGAN_SLOT_CYBERWARE_REGISTRY, + ORGAN_SLOT_CYBERWARE_OS, + ORGAN_SLOT_CYBERWARE_SKIN, + ORGAN_SLOT_CYBERWARE_FILTER, + ORGAN_SLOT_CYBERWARE_STASH, + ORGAN_SLOT_CYBERWARE_LEGS, +)) + +/** + * Where each socket's chips sit on the ring. "start" is the bearing of its + * first chip in degrees clockwise from twelve o'clock, "turn" the way the rest + * follow (1 clockwise, -1 counter-clockwise), "fits" how many its arc holds + * before another row starts further out. "arc" is the ring arc it lights and + * "region" the body part. The art in chrome_wheel_ring.dmi is drawn to match. + */ +GLOBAL_LIST_INIT(chrome_wheel_sockets, list( + CHROME_WHEEL_SOCKET_EYES = list("start" = -9, "turn" = -1, "fits" = 2, "arc" = "head", "region" = "head"), + CHROME_WHEEL_SOCKET_HEAD = list("start" = 9, "turn" = 1, "fits" = 2, "arc" = "head", "region" = "head"), + CHROME_WHEEL_SOCKET_OS = list("start" = 47, "turn" = 1, "fits" = 2, "arc" = "os", "region" = "spine"), + CHROME_WHEEL_SOCKET_RIGHT_ARM = list("start" = 83, "turn" = 1, "fits" = 4, "arc" = "r_arm", "region" = "r_arm"), + CHROME_WHEEL_SOCKET_LEGS = list("start" = 180, "turn" = 1, "fits" = 3, "arc" = "legs", "region" = "legs"), + CHROME_WHEEL_SOCKET_LEFT_ARM = list("start" = -83, "turn" = -1, "fits" = 4, "arc" = "l_arm", "region" = "l_arm"), + CHROME_WHEEL_SOCKET_SKIN = list("start" = -47, "turn" = -1, "fits" = 2, "arc" = "skin", "region" = "torso"), +)) + +/// Positions a socket holds for one organ slot, filled or not, so the rest of +/// that socket never moves when the slot is filled or emptied. Socket => +/// list(slot, positions held). The OS owns the first chip of its arc. Chrome +/// optics own the first three of theirs (their own ability, the chrome read and +/// the zoom, cyberware_zoom.dm); the third starts the eyes' second row. Arm +/// chrome (chrome_wheel_arms.dm) owns the top of the right arm's arc, where it +/// meets the torso. +GLOBAL_LIST_INIT(chrome_wheel_reserved, list( + CHROME_WHEEL_SOCKET_OS = list(ORGAN_SLOT_CYBERWARE_OS, 1), + CHROME_WHEEL_SOCKET_EYES = list(ORGAN_SLOT_EYES, 3), + CHROME_WHEEL_SOCKET_RIGHT_ARM = list(CHROME_WHEEL_ARMS_KEY, 1), +)) + +// ---- Collection -------------------------------------------------------- + +/** + * Every ability the bearer's installed chrome gives them, then Arm chrome if + * they have it. Offline ware stays in (it shows dark, and firing it gives the + * ability's own feedback); ware that is not in the body has no owner on its + * actions and drops out on its own. + */ +/proc/get_chrome_wheel_actions(mob/living/carbon/bearer) + RETURN_TYPE(/list) + . = list() + for(var/obj/item/organ/ware as anything in get_installed_cyberware(bearer)) + for(var/datum/action/ability in ware.actions) + if(QDELETED(ability) || ability.owner != bearer || !ability.owner_has_control) + continue + . += ability + var/datum/component/chrome_wheel/wheel = bearer?.GetComponent(/datum/component/chrome_wheel) + var/datum/action/chrome_arms/arms = wheel?.arm_action + if(arms && !QDELETED(arms) && arms.owner == bearer) + . += arms + +/// The ability of whatever OS-slot chrome the bearer runs, or null. +/proc/get_os_chrome_action(mob/living/carbon/bearer) + for(var/obj/item/organ/ware as anything in get_installed_cyberware(bearer)) + if(ware.slot != ORGAN_SLOT_CYBERWARE_OS) + continue + for(var/datum/action/ability in ware.actions) + if(!QDELETED(ability) && ability.owner == bearer && ability.owner_has_control) + return ability + return null + +/// The bearer's chrome wheel, made on first use. Null for a body with no chrome +/// abilities and no wheel yet, so a plain body never grows the component. +/proc/get_chrome_wheel(mob/living/carbon/bearer) + if(!iscarbon(bearer)) + return null + var/datum/component/chrome_wheel/wheel = bearer.GetComponent(/datum/component/chrome_wheel) + if(wheel || !length(get_chrome_wheel_actions(bearer))) + return wheel + wheel = bearer.AddComponent(/datum/component/chrome_wheel) + wheel?.sync_abilities() + return wheel + +// ---- Sub-option hooks -------------------------------------------------- + +/** + * One choice an ability offers. [id] must stay the same between calls for the + * same choice: the wheel remembers a pick by id and asks the ability for its + * options again before firing it. + */ +/datum/chrome_wheel_option + /// Stable key for this choice within its ability. Non-empty text: a false + /// id reads as "no option" and would fire the ability itself. + var/id + /// What the readout calls the pick. + var/name + var/icon + var/icon_state + +/datum/chrome_wheel_option/New(id, name, icon, icon_state) + src.id = id + src.name = name + src.icon = icon + src.icon_state = icon_state + +/** + * Choices this ability offers on top of firing it plainly: a list of + * /datum/chrome_wheel_option, or null for none (the default, and every ability + * today). + */ +/datum/action/proc/chrome_wheel_options(mob/living/carbon/bearer) + RETURN_TYPE(/list) + return null + +/** + * Fires one of this ability's chrome wheel sub-options. The default fires the + * ability itself; an override stashes the choice first, then calls Trigger() + * so the button's availability checks and targeting still apply. + */ +/datum/action/proc/use_chrome_wheel_option(mob/living/carbon/bearer, option_id) + return Trigger(bearer) + +/// The option with this id that the ability offers right now, or null. +/proc/chrome_wheel_find_option(datum/action/ability, mob/living/carbon/bearer, option_id) + for(var/datum/chrome_wheel_option/option in ability?.chrome_wheel_options(bearer)) + if(option.id == option_id) + return option + return null + +// ---- Names ------------------------------------------------------------- + +/** + * What the wheel calls this ability: its own name, or for a ware's plain + * deploy or use button, the ware without its housing ("Mantis Blade", not + * "Toggle Mantis Blade housing (left)"). Override for a better one. + */ +/datum/action/proc/chrome_wheel_name() + var/obj/item/organ/ware = target + if(istype(ware) && (istype(src, /datum/action/item_action/organ_action/toggle) || istype(src, /datum/action/item_action/organ_action/use))) + var/static/regex/hardware = regex(@"(\s+(spool\s+)?housing|\s+drill fist)?(\s*\((left|right)\))?$", "i") + return hardware.Replace(format_text(ware.name), "") + return format_text(name) + +/// The name of the chrome an ability lives in, or "" for one that lives in nothing. +/proc/chrome_wheel_ware_name(datum/action/ability) + var/obj/item/organ/ware = ability?.target + return istype(ware) ? format_text(ware.name) : "" + +/// Display names, ability => text. +/proc/chrome_wheel_names(list/abilities) + RETURN_TYPE(/list) + . = list() + for(var/datum/action/ability as anything in abilities) + .[ability] = ability.chrome_wheel_name() + +// ---- Layout ------------------------------------------------------------ + +/// Which socket an organ's abilities sit in, from its slot and the zone it lives in. +/proc/chrome_wheel_socket(obj/item/organ/ware) + if(!istype(ware)) + return CHROME_WHEEL_SOCKET_OS + if(ware.slot == ORGAN_SLOT_EYES || ware.zone == BODY_ZONE_PRECISE_EYES) + return CHROME_WHEEL_SOCKET_EYES + if(ware.slot == ORGAN_SLOT_CYBERWARE_OS) + return CHROME_WHEEL_SOCKET_OS + if(ware.slot == ORGAN_SLOT_CYBERWARE_SKIN) + return CHROME_WHEEL_SOCKET_SKIN + switch(ware.zone) + if(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_MOUTH) + return CHROME_WHEEL_SOCKET_HEAD + if(BODY_ZONE_R_ARM, BODY_ZONE_PRECISE_R_HAND) + return CHROME_WHEEL_SOCKET_RIGHT_ARM + if(BODY_ZONE_L_ARM, BODY_ZONE_PRECISE_L_HAND) + return CHROME_WHEEL_SOCKET_LEFT_ARM + if(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG, BODY_ZONE_PRECISE_R_FOOT, BODY_ZONE_PRECISE_L_FOOT) + return CHROME_WHEEL_SOCKET_LEGS + // The chest and anything else: the OS's arc, after the OS. + return CHROME_WHEEL_SOCKET_OS + +/// Which socket an ability's chip sits in: its chrome's, or for Arm chrome the right arm's. +/proc/chrome_wheel_ability_socket(datum/action/ability) + if(istype(ability, /datum/action/chrome_arms)) + return CHROME_WHEEL_SOCKET_RIGHT_ARM + return chrome_wheel_socket(ability.target) + +/// What claims an ability a reserved position (GLOB.chrome_wheel_reserved): +/// its chrome's organ slot, or CHROME_WHEEL_ARMS_KEY for Arm chrome. +/proc/chrome_wheel_reserve_key(datum/action/ability) + if(istype(ability, /datum/action/chrome_arms)) + return CHROME_WHEEL_ARMS_KEY + var/obj/item/organ/ware = ability.target + return istype(ware) ? ware.slot : null + +/// Hand chrome rides its arm's socket, after the arm's own chrome, with its +/// own leader to the hand. +/proc/chrome_wheel_is_hand(obj/item/organ/ware) + if(!istype(ware)) + return FALSE + return ware.slot == ORGAN_SLOT_CYBERWARE_HANDS || ware.zone == BODY_ZONE_PRECISE_L_HAND || ware.zone == BODY_ZONE_PRECISE_R_HAND + +/// Where an organ slot ranks inside its socket. Unlisted slots rank last. +/proc/chrome_wheel_slot_rank(obj/item/organ/ware) + var/rank = istype(ware) ? GLOB.chrome_wheel_slot_order.Find(ware.slot) : 0 + return rank || length(GLOB.chrome_wheel_slot_order) + 1 + +/// Order inside a socket: hand chrome last, then ranked organ slots, other +/// slots by name, and each organ's own button order. +/proc/cmp_chrome_wheel_abilities(datum/action/first, datum/action/second) + var/obj/item/organ/first_ware = first.target + var/obj/item/organ/second_ware = second.target + // Abilities that live in no organ (Arm chrome) go last; their place is reserved anyway. + if(!isorgan(first_ware) || !isorgan(second_ware)) + return !isorgan(first_ware) - !isorgan(second_ware) + var/hand_gap = chrome_wheel_is_hand(first_ware) - chrome_wheel_is_hand(second_ware) + if(hand_gap) + return hand_gap + var/rank_gap = chrome_wheel_slot_rank(first_ware) - chrome_wheel_slot_rank(second_ware) + if(rank_gap) + return rank_gap + if(first_ware != second_ware) + var/first_slot = istype(first_ware) ? first_ware.slot : "" + var/second_slot = istype(second_ware) ? second_ware.slot : "" + return sorttext(second_slot, first_slot) || sorttext(second.name, first.name) + return LAZYFIND(first_ware.actions, first) - LAZYFIND(second_ware.actions, second) + +/// How many chips the socket's arc holds before another row starts. +/proc/chrome_wheel_socket_fits(socket) + var/list/spec = GLOB.chrome_wheel_sockets[socket] + return spec ? spec["fits"] : 1 + +/** + * Pixel offset from the centre of the view of position [index] in [socket], + * as list(x, y). Positions run along the socket's arc CHROME_WHEEL_STEP + * degrees apart; once the arc is full they start again a row further out. + */ +/proc/chrome_wheel_position(socket, index) + RETURN_TYPE(/list) + var/list/spec = GLOB.chrome_wheel_sockets[socket] + var/fits = spec["fits"] + var/row = round((index - 1) / fits) + var/angle = spec["start"] + spec["turn"] * ((index - 1) % fits) * CHROME_WHEEL_STEP + var/radius = CHROME_WHEEL_RADIUS + row * CHROME_WHEEL_ROW_GAP + return list(round(sin(angle) * radius, 1), round(cos(angle) * radius + CHROME_WHEEL_CENTRE_Y, 1)) + +/** + * Sorts the abilities into sockets: ability => list("socket", "index", + * "hand"). Where an ability lands depends only on its own socket's chrome, + * never on what the rest of the body carries. + */ +/proc/chrome_wheel_placements(list/abilities) + RETURN_TYPE(/list) + var/list/by_socket = list() + for(var/datum/action/ability as anything in abilities) + var/socket = chrome_wheel_ability_socket(ability) + if(!by_socket[socket]) + by_socket[socket] = list() + var/list/members = by_socket[socket] + members += ability + var/list/placements = list() + for(var/socket in by_socket) + var/list/members = sortTim(by_socket[socket], GLOBAL_PROC_REF(cmp_chrome_wheel_abilities)) + var/list/reserved = GLOB.chrome_wheel_reserved[socket] + var/reserved_slot = length(reserved) ? reserved[1] : null + var/reserved_count = length(reserved) ? reserved[2] : 0 + var/next_reserved = 1 + var/next_free = reserved_count + 1 + for(var/datum/action/ability as anything in members) + var/obj/item/organ/ware = ability.target + var/index + if(reserved_slot && chrome_wheel_reserve_key(ability) == reserved_slot && next_reserved <= reserved_count) + index = next_reserved++ + else + index = next_free++ + placements[ability] = list("socket" = socket, "index" = index, "hand" = chrome_wheel_is_hand(ware)) + return placements + +/// Where each ability's chip sits: ability => list(x, y), from the centre of the view. +/proc/chrome_wheel_layout(list/abilities) + RETURN_TYPE(/list) + var/list/placements = chrome_wheel_placements(abilities) + . = list() + for(var/datum/action/ability as anything in placements) + var/list/placement = placements[ability] + .[ability] = chrome_wheel_position(placement["socket"], placement["index"]) + +// ---- Pointing ---------------------------------------------------------- + +/// Bearing of an offset from the centre of the view, as seen from the ring's +/// centre: degrees clockwise from twelve o'clock. +/proc/chrome_wheel_bearing(x, y) + return delta_to_angle(x, y - CHROME_WHEEL_CENTRE_Y) + +/** + * Which of [points] (a list of list(x, y) chip centres) the mouse at offset + * (dx, dy) means, as an index, or 0 for none. Inside the body nothing is + * pointed at. A chip right under the mouse is the one meant; anywhere else, + * the mouse points at the chip whose bearing from the ring's centre is + * nearest its own, so a quick flick outward works without landing on it. + * [on_ring], if given, says which points a bearing can pick (the first row); + * the rest only answer the mouse right on them. With [reach], a bearing only + * picks within that many pixels of the ring's centre. + */ +/proc/chrome_wheel_pick(dx, dy, list/points, list/on_ring, reach) + var/ring_y = dy - CHROME_WHEEL_CENTRE_Y + var/from_centre = sqrt(dx ** 2 + ring_y ** 2) + if(from_centre < CHROME_WHEEL_DEADZONE) + return 0 + var/nearest = 0 + var/nearest_gap = INFINITY + for(var/i in 1 to length(points)) + var/list/point = points[i] + var/gap = sqrt((point[1] - dx) ** 2 + (point[2] - dy) ** 2) + if(gap < nearest_gap) + nearest = i + nearest_gap = gap + if(nearest_gap <= CHROME_WHEEL_PICK_RANGE) + return nearest + if(reach && from_centre > reach) + return 0 + var/mouse_bearing = delta_to_angle(dx, ring_y) + var/pointed = 0 + var/pointed_turn = INFINITY + for(var/i in 1 to length(points)) + if(on_ring && !on_ring[i]) + continue + var/list/point = points[i] + var/turn = abs(WRAP(mouse_bearing - chrome_wheel_bearing(point[1], point[2]) + 180, 0, 360) - 180) + if(turn < pointed_turn) + pointed = i + pointed_turn = turn + return pointed_turn <= CHROME_WHEEL_POINT_TOLERANCE ? pointed : 0 + +/// The mouse position in a screen mouse params string, as pixels from the +/// centre of the viewer's map: list(x, y), or null if the params have none. +/proc/chrome_wheel_mouse_offset(params, client/viewer) + if(!viewer) + return null + var/list/modifiers = params2list(params) + var/screen_loc = LAZYACCESS(modifiers, SCREEN_LOC) + if(!screen_loc) + return null + var/list/pixels = screen_loc_to_offset(screen_loc, viewer.view) + var/list/view_pixels = view_to_pixels(viewer.view) + // screen_loc_to_offset counts tiles from 1, so the first tile starts a tile in. + return list(pixels[1] - ICON_SIZE_X - view_pixels[1] / 2, pixels[2] - ICON_SIZE_Y - view_pixels[2] / 2) + +/// The key a bind sits on, without its modifiers: "ShiftV" is held while "V" +/// is. Bare modifier binds ("Alt") stay as they are. +/proc/chrome_wheel_raw_key(full_key) + var/raw = full_key + var/last_modifier + for(var/modifier in list("Alt", "Ctrl", "Shift")) + if(findtextEx(raw, modifier) == 1) + raw = copytext(raw, length(modifier) + 1) + last_modifier = modifier + return raw || last_modifier + +// ---- Looks ------------------------------------------------------------- + +/// The radial.dmi "module_selected" frame the Chrome button puts round the pick, shared. +/proc/chrome_wheel_marker(frame_state) + var/static/list/markers = list() + if(!markers[frame_state]) + markers[frame_state] = mutable_appearance('icons/hud/radial.dmi', frame_state, FLOAT_LAYER - 0.1) + return markers[frame_state] + +/** + * What an ability's chip shows: the ability's own button look, minus the + * button background and text (chrome_ability_look(), chrome_hud.dm), so the + * chip carries the same icon and item overlay a button would. + */ +/proc/chrome_wheel_ability_look(datum/action/ability) + RETURN_TYPE(/mutable_appearance) + var/mutable_appearance/look = chrome_ability_look(ability) + // Keeps its own colour, follows the chip's pop, and shrinks smoothly. + look.appearance_flags = RESET_COLOR | NO_CLIENT_COLOR + return look + +/// Whether an ability is "on" right now: a toggled arm with its hardware out, +/// or a click-to-target ability armed and waiting for the click. +/proc/chrome_wheel_ability_active(datum/action/ability, mob/living/carbon/bearer) + // Arm chrome is on while any arm has its hardware out. + if(istype(ability, /datum/action/chrome_arms)) + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing as anything in get_chrome_arm_housings(bearer)) + if(housing.chrome_arm_deployed()) + return TRUE + return FALSE + // The zoom is a toggle, not a click-to-target: it is on while the optics are zoomed. + if(istype(ability, /datum/action/cooldown/cyberware/optics_zoom)) + var/datum/action/cooldown/cyberware/optics_zoom/zoom = ability + var/obj/item/organ/eyes/robotic/cyberware/optics = zoom.organ + return istype(optics) && optics.is_zoomed() + if(istype(ability, /datum/action/cooldown)) + return bearer.click_intercept == ability + var/obj/item/organ/cyberimp/arm/toolkit/arm = ability.target + if(istype(arm) && istype(ability, /datum/action/item_action/organ_action/toggle)) + return arm.active_item && !(arm.active_item in arm) + return FALSE + +/// Deciseconds left on an ability's cooldown, 0 for one that has none. +/proc/chrome_wheel_cooldown_left(datum/action/ability) + var/datum/action/cooldown/timed = ability + return istype(timed) ? max(timed.next_use_time - world.time, 0) : 0 + +/// Whether the chrome an ability lives in is down: rebooting from an EMP, or +/// browned out over capacity. Arm chrome is down when every arm is. +/proc/chrome_wheel_offline(datum/action/ability) + if(istype(ability, /datum/action/chrome_arms)) + var/list/housings = get_chrome_arm_housings(ability.owner) + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing as anything in housings) + var/datum/component/cyberware/arm_chrome = housing.GetComponent(/datum/component/cyberware) + if(arm_chrome?.is_running()) + return FALSE + return length(housings) > 0 + var/obj/item/organ/ware = ability.target + var/datum/component/cyberware/chrome = istype(ware) ? ware.GetComponent(/datum/component/cyberware) : null + return chrome && !chrome.is_running() + +/// Whether an ability can't be used right now for any other reason than its +/// chrome being down or its cooldown: its button's own checks say no. +/proc/chrome_wheel_unavailable(datum/action/ability, cooldown_left) + // On cooldown IsAvailable() is FALSE anyway; the shade shows that instead. + return !cooldown_left && !ability.IsAvailable() + +/// The tier colour of the chrome an ability lives in. Arm chrome wears the +/// colour of the best arm hardware it deploys. +/proc/chrome_wheel_tier_color(datum/action/ability) + if(istype(ability, /datum/action/chrome_arms)) + var/best_tier = 0 + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing as anything in get_chrome_arm_housings(ability.owner)) + var/datum/component/cyberware/arm_chrome = housing.GetComponent(/datum/component/cyberware) + best_tier = max(best_tier, arm_chrome?.tier || 0) + return best_tier ? cyberware_tier_color(best_tier) : CHROME_WHEEL_HUD_COLOR + var/obj/item/organ/ware = ability.target + var/datum/component/cyberware/chrome = istype(ware) ? ware.GetComponent(/datum/component/cyberware) : null + return chrome ? cyberware_tier_color(chrome.tier) : CHROME_WHEEL_HUD_COLOR + +/// [color] taken [amount] of the way to white. +/proc/chrome_wheel_lighten(color, amount) + var/list/channels = rgb2num(color) + return rgb(round(channels[1] + (255 - channels[1]) * amount), round(channels[2] + (255 - channels[2]) * amount), round(channels[3] + (255 - channels[3]) * amount)) + +/// One of the chip art states as an overlay that keeps its own colour. +/proc/chrome_wheel_chip_piece(piece_state, piece_color, piece_alpha = 255) + var/mutable_appearance/piece = mutable_appearance(CHROME_WHEEL_ICON, piece_state, FLOAT_LAYER, appearance_flags = RESET_COLOR | NO_CLIENT_COLOR | PIXEL_SCALE) + piece.color = piece_color + piece.alpha = piece_alpha + return piece + +/// Sorts chips clockwise from twelve o'clock, the order they pop in. +/proc/cmp_chrome_wheel_bearing(atom/movable/screen/chrome_wheel/chip/first, atom/movable/screen/chrome_wheel/chip/second) + return chrome_wheel_bearing(first.chip_x, first.chip_y) - chrome_wheel_bearing(second.chip_x, second.chip_y) + +// ---- The wheel --------------------------------------------------------- + +/datum/component/chrome_wheel + /// The ability Use chrome fires. + var/datum/weakref/selected_ref + /// The id of the sub-option of that ability Use chrome fires instead, if one was picked. + var/selected_option + /// The ability whose buttons carry the selection frame. + var/datum/weakref/marked_ref + /// CHROME_WHEEL_CLOSED, CHROME_WHEEL_HELD or CHROME_WHEEL_CLICK. + var/wheel_mode = CHROME_WHEEL_CLOSED + /// REALTIMEOFDAY when the wheel key went down. + var/press_time = 0 + /// The client the wheel is drawn on, so it comes off that screen even after + /// the client has moved to another mob. + var/client/shown_to + /// Screen-centre object every visible part of the wheel hangs off. + var/atom/movable/screen/chrome_wheel/hub/hub + /// Full-view mouse tracker under the wheel. + var/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/catcher + /// Every screen object the open wheel made, for teardown. + var/list/parts + /// One chip per ability. + var/list/chips + /// Leader key => list("leader", "node", "node_state", "alpha"): the line + /// from a socket (or a hand chip) to the body, and its dot on the body. + var/list/leaders + /// Arc name => its piece of ring art. + var/list/arcs + /// The body part lit under the chip pointed at. + var/atom/movable/screen/chrome_wheel/art/glow + /// The chip the mouse is on. + var/atom/movable/screen/chrome_wheel/chip/highlighted + /// Readout lines, and the pick mark it shows when nothing is pointed at. + var/atom/movable/screen/chrome_wheel/readout/readout_title + var/atom/movable/screen/chrome_wheel/readout/readout_sub + var/atom/movable/screen/chrome_wheel/readout/readout_status + var/atom/movable/screen/chrome_wheel/readout/readout_mark + /// What the readout last showed. + var/readout_state + /// First mouse position reported after opening. Nothing is pointed at until + /// the mouse leaves it, so a cursor that was resting somewhere when the + /// key went down picks nothing by accident. + var/list/mouse_start + /// Whether the mouse has left [mouse_start]. + var/mouse_moved = FALSE + /// A closed wheel still fading out: its client, its pieces and the timer + /// that takes them away. + var/client/fading_client + var/list/fading_parts + var/fading_timer + /// The second body-part glow, for a chip that lights both arms. + var/atom/movable/screen/chrome_wheel/art/glow_twin + /// Opened from the Chrome button: a chip clicked is fired as well as picked. + var/fire_on_pick = FALSE + /// The one HUD button chrome gets (chrome_hud.dm), while any chrome has an ability. + var/datum/action/chrome_button/hud_button + /// Arm chrome (chrome_wheel_arms.dm), while there is arm hardware to deploy. + var/datum/action/chrome_arms/arm_action + /// What the Chrome button last showed, so it only redraws on a change. + var/button_state + /// Set while sync_abilities() runs: granting and removing actions calls back into it. + var/syncing = FALSE + +/datum/component/chrome_wheel/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/chrome_wheel/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOB_LOGOUT, PROC_REF(on_logout)) + RegisterSignal(parent, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + // Things the Chrome button shows that no button redraw announces: chrome going + // down or coming back (checked every Life), an EMP, arm hardware in or out of a hand. + RegisterSignal(parent, COMSIG_LIVING_LIFE, PROC_REF(on_life)) + RegisterSignal(parent, COMSIG_ATOM_EMP_ACT, PROC_REF(on_emp)) + RegisterSignals(parent, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM), PROC_REF(on_hands_changed)) + // A stun drops whatever chrome was armed, like death or passing out does. + RegisterSignal(parent, SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), PROC_REF(on_incapacitated)) + +/datum/component/chrome_wheel/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOB_LOGOUT, COMSIG_MOB_STATCHANGE, COMSIG_LIVING_LIFE, COMSIG_ATOM_EMP_ACT, COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM, SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED))) + +/datum/component/chrome_wheel/Destroy(force) + close_wheel() + clear_fading() + unmark() + selected_ref = null + QDEL_NULL(hud_button) + QDEL_NULL(arm_action) + return ..() + +/datum/component/chrome_wheel/proc/on_logout(datum/source) + SIGNAL_HANDLER + close_wheel() + clear_fading() + +/datum/component/chrome_wheel/proc/on_stat_change(datum/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != CONSCIOUS) + disarm() + if(new_stat >= UNCONSCIOUS) + close_wheel() + refresh_button() + +/datum/component/chrome_wheel/proc/on_incapacitated(datum/source) + SIGNAL_HANDLER + disarm() + +/// Drops the aim of a chrome ability armed and waiting for its click. The cooldown is untouched. +/datum/component/chrome_wheel/proc/disarm() + var/mob/living/carbon/bearer = parent + var/datum/action/cooldown/cyberware/armed = bearer.click_intercept + if(istype(armed) && armed.owner == bearer) + armed.unset_click_ability(bearer, refund_cooldown = TRUE) + refresh_button() + +/datum/component/chrome_wheel/proc/on_life(datum/source, seconds_per_tick, times_fired) + SIGNAL_HANDLER + refresh_button() + +/datum/component/chrome_wheel/proc/on_emp(datum/source, severity, protection) + SIGNAL_HANDLER + // The chrome takes the pulse after the body's signal; look once it has. + addtimer(CALLBACK(src, PROC_REF(refresh_button)), 1, TIMER_UNIQUE | TIMER_OVERRIDE) + +/datum/component/chrome_wheel/proc/on_hands_changed(datum/source, obj/item/item) + SIGNAL_HANDLER + refresh_button() + +// ---- HUD --------------------------------------------------------------- + +/** + * Gives or takes the Chrome button and Arm chrome to match the chrome the + * body carries now. Runs whenever a chrome ability is granted or removed + * (chrome_hud.dm), so the button comes with the first chrome ability and + * goes with the last. + */ +/datum/component/chrome_wheel/proc/sync_abilities() + if(syncing || QDELETED(src)) + return + var/mob/living/carbon/bearer = parent + if(QDELETED(bearer)) + return + syncing = TRUE + if(length(get_chrome_arm_housings(bearer))) + if(!arm_action) + arm_action = new(src) + if(arm_action.owner != bearer) + arm_action.Grant(bearer) + else if(arm_action) + QDEL_NULL(arm_action) + if(length(get_chrome_wheel_actions(bearer))) + if(!hud_button) + hud_button = new(src) + if(hud_button.owner != bearer) + hud_button.Grant(bearer) + else if(hud_button) + QDEL_NULL(hud_button) + syncing = FALSE + refresh_button(force = TRUE) + +/// The ability the Chrome button shows: a chrome ability armed and waiting for +/// its click (Use OS chrome can arm one that isn't the pick), else the pick, +/// while its chrome is still in this body. +/datum/component/chrome_wheel/proc/get_button_pick() + var/mob/living/carbon/bearer = parent + var/datum/action/cooldown/cyberware/armed = bearer?.click_intercept + if(istype(armed) && armed.owner == bearer && !QDELETED(armed)) + return armed + var/datum/action/ability = selected_ref?.resolve() + if(!ability || QDELETED(ability) || ability.owner != parent) + return null + return ability + +/// A chrome ability's button would have redrawn: so does the Chrome button, if it shows that one. +/datum/component/chrome_wheel/proc/on_ability_changed(datum/action/ability) + // Or the one it showed until now: a disarmed ability hands the button back to the pick. + if(ability == get_button_pick() || (button_state && findtext(button_state, REF(ability)))) + refresh_button() + +/// Redraws the Chrome button when what it shows has changed, or always with [force]. +/datum/component/chrome_wheel/proc/refresh_button(force = FALSE) + // No HUD shows it (no client, an NPC): a button made later is drawn current anyway. + if(!hud_button || QDELETED(hud_button) || !length(hud_button.viewers)) + button_state = null + return + var/mob/living/carbon/bearer = parent + var/datum/action/pick = get_button_pick() + var/state = "none" + if(pick) + var/cooldown_left = chrome_wheel_cooldown_left(pick) + state = "[REF(pick)]|[chrome_ability_look_key(pick)]|[CEILING(cooldown_left / 10, 1)]|[chrome_wheel_offline(pick)]|[pick.IsAvailable()]|[chrome_wheel_ability_active(pick, bearer)]" + if(!force && state == button_state) + return + button_state = state + hud_button.build_all_button_icons(ALL, TRUE) + +/// The Chrome button was clicked: open the wheel for the mouse, or shut it. +/datum/component/chrome_wheel/proc/button_clicked() + if(is_open()) + close_wheel() + return + open_wheel(CHROME_WHEEL_CLICK, fire = TRUE) + +/// The "Deploy arm chrome" key: fire Arm chrome, as its chip would. +/datum/component/chrome_wheel/proc/use_arms() + var/mob/living/carbon/bearer = parent + if(!arm_action || arm_action.owner != bearer) + bearer.balloon_alert(bearer, "no arm chrome!") + return FALSE + return fire(arm_action) + +/// Whether the wheel is on screen. +/datum/component/chrome_wheel/proc/is_open() + return wheel_mode != CHROME_WHEEL_CLOSED + +// ---- Keys -------------------------------------------------------------- + +/// Wheel key down: open the wheel, or shut one a tap left open. +/datum/component/chrome_wheel/proc/wheel_key_down() + switch(wheel_mode) + if(CHROME_WHEEL_CLICK) + close_wheel() + return + if(CHROME_WHEEL_HELD) + return + if(open_wheel(CHROME_WHEEL_HELD)) + press_time = REALTIMEOFDAY + +/// Wheel key up: after a tap the wheel stays open for a click; after a hold, +/// whatever the mouse is on is picked and the wheel closes. +/datum/component/chrome_wheel/proc/wheel_key_up() + if(wheel_mode != CHROME_WHEEL_HELD) + return + if(REALTIMEOFDAY - press_time < CHROME_WHEEL_HOLD_TIME) + wheel_mode = CHROME_WHEEL_CLICK + return + choose(highlighted) + +/** + * Whether a key the "Chrome wheel" bind sits on is still down. keyUp only + * reaches binds by their bare key (bindings_client.dm), so a bind on a + * modifier combo such as ShiftV never hears its own release; while the wheel + * is held open, this poll catches it instead. + */ +/datum/component/chrome_wheel/proc/wheel_key_held() + var/mob/living/carbon/bearer = parent + var/client/viewer = bearer.client + if(!viewer) + return FALSE + var/list/bindings = viewer.prefs?.key_bindings + for(var/full_key in LAZYACCESS(bindings, CHROME_WHEEL_KEYBIND)) + if(viewer.keys_held[chrome_wheel_raw_key(full_key)]) + return TRUE + return FALSE + +/// Use chrome: fire the picked ability. With nothing picked, a lone ability +/// is fired; with several, the wheel opens for a click. +/datum/component/chrome_wheel/proc/use_selected() + if(wheel_mode != CHROME_WHEEL_CLOSED) + if(highlighted) + select_chip(highlighted, quiet = TRUE) + close_wheel() + var/datum/action/ability = get_selected_ability() + if(ability) + return fire(ability, selected_option) + var/mob/living/carbon/bearer = parent + var/list/abilities = get_chrome_wheel_actions(bearer) + switch(length(abilities)) + if(0) + bearer.balloon_alert(bearer, "no chrome!") + if(1) + select(abilities[1]) + return fire(abilities[1]) + else + open_wheel(CHROME_WHEEL_CLICK) + return FALSE + +/// Use OS chrome: fire the OS-slot ware's ability, leaving the pick alone. +/datum/component/chrome_wheel/proc/use_os() + var/mob/living/carbon/bearer = parent + var/datum/action/ability = get_os_chrome_action(bearer) + if(!ability) + bearer.balloon_alert(bearer, "no OS chrome!") + return FALSE + return fire(ability) + +/** + * Fires an ability exactly as its button would: Trigger() with the bearer as + * the clicker, so availability feedback, cooldowns and targeting modes are the + * button's own. Paced like a keybound action button (action.dm keydown()). + */ +/datum/component/chrome_wheel/proc/fire(datum/action/ability, option_id) + var/mob/living/carbon/bearer = parent + if(bearer.next_click > world.time) + return FALSE + bearer.next_click = world.time + CLICK_CD_ACTIVATE_ABILITY + // Async because some triggers wait on the player: a multi-tool arm opens its own radial. + if(option_id) + INVOKE_ASYNC(ability, TYPE_PROC_REF(/datum/action, use_chrome_wheel_option), bearer, option_id) + else + INVOKE_ASYNC(ability, TYPE_PROC_REF(/datum/action, Trigger), bearer) + // A toggle (zoom, arm hardware) changes nothing a redraw announces. + refresh_button() + return TRUE + +// ---- Selection --------------------------------------------------------- + +/datum/component/chrome_wheel/proc/select(datum/action/ability, option_id) + selected_ref = WEAKREF(ability) + selected_option = option_id + mark(ability) + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in chips) + draw_chip(chip) + update_readout() + +/datum/component/chrome_wheel/proc/deselect() + selected_ref = null + selected_option = null + unmark() + +/// The picked ability, while it is still chrome in this body and its picked +/// option is still on offer. A pick that went stale is dropped. +/datum/component/chrome_wheel/proc/get_selected_ability() + var/datum/action/ability = selected_ref?.resolve() + if(!ability) + if(selected_ref) + deselect() + return null + var/mob/living/carbon/bearer = parent + if(!(ability in get_chrome_wheel_actions(bearer)) || (selected_option && !chrome_wheel_find_option(ability, bearer, selected_option))) + deselect() + return null + return ability + +/// Shows the pick on the Chrome button, and watches it for leaving the body. +/datum/component/chrome_wheel/proc/mark(datum/action/ability) + if(marked_ref?.resolve() != ability) + unmark() + marked_ref = WEAKREF(ability) + RegisterSignal(ability, COMSIG_ACTION_REMOVED, PROC_REF(on_marked_removed)) + refresh_button() + +/datum/component/chrome_wheel/proc/unmark() + var/datum/action/marked = marked_ref?.resolve() + marked_ref = null + if(marked) + UnregisterSignal(marked, COMSIG_ACTION_REMOVED) + refresh_button() + +/// The picked ability left this body (the ware was cut out, or moved to someone +/// else): drop the pick now, so it never follows it into another body. +/datum/component/chrome_wheel/proc/on_marked_removed(datum/action/source, mob/old_owner) + SIGNAL_HANDLER + if(old_owner == parent) + deselect() + +/// Picks the ability on [chip] while it is still chrome in this body. Returns +/// whether it did: a ware cut out while the wheel was held open drops out here, +/// so its chip can't be picked and the pick can't follow it into someone else. +/datum/component/chrome_wheel/proc/select_chip(atom/movable/screen/chrome_wheel/chip/chip, quiet = FALSE) + var/datum/action/ability = chip?.ability_ref?.resolve() + if(!ability || !(ability in get_chrome_wheel_actions(parent))) + return FALSE + select(ability) + if(!quiet) + var/mob/living/carbon/bearer = parent + bearer.balloon_alert(bearer, lowertext(chip.name)) + return TRUE + +/// Pick [chip] (or nothing, for null) and close the wheel. The pick lands at +/// once; the wheel stays up a moment longer for the lock and the fade. A wheel +/// opened from the Chrome button fires the pick as well. +/datum/component/chrome_wheel/proc/choose(atom/movable/screen/chrome_wheel/chip/chip) + var/fire_it = fire_on_pick + var/picked = chip && select_chip(chip, quiet = fire_it) + close_wheel(linger = TRUE, chosen = picked ? chip : null) + if(picked && fire_it) + var/datum/action/ability = get_selected_ability() + if(ability) + fire(ability, selected_option) + +// ---- Opening and closing ----------------------------------------------- + +/// Opens the wheel in [mode]. With [fire], a chip clicked is fired as well as picked. +/datum/component/chrome_wheel/proc/open_wheel(mode, fire = FALSE) + var/mob/living/carbon/bearer = parent + var/client/viewer = bearer.client + if(wheel_mode != CHROME_WHEEL_CLOSED || !viewer || bearer.stat >= UNCONSCIOUS) + return FALSE + var/list/abilities = get_chrome_wheel_actions(bearer) + if(!length(abilities)) + bearer.balloon_alert(bearer, "no chrome!") + return FALSE + clear_fading() + build_wheel(abilities) + + catcher = new + catcher.wheel = src + catcher.assign_to_mob(bearer) + catcher.update_for_view(viewer.view) + shown_to = viewer + viewer.screen += catcher + viewer.screen += hub + play_open() + + wheel_mode = mode + fire_on_pick = fire + mouse_start = null + mouse_moved = FALSE + START_PROCESSING(SSfastprocess, src) + return TRUE + +/** + * Makes every piece of the wheel for [abilities] and hangs it off a new hub, + * without putting anything on a screen. + */ +/datum/component/chrome_wheel/proc/build_wheel(list/abilities) + var/list/placements = chrome_wheel_placements(abilities) + var/list/names = chrome_wheel_names(abilities) + parts = list() + chips = list() + leaders = list() + arcs = list() + readout_state = null + hub = new + parts += hub + add_piece(/atom/movable/screen/chrome_wheel/art/backdrop) + add_piece(/atom/movable/screen/chrome_wheel/art, "body") + glow = add_piece(/atom/movable/screen/chrome_wheel/art, null, 2.2) + glow.alpha = 0 + glow_twin = add_piece(/atom/movable/screen/chrome_wheel/art, null, 2.2) + glow_twin.alpha = 0 + var/list/arc_names = list() + for(var/socket in GLOB.chrome_wheel_sockets) + arc_names |= GLOB.chrome_wheel_sockets[socket]["arc"] + for(var/arc_name in arc_names) + arcs[arc_name] = add_piece(/atom/movable/screen/chrome_wheel/art, "arc_[arc_name]", 2.25) + + // Which sockets hold chrome of their own, and which positions are taken. + var/list/occupied = list() + var/list/taken = list() + for(var/datum/action/ability as anything in placements) + var/list/placement = placements[ability] + taken["[placement["socket"]]|[placement["index"]]"] = TRUE + // Arm chrome borrows the right arm's arc; it doesn't make the right arm chromed. + if(!placement["hand"] && !istype(ability, /datum/action/chrome_arms)) + occupied[placement["socket"]] = TRUE + for(var/socket in GLOB.chrome_wheel_sockets) + add_leader(socket, socket, occupied[socket]) + if(!taken["[socket]|1"]) + var/list/point = chrome_wheel_position(socket, 1) + var/atom/movable/screen/chrome_wheel/empty_socket/outline = add_piece(/atom/movable/screen/chrome_wheel/empty_socket) + outline.pixel_x = point[1] + outline.pixel_y = point[2] + + for(var/datum/action/ability as anything in abilities) + var/list/placement = placements[ability] + var/socket = placement["socket"] + var/list/point = chrome_wheel_position(socket, placement["index"]) + var/atom/movable/screen/chrome_wheel/chip/chip = add_piece(/atom/movable/screen/chrome_wheel/chip) + chip.ability_ref = WEAKREF(ability) + chip.name = names[ability] + chip.socket = socket + chip.index = placement["index"] + chip.leader_key = socket + chip.region = GLOB.chrome_wheel_sockets[socket]["region"] + if(istype(ability, /datum/action/chrome_arms)) + // Lights both arms: their arcs, their leaders and the arms on the body. + chip.region = CHROME_WHEEL_REGION_ARMS + else if(placement["hand"]) + var/hand = (socket == CHROME_WHEEL_SOCKET_LEFT_ARM) ? "l_hand" : "r_hand" + chip.region = hand + // The ring art has a run to the hand from each first-row position. + if(chip.index <= chrome_wheel_socket_fits(socket)) + chip.leader_key = "[hand]_[chip.index]" + add_leader(chip.leader_key, hand, TRUE) + chip.chip_x = point[1] + chip.chip_y = point[2] + chip.pixel_x = point[1] + chip.pixel_y = point[2] + chips += chip + draw_chip(chip) + // Nothing is pointed at yet: the arcs start dim, as they are whenever the pointer leaves a chip. + light_up(null) + build_readout() + update_readout() + +/// A new piece of the wheel on the hub. [art_state] sets its icon state and +/// [piece_layer] its layer, when given. +/datum/component/chrome_wheel/proc/add_piece(piece_type, art_state, piece_layer) + var/atom/movable/screen/chrome_wheel/piece = new piece_type + if(art_state) + piece.icon_state = art_state + if(piece_layer) + piece.layer = piece_layer + parts += piece + hub.vis_contents += piece + return piece + +/// The leader for [key] and the dot where it meets the body, from the ring art. +/datum/component/chrome_wheel/proc/add_leader(key, art_key, filled) + if(leaders[key]) + return + var/atom/movable/screen/chrome_wheel/art/line = add_piece(/atom/movable/screen/chrome_wheel/art, "leader_[key]", 2.3) + var/atom/movable/screen/chrome_wheel/art/dot = add_piece(/atom/movable/screen/chrome_wheel/art, "node_[art_key]", 2.4) + leaders[key] = list("leader" = line, "node" = dot, "node_state" = "node_[art_key]", "alpha" = filled ? 100 : 40) + style_leader(key, null) + +/// The readout panel's frame and its empty text lines. +/datum/component/chrome_wheel/proc/build_readout() + var/left = CHROME_WHEEL_READOUT_LEFT + var/top = CHROME_WHEEL_READOUT_TOP + var/width = CHROME_WHEEL_READOUT_WIDTH + var/height = CHROME_WHEEL_READOUT_HEIGHT + add_readout_block(left, top - height, width, height, CHROME_WHEEL_PANEL_COLOR, 205, 4) + add_readout_block(left, top - 1, width, 1, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left, top - height, width, 1, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left, top - height, 1, height, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left + width - 1, top - height, 1, height, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left, top - 12, 2, 12, CHROME_WHEEL_HUD_COLOR, 255, 4.1) + // Text boxes are taller than their line and centred on it, as the chrome + // read sets them: Grand9K caps start 5 px under a 16 px box's top, + // TinyUnicode caps 8 px under a 17 px one. + readout_title = add_readout_text(left + 5, top - 11 - 2, width - 9, 16) + readout_sub = add_readout_text(left + 5, top - 9 - 13, width - 9, 17) + readout_status = add_readout_text(left + 5, top - 11 - 3, width - 10, 16) + readout_mark = add_piece(/atom/movable/screen/chrome_wheel/readout, "pick_glyph", 4.3) + readout_mark.color = CHROME_WHEEL_PICKED_COLOR + readout_mark.alpha = 0 + readout_mark.pixel_x = left + 140 + readout_mark.pixel_y = top - 22 + +/datum/component/chrome_wheel/proc/add_readout_block(x, y, width, height, block_color, block_alpha, block_layer) + var/atom/movable/screen/chrome_wheel/readout/block = add_piece(/atom/movable/screen/chrome_wheel/readout, null, block_layer) + block.color = block_color + block.alpha = block_alpha + block.transform = chrome_readout_quad(x, y, width, height) + return block + +/datum/component/chrome_wheel/proc/add_readout_text(x, y, width, height) + var/atom/movable/screen/chrome_wheel/readout/line = add_piece(/atom/movable/screen/chrome_wheel/readout, null, 4.2) + line.icon_state = null + line.maptext_x = x + line.maptext_y = y + line.maptext_width = width + line.maptext_height = height + return line + +/// Fades the wheel in and pops the chips out one after another, clockwise from the top. +/datum/component/chrome_wheel/proc/play_open() + hub.alpha = 0 + animate(hub, alpha = 255, time = 1) + var/list/order = sortTim(chips.Copy(), GLOBAL_PROC_REF(cmp_chrome_wheel_bearing)) + for(var/i in 1 to length(order)) + var/atom/movable/screen/chrome_wheel/chip/chip = order[i] + var/matrix/small = matrix(0.6, 0, 0, 0, 0.6, 0) + chip.transform = small + animate(chip, transform = small, time = (i - 1) * 0.08) + animate(transform = matrix(), time = 0.8, easing = BACK_EASING | EASE_OUT) + +/** + * Shuts the wheel and gives the mouse back to the world. With [linger] the + * pieces stay on screen a moment to fade, after [chosen] locks. + */ +/datum/component/chrome_wheel/proc/close_wheel(linger = FALSE, atom/movable/screen/chrome_wheel/chip/chosen) + STOP_PROCESSING(SSfastprocess, src) + wheel_mode = CHROME_WHEEL_CLOSED + fire_on_pick = FALSE + if(shown_to) + shown_to.screen -= catcher + QDEL_NULL(catcher) + if(linger && shown_to && hub) + clear_fading() + fading_client = shown_to + fading_parts = parts + if(chosen) + play_lock(chosen) + animate(hub, alpha = hub.alpha, time = chosen ? 1 : 0) + animate(alpha = 0, time = 1) + fading_timer = addtimer(CALLBACK(src, PROC_REF(fade_done)), CHROME_WHEEL_LINGER_TIME, TIMER_STOPPABLE | TIMER_CLIENT_TIME | TIMER_DELETE_ME) + else + shown_to?.screen -= hub + QDEL_LIST(parts) + shown_to = null + hub = null + parts = null + chips = null + leaders = null + arcs = null + glow = null + glow_twin = null + highlighted = null + readout_title = null + readout_sub = null + readout_status = null + readout_mark = null + readout_state = null + mouse_start = null + mouse_moved = FALSE + +/datum/component/chrome_wheel/proc/fade_done() + fading_timer = null + clear_fading() + +/// Takes a fading wheel off its screen now. +/datum/component/chrome_wheel/proc/clear_fading() + if(fading_timer) + deltimer(fading_timer) + fading_timer = null + if(fading_client && length(fading_parts)) + fading_client.screen -= fading_parts[1] + fading_client = null + QDEL_LIST(fading_parts) + +/// The pick locking on: its frame flashes white and the brackets snap in. +/datum/component/chrome_wheel/proc/play_lock(atom/movable/screen/chrome_wheel/chip/chip) + var/settled = chip.color + chip.color = "#ffffff" + animate(chip, color = "#ffffff", time = 0.5) + animate(color = settled, time = 0) + for(var/atom/movable/screen/chrome_wheel/bracket/corner as anything in chip.brackets) + if(!corner.alpha) + continue + var/final_x = corner.pixel_x + var/final_y = corner.pixel_y + corner.pixel_x = final_x + corner.out_x * 7 + corner.pixel_y = final_y + corner.out_y * 7 + corner.color = "#ffffff" + animate(corner, pixel_x = final_x, pixel_y = final_y, time = 1, easing = BACK_EASING | EASE_OUT) + animate(corner, color = "#ffffff", time = 0.5, flags = ANIMATION_PARALLEL) + animate(color = CHROME_WHEEL_PICKED_COLOR, time = 0) + +/// While open: catch a modifier bind's release, and keep the chips current. +/datum/component/chrome_wheel/process(seconds_per_tick) + if(wheel_mode == CHROME_WHEEL_CLOSED) + return PROCESS_KILL + if(wheel_mode == CHROME_WHEEL_HELD && !wheel_key_held()) + wheel_key_up() + if(wheel_mode == CHROME_WHEEL_CLOSED) + return PROCESS_KILL + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in chips) + draw_chip(chip) + update_readout() + +// ---- Drawing ----------------------------------------------------------- + +/** + * Brings a chip up to date: its frame in its tier colour (blinking red while + * the chrome is down), the ability's icon, the green inner line while it is + * deployed or armed, the pick's brackets, and a draining shade with seconds + * while it cools down. Bigger and brighter while pointed at. Redraws only on + * change. + */ +/datum/component/chrome_wheel/proc/draw_chip(atom/movable/screen/chrome_wheel/chip/chip) + var/mob/living/carbon/bearer = parent + var/datum/action/ability = chip.ability_ref?.resolve() + if(!ability) + if(chip.drawn_state != "gone") + chip.drawn_state = "gone" + chip.cut_overlays() + chip.underlays.Cut() + chip.alpha = 70 + return + var/datum/action/cooldown/timed = istype(ability, /datum/action/cooldown) ? ability : null + var/cooldown_left = timed ? max(timed.next_use_time - world.time, 0) : 0 + var/offline = chrome_wheel_offline(ability) + var/unavailable = !offline && chrome_wheel_unavailable(ability, cooldown_left) + var/hovered = (chip == highlighted) + var/picked = (selected_ref?.resolve() == ability) + var/active = chrome_wheel_ability_active(ability, bearer) + var/state = "[offline]|[unavailable]|[hovered]|[picked]|[active]|[timed?.next_use_time]|[!!cooldown_left]|[chrome_ability_look_key(ability)]" + if(state != chip.drawn_state) + chip.drawn_state = state + paint_chip(chip, ability, offline, unavailable, hovered, picked, active) + update_shade(chip, cooldown_left, timed ? max(timed.cooldown_time, cooldown_left) : 0) + if(hovered) + light_up(chip) + update_count(chip, cooldown_left) + +/datum/component/chrome_wheel/proc/paint_chip(atom/movable/screen/chrome_wheel/chip/chip, datum/action/ability, offline, unavailable, hovered, picked, active) + var/size = hovered ? 26 : 22 + chip.size = size + var/base_color = offline ? CHROME_WHEEL_OFFLINE_COLOR : chrome_wheel_tier_color(ability) + chip.icon_state = "frame_[size][offline ? "_blink" : ""]" + chip.color = hovered ? chrome_wheel_lighten(base_color, 0.35) : base_color + chip.alpha = 255 + chip.cut_overlays() + chip.underlays.Cut() + chip.underlays += chrome_wheel_chip_piece("fill_[size]") + if(hovered) + chip.add_overlay(chrome_wheel_chip_piece("halo_26", base_color, 90)) + var/mutable_appearance/look = chrome_wheel_ability_look(ability) + // The icon fills the chip but for a pixel all round. + var/scale = (size - 2) / ICON_SIZE_X + look.transform = matrix(scale, 0, 0, 0, scale, 0) + // Red like its button when it can't be used. + if(offline || unavailable) + look.color = ability.transparent_when_unavailable ? rgb(128, 0, 0, 128) : rgb(128, 0, 0) + chip.add_overlay(look) + if(active) + chip.add_overlay(chrome_wheel_chip_piece("deployed_[size]")) + draw_brackets(chip, picked) + +/// The pick's corner brackets, a couple of pixels clear of the chip's corners. +/datum/component/chrome_wheel/proc/draw_brackets(atom/movable/screen/chrome_wheel/chip/chip, picked) + if(!picked) + for(var/atom/movable/screen/chrome_wheel/bracket/corner as anything in chip.brackets) + corner.alpha = 0 + return + if(!chip.brackets) + chip.brackets = list() + for(var/corner_name in list("nw", "ne", "sw", "se")) + var/atom/movable/screen/chrome_wheel/bracket/corner = new + corner.icon_state = "bracket_[corner_name]" + corner.out_x = findtext(corner_name, "w") ? -1 : 1 + corner.out_y = findtext(corner_name, "n") ? 1 : -1 + chip.brackets += corner + chip.vis_contents += corner + // The bracket art has its corner pixel one in from the edge of its tile; a + // chip's brackets sit two pixels outside its corners. + var/reach = chip.size / 2 + 2 - 15 + for(var/atom/movable/screen/chrome_wheel/bracket/corner as anything in chip.brackets) + animate(corner) + corner.alpha = 255 + corner.color = CHROME_WHEEL_PICKED_COLOR + corner.pixel_x = corner.out_x * reach + corner.pixel_y = corner.out_y * reach + +/// The cooldown wipe: a dark block over the chip's inside that drains downward +/// to nothing as the cooldown runs out, with a faint line along its top. +/// Animated on the client, so it costs no ticks. +/datum/component/chrome_wheel/proc/update_shade(atom/movable/screen/chrome_wheel/chip/chip, cooldown_left, cooldown_total) + if(!cooldown_left) + for(var/atom/movable/screen/chrome_wheel/chip_part/part in list(chip.shade, chip.shade_edge)) + animate(part) + part.alpha = 0 + return + if(!chip.shade) + chip.shade = new + chip.shade.color = COLOR_BLACK + chip.shade_edge = new + chip.shade_edge.layer = 3.2 + chip.vis_contents += list(chip.shade, chip.shade_edge) + var/list/start = chrome_wheel_shade_shape(chip.size, cooldown_left / max(cooldown_total, 1)) + var/list/finish = chrome_wheel_shade_shape(chip.size, 0) + chip.shade.alpha = 185 + chip.shade.transform = start[1] + animate(chip.shade, transform = finish[1], time = cooldown_left) + chip.shade_edge.alpha = 110 + chip.shade_edge.transform = start[2] + animate(chip.shade_edge, transform = finish[2], time = cooldown_left) + +/** + * The cooldown wipe's shape on a chip of [size] with [fraction] of its + * cooldown left: list(shade transform, edge transform). The px state is a + * full tile, squashed to the chip's inside and sitting on its floor; the edge + * is the wipe's top row. + */ +/proc/chrome_wheel_shade_shape(size, fraction) + RETURN_TYPE(/list) + var/inside = size - 2 + var/height = max(inside * min(fraction, 1), 0.3) + return list( + matrix(inside / 32, 0, 0, 0, height / 32, -(inside - height) / 2), + matrix(inside / 32, 0, 0, 0, 1 / 32, max(height, 1) - inside / 2 - 0.5), + ) + +/// Whole seconds left on the cooldown, in the chip's bottom left corner. +/datum/component/chrome_wheel/proc/update_count(atom/movable/screen/chrome_wheel/chip/chip, cooldown_left) + var/seconds = cooldown_left ? CEILING(cooldown_left / 10, 1) : 0 + var/state = "[seconds]|[chip.size]" + if(state == chip.count_state) + return + chip.count_state = state + if(!seconds) + if(chip.count) + chip.count.maptext = null + return + if(!chip.count) + chip.count = new + chip.count.icon_state = null + chip.count.layer = 3.3 + chip.count.maptext_width = 32 + chip.count.maptext_height = 17 + chip.vis_contents += chip.count + // Box centred like the readout's lines (caps 8 px under a 17 px box's top), + // putting the number three pixels in from the chip's bottom left corner. + chip.count.maptext = chrome_readout_tiny_html("[seconds]") + chip.count.maptext_x = 16 - chip.size / 2 + 3 + chip.count.maptext_y = 18 - chip.size / 2 + +/// Sets a leader and its node to [lit_color], or back to the dim HUD colour for null. +/datum/component/chrome_wheel/proc/style_leader(key, lit_color) + var/list/leader = leaders?[key] + if(!leader) + return + var/atom/movable/screen/chrome_wheel/art/line = leader["leader"] + var/atom/movable/screen/chrome_wheel/art/dot = leader["node"] + if(lit_color) + line.color = lit_color + line.alpha = 235 + dot.icon_state = "[leader["node_state"]]_lit" + dot.color = chrome_wheel_lighten(lit_color, 0.6) + dot.alpha = 255 + return + line.color = CHROME_WHEEL_HUD_COLOR + line.alpha = leader["alpha"] + dot.icon_state = leader["node_state"] + dot.color = CHROME_WHEEL_HUD_COLOR + dot.alpha = min(255, leader["alpha"] + 60) + +/// Lights the arc, leader and body part of [chip] in its colour, and puts +/// everything else back. Null lights nothing. +/datum/component/chrome_wheel/proc/light_up(atom/movable/screen/chrome_wheel/chip/chip) + var/datum/action/ability = chip?.ability_ref?.resolve() + var/lit_color + if(ability) + lit_color = chrome_wheel_offline(ability) ? CHROME_WHEEL_OFFLINE_COLOR : chrome_wheel_tier_color(ability) + // Arm chrome lights both arms; anything else its own socket and body part. + var/both_arms = lit_color && chip.region == CHROME_WHEEL_REGION_ARMS + var/list/lit_leaders = list() + var/list/lit_arcs = list() + var/list/lit_regions = list() + if(both_arms) + for(var/arm_socket in list(CHROME_WHEEL_SOCKET_LEFT_ARM, CHROME_WHEEL_SOCKET_RIGHT_ARM)) + lit_leaders += arm_socket + lit_arcs += GLOB.chrome_wheel_sockets[arm_socket]["arc"] + lit_regions += GLOB.chrome_wheel_sockets[arm_socket]["region"] + else if(lit_color) + lit_leaders += chip.leader_key + lit_arcs += GLOB.chrome_wheel_sockets[chip.socket]["arc"] + lit_regions += chip.region + for(var/key in leaders) + style_leader(key, (key in lit_leaders) ? lit_color : null) + for(var/arc_name in arcs) + var/atom/movable/screen/chrome_wheel/art/arc = arcs[arc_name] + var/lit = (arc_name in lit_arcs) + arc.color = lit ? lit_color : CHROME_WHEEL_HUD_COLOR + arc.alpha = lit ? 200 : 80 + var/list/glows = list(glow, glow_twin) + for(var/i in 1 to length(glows)) + var/atom/movable/screen/chrome_wheel/art/body_glow = glows[i] + if(!body_glow) + continue + if(i > length(lit_regions)) + body_glow.alpha = 0 + continue + body_glow.icon_state = "glow_[lit_regions[i]]" + body_glow.color = lit_color + body_glow.alpha = 255 + +/** + * The readout under the ring: the chip pointed at, the chrome it lives in in + * its tier colour, and on the right how it stands ("14s", "offline", "out"). + * With nothing pointed at it shows the pick, dim, with a small bracket mark. + */ +/datum/component/chrome_wheel/proc/update_readout() + if(!readout_title) + return + var/mob/living/carbon/bearer = parent + var/atom/movable/screen/chrome_wheel/chip/shown = highlighted + var/datum/action/ability = shown?.ability_ref?.resolve() + var/idle = !ability + var/title + if(idle) + ability = get_selected_ability() + if(ability) + var/datum/chrome_wheel_option/option = selected_option ? chrome_wheel_find_option(ability, bearer, selected_option) : null + title = option ? option.name : ability.chrome_wheel_name() + else + title = shown.name + var/sub = ability ? chrome_wheel_ware_name(ability) : "" + var/sub_color = CHROME_WHEEL_DIM_COLOR + var/title_color = CHROME_WHEEL_DIM_COLOR + var/status + var/status_color + if(ability && !idle) + title_color = CHROME_WHEEL_NAME_COLOR + var/datum/action/cooldown/timed = istype(ability, /datum/action/cooldown) ? ability : null + var/cooldown_left = timed ? max(timed.next_use_time - world.time, 0) : 0 + if(chrome_wheel_offline(ability)) + status = "offline" + status_color = CHROME_WHEEL_OFFLINE_COLOR + sub_color = CHROME_WHEEL_OFFLINE_NAME_COLOR + else + sub_color = chrome_wheel_tier_color(ability) + if(chrome_wheel_ability_active(ability, bearer)) + status = timed ? "armed" : "out" + status_color = CHROME_WHEEL_DEPLOYED_COLOR + else if(cooldown_left) + status = "[CEILING(cooldown_left / 10, 1)]s" + status_color = "#ffffff" + var/state = "[idle]|[title]|[title_color]|[sub]|[sub_color]|[status]" + if(state == readout_state) + return + readout_state = state + var/static/datum/font/header_font = new /datum/font/grand9k/size_6pt() + var/static/datum/font/list_font = new /datum/font/tiny_unicode/size_12pt() + var/status_width = status ? header_font.get_metrics(status) + 6 : 0 + if(title) + title = chrome_readout_fit(title, header_font, CHROME_WHEEL_READOUT_WIDTH - 10 - status_width) + readout_title.maptext = chrome_readout_header_html(html_encode(title), title_color) + else + readout_title.maptext = null + if(sub) + sub = chrome_readout_fit(sub, list_font, CHROME_WHEEL_READOUT_WIDTH - 10) + readout_sub.maptext = chrome_readout_tiny_html("[html_encode(sub)]") + else + readout_sub.maptext = null + readout_status.maptext = status ? chrome_readout_header_html(status, status_color, "right") : null + readout_mark.alpha = (idle && ability) ? 200 : 0 + +// ---- Mouse ------------------------------------------------------------- + +/datum/component/chrome_wheel/proc/mouse_moved(params) + if(wheel_mode == CHROME_WHEEL_CLOSED) + return + var/list/offset = chrome_wheel_mouse_offset(params, shown_to) + if(!offset) + return + if(!mouse_moved) + if(!mouse_start) + mouse_start = offset + return + if(abs(offset[1] - mouse_start[1]) + abs(offset[2] - mouse_start[2]) < 4) + return + mouse_moved = TRUE + point_at(offset) + +/datum/component/chrome_wheel/proc/mouse_clicked(params) + if(wheel_mode == CHROME_WHEEL_CLOSED) + return + var/list/offset = chrome_wheel_mouse_offset(params, shown_to) + if(!offset) + close_wheel() + return + point_at(offset) + choose(highlighted) + +/// Highlights whatever the mouse at [offset] points at. A wheel opened from the +/// Chrome button fires what a click picks, so out past the ring a click picks nothing. +/datum/component/chrome_wheel/proc/point_at(list/offset) + var/list/points = list() + var/list/on_ring = list() + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in chips) + points += list(list(chip.chip_x, chip.chip_y)) + on_ring += chip.index <= chrome_wheel_socket_fits(chip.socket) + var/index = chrome_wheel_pick(offset[1], offset[2], points, on_ring, fire_on_pick ? CHROME_WHEEL_FIRE_REACH : null) + set_highlight(index ? chips[index] : null) + +/// Moves the highlight: the old chip shrinks back, the new one grows and lights its part of the body. +/datum/component/chrome_wheel/proc/set_highlight(atom/movable/screen/chrome_wheel/chip/chip) + if(chip == highlighted) + return + var/atom/movable/screen/chrome_wheel/chip/old = highlighted + highlighted = chip + if(old) + draw_chip(old) + if(chip) + draw_chip(chip) + else + light_up(null) + update_readout() + +#undef CHROME_WHEEL_READOUT_TOP +#undef CHROME_WHEEL_READOUT_LEFT +#undef CHROME_WHEEL_READOUT_WIDTH +#undef CHROME_WHEEL_READOUT_HEIGHT diff --git a/voidcrew/modules/cyberware/chrome_wheel_arms.dm b/voidcrew/modules/cyberware/chrome_wheel_arms.dm new file mode 100644 index 00000000000..4a84015724f --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_wheel_arms.dm @@ -0,0 +1,111 @@ +/** + * # Arm chrome + * + * One ability that deploys or stows all of the bearer's arm hardware at once: + * the toolkit arms that put something in a hand (Mantis, Shiv and Scorch + * pairs, Widowline, Rockjaw, Fixer's, Ronin and Holdout, Bunker Buster). Arm + * chrome with nothing to hand out (Icepick, Skyhook, Graverobber, Angler, + * Gorilla) is left alone. + * + * If anything is out, everything goes back in. Otherwise every arm that can + * deploys: running chrome, its own button usable, a free hand on its side. + * A held item is never dropped to make room, so a full hand just keeps its + * arm stowed, and an EMP'd or browned-out arm stays put. A pair comes out + * together because both housings deploy in the same press. + * + * The bearer's chrome wheel owns the ability (chrome_wheel.dm sync_abilities()) + * and gives it a fixed chip at the top of the right arm's arc. It has no HUD + * button, like every chrome ability (chrome_hud.dm); the "Deploy arm chrome" + * key fires it (chrome_wheel_keybindings.dm). + */ +/datum/action/chrome_arms + name = "Arm chrome" + button_icon = CHROME_HUD_ICON + button_icon_state = "arms" + check_flags = AB_CHECK_CONSCIOUS + +/datum/action/chrome_arms/is_chrome_ability() + return TRUE + +/datum/action/chrome_arms/Trigger(mob/clicker, trigger_flags) + . = ..() + if(!.) + return FALSE + return chrome_arms_toggle(owner) + +/// The bearer's installed arm chrome that hands something out, deployed or not. +/proc/get_chrome_arm_housings(mob/living/carbon/bearer) + RETURN_TYPE(/list) + . = list() + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing in get_installed_cyberware(bearer)) + if(housing.chrome_arm_item()) + . += housing + +/** + * Stows every deployed arm if any is out, or else deploys every arm that can. + * Returns whether anything moved. + */ +/proc/chrome_arms_toggle(mob/living/carbon/bearer) + var/list/housings = get_chrome_arm_housings(bearer) + if(!length(housings)) + bearer?.balloon_alert(bearer, "no arm chrome!") + return FALSE + var/list/deployed = list() + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing as anything in housings) + if(housing.chrome_arm_deployed()) + deployed += housing + if(length(deployed)) + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing as anything in deployed) + housing.Retract() + return TRUE + if(HAS_TRAIT(bearer, TRAIT_INCAPACITATED)) + bearer.balloon_alert(bearer, "incapacitated!") + return FALSE + if(HAS_TRAIT(bearer, TRAIT_HANDS_BLOCKED)) + bearer.balloon_alert(bearer, "hands blocked!") + return FALSE + var/out = 0 + var/hands_full = FALSE + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/housing as anything in housings) + if(housing.chrome_arm_deploy()) + out++ + else if(housing.chrome_arm_can_run() && !housing.chrome_arm_hand_free()) + hands_full = TRUE + if(!out) + bearer.balloon_alert(bearer, hands_full ? "hands full!" : "chrome offline!") + return out > 0 + +/// The first thing this arm hands out, while it is still stowed in it or out in a hand; null for arm chrome with nothing to hand out. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/chrome_arm_item() + for(var/datum/weakref/item_ref as anything in items_list) + var/obj/item/stowed = item_ref.resolve() + if(stowed) + return stowed + return null + +/// Whether this arm's item is out of the housing. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/chrome_arm_deployed() + // The toolkit sets active_item before it knows a hand was free, so "out" is "not in us". + return active_item && !(active_item in src) + +/// Whether the chrome runs and its own deploy button could be pressed. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/chrome_arm_can_run() + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(!owner || !chrome?.is_running()) + return FALSE + var/datum/action/toggle = locate(/datum/action/item_action/organ_action/toggle) in actions + return !toggle || toggle.IsAvailable() + +/// Whether the hand on this arm's side is empty. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/chrome_arm_hand_free() + return !!owner?.get_empty_held_index_for_side(zone == BODY_ZONE_R_ARM ? RIGHT_HANDS : LEFT_HANDS) + +/// Puts this arm's first item in its hand, if the chrome runs and the hand is empty. Returns whether it came out. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/chrome_arm_deploy() + if(chrome_arm_deployed() || !chrome_arm_can_run() || !chrome_arm_hand_free()) + return FALSE + var/obj/item/augment = chrome_arm_item() + if(!augment || !(augment in src)) + return FALSE + Extend(augment) + return chrome_arm_deployed() diff --git a/voidcrew/modules/cyberware/chrome_wheel_hud.dm b/voidcrew/modules/cyberware/chrome_wheel_hud.dm new file mode 100644 index 00000000000..d3273eda2e3 --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_wheel_hud.dm @@ -0,0 +1,142 @@ +/** + * Screen objects for the chrome wheel (chrome_wheel.dm). + * + * Everything the player sees hangs off one hub at the centre of the view, in + * its vis_contents, on the above-HUD plane. None of it takes the mouse: a + * near-invisible full-view catcher sits under it and reports every move and + * click to the wheel, which works out the chip from the mouse position. + * Every piece opts out of clear_screen(), because a knockdown rebuilds the HUD + * mid-hold; the wheel takes its own pieces off the screen when it closes. + * + * Pieces keep the alpha and transform of whatever they hang off, so the hub's + * fade reaches everything and a chip's pop carries its brackets, shade and + * count with it. Each sets its own colour. + * + * Layers, bottom up: backdrop, body, body glow, arcs, leaders, nodes, empty + * sockets, chips (and their shade, count and brackets), readout. + */ +/atom/movable/screen/chrome_wheel + icon = CHROME_WHEEL_ICON + icon_state = null + plane = ABOVE_HUD_PLANE + layer = 3 + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_PLANE + clear_with_screen = FALSE + appearance_flags = RESET_COLOR | NO_CLIENT_COLOR | PIXEL_SCALE + +/// The wheel's anchor at the centre of the view. Draws nothing itself. +/atom/movable/screen/chrome_wheel/hub + screen_loc = "CENTER" + +/** + * A piece of the 240x240 ring art (chrome_wheel_ring.dmi). The art is centred + * on the ring, which sits CHROME_WHEEL_CENTRE_Y above the middle of the view. + */ +/atom/movable/screen/chrome_wheel/art + icon = CHROME_WHEEL_RING_ICON + layer = 2.1 + pixel_x = -104 + pixel_y = -102 + +/// The soft dark backdrop. Stored at half size and drawn doubled, smoothly. +/atom/movable/screen/chrome_wheel/art/backdrop + icon_state = "scrim" + layer = 2 + appearance_flags = RESET_COLOR | NO_CLIENT_COLOR + +/atom/movable/screen/chrome_wheel/art/backdrop/Initialize(mapload, datum/hud/hud_owner) + . = ..() + transform = matrix(2, 0, 0, 0, 2, 0) + +/// The dim dashed outline of a socket with nothing in it. +/atom/movable/screen/chrome_wheel/empty_socket + icon_state = "socket" + layer = 2.5 + +/// One ability on the ring. +/atom/movable/screen/chrome_wheel/chip + icon_state = "frame_22" + /// The ability this chip fires. + var/datum/weakref/ability_ref + /// CHROME_WHEEL_SOCKET_* it sits in, and its position there, from 1. + var/socket + var/index = 1 + /// Which leader lights when it is pointed at: its socket's, or for hand + /// chrome its own run to the hand ("l_hand_2"). + var/leader_key + /// Which part of the body lights when it is pointed at. + var/region + /// Offset of its centre from the centre of the view, in pixels. + var/chip_x = 0 + var/chip_y = 0 + /// What the chip was last drawn as, so unchanged chips are left alone. + var/drawn_state + /// What its cooldown count last showed. + var/count_state + /// Size it was last drawn at: 22, or 26 while pointed at. + var/size = 22 + /// Cooldown wipe and its top edge, made the first time the chip cools down. + var/atom/movable/screen/chrome_wheel/chip_part/shade + var/atom/movable/screen/chrome_wheel/chip_part/shade_edge + /// Cooldown seconds. + var/atom/movable/screen/chrome_wheel/chip_part/count + /// The pick's corner brackets, made the first time this chip is picked. + var/list/brackets + +/atom/movable/screen/chrome_wheel/chip/Destroy() + vis_contents.Cut() + QDEL_NULL(shade) + QDEL_NULL(shade_edge) + QDEL_NULL(count) + QDEL_LIST(brackets) + ability_ref = null + return ..() + +/// Something drawn on a chip: the cooldown wipe, its edge or its count. +/atom/movable/screen/chrome_wheel/chip_part + icon_state = "px" + layer = 3.1 + +/// One corner of the lock brackets on the picked chip. +/atom/movable/screen/chrome_wheel/bracket + icon = CHROME_WHEEL_BRACKET_ICON + layer = 3.4 + color = CHROME_WHEEL_PICKED_COLOR + /// Which way is outward from the chip, for the snap in: -1 or 1 on each axis. + var/out_x = 0 + var/out_y = 0 + +/// A part of the readout panel under the ring: a flat block, or a line of text. +/atom/movable/screen/chrome_wheel/readout + icon_state = "px" + layer = 4 + +/** + * tg's cursor catcher (the scope's), lifted above the HUD so the mouse reaches + * the wheel wherever it is on the map. Its icon is white at alpha 1: invisible, + * but solid to the mouse. + */ +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel + // overlay_fullscreen() appends a severity to the base state; nothing here goes + // through it, so name the state that exists. + icon_state = "fullscreen_blocker0" + plane = ABOVE_HUD_PLANE + layer = 1 + clear_with_screen = FALSE + /// The wheel to report to. + var/datum/component/chrome_wheel/wheel + +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/Destroy() + wheel = null + owner = null + return ..() + +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/MouseMove(location, control, params) + . = ..() + if(usr == owner) + wheel?.mouse_moved(params) + +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/Click(location, control, params) + if(usr == owner) + wheel?.mouse_clicked(params) diff --git a/voidcrew/modules/cyberware/chrome_wheel_keybindings.dm b/voidcrew/modules/cyberware/chrome_wheel_keybindings.dm new file mode 100644 index 00000000000..2578ebd6ab1 --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_wheel_keybindings.dm @@ -0,0 +1,85 @@ +/** + * The chrome wheel's binds (chrome_wheel.dm). Defaults sit on V, the one free + * letter by the movement keys: hold V for the wheel, Shift+V fires the pick + * (beside tg's Shift+E/Q/B variants), Ctrl+V fires the OS and Alt+V deploys or + * stows all arm chrome (chrome_wheel_arms.dm). Alt alone is tg's "Block + * movement", held only for the moment the combo is down. All of them rebind in + * the keybind settings like any other. + */ +/datum/keybinding/living/chrome + +/datum/keybinding/living/chrome/can_use(client/user) + return iscarbon(user.mob) + +/// The bearer's wheel, or null with a "no chrome" balloon for a body without any. +/datum/keybinding/living/chrome/proc/get_wheel(client/user) + var/mob/living/carbon/bearer = user.mob + if(bearer.stat == DEAD) + return null + var/datum/component/chrome_wheel/wheel = get_chrome_wheel(bearer) + if(!wheel) + bearer.balloon_alert(bearer, "no chrome!") + return wheel + +/datum/keybinding/living/chrome/wheel + hotkey_keys = list("V") + name = CHROME_WHEEL_KEYBIND + full_name = "Chrome wheel" + description = "Hold to pick a chrome ability." + keybind_signal = COMSIG_KB_LIVING_CHROME_WHEEL_DOWN + +/datum/keybinding/living/chrome/wheel/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.wheel_key_down() + return TRUE + +/datum/keybinding/living/chrome/wheel/up(client/user, turf/target) + . = ..() + if(.) + return + var/datum/component/chrome_wheel/wheel = user.mob.GetComponent(/datum/component/chrome_wheel) + wheel?.wheel_key_up() + +/datum/keybinding/living/chrome/use + hotkey_keys = list("ShiftV") + name = "use_chrome" + full_name = "Use chrome" + description = "Use the chrome ability picked on the wheel." + keybind_signal = COMSIG_KB_LIVING_USE_CHROME_DOWN + +/datum/keybinding/living/chrome/use/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.use_selected() + return TRUE + +/datum/keybinding/living/chrome/use_os + hotkey_keys = list("CtrlV") + name = "use_os_chrome" + full_name = "Use OS chrome" + description = "Use your operating system chrome." + keybind_signal = COMSIG_KB_LIVING_USE_OS_CHROME_DOWN + +/datum/keybinding/living/chrome/use_os/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.use_os() + return TRUE + +/datum/keybinding/living/chrome/deploy_arms + hotkey_keys = list("AltV") + name = "deploy_arm_chrome" + full_name = "Deploy arm chrome" + description = "Deploy or stow all your arm chrome." + keybind_signal = COMSIG_KB_LIVING_DEPLOY_ARM_CHROME_DOWN + +/datum/keybinding/living/chrome/deploy_arms/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.use_arms() + return TRUE diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm new file mode 100644 index 00000000000..0284e0a0c88 --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -0,0 +1,1065 @@ +/** + * # Chrome animations + * + * Four pieces of motion that sit on top of chrome that already works. None of + * them changes what the chrome does or when: every proc here only draws, and + * the mechanics call into it after they have already happened. + * + * - Mantis blades swing out of the housing and fold back into it. + * - The Ghostskin field flickers in and out: hard steps of alpha with a + * pixel of jitter, and a sharper stutter when something knocks it down. + * - The Cascade's spine nodes ignite neck to hips, a ring rolls out across the + * floor, and the nodes die hips to neck when the crash hits. + * - An EMP fills every worn piece with static, arcs between them, kills them one + * after another, and they blink back on when they reboot. + * + * Two ways of drawing, chosen per effect: + * - One-shot frames ride a holder object in the bearer's vis_contents. A fresh + * object always plays its icon state from the first frame, which an overlay + * does not, so swings, arcs and sparks are holders that delete themselves. + * - Anything that must sit at the worn chrome's layer (under suits, gloves and + * hands) is a "look" on the worn overlay itself: the bodypart overlay asks + * the implant for its look when it draws, and the look is part of the limb's + * render key, so a look change is one update_body_parts() away. + * + * Nothing here loops on the server. Timers are one-shot and owned by the thing + * they animate, so they die with it. + */ + +#define CYBERWARE_ANIM_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim.dmi' +#define CYBERWARE_ANIM_LARGE_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi' +/// Batch-2 art: breathing LEDs and the looks of cyberware_anim2.dm. +#define CYBERWARE_ANIM2_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim2.dmi' + +/// Worn looks. Null is the plain overlay. +#define CHROME_LOOK_STATIC "static" +#define CHROME_LOOK_DARK "dark" +#define CHROME_LOOK_FLASH "flash" +#define CHROME_LOOK_FOLD "fold" +/// Brownout looks (cyberware_anim2.dm): browned out, and the warm stutter on the way down or up. +#define CHROME_LOOK_BROWN "brown" +#define CHROME_LOOK_WARM "warm" + +/// Layer for a blade swing: just over the held item it turns into. +#define CHROME_ANIM_BLADE_LAYER (-(HANDS_LAYER - 0.5)) +/// Layer for arcs, sparks and crackle: over everything the bearer wears. +#define CHROME_ANIM_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) +/// The mask on a far arm's picture: the near hand's held item, cut out of it. +#define CHROME_ANIM_ARM_COVER_FILTER "chrome_arm_cover" + +// ========================================================================= +// THE HOLDER +// ========================================================================= + +/** + * One short animation drawn on a bearer. Created per use and deleted after + * its lifetime, so its icon state always starts on the first frame. Inherits + * the bearer's facing and plane; clicks pass through it. + */ +/obj/effect/abstract/cyberware_anim + name = "chrome" + icon = CYBERWARE_ANIM_LARGE_ICON + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_DIR | VIS_INHERIT_PLANE + blocks_emissive = EMISSIVE_BLOCK_NONE + /// What we are drawn on. + var/atom/movable/bearer + /// The arm this is a picture of, "left" or "right", once it is sorted as one arm's (sort_as_arm()). + var/arm_side + /// What the near hand holds while this is the far arm's picture: it is cut out of it. + var/obj/item/arm_cover + +/obj/effect/abstract/cyberware_anim/Destroy() + detach() + return ..() + +/obj/effect/abstract/cyberware_anim/proc/detach() + set_arm_cover(null) + if(!bearer) + return + UnregisterSignal(bearer, list(COMSIG_QDELETING, COMSIG_ATOM_POST_DIR_CHANGE, COMSIG_MOB_UPDATE_HELD_ITEMS)) + bearer.vis_contents -= src + bearer = null + +/obj/effect/abstract/cyberware_anim/proc/on_bearer_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/** + * Plays [state] from [icon_file] on [bearer] for [lifetime], then deletes + * itself. Large-sheet states are 64x64 and get centred on the bearer's tile. + * [glow] adds an emissive twin, so the frames light up in the dark. + */ +/proc/cyberware_anim_play(atom/movable/bearer, icon_file, state, anim_layer, lifetime, glow = FALSE) + if(QDELETED(bearer) || !state) + return null + var/obj/effect/abstract/cyberware_anim/holder = new(null) + holder.bearer = bearer + holder.icon = icon_file + holder.icon_state = state + holder.layer = anim_layer + if(icon_file == CYBERWARE_ANIM_LARGE_ICON) + holder.pixel_x = -16 + holder.pixel_y = -16 + if(glow) + holder.add_overlay(emissive_appearance(icon_file, state, bearer)) + holder.RegisterSignal(bearer, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + bearer.vis_contents += holder + QDEL_IN(holder, lifetime) + return holder + +// ========================================================================= +// ONE ARM'S PICTURE +// ========================================================================= + +/** + * Sorts this picture as one of the [side] ("left" or "right") arm's from now on. A picture in vis_contents + * draws over every overlay of its bearer whatever its own float layer, held items included, and pictures draw + * over one another in the order they went in, whatever their float layers. So side on a far arm's picture + * would cover the near arm. Here what the near hand holds is cut out of the far arm's picture with a mask of + * that item's held sprite, for as long as it is drawn in that hand, and the near arm's pictures are put back + * on over it (under_near_arm()). Kept right as the bearer turns and as the hands redraw. The art itself + * leaves out the body where it hides a far arm. + */ +/obj/effect/abstract/cyberware_anim/proc/sort_as_arm(side) + if(QDELETED(bearer) || arm_side || !side) + return + arm_side = side + RegisterSignal(bearer, COMSIG_ATOM_POST_DIR_CHANGE, PROC_REF(on_arm_bearer_changed)) + RegisterSignal(bearer, COMSIG_MOB_UPDATE_HELD_ITEMS, PROC_REF(on_arm_bearer_changed)) + fit_to_facing() + +/// Sorts an arm's picture for the way its bearer faces and what the other hand shows right now. +/obj/effect/abstract/cyberware_anim/proc/fit_to_facing() + if(QDELETED(bearer) || !arm_side) + return + var/far = (bearer.dir == EAST && arm_side == "left") || (bearer.dir == WEST && arm_side == "right") + var/obj/item/near_item = far ? near_held_item() : null + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/near_blade = near_item + // A blade hidden under a picture of its own is not in the hand to be cut out: that picture is in front. + if(istype(near_blade) && world.time < near_blade.inhand_hidden_until) + near_item = null + set_arm_cover(near_item) + if(far) + under_near_arm() + +/** + * Puts the near arm's pictures back on over this far arm's picture: each is taken off its bearer and put + * straight back, in the order they were in. A picture put back in the same tick goes on playing where it was. + */ +/obj/effect/abstract/cyberware_anim/proc/under_near_arm() + var/list/near_pictures + for(var/obj/effect/abstract/cyberware_anim/other in bearer.vis_contents) + if(other.arm_side && other.arm_side != arm_side) + LAZYADD(near_pictures, other) + for(var/obj/effect/abstract/cyberware_anim/other as anything in near_pictures) + bearer.vis_contents -= other + bearer.vis_contents += other + +/// What the bearer holds in the hand on the other side from this arm. +/obj/effect/abstract/cyberware_anim/proc/near_held_item() + var/mob/living/holder = bearer + if(!isliving(holder)) + return null + for(var/obj/item/held in holder.held_items) + if(IS_LEFT_INDEX(holder.get_held_index_of_item(held)) != (arm_side == "left")) + return held + return null + +/// Cuts [item]'s held sprite out of this picture, or nothing out of it if null. +/obj/effect/abstract/cyberware_anim/proc/set_arm_cover(obj/item/item) + if(arm_cover) + UnregisterSignal(arm_cover, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + var/icon/mask = item && cyberware_held_mask(item, bearer) + arm_cover = mask ? item : null + if(arm_cover) + RegisterSignals(arm_cover, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING), PROC_REF(on_arm_cover_gone)) + add_filter(CHROME_ANIM_ARM_COVER_FILTER, 1, alpha_mask_filter(icon = mask, flags = MASK_INVERSE)) + else if(arm_side) + remove_filter(CHROME_ANIM_ARM_COVER_FILTER) + +/obj/effect/abstract/cyberware_anim/proc/on_arm_cover_gone(datum/source) + SIGNAL_HANDLER + set_arm_cover(null) + +/obj/effect/abstract/cyberware_anim/proc/on_arm_bearer_changed(datum/source) + SIGNAL_HANDLER + fit_to_facing() + +/** + * The shape [item] has in [holder]'s hand, facing the way [holder] faces now, as a mask: the first frame of + * its held sprite. Null if it has none. Each sprite and facing is made once. + */ +/proc/cyberware_held_mask(obj/item/item, mob/living/holder) + if(!isliving(holder)) + return null + var/index = holder.get_held_index_of_item(item) + if(!index) + return null + var/file = IS_RIGHT_INDEX(index) ? item.righthand_file : item.lefthand_file + var/state = item.inhand_icon_state || item.icon_state + if(!file || !state || !icon_exists(file, state)) + return null + var/static/list/masks = list() + var/list/by_state = masks[file] + if(!by_state) + by_state = list() + masks[file] = by_state + var/key = "[state]-[holder.dir]" + . = by_state[key] + if(!.) + . = icon(file, state, holder.dir, 1) + by_state[key] = . + +// ========================================================================= +// WORN LOOKS +// ========================================================================= + +/// The EMP look the reboot show has put on this piece. Null means "whatever +/// the reboot state says": dark while down, plain otherwise. +/datum/component/cyberware/var/emp_look +/// The brownout look the brownout show has put on this piece. Null means +/// "whatever the brownout state says": browned out or plain. +/datum/component/cyberware/var/brownout_look + +/** + * The look this implant's worn overlay draws with right now, or null for the + * plain overlay. Read by the bodypart overlay on every draw. + */ +/obj/item/organ/cyberimp/proc/cyberware_worn_look() + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(!chrome) + return null + if(chrome.emp_look) + return chrome.emp_look + if(chrome.emp_down) + return CHROME_LOOK_DARK + if(chrome.brownout_look) + return chrome.brownout_look + if(chrome.browned_out) + return CHROME_LOOK_BROWN + return null + +/// What this implant adds to its limb's render key, so a look change redraws. +/obj/item/organ/cyberimp/proc/cyberware_worn_key() + return "[cyberware_worn_look()]" + +/** + * Dresses one implant's freshly built worn images in its current look. + * Called by /datum/bodypart_overlay/augment/cyberware before it adds + * emissive blockers, so a see-through image gets a see-through blocker. + */ +/obj/item/organ/cyberimp/proc/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + var/look = cyberware_worn_look() + if(!look) + return cyberware_dress_leds(imageset, image_layer, limb) + var/list/dressed = list() + for(var/image/part as anything in imageset) + var/glow = PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE + switch(look) + if(CHROME_LOOK_FOLD) + // The folded housing waits while the swing plays over it. + if(glow) + continue + part.alpha = 0 + if(CHROME_LOOK_DARK) + if(glow) + continue + part.color = list(0.18, 0.18, 0.2, 0.32, 0.32, 0.34, 0.08, 0.08, 0.1, 0.02, 0.02, 0.03) + if(CHROME_LOOK_FLASH) + if(glow) + continue + part.color = list(0.55, 0, 0, 0, 0.55, 0, 0, 0, 0.55, 0.42, 0.5, 0.55) + dressed += part + dressed += emissive_appearance(part.icon, part.icon_state, limb?.owner || limb, part.layer) + continue + if(CHROME_LOOK_STATIC) + if(!glow) + // Static clipped to the piece's own pixels. + part.appearance_flags |= KEEP_TOGETHER + var/mutable_appearance/noise = mutable_appearance('icons/effects/effects.dmi', "static_base", alpha = 170) + noise.blend_mode = BLEND_INSET_OVERLAY + part.overlays += noise + if(CHROME_LOOK_BROWN) + if(glow) + continue + part.color = list(0.62, 0, 0, 0, 0.62, 0, 0, 0, 0.66, 0, 0, 0) + if(CHROME_LOOK_WARM) + if(glow) + // A sagging supply: the lights still burn, weaker. + dressed += emissive_appearance(part.icon, part.icon_state, limb?.owner || limb, part.layer, alpha = 110) + continue + part.color = list(0.9, 0, 0, 0, 0.74, 0, 0, 0, 0.58, 0.05, 0.02, 0) + dressed += part + return dressed + +/** + * The plain look: any worn state with breathing LEDs in the batch-2 sheet + * gets them, and the breathing glow replaces the piece's own still glow. + * The LED states loop on their own, so a lit piece costs the server nothing. + */ +/obj/item/organ/cyberimp/proc/cyberware_dress_leds(list/imageset, image_layer, obj/item/bodypart/limb) + var/list/leds + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + continue + var/led_state = "[part.icon_state]_led" + if(icon_exists(CYBERWARE_ANIM2_ICON, led_state)) + LAZYSET(leds, "[part.icon_state]", led_state) + if(!leds) + return imageset + var/list/dressed = list() + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + var/glow_state = "[part.icon_state]" + if(length(glow_state) > 2 && copytext(glow_state, -2) == "_e" && leds[copytext(glow_state, 1, -2)]) + continue + dressed += part + for(var/worn_state in leds) + dressed += image(icon = CYBERWARE_ANIM2_ICON, icon_state = leds[worn_state], layer = image_layer) + dressed += emissive_appearance(CYBERWARE_ANIM2_ICON, "[leds[worn_state]]_e", limb?.owner || limb, image_layer) + return dressed + +// ========================================================================= +// MANTIS BLADES: UNFOLD AND FOLD +// ========================================================================= + +/// How long the blade swings out before the held blade takes over. +#define BLADE_UNFOLD_SWING (0.3 SECONDS) +/// The whole unfold, last sparks included. +#define BLADE_UNFOLD_TIME (0.4 SECONDS) +/// How long the folded housing waits while the blade folds back in. +#define BLADE_FOLD_SWING (0.3 SECONDS) +/// The whole fold, last sparks included. +#define BLADE_FOLD_TIME (0.5 SECONDS) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware + /// The swing playing on the bearer right now, if any. Weak: it deletes itself. + var/datum/weakref/blade_anim_ref + /// Until this world.time the folded housing is drawn see-through. + var/fold_hide_until = 0 + /// Timer that brings the folded housing back. + var/fold_timer + /// Colour of the flash at the hinge when the blade swings. + var/blade_flash_color = "#bfe6ff" + /// One hinge flash at a time, however fast the blade is toggled. + COOLDOWN_DECLARE(blade_flash_cooldown) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street + blade_flash_color = "#e8e0c8" + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch + blade_flash_color = "#ffb070" + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/cyberware_worn_look() + if(world.time < fold_hide_until) + return CHROME_LOOK_FOLD + return ..() + +/// The swing state for this housing, or null when the sheet has none. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/blade_anim_state(kind) + var/state = "[aug_overlay]_[kind]_[arm_side()]" + return icon_exists(CYBERWARE_ANIM_LARGE_ICON, state) ? state : null + +/// The swing playing on the bearer right now, if any. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/blade_anim() + return blade_anim_ref?.resolve() + +/// The arm this housing is in, as the art names it. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/arm_side() + return zone == BODY_ZONE_L_ARM ? "left" : "right" + +/// Plays [state] on the bearer for [lifetime] as this arm's picture: side on, the far arm's goes behind the near arm. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/play_blade_anim(state, lifetime) + var/obj/effect/abstract/cyberware_anim/swing = cyberware_anim_play(owner, CYBERWARE_ANIM_LARGE_ICON, state, CHROME_ANIM_BLADE_LAYER, lifetime) + swing?.sort_as_arm(arm_side()) + blade_anim_ref = WEAKREF(swing) + return swing + +/// Stops any swing and brings the folded housing back if it was waiting. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/stop_blade_anim() + qdel(blade_anim()) + blade_anim_ref = null + if(fold_timer) + deltimer(fold_timer) + fold_timer = null + fold_hide_until = 0 + +/// The blade swings out of the housing. Called once the blade is in a hand. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/play_blade_unfold(obj/item/blade) + var/state = blade_anim_state("unfold") + if(!state || !owner) + return + stop_blade_anim() + play_blade_anim(state, BLADE_UNFOLD_TIME) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/held_blade = blade + if(istype(held_blade)) + held_blade.hide_inhand_for(BLADE_UNFOLD_SWING) + blade_flash(0.7, BLADE_UNFOLD_SWING) + +/** + * The unfold ends on the guard and keeps it under its last sparks, so a blade is drawn while the held blade + * comes back, whenever that reaches the client. If the blade takes another pose or swings while the unfold is + * still on it, the unfold stops, so its guard is never drawn over the new picture, and the held blade shows at + * once unless something else is hiding it for longer. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/unfold_overtaken(obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade) + var/obj/effect/abstract/cyberware_anim/unfold = blade_anim() + if(isnull(unfold) || unfold.icon_state != blade_anim_state("unfold")) + return FALSE + stop_blade_anim() + if(blade.inhand_hidden_until <= world.time + BLADE_UNFOLD_SWING) + blade.show_inhand() + return TRUE + +/** + * The blade folds back into the housing. Called once it is stowed; the + * folded housing stays see-through until the swing has played. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/play_blade_fold() + var/state = blade_anim_state("fold") + if(!state || !owner) + return + stop_blade_anim() + play_blade_anim(state, BLADE_FOLD_TIME) + fold_hide_until = world.time + BLADE_FOLD_SWING + fold_timer = addtimer(CALLBACK(src, PROC_REF(end_fold_hide)), BLADE_FOLD_SWING, TIMER_STOPPABLE) + blade_flash(0.5, BLADE_FOLD_SWING) + +/// A brief light at the hinge while the blade swings. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/blade_flash(power, duration) + if(!COOLDOWN_FINISHED(src, blade_flash_cooldown)) + return + COOLDOWN_START(src, blade_flash_cooldown, duration) + owner.mob_light(1.5, power, blade_flash_color, duration) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/end_fold_hide() + fold_timer = null + fold_hide_until = 0 + owner?.update_body_parts() + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis + /// Until this world.time the blade is not drawn in the hand: the swing is. + var/inhand_hidden_until = 0 + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/hide_inhand_for(duration) + // A longer hide already running (the impale's or the scissor's blade art) is never cut short by a swing. + if(world.time + duration <= inhand_hidden_until) + return + inhand_hidden_until = world.time + duration + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + // Each hide ends on its own timer, and a timer ends nothing while a later hide still runs. + addtimer(CALLBACK(src, PROC_REF(inhand_hide_expired)), duration, TIMER_DELETE_ME) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/inhand_hide_expired() + if(world.time < inhand_hidden_until) + return + show_inhand() + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/show_inhand() + if(!inhand_hidden_until) + return + inhand_hidden_until = 0 + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + +// The blade is in the hand from the first tick; only its drawing waits. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/build_worn_icon(default_layer = 0, default_icon_file = null, isinhands = FALSE, female_uniform = NO_FEMALE_UNIFORM, override_state = null, override_file = null) + . = ..() + if(isinhands && world.time < inhand_hidden_until) + var/mutable_appearance/standing = . + standing.alpha = 0 + +// ========================================================================= +// IMPACTS +// ========================================================================= + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis + /// What a hit with this blade plays over its target, in place of tg's flying blade. + var/impact_state = "impact_mantis" + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street + impact_state = "impact_shiv" + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch + impact_state = "impact_scorch" + +// The swinger's lunge stays; only the picture over the target changes. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/animate_attack(atom/movable/attacker, atom/attacked_atom, animation_type) + // The arm swings the blade (mantis_gore/mantis_swing.dm), and the impact waits for its strike. The swing is the + // picture on its own where there is no impact (a wall), never tg's flying blade beside it. + if(mantis_play_swing(attacker, attacked_atom)) + mantis_swing_impact(attacker, attacked_atom) + return + if(!cyberware_play_impact(attacker, attacked_atom, impact_state)) + return ..() + +// No picture over what a wire hits, nor tg's flying spool: the lash from the hand is its picture +// (hyperviolence/wire/wire_held.dm). Walls and floors keep tg's own. +/obj/item/melee/cyberware_monowire/animate_attack(atom/movable/attacker, atom/attacked_atom, animation_type) + if(!ismovable(attacked_atom)) + return ..() + +/** + * A chrome weapon's hit drawn over what it hit: a short slash, flash or + * crackle, upright even on someone lying down, and turned to come from the + * attacker's side. [mirrored] TRUE or FALSE turns it that way instead (the + * Mantis swing turns it by the hand that swung). Returns the holder, or null + * if there was nothing to draw on. + */ +/proc/cyberware_play_impact(atom/movable/attacker, atom/attacked_atom, impact_state, mirrored = null) + if(!impact_state || !ismovable(attacked_atom)) + return null + var/obj/effect/abstract/cyberware_anim/impact = cyberware_anim_play(attacked_atom, CYBERWARE_ANIM_ICON, impact_state, CHROME_ANIM_TOP_LAYER, 0.4 SECONDS, glow = impact_state != "impact_shiv") + if(!impact) + return null + impact.appearance_flags |= RESET_TRANSFORM + // The art swings in from the left; a blow from the right is mirrored, one from above or below either way. + if(isnull(mirrored)) + var/direction = get_dir(attacker, attacked_atom) + mirrored = (direction & WEST) || (!(direction & EAST) && prob(50)) + if(mirrored) + impact.transform = matrix(-1, 0, 0, 0, 1, 0) + return impact + +#undef BLADE_UNFOLD_SWING +#undef BLADE_UNFOLD_TIME +#undef BLADE_FOLD_SWING +#undef BLADE_FOLD_TIME + +// ========================================================================= +// GHOSTSKIN FIELD: THE SIGNAL FLICKER +// ========================================================================= + +/// How long the field takes to flicker in. +#define CLOAK_ENTER_TIME (0.6 SECONDS) + +/obj/item/organ/cyberimp/cyberware/ghostskin + /// world.time the field finishes flickering in. Moving under the field waits for it. + var/cloak_enter_until = 0 + +/** + * Hard 1 px jumps sideways alongside one of the alpha chains below. [steps] + * are (jump, hold) pairs; the jumps are relative and add up to nothing, so the + * bearer ends where it started however the chain is cut short. + */ +/proc/cyberware_cloak_jolt(mob/living/wearer, list/steps) + for(var/i in 1 to length(steps) step 2) + var/jump = steps[i] + var/hold = steps[i + 1] + if(i == 1) + animate(wearer, pixel_w = jump, time = 0, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + else + animate(pixel_w = jump, time = 0, flags = ANIMATION_RELATIVE) + if(hold) + animate(pixel_w = 0, time = hold, flags = ANIMATION_RELATIVE) + +/** + * The field comes up: the bearer's alpha stutters through hard steps and eases + * down to field alpha, jumping a pixel sideways on the way. While the field + * holds there is nothing on the bearer but that alpha. + */ +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_flicker_in(mob/living/carbon/wearer) + cloak_enter_until = world.time + CLOAK_ENTER_TIME + var/faded = field_alpha + // The alpha chain first: it replaces whatever the bearer was animating. + animate(wearer, alpha = max(115, faded), time = 0) + animate(alpha = max(115, faded), time = 0.5) + animate(alpha = 230, time = 0) + animate(alpha = 230, time = 1) + animate(alpha = max(75, faded), time = 0) + animate(alpha = max(75, faded), time = 0.5) + animate(alpha = 180, time = 0) + animate(alpha = 180, time = 1) + animate(alpha = max(55, faded), time = 0) + animate(alpha = max(55, faded), time = 0.5) + animate(alpha = 125, time = 0) + animate(alpha = 125, time = 0.5) + animate(alpha = faded, time = 2, easing = SINE_EASING) + cyberware_cloak_jolt(wearer, list(1, 0.5, -1, 1, -1, 0.5, 1, 1, 1, 0.5, -1, 0.5)) + +/// The field runs out or is switched off: the flicker in reverse, back to full view. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_flicker_out(mob/living/carbon/wearer) + cloak_enter_until = 0 + var/faded = field_alpha + animate(wearer, alpha = 110, time = 0) + animate(alpha = 110, time = 0.5) + animate(alpha = max(60, faded), time = 0) + animate(alpha = max(60, faded), time = 0.5) + animate(alpha = 175, time = 0) + animate(alpha = 175, time = 0.5) + animate(alpha = 105, time = 0) + animate(alpha = 105, time = 0.5) + animate(alpha = 235, time = 0) + animate(alpha = 235, time = 0.5) + animate(alpha = 255, time = 1, easing = SINE_EASING) + cyberware_cloak_jolt(wearer, list(-1, 0.5, 1, 0.5, 1, 0.5, -1, 0.5, -1, 0.5, 1, 1)) + +/// The field is knocked down: full view, a dip, full view, with hard 2 px jolts. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_flicker_break(mob/living/carbon/wearer) + cloak_enter_until = 0 + animate(wearer, alpha = 255, time = 0) + animate(alpha = 255, time = 0.5) + animate(alpha = 140, time = 0) + animate(alpha = 140, time = 0.5) + animate(alpha = 255, time = 0) + animate(alpha = 255, time = 0.5) + cyberware_cloak_jolt(wearer, list(2, 0.5, -4, 0.5, 3, 0.5, -1, 0)) + +/// A quiet drop (the weave pulled, or switched off while not worn): straight back to full view. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_reset(mob/living/carbon/wearer) + cloak_enter_until = 0 + if(!QDELETED(wearer)) + animate(wearer, alpha = 255, time = 0) + +#undef CLOAK_ENTER_TIME + +// ========================================================================= +// CASCADE: SPINE NODES AND THE TIME RING +// ========================================================================= + +/// Time between node frames. +#define CASCADE_NODE_STEP (0.1 SECONDS) +/// When the ring rolls out: as the last node lights. +#define CASCADE_RING_DELAY (0.45 SECONDS) + +/obj/item/organ/cyberimp/cyberware/cascade + /// The spine node frame drawn over the lattice, or null for the plain one. + var/node_look + /// Node frames still to play, in order. + var/list/node_queue + /// Timer for the next node frame. + var/node_timer + /// Timer for the floor ring. + var/ring_timer + /// How far the ring rolls out (transform scale of a two-tile ring). + var/ring_scale = 3.3 + /// How bright the ring starts. + var/ring_alpha = 235 + /// How long the ring takes to roll out and fade. + var/ring_time = 0.65 SECONDS + +/obj/item/organ/cyberimp/cyberware/cascade/milspec + ring_scale = 2.7 + ring_alpha = 190 + ring_time = 0.55 SECONDS + +/obj/item/organ/cyberimp/cyberware/cascade/street + ring_scale = 2.2 + ring_alpha = 140 + ring_time = 0.45 SECONDS + +/obj/item/organ/cyberimp/cyberware/cascade/cyberware_worn_key() + return "[..()]-[node_look]" + +/obj/item/organ/cyberimp/cyberware/cascade/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + if(cyberware_worn_look() || !node_look) + return ..() + var/state = "[aug_overlay]_[node_look]" + if(!icon_exists(CYBERWARE_ANIM_ICON, state)) + return imageset + // The node frame replaces the lattice's own idle glow. + var/list/dressed = list() + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + continue + dressed += part + dressed += cyberware_spine_node_images(CYBERWARE_ANIM_ICON, state, node_look != "dead", image_layer, limb?.owner || limb) + return dressed + +/** + * The images of one spine node frame. Lit nodes shine through whatever is worn on the back: they draw at + * CYBERWARE_SPINE_SHINE_LAYER, just over the backpack. Dark nodes stay with the housing, under the clothes. A + * frame with lit and dark nodes both is split in its sheet into "[state]_lit" and "[state]_dark". + */ +/proc/cyberware_spine_node_images(icon_file, state, lit, image_layer, atom/glow_owner) + var/list/images = list() + var/has_glow = icon_exists(icon_file, "[state]_e") + if(!lit) + images += image(icon = icon_file, icon_state = state, layer = image_layer) + if(has_glow) + images += emissive_appearance(icon_file, "[state]_e", glow_owner, image_layer) + return images + var/lit_state = state + if(icon_exists(icon_file, "[state]_lit")) + lit_state = "[state]_lit" + images += image(icon = icon_file, icon_state = "[state]_dark", layer = image_layer) + images += image(icon = icon_file, icon_state = lit_state, layer = -CYBERWARE_SPINE_SHINE_LAYER) + if(has_glow) + images += emissive_appearance(icon_file, "[state]_e", glow_owner, -CYBERWARE_SPINE_SHINE_LAYER) + return images + +/// Plays node frames one after another, then holds the last. +/obj/item/organ/cyberimp/cyberware/cascade/proc/play_node_looks(list/looks) + if(node_timer) + deltimer(node_timer) + node_timer = null + node_queue = looks.Copy() + advance_node_look() + +/obj/item/organ/cyberimp/cyberware/cascade/proc/advance_node_look() + node_timer = null + if(!length(node_queue)) + return + node_look = popleft(node_queue) + owner?.update_body_parts() + if(length(node_queue)) + node_timer = addtimer(CALLBACK(src, PROC_REF(advance_node_look)), CASCADE_NODE_STEP, TIMER_STOPPABLE) + +/// Back to the plain lattice, no frames pending, no ring on the way. +/obj/item/organ/cyberimp/cyberware/cascade/proc/clear_node_looks(mob/living/carbon/redraw) + if(node_timer) + deltimer(node_timer) + node_timer = null + if(ring_timer) + deltimer(ring_timer) + ring_timer = null + node_queue = null + if(isnull(node_look)) + return + node_look = null + redraw?.update_body_parts() + +/// The window opens: nodes ignite neck to hips, then the ring rolls out. +/obj/item/organ/cyberimp/cyberware/cascade/proc/play_window_open() + if(!owner) + return + play_node_looks(list("boot1", "boot2", "boot3", "boot4", "boot5", "lit")) + if(ring_timer) + deltimer(ring_timer) + ring_timer = addtimer(CALLBACK(src, PROC_REF(roll_ring)), CASCADE_RING_DELAY, TIMER_STOPPABLE) + +/obj/item/organ/cyberimp/cyberware/cascade/proc/roll_ring() + ring_timer = null + var/turf/here = get_turf(owner) + if(here) + new /obj/effect/temp_visual/cyberware_time_ring(here, ring_scale, ring_alpha, ring_time) + +/// The crash: nodes die hips to neck and stay dark. +/obj/item/organ/cyberimp/cyberware/cascade/proc/play_crash() + if(!owner) + return + if(ring_timer) + deltimer(ring_timer) + ring_timer = null + play_node_looks(list("crash1", "crash2", "crash3", "crash4", "crash5", "dead")) + +/// The lattice in the bearer's OS slot, if it is a Cascade. +/proc/cyberware_bearer_lattice(mob/living/bearer) + if(!iscarbon(bearer)) + return null + var/mob/living/carbon/carbon_bearer = bearer + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = carbon_bearer.get_organ_slot(ORGAN_SLOT_CYBERWARE_OS) + return istype(lattice) ? lattice : null + +/// Hook for the window's on_apply(). +/proc/cyberware_cascade_window_fx(mob/living/bearer) + cyberware_bearer_lattice(bearer)?.play_window_open() + +/// Hook for the window's on_remove(), after it has (or hasn't) applied the crash. +/proc/cyberware_cascade_window_end_fx(mob/living/bearer) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = cyberware_bearer_lattice(bearer) + if(lattice && !bearer.has_status_effect(/datum/status_effect/cyberware_cascade_crash)) + lattice.clear_node_looks(bearer) + +/// Hook for the crash's on_apply(). +/proc/cyberware_cascade_crash_fx(mob/living/bearer) + cyberware_bearer_lattice(bearer)?.play_crash() + +/// Hook for the crash's on_remove(). +/proc/cyberware_cascade_crash_end_fx(mob/living/bearer) + cyberware_bearer_lattice(bearer)?.clear_node_looks(bearer) + +/// The floor ring that rolls out when a Cascade window opens. +/obj/effect/temp_visual/cyberware_time_ring + icon = CYBERWARE_ANIM_LARGE_ICON + icon_state = "time_ring" + pixel_x = -16 + pixel_y = -16 + layer = ABOVE_OPEN_TURF_LAYER + plane = FLOOR_PLANE + randomdir = FALSE + duration = 0.65 SECONDS + +/obj/effect/temp_visual/cyberware_time_ring/Initialize(mapload, max_scale = 3.3, peak_alpha = 235, ring_time = 0.65 SECONDS) + duration = ring_time + . = ..() + add_overlay(emissive_appearance(icon, icon_state, src)) + transform = matrix().Scale(0.2) + alpha = peak_alpha + animate(src, transform = matrix().Scale(max_scale), time = duration, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(src, alpha = 0, time = duration, easing = QUAD_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + +#undef CASCADE_NODE_STEP +#undef CASCADE_RING_DELAY + +// ========================================================================= +// EMP ON CHROME +// ========================================================================= + +/// From the hit to the first piece going dark. +#define EMP_SHOW_FIRST_DEATH (0.55 SECONDS) +/// Between one piece going dark and the next. +#define EMP_SHOW_DEATH_STAGGER (0.2 SECONDS) +/// How long the arcs crackle. +#define EMP_SHOW_ARC_TIME (0.8 SECONDS) +/// Most arcs drawn at once. +#define EMP_SHOW_MAX_ARCS 3 + +/** + * The EMP picture for one bearer. Each worn piece hit in a burst fills with + * static, arcs jump between the pieces, and they go dark one after another; + * the mechanical reboot timing is untouched, only the picture staggers. When a + * piece comes back it blinks bright, dark, then on. + * + * Looks live on each piece's cyberware component (emp_look), and clearing + * them always falls back to the right picture for the reboot state, so + * deleting this at any point leaves the bearer drawn correctly. + */ +/datum/component/chrome_emp_show + dupe_mode = COMPONENT_DUPE_UNIQUE + /// Pieces in the burst that have not gone dark yet. + var/list/datum/weakref/crackling = list() + /// Pieces whose look this show has set, so it can take them back off. + var/list/datum/weakref/touched = list() + /// When the current burst started; a later hit starts a new one. + var/burst_started = -1 + /// Pieces scheduled to go dark in the current burst. + var/burst_deaths = 0 + /// Holders this show is playing. + var/list/obj/effect/abstract/cyberware_anim/holders = list() + /// Steps still to run. At zero the show deletes itself. + var/pending = 0 + /// The current blink batch: pieces that rebooted on the same tick. + var/list/datum/weakref/blink_batch + /// When the current blink batch started. + var/blink_started = -1 + +/datum/component/chrome_emp_show/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/chrome_emp_show/RegisterWithParent() + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/chrome_emp_show/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_LIVING_DEATH) + +/datum/component/chrome_emp_show/Destroy() + var/list/playing = holders.Copy() + holders.Cut() + QDEL_LIST(playing) + var/mob/living/carbon/bearer = parent + var/changed = FALSE + for(var/datum/weakref/piece_ref as anything in touched) + var/obj/item/organ/piece = piece_ref.resolve() + var/datum/component/cyberware/chrome = piece?.GetComponent(/datum/component/cyberware) + if(chrome?.emp_look) + chrome.emp_look = null + changed = TRUE + touched.Cut() + crackling.Cut() + if(changed && !QDELETED(bearer)) + bearer.update_body_parts() + return ..() + +/datum/component/chrome_emp_show/proc/on_death(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// The show for [bearer], made if there isn't one. +/proc/chrome_emp_show_for(mob/living/carbon/bearer) + if(!iscarbon(bearer) || QDELETED(bearer) || bearer.stat == DEAD) + return null + return bearer.GetComponent(/datum/component/chrome_emp_show) || bearer.AddComponent(/datum/component/chrome_emp_show) + +/// Worn chrome only: the show draws on the worn overlay. +/proc/chrome_emp_show_wants(obj/item/organ/piece) + var/obj/item/organ/cyberimp/implant = piece + return istype(implant) && implant.bodypart_aug && implant.owner + +/// Region name a piece arcs from, matching the arc sheet. +/proc/chrome_emp_region(obj/item/organ/piece) + switch(piece.zone) + if(BODY_ZONE_R_ARM) + return "arm_r" + if(BODY_ZONE_L_ARM) + return "arm_l" + if(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG) + return "legs" + return "chest" + +/// Hook for start_emp_reboot(): the piece was just knocked offline. +/proc/cyberware_emp_hit_fx(obj/item/organ/piece) + if(!chrome_emp_show_wants(piece)) + return + var/datum/component/chrome_emp_show/show = chrome_emp_show_for(piece.owner) + if(show) + show.hit(piece) + else + // No show on a corpse: just draw the piece dark. + piece.owner.update_body_parts() + +/// Hook for finish_emp_reboot(): the piece is back online. +/proc/cyberware_emp_reboot_fx(obj/item/organ/piece) + if(!chrome_emp_show_wants(piece)) + return + var/datum/component/chrome_emp_show/show = chrome_emp_show_for(piece.owner) + if(show) + show.blink(piece) + else + piece.owner.update_body_parts() + +/datum/component/chrome_emp_show/proc/set_look(obj/item/organ/piece, look) + var/datum/component/cyberware/chrome = piece.GetComponent(/datum/component/cyberware) + if(!chrome) + return + chrome.emp_look = look + touched |= WEAKREF(piece) + +/// A piece is hit: static now, dark on its turn. +/datum/component/chrome_emp_show/proc/hit(obj/item/organ/piece) + var/datum/weakref/piece_ref = WEAKREF(piece) + if(burst_started != world.time && !length(crackling)) + // A fresh burst. Everything hit this tick joins it; the crackle starts next tick. + burst_started = world.time + burst_deaths = 0 + pending++ + addtimer(CALLBACK(src, PROC_REF(start_burst)), 0) + set_look(piece, CHROME_LOOK_STATIC) + crackling |= piece_ref + var/dies_at = burst_started + EMP_SHOW_FIRST_DEATH + EMP_SHOW_DEATH_STAGGER * burst_deaths + burst_deaths++ + pending++ + addtimer(CALLBACK(src, PROC_REF(piece_dies), piece_ref), max(dies_at - world.time, 0.5)) + +/// The tick after the hit: show the static, throw the arcs, shake the bearer. +/datum/component/chrome_emp_show/proc/start_burst() + var/mob/living/carbon/bearer = parent + bearer.update_body_parts() + var/list/regions = list() + for(var/datum/weakref/piece_ref as anything in crackling) + var/obj/item/organ/piece = piece_ref.resolve() + if(piece?.owner == bearer) + regions |= chrome_emp_region(piece) + sortTim(regions, GLOBAL_PROC_REF(cmp_text_asc)) + var/list/arcs = list() + if(length(regions) == 1) + arcs += "chrome_arc_[regions[1]]" + else + for(var/i in 1 to length(regions) - 1) + arcs += "chrome_arc_[regions[i]]_[regions[i + 1]]" + if(length(regions) >= 3) + arcs += "chrome_arc_[regions[1]]_[regions[length(regions)]]" + for(var/i in 1 to min(length(arcs), EMP_SHOW_MAX_ARCS)) + add_holder(cyberware_anim_play(bearer, CYBERWARE_ANIM_LARGE_ICON, arcs[i], CHROME_ANIM_TOP_LAYER, EMP_SHOW_ARC_TIME, glow = TRUE)) + bearer.do_jitter_animation(0) + var/turf/here = get_turf(bearer) + if(here) + new /obj/effect/temp_visual/emp(here) + step_done() + +/// One piece's turn to go dark, with a spark where it dies. +/datum/component/chrome_emp_show/proc/piece_dies(datum/weakref/piece_ref) + crackling -= piece_ref + var/obj/item/organ/piece = piece_ref.resolve() + var/mob/living/carbon/bearer = parent + if(piece?.owner == bearer) + var/datum/component/cyberware/chrome = piece.GetComponent(/datum/component/cyberware) + if(chrome?.emp_look == CHROME_LOOK_STATIC) + chrome.emp_look = null + bearer.update_body_parts() + add_holder(cyberware_anim_play(bearer, CYBERWARE_ANIM_LARGE_ICON, "chrome_spark_[chrome_emp_region(piece)]", CHROME_ANIM_TOP_LAYER, 0.2 SECONDS, glow = TRUE)) + step_done() + +/// A piece is back: bright, dark, bright, on. Pieces back on the same tick blink together. +/datum/component/chrome_emp_show/proc/blink(obj/item/organ/piece) + crackling -= WEAKREF(piece) + if(blink_started != world.time) + blink_started = world.time + blink_batch = list() + var/list/batch = blink_batch + pending += 3 + addtimer(CALLBACK(src, PROC_REF(blink_step), batch, CHROME_LOOK_DARK), 0.5) + addtimer(CALLBACK(src, PROC_REF(blink_step), batch, CHROME_LOOK_FLASH), 2) + addtimer(CALLBACK(src, PROC_REF(blink_step), batch, null), 2.5) + blink_batch |= WEAKREF(piece) + set_look(piece, CHROME_LOOK_FLASH) + var/mob/living/carbon/bearer = parent + bearer.update_body_parts() + +/datum/component/chrome_emp_show/proc/blink_step(list/batch, look) + var/mob/living/carbon/bearer = parent + for(var/datum/weakref/piece_ref as anything in batch) + var/obj/item/organ/piece = piece_ref.resolve() + var/datum/component/cyberware/chrome = piece?.GetComponent(/datum/component/cyberware) + // A piece hit again mid-blink belongs to the new burst now. + if(!chrome || piece.owner != bearer || chrome.emp_down) + continue + chrome.emp_look = look + bearer.update_body_parts() + step_done() + +/datum/component/chrome_emp_show/proc/add_holder(obj/effect/abstract/cyberware_anim/holder) + if(!holder) + return + holders += holder + RegisterSignal(holder, COMSIG_QDELETING, PROC_REF(on_holder_deleted)) + +/datum/component/chrome_emp_show/proc/on_holder_deleted(datum/source) + SIGNAL_HANDLER + holders -= source + +/datum/component/chrome_emp_show/proc/step_done() + pending-- + if(pending <= 0 && !length(crackling)) + qdel(src) + +#undef EMP_SHOW_FIRST_DEATH +#undef EMP_SHOW_DEATH_STAGGER +#undef EMP_SHOW_ARC_TIME +#undef EMP_SHOW_MAX_ARCS + +// ========================================================================= +// CLEANUP +// ========================================================================= + +/** + * Takes every animation this organ is running off [old_owner] and resets its + * looks. Called when the chrome leaves a body and when it is deleted. + */ +/obj/item/organ/proc/cyberware_anim_clear(mob/living/carbon/old_owner) + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(chrome) + chrome.emp_look = null + chrome.brownout_look = null + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + stop_blade_anim() + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade in contents) + blade.inhand_hidden_until = 0 + +/obj/item/organ/cyberimp/cyberware/ghostskin/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + cloak_enter_until = 0 + +/obj/item/organ/cyberimp/cyberware/cascade/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_node_looks() + +#undef CYBERWARE_ANIM_ICON +#undef CYBERWARE_ANIM_LARGE_ICON +#undef CYBERWARE_ANIM2_ICON +#undef CHROME_LOOK_STATIC +#undef CHROME_LOOK_DARK +#undef CHROME_LOOK_FLASH +#undef CHROME_LOOK_FOLD +#undef CHROME_LOOK_BROWN +#undef CHROME_LOOK_WARM +#undef CHROME_ANIM_BLADE_LAYER +#undef CHROME_ANIM_TOP_LAYER +#undef CHROME_ANIM_ARM_COVER_FILTER diff --git a/voidcrew/modules/cyberware/cyberware_anim2.dm b/voidcrew/modules/cyberware/cyberware_anim2.dm new file mode 100644 index 00000000000..7a799de2ca7 --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_anim2.dm @@ -0,0 +1,1045 @@ +/** + * # Chrome animations, second batch + * + * More motion on chrome that already works, built the same way as + * cyberware_anim.dm: every proc here only draws, and the mechanics call into + * it after they have already happened. Nothing here changes a number, a timer + * or who gets hit. + * + * - Redline Burn: nodes flash white and run red-hot, eyes flood red, a red + * halo breathes round the body and the roar rolls a ring across the floor. + * In the crash the outline gutters out, the spine runs white and the charge + * earths through the deck in arcs that fade; the nodes cool to grey and the + * body slumps a little. + * - Lazarus Node: a magenta charge crawls over the body through the windup, + * then a white jolt, a burst and a little spring as they come up. + * - Leaps: the Hopper crouches, kicks up dust and arcs over to where it lands, + * with a shadow running along the floor under it. The Piledriver glides to + * its mark and slams down with a ring and a cracked floor. + * - Weapon trails: a Scorch cut throws embers; the Widowline Arc's wire + * lashes out, a small crackle runs along it and sparks burst where it hit. + * - Brownout: worn chrome stutters, warms and dims together, and stutters + * back on when the load comes off. (The LEDs breathing at rest are part of + * the plain worn look in cyberware_anim.dm.) + * - Chrome cradle: the rig's arms come down on the servo beats, carry the ware + * in and sink it, and the patient glitches as it boots. + * + * Timers are one-shot and owned by the thing they animate, or global procs + * holding weak references, so nothing outlives what it draws on. + */ + +#define CYBERWARE_ANIM2_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim2.dmi' +#define CYBERWARE_ANIM2_LARGE_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi' +#define CHROME_CRADLE_RIG_ICON 'voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi' + +/// Layer for flashes, arcs and bursts: over everything the bearer wears. +#define CHROME_ANIM2_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) + +/// Worn looks shared with cyberware_anim.dm. +#define CHROME_LOOK_BROWN "brown" +#define CHROME_LOOK_WARM "warm" + +// ========================================================================= +// SHARED HELPERS +// ========================================================================= + +/// Plays a 64x64 state from the batch-2 sheet centred on [bearer]. +/proc/cyberware_anim2_play_large(atom/movable/bearer, state, lifetime, glow = TRUE) + var/obj/effect/abstract/cyberware_anim/holder = cyberware_anim_play(bearer, CYBERWARE_ANIM2_LARGE_ICON, state, CHROME_ANIM2_TOP_LAYER, lifetime, glow) + if(holder) + holder.pixel_x = -16 + holder.pixel_y = -16 + return holder + +/** + * A particle emitter riding [bearer] for [lifetime]. It stops making new + * particles after [emit_time] so the last ones fade out before it goes. + */ +/proc/cyberware_anim_particles(atom/movable/bearer, particles_type, anim_layer, emit_time, lifetime) + if(QDELETED(bearer)) + return null + var/obj/effect/abstract/cyberware_anim/holder = new(null) + holder.bearer = bearer + holder.icon = null + holder.layer = anim_layer + holder.particles = new particles_type + holder.RegisterSignal(bearer, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + bearer.vis_contents += holder + addtimer(CALLBACK(holder, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, stop_particles)), emit_time) + QDEL_IN(holder, lifetime) + return holder + +/obj/effect/abstract/cyberware_anim/proc/stop_particles() + if(particles) + particles.spawning = 0 + +/// Grey dust kicked up by a landing or a take-off. +/proc/cyberware_grey_dust(turf/where) + if(isnull(where)) + return null + var/obj/effect/temp_visual/mook_dust/dust = new(where) + dust.color = list(0.45, 0.45, 0.45, 0.45, 0.45, 0.45, 0.2, 0.2, 0.2, 0.33, 0.33, 0.35) + return dust + +#define CHROME_FLASH_FILTER "cyberware_flash" + +/// A white flash over [target] that fades back to its own colours. +/proc/cyberware_flash_fx(atom/movable/target, fade_time = 0.25 SECONDS) + if(QDELETED(target)) + return + var/static/list/flash = list(0.4, 0, 0, 0, 0, 0.4, 0, 0, 0, 0, 0.4, 0, 0, 0, 0, 1, 0.6, 0.6, 0.62, 0) + target.add_filter(CHROME_FLASH_FILTER, 43, color_matrix_filter(flash)) + // Beside whatever else is moving the target: a plain animate() on its filter would end a fall or a shove + // already under way, and they would snap to the floor. + animate(target.get_filter(CHROME_FLASH_FILTER), color = COLOR_MATRIX_IDENTITY, time = fade_time, flags = ANIMATION_PARALLEL) + // A rebuild of the target's filters lands on the faded end, not the flash. + target.filter_data[CHROME_FLASH_FILTER]["color"] = COLOR_MATRIX_IDENTITY + addtimer(CALLBACK(target, TYPE_PROC_REF(/datum, remove_filter), CHROME_FLASH_FILTER), fade_time + 0.5) + +#undef CHROME_FLASH_FILTER + +// ========================================================================= +// GLITCH (a patient booting new chrome) +// ========================================================================= + +#define CHROME_GLITCH_SLICES "cyberware_glitch_slices" +#define CHROME_GLITCH_RED "cyberware_glitch_red" +#define CHROME_GLITCH_CYAN "cyberware_glitch_cyan" + +/** + * Tears [target] into shifted slices with a red and cyan fringe and a burst + * of static over it, for [duration]. With [flash] it whites out first. With + * [inset] the static keeps to the target's own pixels, which needs a target + * drawn as one piece (KEEP_TOGETHER, as people are). + */ +/proc/cyberware_glitch_fx(atom/movable/target, duration = 0.25 SECONDS, flash = FALSE, inset = FALSE) + if(QDELETED(target)) + return + target.add_filters(list( + list("name" = CHROME_GLITCH_SLICES, "priority" = 40, "params" = displacement_map_filter(icon(CYBERWARE_ANIM2_ICON, "glitch_slices"), size = 3)), + list("name" = CHROME_GLITCH_RED, "priority" = 41, "params" = drop_shadow_filter(x = -1, y = 0, size = 0, color = "#ff2020a0")), + list("name" = CHROME_GLITCH_CYAN, "priority" = 42, "params" = drop_shadow_filter(x = 1, y = 0, size = 0, color = "#20e0ffa0")), + )) + // The slices jump about, then settle. + var/slices = target.get_filter(CHROME_GLITCH_SLICES) + animate(slices, y = 7, time = 0.5) + animate(y = 13, size = 2, time = 0.5) + animate(y = 3, size = 0, time = max(duration - 1, 0.5)) + target.filter_data[CHROME_GLITCH_SLICES]["size"] = 0 + if(flash) + cyberware_flash_fx(target, duration * 0.5) + var/obj/effect/abstract/cyberware_anim/tear = cyberware_anim_play(target, CYBERWARE_ANIM2_ICON, "glitch_tear", CHROME_ANIM2_TOP_LAYER, duration) + if(tear && inset) + tear.blend_mode = BLEND_INSET_OVERLAY + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_glitch_end), WEAKREF(target)), duration) + +/proc/cyberware_glitch_end(datum/weakref/target_ref) + var/atom/movable/target = target_ref?.resolve() + if(QDELETED(target)) + return + target.remove_filter(list(CHROME_GLITCH_SLICES, CHROME_GLITCH_RED, CHROME_GLITCH_CYAN)) + +#undef CHROME_GLITCH_SLICES +#undef CHROME_GLITCH_RED +#undef CHROME_GLITCH_CYAN + +// ========================================================================= +// REDLINE BURN +// ========================================================================= + +#define REDLINE_LOOK_FLASH "flash" +/// The crash's first instant: the whole core lit white down the spine. +#define REDLINE_LOOK_SURGE "surge" +#define REDLINE_LOOK_HOT "hot" +#define REDLINE_LOOK_COOL1 "cool1" +#define REDLINE_LOOK_COOL2 "cool2" +#define REDLINE_SAG_FILTER "cyberware_redline_sag" +/// How long the white kick-in flash holds before the nodes run red. +#define REDLINE_FLASH_TIME (0.15 SECONDS) +/// The crash: how long the spine runs white before the charge leaves it for the floor. +#define REDLINE_SPINE_FLASH (0.1 SECONDS) +/// From the discharge to the nodes going grey. +#define REDLINE_COOL_STEP (0.35 SECONDS) +/// The outline's last stutters, drawn by a filter of its own once the window has taken its outline away. +#define REDLINE_GUTTER_FILTER "cyberware_redline_gutter" +/// The window's outline colour (CYBERWARE_REDLINE_OUTLINE_COLOR, ware_legend.dm), lit and out. +#define REDLINE_GUTTER_ON "#ff2a2a" +#define REDLINE_GUTTER_OUT "#ff2a2a00" +/// How long that outline stands at full strength before its first stutter. +#define REDLINE_GUTTER_HOLD (0.1 SECONDS) +/// How long the arcs stand on the deck, how often they change, and how long their tracks take to glow, cool and +/// fade after them. +#define REDLINE_ARC_TIME (0.15 SECONDS) +#define REDLINE_ARC_FLICKER (0.05 SECONDS) +#define REDLINE_GLOW_TIME (2.2 SECONDS) +/// How long the body takes to straighten up out of the slump when the crash ends. +#define REDLINE_STRAIGHTEN_TIME (0.3 SECONDS) +/// The sheet of deck effects, 96 px and centred on the bearer's tile. +#define CYBERWARE_GROUND_ICON 'voidcrew/modules/cyberware/icons/cyberware_ground.dmi' +/// The eyes during the burn. +#define REDLINE_EYE_COLOR "#ff2a1a" +/// The eyes as the red drains out of them. +#define REDLINE_EYE_DYING "#8a2420" + +/obj/item/organ/cyberimp/cyberware/redline + /// The node look drawn over the core, or null for the plain one. + var/burn_look + /// Timer for the next node look. + var/burn_look_timer + /// Who carries our filters and our death watch. + var/datum/weakref/burn_fx_ref + /// The red light the burn throws. + var/datum/weakref/burn_light_ref + +/obj/item/organ/cyberimp/cyberware/redline/cyberware_worn_key() + return "[..()]-[burn_look]" + +/obj/item/organ/cyberimp/cyberware/redline/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + if(cyberware_worn_look() || !burn_look) + return ..() + var/state = "[aug_overlay]_[burn_look]" + if(!icon_exists(CYBERWARE_ANIM2_ICON, state)) + return ..() + // The node look replaces the core's own glow. + var/list/dressed = list() + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + continue + dressed += part + // Nodes that still glow shine through whatever is worn on the back; cold ones stay under it. + dressed += cyberware_spine_node_images(CYBERWARE_ANIM2_ICON, state, burn_look != REDLINE_LOOK_COOL2, image_layer, limb?.owner || limb) + if(burn_look == REDLINE_LOOK_HOT || burn_look == REDLINE_LOOK_FLASH || burn_look == REDLINE_LOOK_SURGE) + dressed += redline_eyes(limb, image_layer) + else if(burn_look == REDLINE_LOOK_COOL1) + // The crash: the red drains out of them. + dressed += redline_eyes(limb, image_layer, REDLINE_EYE_DYING, FALSE) + return dressed + +/// Red eyes for the burn, drawn from the bearer's own eye sprites so they sit where their eyes are. +/obj/item/organ/cyberimp/cyberware/redline/proc/redline_eyes(obj/item/bodypart/limb, image_layer, eye_color = REDLINE_EYE_COLOR, glow = TRUE) + var/mob/living/carbon/human/bearer = limb?.owner + if(!istype(bearer)) + return list() + var/obj/item/organ/eyes/eyes = bearer.get_organ_slot(ORGAN_SLOT_EYES) + var/obj/item/bodypart/head/head = bearer.get_bodypart(BODY_ZONE_HEAD) + if(!istype(eyes) || isnull(eyes.eye_icon_state) || !head) + return list() + if(bearer.check_obscured_slots() & ITEM_SLOT_EYES) + return list() + var/list/eye_images = list() + for(var/side in list("_l", "_r")) + var/mutable_appearance/eye = mutable_appearance('icons/mob/human/human_face.dmi', "[eyes.eye_icon_state][side]", image_layer, bearer) + eye.color = eye_color + if(head.worn_face_offset) + head.worn_face_offset.apply_offset(eye) + eye_images += eye + if(!glow) + continue + var/mutable_appearance/eye_glow = emissive_appearance(eye.icon, eye.icon_state, bearer, image_layer) + if(head.worn_face_offset) + head.worn_face_offset.apply_offset(eye_glow) + eye_images += eye_glow + return eye_images + +/obj/item/organ/cyberimp/cyberware/redline/proc/set_burn_look(look) + burn_look_timer = null + if(burn_look == look) + return + burn_look = look + owner?.update_body_parts() + +/obj/item/organ/cyberimp/cyberware/redline/proc/queue_burn_look(look, delay) + if(burn_look_timer) + deltimer(burn_look_timer) + burn_look_timer = addtimer(CALLBACK(src, PROC_REF(set_burn_look), look), delay, TIMER_STOPPABLE) + +/// The window opens: white-hot nodes, then red, red eyes and a red light. The red outline around the +/// body is the window's own (ware_legend.dm). +/obj/item/organ/cyberimp/cyberware/redline/proc/play_burn_window(mob/living/carbon/bearer) + if(QDELETED(bearer) || bearer != owner) + return + clear_burn_fx() + watch_burn_bearer(bearer) + set_burn_look(REDLINE_LOOK_FLASH) + queue_burn_look(REDLINE_LOOK_HOT, REDLINE_FLASH_TIME) + burn_light_ref = WEAKREF(bearer.mob_light(2, 0.7, "#ff3b22", burn_window + 2 SECONDS)) + +/// The roar: a red ring rolls out as far as it reaches. +/obj/item/organ/cyberimp/cyberware/redline/proc/play_burn_roar(mob/living/carbon/bearer, reach) + var/turf/here = get_turf(bearer) + if(here && reach > 0) + new /obj/effect/temp_visual/circle_wave/cyberware_roar(here, reach) + +/** + * The crash. The governor slams shut and the charge it was holding has to go somewhere: the outline gutters out, + * the red drains from the eyes, the spine runs white from neck to hips, and then the charge earths through the + * deck (see [/obj/item/organ/cyberimp/cyberware/redline/proc/ground_burn]). The body slumps. + */ +/obj/item/organ/cyberimp/cyberware/redline/proc/play_burn_crash(mob/living/carbon/bearer) + if(QDELETED(bearer) || bearer != owner) + return + stop_burn_glow(bearer) + watch_burn_bearer(bearer) + if(burn_look_timer) + deltimer(burn_look_timer) + set_burn_look(REDLINE_LOOK_SURGE) + burn_look_timer = addtimer(CALLBACK(src, PROC_REF(ground_burn)), REDLINE_SPINE_FLASH, TIMER_STOPPABLE) + // The window took its outline with it. Ours holds for a beat, then stutters out (gutter_burn). Both filters go + // on before the slump starts to move: adding a filter rebuilds every filter on the body from its stored values + // and drops whatever was animating. + bearer.add_filter(REDLINE_GUTTER_FILTER, 2, outline_filter(1, REDLINE_GUTTER_ON)) + bearer.add_filter(REDLINE_SAG_FILTER, 31, displacement_map_filter(icon(CYBERWARE_ANIM2_ICON, "sag_map"), size = 0)) + animate(bearer.get_filter(REDLINE_SAG_FILTER), size = 2, time = 0.3 SECONDS, easing = QUAD_EASING | EASE_OUT) + bearer.filter_data[REDLINE_SAG_FILTER]["size"] = 2 + addtimer(CALLBACK(src, PROC_REF(gutter_burn), WEAKREF(bearer), 1), REDLINE_GUTTER_HOLD) + +/** + * The outline's last stutters, one a call: out, back dimmer, out, back dimmer still, gone. + * + * Stepped on a timer and stored as it goes, not animated. A second filter animation started on a body in the same + * instant as the slump's does not play, and an outline left lit in its stored values comes back at full strength + * the next time anything rebuilds the body's filters. + */ +/obj/item/organ/cyberimp/cyberware/redline/proc/gutter_burn(datum/weakref/bearer_ref, step) + var/static/list/colours = list(REDLINE_GUTTER_OUT, "#ff2a2ab4", REDLINE_GUTTER_OUT, "#ff2a2a70") + var/static/list/holds = list(0.05 SECONDS, 0.1 SECONDS, 0.05 SECONDS, 0.05 SECONDS) + var/mob/living/carbon/bearer = bearer_ref?.resolve() + if(QDELETED(bearer) || !bearer.get_filter(REDLINE_GUTTER_FILTER)) + return + if(step > length(colours)) + bearer.remove_filter(REDLINE_GUTTER_FILTER) + return + bearer.modify_filter(REDLINE_GUTTER_FILTER, list("color" = colours[step])) + addtimer(CALLBACK(src, PROC_REF(gutter_burn), bearer_ref, step + 1), holds[step]) + +/** + * The charge leaves the spine for the floor. The nodes cool behind it, arcs crawl out across the deck under + * everybody's feet, and on a bearer still standing it runs down the legs first: behind the body, and in front of + * it only when the back is to the camera. + */ +/obj/item/organ/cyberimp/cyberware/redline/proc/ground_burn() + burn_look_timer = null + var/mob/living/carbon/bearer = burn_fx_ref?.resolve() + if(QDELETED(bearer) || bearer != owner) + return + set_burn_look(REDLINE_LOOK_COOL1) + queue_burn_look(REDLINE_LOOK_COOL2, REDLINE_COOL_STEP) + var/turf/deck = get_turf(bearer) + if(!isturf(bearer.loc) || isgroundlessturf(deck)) + return + var/lying = bearer.body_position == LYING_DOWN + new /obj/effect/temp_visual/cyberware_redline_ground(deck, lying) + playsound(deck, SFX_SPARKS, 40, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + if(lying) + return + var/obj/effect/abstract/cyberware_anim/leg = cyberware_anim_play(bearer, CYBERWARE_ANIM2_ICON, "redline_legarc", CHROME_ANIM2_TOP_LAYER, REDLINE_ARC_TIME, glow = TRUE) + if(leg && !(bearer.dir & NORTH)) + leg.vis_flags |= VIS_UNDERLAY + +/// Takes the light off; the nodes and the slump stay. +/obj/item/organ/cyberimp/cyberware/redline/proc/stop_burn_glow(mob/living/carbon/bearer) + qdel(burn_light_ref?.resolve()) + burn_light_ref = null + +/obj/item/organ/cyberimp/cyberware/redline/proc/watch_burn_bearer(mob/living/carbon/bearer) + burn_fx_ref = WEAKREF(bearer) + RegisterSignal(bearer, COMSIG_LIVING_DEATH, PROC_REF(on_burn_bearer_death), override = TRUE) + +/obj/item/organ/cyberimp/cyberware/redline/proc/on_burn_bearer_death(datum/source) + SIGNAL_HANDLER + clear_burn_fx() + +/// Back to the plain core: no node look, no halo, light or slump. [straighten] eases a living body up out of the +/// slump (the crash running its course); otherwise the slump goes at once. +/obj/item/organ/cyberimp/cyberware/redline/proc/clear_burn_fx(straighten = FALSE) + if(burn_look_timer) + deltimer(burn_look_timer) + burn_look_timer = null + var/mob/living/carbon/bearer = burn_fx_ref?.resolve() + burn_fx_ref = null + stop_burn_glow(bearer) + if(!QDELETED(bearer)) + UnregisterSignal(bearer, COMSIG_LIVING_DEATH) + bearer.remove_filter(REDLINE_GUTTER_FILTER) + var/sag = bearer.get_filter(REDLINE_SAG_FILTER) + if(sag && straighten && bearer.stat != DEAD) + animate(sag, size = 0, time = REDLINE_STRAIGHTEN_TIME, easing = QUAD_EASING | EASE_OUT) + bearer.filter_data[REDLINE_SAG_FILTER]["size"] = 0 + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_redline_straightened), WEAKREF(bearer)), REDLINE_STRAIGHTEN_TIME) + else + bearer.remove_filter(REDLINE_SAG_FILTER) + if(isnull(burn_look)) + return + burn_look = null + if(!QDELETED(bearer)) + bearer.update_body_parts() + +/// The body is upright again: the slump's filter, flat by now, comes off. A slump put on since is left alone. +/proc/cyberware_redline_straightened(datum/weakref/bearer_ref) + var/mob/living/carbon/bearer = bearer_ref?.resolve() + if(QDELETED(bearer) || bearer.filter_data?[REDLINE_SAG_FILTER]?["size"]) + return + bearer.remove_filter(REDLINE_SAG_FILTER) + +/// The core in the bearer's OS slot, if it is a Redline model. +/proc/cyberware_bearer_redline(mob/living/bearer) + if(!iscarbon(bearer)) + return null + var/mob/living/carbon/carbon_bearer = bearer + var/obj/item/organ/cyberimp/cyberware/redline/core = carbon_bearer.get_organ_slot(ORGAN_SLOT_CYBERWARE_OS) + return istype(core) ? core : null + +/// Hook for the window's on_apply(). +/proc/cyberware_redline_window_fx(mob/living/bearer) + cyberware_bearer_redline(bearer)?.play_burn_window(bearer) + +/// Hook for the roar, with the reach it was given. +/proc/cyberware_redline_roar_fx(mob/living/bearer, reach) + cyberware_bearer_redline(bearer)?.play_burn_roar(bearer, reach) + +/// Hook for the window's on_remove(), after it has (or hasn't) applied the crash. +/proc/cyberware_redline_window_end_fx(mob/living/bearer) + var/obj/item/organ/cyberimp/cyberware/redline/core = cyberware_bearer_redline(bearer) + if(core && !bearer.has_status_effect(/datum/status_effect/cyberware_redline_crash)) + core.clear_burn_fx() + +/// Hook for the crash's on_apply(). +/proc/cyberware_redline_crash_fx(mob/living/bearer) + cyberware_bearer_redline(bearer)?.play_burn_crash(bearer) + +/// Hook for the crash's on_remove(). +/proc/cyberware_redline_crash_end_fx(mob/living/bearer) + cyberware_bearer_redline(bearer)?.clear_burn_fx(straighten = TRUE) + +/obj/item/organ/cyberimp/cyberware/redline/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_burn_fx() + +/// The roar's ring, out as far as the roar reaches. It rolls across the floor, so it is drawn on the floor: under +/// everyone and everything standing in its way, whichever side of the bearer they are on. +/obj/effect/temp_visual/circle_wave/cyberware_roar + color = "#ff4a2a" + max_alpha = 200 + duration = 0.5 SECONDS + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + +/obj/effect/temp_visual/circle_wave/cyberware_roar/Initialize(mapload, reach = 5) + // The 64 px ring, scaled so its edge lands [reach] tiles out. + amount_to_scale = reach + 0.5 + return ..() + +/** + * The Redline's charge earthing through the deck. Arcs crawl out across the floor under everybody's feet and + * flicker; the tracks they leave glow, cool to a scorch and fade to nothing. Nothing of it outlasts its duration. + * + * It is floor: on the floor plane, and shifted with pixel_w and pixel_z so the 96 px picture stays on its own + * tile's row. Shifted with pixel_y it would sort a row forward, and its glow would light the bearer through. + */ +/obj/effect/temp_visual/cyberware_redline_ground + icon = CYBERWARE_GROUND_ICON + icon_state = "redline_arcs_standing_1" + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + pixel_w = -32 + pixel_z = -32 + randomdir = FALSE + duration = REDLINE_ARC_TIME + REDLINE_GLOW_TIME + light_range = 2 + light_power = 0.9 + light_color = "#ff5a3a" + /// Where on the tile the charge reaches the deck: "standing" at the feet, "lying" along the length of the back. + var/shape = "standing" + +/obj/effect/temp_visual/cyberware_redline_ground/Initialize(mapload, lying = FALSE) + . = ..() + shape = lying ? "lying" : "standing" + show_arcs(1) + addtimer(CALLBACK(src, PROC_REF(show_arcs), 2), REDLINE_ARC_FLICKER) + addtimer(CALLBACK(src, PROC_REF(show_arcs), 1), REDLINE_ARC_FLICKER * 2) + addtimer(CALLBACK(src, PROC_REF(leave_glow)), REDLINE_ARC_TIME) + +/// One of the two arc pictures, and the same picture again as its glow, so the two are never out of step. +/obj/effect/temp_visual/cyberware_redline_ground/proc/show_arcs(picture) + icon_state = "redline_arcs_[shape]_[picture]" + cut_overlays() + add_overlay(emissive_appearance(icon, icon_state, src)) + +/// The arcs are gone: their tracks glow, cool and fade. +/obj/effect/temp_visual/cyberware_redline_ground/proc/leave_glow() + icon_state = "redline_glow_[shape]" + cut_overlays() + add_overlay(emissive_appearance(icon, icon_state, src, alpha = 150)) + set_light(0) + animate(src, alpha = 255, time = REDLINE_GLOW_TIME * 0.15) + animate(color = "#6a5350", time = REDLINE_GLOW_TIME * 0.3) + animate(alpha = 0, time = REDLINE_GLOW_TIME * 0.5) + +#undef REDLINE_LOOK_FLASH +#undef REDLINE_LOOK_SURGE +#undef REDLINE_LOOK_HOT +#undef REDLINE_LOOK_COOL1 +#undef REDLINE_LOOK_COOL2 +#undef REDLINE_SAG_FILTER +#undef REDLINE_FLASH_TIME +#undef REDLINE_COOL_STEP +#undef REDLINE_SPINE_FLASH +#undef REDLINE_GUTTER_FILTER +#undef REDLINE_GUTTER_ON +#undef REDLINE_GUTTER_OUT +#undef REDLINE_GUTTER_HOLD +#undef REDLINE_ARC_TIME +#undef REDLINE_ARC_FLICKER +#undef REDLINE_STRAIGHTEN_TIME +#undef REDLINE_GLOW_TIME +#undef REDLINE_EYE_DYING +#undef CYBERWARE_GROUND_ICON +#undef REDLINE_EYE_COLOR + +// ========================================================================= +// LAZARUS NODE +// ========================================================================= + +#define LAZARUS_SPRING_OFFSET "cyberware_lazarus_spring" +#define LAZARUS_CHARGE_FILTER "cyberware_lazarus_charge" + +/obj/item/organ/cyberimp/cyberware/lazarus + /// Who carries the charge's magenta halo. + var/datum/weakref/charge_fx_ref + /// The charge glow on the chest through the windup. + var/datum/weakref/charge_glow_ref + /// The arcs crawling over the body through the windup. + var/datum/weakref/charge_arcs_ref + /// The magenta light of the charge. + var/datum/weakref/charge_light_ref + +/// The windup: the node charges, magenta arcs crawl out over the body. +/obj/item/organ/cyberimp/cyberware/lazarus/proc/play_charge_fx(mob/living/carbon/patient, windup) + clear_charge_fx() + if(QDELETED(patient)) + return + charge_glow_ref = WEAKREF(cyberware_anim_play(patient, CYBERWARE_ANIM2_ICON, "lazarus_charge", CHROME_ANIM2_TOP_LAYER, windup, glow = TRUE)) + charge_arcs_ref = WEAKREF(cyberware_anim2_play_large(patient, "lazarus_arcs", windup)) + charge_light_ref = WEAKREF(patient.mob_light(1.5, 0.8, "#e700e3", windup)) + // The whole body takes on the charge, brighter as it fills. + charge_fx_ref = WEAKREF(patient) + patient.add_filter(LAZARUS_CHARGE_FILTER, 33, drop_shadow_filter(x = 0, y = 0, size = 1, color = "#e700e340")) + animate(patient.get_filter(LAZARUS_CHARGE_FILTER), size = 2.5, color = "#e700e3b0", time = windup, easing = QUAD_EASING | EASE_IN) + patient.filter_data[LAZARUS_CHARGE_FILTER]["size"] = 2.5 + patient.filter_data[LAZARUS_CHARGE_FILTER]["color"] = "#e700e3b0" + +/// Takes the charge off whoever it is on. +/obj/item/organ/cyberimp/cyberware/lazarus/proc/clear_charge_fx() + qdel(charge_glow_ref?.resolve()) + qdel(charge_arcs_ref?.resolve()) + qdel(charge_light_ref?.resolve()) + charge_glow_ref = null + charge_arcs_ref = null + charge_light_ref = null + var/mob/living/carbon/patient = charge_fx_ref?.resolve() + charge_fx_ref = null + if(!QDELETED(patient)) + patient.remove_filter(LAZARUS_CHARGE_FILTER) + +/// The jolt: a white flash, a burst and a light, and a little spring off the floor. +/obj/item/organ/cyberimp/cyberware/lazarus/proc/play_jolt_fx(mob/living/carbon/patient) + clear_charge_fx() + if(QDELETED(patient)) + return + cyberware_anim2_play_large(patient, "lazarus_jolt", 0.5 SECONDS) + patient.mob_light(2.5, 1.4, "#ffe6ff", 0.3 SECONDS) + cyberware_flash_fx(patient, 0.25 SECONDS) + patient.add_offsets(LAZARUS_SPRING_OFFSET, z_add = 3, animate = FALSE) + addtimer(CALLBACK(patient, TYPE_PROC_REF(/mob/living, remove_offsets), LAZARUS_SPRING_OFFSET), 0.5) + +/obj/item/organ/cyberimp/cyberware/lazarus/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_charge_fx() + +#undef LAZARUS_SPRING_OFFSET +#undef LAZARUS_CHARGE_FILTER + +// ========================================================================= +// LEAPS +// ========================================================================= + +/// Set at take-off to when the leap should be down. A leap whose landing never +/// runs is put back on the floor shortly after. +/mob/living/var/cyberware_leap_until = 0 +/// How the body's steps were animated before a leap switched that off, to put back once it has landed. +/mob/living/var/cyberware_leap_steps + +/// Where a leaping body draws among the things on its row: over anyone standing there, since it is above them. +#define CYBERWARE_LEAP_LAYER (ABOVE_MOB_LAYER + 0.05) +/// How long a leap that holds its height takes to come down at the end. +#define CYBERWARE_LEAP_SLAM (0.1 SECONDS) + +/// Where the body sits at rest, before any leap offset. +/proc/cyberware_leap_rest(mob/living/jumper, pixel) + var/base = jumper.base_pixel_z + switch(pixel) + if(PIXEL_W_OFFSET) + base = jumper.base_pixel_w + if(PIXEL_Y_OFFSET) + base = jumper.base_pixel_y + return base + jumper.has_offset(pixel = pixel) + +/** + * Take-off. The body arcs from [launch] over to [landing] and is down on the landing tile when [airtime] ends, so + * the landing's dust, ring and sound meet it there. With [height] under [peak] it rises to [peak] halfway and + * falls; with [height] at [peak] it climbs, holds that height over to its mark and drops in the last + * CYBERWARE_LEAP_SLAM of the flight. The body stays on its own tile until the landing moves it; only the drawing + * travels. + * + * The fall is started by a timer of its own, on the same clock as the landing: a server running slow holds the + * body up until its landing is nearly due, and never stands it on the tile ahead of its own impact. + * + * The run along the floor goes at an even pace and only the height eases, so the body never hangs at the top of + * its arc. The run up and down the screen is pixel_y, which carries the body's place in the front-to-back order + * along with it: it draws in front of what it has passed and behind what it hasn't. The height is pixel_z, which + * changes nothing but the drawing. On its row it draws over whoever stands there. + */ +/proc/cyberware_leap_takeoff_fx(mob/living/jumper, turf/launch, turf/landing, airtime, height, peak, obj/effect/afterimage) + if(QDELETED(jumper)) + return + if(afterimage) + // The crouch the pistons fired out of. + var/matrix/squash = matrix(afterimage.transform) + squash.Scale(1.08, 0.86) + squash.Translate(0, -2) + afterimage.transform = squash + cyberware_grey_dust(launch) + var/dx = 0 + var/dy = 0 + if(launch && landing) + dx = (landing.x - launch.x) * ICON_SIZE_X + dy = (landing.y - launch.y) * ICON_SIZE_Y + var/rest_w = cyberware_leap_rest(jumper, PIXEL_W_OFFSET) + var/rest_y = cyberware_leap_rest(jumper, PIXEL_Y_OFFSET) + var/rest_z = cyberware_leap_rest(jumper, PIXEL_Z_OFFSET) + if(jumper.layer == initial(jumper.layer) || jumper.layer == LYING_MOB_LAYER) + jumper.layer = CYBERWARE_LEAP_LAYER + // The landing moves the body onto a tile its drawing has already reached, so that move must not glide. The + // client only honours this for moves that come after it has heard of it, so it is set here, not at the landing. + if(jumper.animate_movement != NO_STEPS) + jumper.cyberware_leap_steps = jumper.animate_movement + jumper.animate_movement = NO_STEPS + animate(jumper, pixel_w = rest_w + dx, pixel_y = rest_y + dy, time = airtime, flags = ANIMATION_PARALLEL) + var/fall = airtime * 0.5 + var/rise = airtime * 0.5 + if(height >= peak) + fall = min(CYBERWARE_LEAP_SLAM, airtime * 0.5) + rise = (airtime - fall) * 0.4 + animate(jumper, pixel_z = rest_z + peak, time = rise, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_leap_fall_fx), WEAKREF(jumper), fall), airtime - fall) + if(dx || dy) + new /obj/effect/temp_visual/cyberware_leap_shadow(launch, dx, dy, airtime) + jumper.cyberware_leap_until = world.time + airtime + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_leap_failsafe), WEAKREF(jumper)), airtime + 0.5 SECONDS) + +/// The last of a leap: the body comes down over its landing tile, arriving as the landing is due. +/proc/cyberware_leap_fall_fx(datum/weakref/jumper_ref, fall) + var/mob/living/jumper = jumper_ref?.resolve() + if(QDELETED(jumper) || !jumper.cyberware_leap_until) + return + animate(jumper, pixel_z = cyberware_leap_rest(jumper, PIXEL_Z_OFFSET), time = fall, easing = QUAD_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + +/** + * Touchdown: the arc has brought the body down over its landing tile, and the drawing goes back to the body. The + * move onto that tile, which the caller makes right after this, must not glide: the drawing is already there. + */ +/proc/cyberware_leap_touchdown_fx(mob/living/jumper) + if(QDELETED(jumper)) + return + jumper.cyberware_leap_until = 0 + if(jumper.layer == CYBERWARE_LEAP_LAYER) + jumper.layer = jumper.body_position == LYING_DOWN ? LYING_MOB_LAYER : initial(jumper.layer) + // Ends every track of the arc where it stands. + animate(jumper, pixel_w = cyberware_leap_rest(jumper, PIXEL_W_OFFSET), pixel_y = cyberware_leap_rest(jumper, PIXEL_Y_OFFSET), pixel_z = cyberware_leap_rest(jumper, PIXEL_Z_OFFSET), time = 0, flags = ANIMATION_END_NOW) + // Steps glide again once the landing's own move has gone out. + if(!isnull(jumper.cyberware_leap_steps)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_leap_steps_back), WEAKREF(jumper)), 0.2 SECONDS) + +/// Gives a landed body its step animation back, unless it is already in the air again. +/proc/cyberware_leap_steps_back(datum/weakref/jumper_ref) + var/mob/living/jumper = jumper_ref?.resolve() + if(QDELETED(jumper) || jumper.cyberware_leap_until || isnull(jumper.cyberware_leap_steps)) + return + jumper.animate_movement = jumper.cyberware_leap_steps + jumper.cyberware_leap_steps = null + +/// A leap whose landing never ran (the pistons were pulled mid-air) is put back down. +/proc/cyberware_leap_failsafe(datum/weakref/jumper_ref) + var/mob/living/jumper = jumper_ref?.resolve() + if(QDELETED(jumper) || !jumper.cyberware_leap_until || world.time < jumper.cyberware_leap_until) + return + cyberware_leap_touchdown_fx(jumper) + +/// The Piledriver's impact: a ring rolls out over the shockwave and the floor cracks. +/proc/cyberware_piledriver_impact_fx(turf/where, reach) + if(isnull(where)) + return + new /obj/effect/temp_visual/circle_wave/cyberware_slam/floor(where, reach) + new /obj/effect/temp_visual/cyberware_impact_cracks(where, "damaged1", 255) + for(var/direction in GLOB.cardinals) + var/turf/next = get_step(where, direction) + if(next) + new /obj/effect/temp_visual/cyberware_impact_cracks(next, "damaged5", 140) + +/// The shadow that runs along the floor under a leap. +/obj/effect/temp_visual/cyberware_leap_shadow + icon = CYBERWARE_ANIM2_ICON + icon_state = "leap_shadow" + layer = BELOW_MOB_LAYER + randomdir = FALSE + duration = 0.3 SECONDS + +/obj/effect/temp_visual/cyberware_leap_shadow/Initialize(mapload, dx = 0, dy = 0, airtime = 0.2 SECONDS) + duration = airtime + 0.1 SECONDS + . = ..() + animate(src, pixel_x = dx, pixel_y = dy, time = airtime) + animate(src, transform = matrix().Scale(0.7), time = airtime * 0.5, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(transform = matrix(), time = airtime * 0.5, easing = SINE_EASING | EASE_IN) + +/// The Piledriver's ring: grey, out over the shockwave. +/obj/effect/temp_visual/circle_wave/cyberware_slam + color = "#d8d4cc" + max_alpha = 220 + duration = 0.4 SECONDS + layer = BELOW_MOB_LAYER + +/obj/effect/temp_visual/circle_wave/cyberware_slam/Initialize(mapload, reach = 2) + amount_to_scale = reach + 0.5 + return ..() + +/// The same ring drawn on the floor, under everyone and everything standing in its way, whichever side of the +/// landing they are on. The Piledriver's own; the plain one is also what a Gorilla fist throws. +/obj/effect/temp_visual/circle_wave/cyberware_slam/floor + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + +/// Cracks the Piledriver leaves in the floor. They fade on their own. +/obj/effect/temp_visual/cyberware_impact_cracks + icon = 'icons/turf/damaged.dmi' + icon_state = "damaged1" + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + randomdir = FALSE + duration = 8 SECONDS + +/obj/effect/temp_visual/cyberware_impact_cracks/Initialize(mapload, state = "damaged1", peak_alpha = 255) + . = ..() + var/turf/here = loc + // Nothing to crack in space, open air or a chasm. + if(!isfloorturf(here)) + return INITIALIZE_HINT_QDEL + icon_state = state + alpha = peak_alpha + animate(src, alpha = peak_alpha, time = duration - 2 SECONDS) + animate(alpha = 0, time = 2 SECONDS) + +// ========================================================================= +// WEAPON TRAILS +// ========================================================================= + +/// A landed Scorch cut: embers fly off whoever it cut. The burning slash itself +/// is the blade's impact (cyberware_anim.dm), so nothing else is drawn on top. +/proc/cyberware_scorch_trail_fx(mob/living/user, mob/living/target) + var/turf/here = get_turf(target) + if(isnull(here)) + return + new /obj/effect/temp_visual/cyberware_scorch_embers(here) + +/// Only the embers: the picture is blank. +/obj/effect/temp_visual/cyberware_scorch_embers + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 0.7 SECONDS + +/obj/effect/temp_visual/cyberware_scorch_embers/Initialize(mapload) + . = ..() + particles = new /particles/embers/spark/cyberware_scorch + addtimer(CALLBACK(src, PROC_REF(stop_embers)), 1) + +/obj/effect/temp_visual/cyberware_scorch_embers/proc/stop_embers() + if(particles) + particles.spawning = 0 + +/// One burst of embers off a Scorch cut. +/particles/embers/spark/cyberware_scorch + count = 8 + spawning = 8 + lifespan = 0.6 SECONDS + fade = 0.4 SECONDS + velocity = generator(GEN_CIRCLE, 2, 3, SQUARE_RAND) + gravity = list(0, -0.15) + +/// A landed Widowline swing: the wire lashes out to whatever it hit, a thin +/// line in the wire's own colour ([state]) held for half a second. It leaves +/// the hand at ([origin_offset_x], [origin_offset_y]) from the middle of the +/// user's tile, and draws at [beam_layer] (under the body for a hand behind it). +/proc/cyberware_monowire_lash_fx(mob/living/user, atom/target, origin_offset_x = 0, origin_offset_y = 0, state = "monowire_lash", beam_layer = ABOVE_ALL_MOB_LAYER) + if(QDELETED(user) || QDELETED(target) || get_turf(user) == get_turf(target)) + return + user.Beam(target, icon_state = state, icon = CYBERWARE_ANIM2_ICON, time = 0.5 SECONDS, beam_type = /obj/effect/ebeam/wire_line, override_origin_pixel_x = user.pixel_x + origin_offset_x, override_origin_pixel_y = user.pixel_y + origin_offset_y, layer = beam_layer) + +/** + * The Arc's shock: a small crackle where the wire touched. No bolt, no flash, no sparks thrown about: the lash out to + * the target is the wire (wire_lash_at()). On a body it crackles at the part the wire cut (`zone`, else the middle); + * a body nearer the viewer than the wielder is touched on its far side, so it crackles behind that body. + */ +/proc/cyberware_arc_crackle_fx(mob/living/user, atom/target, zone) + if(QDELETED(user) || QDELETED(target)) + return + var/turf/there = get_turf(target) + var/turf/here = get_turf(user) + if(!there || !here) + return + var/obj/effect/temp_visual/cyberware_arc_crackle/crackle = new(there) + var/list/point = list(target.pixel_x, target.pixel_y) + if(isliving(target)) + point = wire_zone_point(target, zone || BODY_ZONE_CHEST) + // A visual shift only (pixel_w/pixel_z): it stays on the target's tile in the map's front-to-back order. + crackle.pixel_w = point[1] + crackle.pixel_z = point[2] + if(there.y < here.y && isliving(target)) + crackle.layer = BELOW_MOB_LAYER + +/obj/effect/temp_visual/cyberware_arc_crackle + icon = CYBERWARE_ANIM2_ICON + icon_state = "arc_crackle" + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 0.3 SECONDS + +/obj/effect/temp_visual/cyberware_arc_crackle/Initialize(mapload) + . = ..() + add_overlay(emissive_appearance(icon, icon_state, src)) + +// ========================================================================= +// BROWNOUT +// ========================================================================= + +/// Step between the frames of a brownout stutter. +#define BROWNOUT_STEP (0.1 SECONDS) + +/** + * The brownout picture for one bearer: every worn piece stutters, warms and + * dims together on the way down, and stutters back on on the way up. The + * brownout itself has already happened; this only plays the picture, and + * clearing it at any point leaves each piece drawn as its state says. + */ +/datum/component/chrome_brownout_show + dupe_mode = COMPONENT_DUPE_UNIQUE + /// Looks still to play, in order. + var/list/steps + /// Timer for the next look. + var/step_timer + /// Pieces whose look this show has set. + var/list/datum/weakref/touched = list() + +/datum/component/chrome_brownout_show/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/chrome_brownout_show/RegisterWithParent() + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/chrome_brownout_show/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_LIVING_DEATH) + +/datum/component/chrome_brownout_show/Destroy() + if(step_timer) + deltimer(step_timer) + step_timer = null + var/changed = FALSE + for(var/datum/weakref/piece_ref as anything in touched) + var/obj/item/organ/piece = piece_ref.resolve() + var/datum/component/cyberware/chrome = piece?.GetComponent(/datum/component/cyberware) + if(chrome?.brownout_look) + chrome.brownout_look = null + changed = TRUE + touched.Cut() + var/mob/living/carbon/bearer = parent + if(changed && !QDELETED(bearer)) + bearer.update_body_parts() + return ..() + +/datum/component/chrome_brownout_show/proc/on_death(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// Starts the stutter: down when [tripped], back up otherwise. +/datum/component/chrome_brownout_show/proc/play(tripped) + if(tripped) + steps = list(CHROME_LOOK_WARM, CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN, CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN) + else + steps = list(CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN, CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN) + if(step_timer) + deltimer(step_timer) + step_timer = null + advance() + +/datum/component/chrome_brownout_show/proc/advance() + step_timer = null + if(!length(steps)) + qdel(src) + return + var/look = popleft(steps) + var/mob/living/carbon/bearer = parent + for(var/obj/item/organ/piece as anything in get_installed_cyberware(bearer)) + var/obj/item/organ/cyberimp/implant = piece + if(!istype(implant) || !implant.bodypart_aug) + continue + var/datum/component/cyberware/chrome = piece.GetComponent(/datum/component/cyberware) + if(!chrome) + continue + chrome.brownout_look = look + touched |= WEAKREF(piece) + bearer.update_body_parts() + step_timer = addtimer(CALLBACK(src, PROC_REF(advance)), BROWNOUT_STEP, TIMER_STOPPABLE) + +/// Hook for the brownout monitor: [bearer]'s chrome just went down ([tripped]) or came back. +/proc/cyberware_brownout_fx(mob/living/carbon/bearer, tripped) + if(!iscarbon(bearer) || QDELETED(bearer)) + return + if(bearer.stat == DEAD) + // No show on a corpse: just draw it as it is. + bearer.update_body_parts() + return + var/datum/component/chrome_brownout_show/show = bearer.GetComponent(/datum/component/chrome_brownout_show) || bearer.AddComponent(/datum/component/chrome_brownout_show) + show?.play(tripped) + +#undef BROWNOUT_STEP + +// ========================================================================= +// CHROME CRADLE RIG +// ========================================================================= + +/// Layers on the cradle: the rig over the ware, both over the patient. +#define RIG_LAYER (ABOVE_MOB_LAYER + 0.02) +#define RIG_WARE_LAYER (ABOVE_MOB_LAYER + 0.01) +/// The gripper's tip, as pixel offsets of the carried ware's centre on the cradle tile: +/// tucked in the rig's head, then down on the patient's chest. Matches the rig sheet's gripper. +#define RIG_WARE_X 1 +#define RIG_WARE_PARKED_Y 21 +#define RIG_WARE_WORK_Y -4 +/// How long each move of the rig takes, matching its art. +#define RIG_GRIP_TIME (0.6 SECONDS) +#define RIG_PRESS_TIME (0.4 SECONDS) +#define RIG_RETRACT_TIME (0.3 SECONDS) + +/obj/machinery/chrome_cradle + /// The rig's current move, or null while it is parked. + var/rig_phase + /// The rig drawing that move. + var/datum/weakref/rig_ref + /// The ware in the gripper. + var/datum/weakref/rig_ware_ref + /// Timer that parks the rig once it has pulled back. + var/rig_park_timer + +/obj/machinery/chrome_cradle/update_overlays() + . = ..() + // The plinth and mast the rig stands on, behind the slab and under the patient. + var/mutable_appearance/frame = mutable_appearance(CHROME_CRADLE_RIG_ICON, "frame", layer - 0.01) + frame.pixel_x = -16 + frame.pixel_y = -16 + . += frame + if(rig_phase) + return + // The parked rig, over whoever lies on the slab. + var/mutable_appearance/rig = mutable_appearance(CHROME_CRADLE_RIG_ICON, "rig", RIG_LAYER) + rig.pixel_x = -16 + rig.pixel_y = -16 + . += rig + var/mutable_appearance/rig_glow = emissive_appearance(CHROME_CRADLE_RIG_ICON, "rig_e", src, RIG_LAYER) + rig_glow.pixel_x = -16 + rig_glow.pixel_y = -16 + . += rig_glow + +/// Plays one move of the rig: [phase] is a state of the rig sheet without its "rig_". +/obj/machinery/chrome_cradle/proc/play_rig(phase) + if(rig_park_timer) + deltimer(rig_park_timer) + rig_park_timer = null + qdel(rig_ref?.resolve()) + var/was_parked = isnull(rig_phase) + rig_phase = phase + if(was_parked) + update_appearance(UPDATE_OVERLAYS) + var/state = "rig_[phase]" + var/obj/effect/abstract/cyberware_anim/rig = cyberware_anim_play(src, CHROME_CRADLE_RIG_ICON, state, RIG_LAYER, CYBERWARE_INSTALL_TIME + 1 SECONDS) + if(!rig) + return + rig.pixel_x = -16 + rig.pixel_y = -16 + rig.add_overlay(emissive_appearance(CHROME_CRADLE_RIG_ICON, "[state]_e", src)) + rig_ref = WEAKREF(rig) + +/// A servo beat of the install: the arms come down, the gripper brings the ware, then presses it in. +/obj/machinery/chrome_cradle/proc/rig_beat(mid_stage) + if(busy_action != "install") + return + if(mid_stage) + play_rig("press") + var/obj/effect/abstract/cyberware_anim/carried = rig_ware_ref?.resolve() + if(carried) + animate(carried, pixel_y = RIG_WARE_WORK_Y - 3, transform = matrix().Scale(0.2), alpha = 0, time = RIG_PRESS_TIME, easing = QUAD_EASING | EASE_IN) + return + if(isnull(rig_phase) || rig_phase == "retract") + play_rig("arms_down") + return + play_rig("grip_down") + qdel(rig_ware_ref?.resolve()) + if(QDELETED(busy_ware)) + return + var/obj/effect/abstract/cyberware_anim/carried = cyberware_anim_play(src, busy_ware.icon, busy_ware.icon_state, RIG_WARE_LAYER, CYBERWARE_INSTALL_TIME) + if(!carried) + return + carried.transform = matrix().Scale(0.45) + carried.pixel_x = RIG_WARE_X + carried.pixel_y = RIG_WARE_PARKED_Y + // It comes out of the head: unseen at first, in view as it clears the pod. + carried.alpha = 0 + animate(carried, pixel_y = RIG_WARE_WORK_Y, time = RIG_GRIP_TIME, easing = CUBIC_EASING | EASE_OUT) + animate(carried, alpha = 255, time = 0.1 SECONDS, flags = ANIMATION_PARALLEL) + rig_ware_ref = WEAKREF(carried) + +/// The sequence is over, done or not: the rig pulls back and parks. +/obj/machinery/chrome_cradle/proc/rig_stop() + qdel(rig_ware_ref?.resolve()) + rig_ware_ref = null + if(isnull(rig_phase) || rig_phase == "retract") + return + play_rig("retract") + rig_park_timer = addtimer(CALLBACK(src, PROC_REF(park_rig)), RIG_RETRACT_TIME, TIMER_STOPPABLE) + +/obj/machinery/chrome_cradle/proc/park_rig() + rig_park_timer = null + qdel(rig_ref?.resolve()) + rig_ref = null + rig_phase = null + update_appearance(UPDATE_OVERLAYS) + +/// The ware boots in its new owner: a glitch runs over them. +/obj/machinery/chrome_cradle/proc/rig_boot_fx(mob/living/carbon/patient) + cyberware_glitch_fx(patient, 0.2 SECONDS, flash = TRUE, inset = TRUE) + +#undef RIG_LAYER +#undef RIG_WARE_LAYER +#undef RIG_WARE_X +#undef RIG_WARE_PARKED_Y +#undef RIG_WARE_WORK_Y +#undef RIG_GRIP_TIME +#undef RIG_PRESS_TIME +#undef RIG_RETRACT_TIME + +#undef CYBERWARE_LEAP_LAYER +#undef CYBERWARE_LEAP_SLAM +#undef CYBERWARE_ANIM2_ICON +#undef CYBERWARE_ANIM2_LARGE_ICON +#undef CHROME_CRADLE_RIG_ICON +#undef CHROME_ANIM2_TOP_LAYER +#undef CHROME_LOOK_BROWN +#undef CHROME_LOOK_WARM diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 367f077f491..0836cb99609 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -20,38 +20,88 @@ * (autosurgeon.dm's "insertion failed!" path). */ /** - * The worn-chrome overlay: tg's augment overlay, drawn one notch higher. - * Stock augments sit at BODY_ADJ_LAYER, which the character-setup underwear - * layer paints over, a bra would cover a Cascade spine rig. Chrome instead - * draws at CYBERWARE_WORN_LAYER: over underwear and undershirts, still under - * eyes, damage, and every EQUIPPED clothing layer. One override covers the - * images, the emissive twins and the emissive blockers alike, because they - * all take their layer from bitflag_to_layer(). + * The worn-chrome overlay: tg's augment overlay, drawn higher. Stock augments + * sit at BODY_ADJ_LAYER, under underwear and every piece of clothing. Chrome + * draws at CYBERWARE_WORN_LAYER instead: over the jumpsuit, still under hands, + * gloves, shoes and suits. One override covers the images, the emissive twins + * and the emissive blockers alike, because they all take their layer from + * bitflag_to_layer(). + * + * Plating grown into the skin (worn_under_clothes) is skin: it stays where + * stock augments sit, under everything that is worn. */ /datum/bodypart_overlay/augment/cyberware /datum/bodypart_overlay/augment/cyberware/bitflag_to_layer(layer) if(layer == EXTERNAL_ADJACENT) + var/obj/item/organ/cyberimp/cyberware/ware = implant + if(istype(ware) && ware.worn_under_clothes) + return ..() return -CYBERWARE_WORN_LAYER return ..() +// Arm hardware whose worn sprite draws the folded item (the Mantis line) is not +// drawn while that item is out in a hand. Hiding here also drops the overlay from +// the limb's icon key, so update_body_parts() redraws the arm on the flip. +/datum/bodypart_overlay/augment/cyberware/can_draw_on_bodypart(obj/item/bodypart/bodypart_owner) + // A bearer being deleted can lose its chrome before its clothes, and taking + // the clothes off redraws the limbs while this overlay is still on them. + if(!implant) + return FALSE + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = implant + if(istype(arm_ware) && arm_ware.worn_overlay_hidden()) + return FALSE + return ..() + +/** + * The parent puts an emissive blocker over every image it draws, the glow + * included. Same layer, later in the list, so a Cascade or Redline would black + * out its own glow. Glows skip the blocker and go last, after every blocker of + * this overlay, the same order tg's worn clothing uses. + */ +/datum/bodypart_overlay/augment/cyberware/get_overlay(layer, obj/item/bodypart/limb) + layer = bitflag_to_layer(layer) + var/list/imageset = implant.get_overlay(layer, limb) + // EMP static, dark and blink looks, the folding housing, the spine nodes (cyberware_anim.dm). + imageset = implant.cyberware_dress_worn(imageset, layer, limb) + if(blocks_emissive == EMISSIVE_BLOCK_NONE || !limb) + return imageset + + var/list/all_images = list() + var/list/glows = list() + for(var/image/overlay as anything in imageset) + if(PLANE_TO_TRUE(overlay.plane) == EMISSIVE_PLANE) + glows += overlay + continue + all_images += overlay + all_images += emissive_blocker(overlay.icon, overlay.icon_state, limb, layer = overlay.layer, alpha = overlay.alpha) + return all_images + glows + +/// The animation look is part of the limb's render key, so changing it redraws the limb. +/datum/bodypart_overlay/augment/cyberware/generate_icon_cache() + . = ..() + if(implant) + . += implant.cyberware_worn_key() + /obj/item/organ/cyberimp/cyberware name = "cyberware" desc = "Aftermarket chrome. Someone sat in a parlor chair for this." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' // Worn look: ware that reads from outside the body sets aug_overlay to a // state in this sheet and tg's bodypart-overlay pipeline draws it ON the - // bearer, over the character-setup underwear, under equipped clothing. + // bearer, over the jumpsuit, under suits and armour. // Stack enough chrome and you stop looking human. aug_icon = 'voidcrew/modules/cyberware/icons/cyberware_worn.dmi' organ_flags = ORGAN_ROBOTIC - failing_desc = "is dark and inert, browned out, EMP-scrambled, or plain broken." + failing_desc = "is dark and inert." /// Neural load this ware puts on its bearer. 0-12; see the tier bands. var/chrome_load = 1 /// CYBERWARE_TIER_*, drives accent colours and the parlor experience. var/tier = CYBERWARE_TIER_1 /// Chrome capacity this ware grants while installed (the Governor hook). var/chrome_capacity_bonus = 0 + /// TRUE for ware that is part of the skin: its worn look draws under clothes, not over the jumpsuit. + var/worn_under_clothes = FALSE /obj/item/organ/cyberimp/cyberware/Initialize(mapload) . = ..() @@ -121,11 +171,17 @@ * Every optic on this base carries the chrome read (cyberware_scan.dm), the * diagnostic bus is what makes chrome eyes chrome eyes. Subtypes that add * their own ability must keep the read in their actions_types list. + * + * Every optic also zooms (cyberware_zoom.dm). That button is added in + * Initialize() whenever optics_zoom is above 0, so it never goes in + * actions_types. */ /obj/item/organ/eyes/robotic/cyberware name = "cyberware optics" desc = "Aftermarket eyes. The irises catch the light in a way real ones don't." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + // The base is never sold; it borrows the plainest optics' sprite so it has one. + icon_state = "nightshade" actions_types = list(/datum/action/cooldown/cyberware/chrome_read) /// Neural load this ware puts on its bearer. var/chrome_load = 1 @@ -140,6 +196,8 @@ /obj/item/organ/eyes/robotic/cyberware/Initialize(mapload) . = ..() AddComponent(/datum/component/cyberware, chrome_load, tier, chrome_capacity_bonus) + // Zoom rides every optic on this base (cyberware_zoom.dm). + setup_optics_zoom() /obj/item/organ/eyes/robotic/cyberware/Destroy() var/turf/drop_turf = get_turf(src) @@ -198,6 +256,8 @@ name = "arm cyberware" desc = "Aftermarket arm hardware. Folds away until you want it." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + // The base is never sold; it borrows a plain arm housing's sprite so it has one. + icon_state = "fixers" // Worn look (see the cyberware base above): the arm pipeline derives // "[aug_overlay]_left/_right" from the installed zone on its own. No // separate hand states in our sheet, so the hand overlay stays off. @@ -209,6 +269,9 @@ var/tier = CYBERWARE_TIER_1 /// Chrome capacity this ware grants while installed. var/chrome_capacity_bonus = 0 + /// TRUE when the worn sprite draws the deployable folded into its housing + /// (the Mantis line). The worn overlay hides while that item is out in a hand. + var/hide_worn_while_deployed = FALSE /obj/item/organ/cyberimp/arm/toolkit/cyberware/Initialize(mapload) . = ..() @@ -241,6 +304,12 @@ if(!special) cyberware_boot_splash(arm_owner, src) +// tg's arm implants register these two on the bearer and never let go on removal, so a +// housing sold on or reinstalled elsewhere kept answering to the body it was pulled from. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(arm_owner, list(COMSIG_CARBON_POST_ATTACH_LIMB, COMSIG_KB_MOB_DROPITEM_DOWN)) + // Deploying and stowing arm hardware is the most visible thing a piece of // chrome does, so it is also the ink's most common cue: every toolkit arm // (Fixer's Fingers picking a tool off the radial, the Rockjaw drill, blades, @@ -254,12 +323,39 @@ // (augments_arms.dm), so "did it deploy" is "did it leave us", not "is it set". if(active_item && !(active_item in src)) cyberware_ink_pulse(owner, CYBERWARE_INK_HARD) + if(hide_worn_while_deployed) + // Any way out of the hand that isn't Retract() (a forced drop, a + // deleted blade) must put the folded sprite back as well. + RegisterSignal(active_item, COMSIG_ITEM_DROPPED, PROC_REF(on_deployed_item_dropped), override = TRUE) + play_blade_unfold(active_item) + owner.update_body_parts() /obj/item/organ/cyberimp/arm/toolkit/cyberware/Retract() var/mob/living/carbon/stowing_owner = owner + var/obj/item/stowing = active_item + // Only a blade coming out of the hand folds back in; one picked off the floor just stows. + var/was_held = stowing && stowing_owner?.is_holding(stowing) . = ..() if(.) cyberware_ink_pulse(stowing_owner, CYBERWARE_INK_SOFT) + if(hide_worn_while_deployed) + UnregisterSignal(stowing, COMSIG_ITEM_DROPPED) + if(was_held && owner) + play_blade_fold() + stowing_owner?.update_body_parts() + +/// TRUE while the worn overlay should not draw: this hardware's worn sprite +/// shows its item folded away, and that item is out in the bearer's hand. +/// Stowed, dropped, deleted or with no bearer, the overlay draws. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/worn_overlay_hidden() + return hide_worn_while_deployed && owner && active_item && owner.is_holding(active_item) + +/// Signal proc for [COMSIG_ITEM_DROPPED] on a deployed item whose folded sprite +/// is part of the worn overlay. Redraws the arm so the housing shows again. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/on_deployed_item_dropped(obj/item/source, mob/user) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_ITEM_DROPPED) + owner?.update_body_parts() /obj/item/organ/cyberimp/arm/toolkit/cyberware/emp_act(severity) . = ..() // toolkit's own EMP retract fires first @@ -296,12 +392,23 @@ /proc/cyberware_insert_check(obj/item/organ/ware, mob/living/carbon/target, silent = FALSE, mob/feedback_to) if(!iscarbon(target)) return FALSE + if(!cyberware_has_limb(ware, target, silent)) + return FALSE if(installed_capacity_check(target, list(ware), ware.cyberware_get_incumbents(target), silent)) return TRUE if(!silent && feedback_to && feedback_to != target) feedback_to.balloon_alert(feedback_to, "no neural headroom!") return FALSE +/// Whether the body still has the limb this ware lives in. Chrome cannot go into a limb that +/// is gone: the insert would seat it on a body with nothing to hold it. +/proc/cyberware_has_limb(obj/item/organ/ware, mob/living/carbon/target, silent = FALSE) + if(target.get_bodypart(deprecise_zone(ware.zone))) + return TRUE + if(!silent) + target.balloon_alert(target, "no limb for it!") + return FALSE + /** * Full gate for a live (non-special) Insert(): a legit install context must * be open for this receiver, and the netted capacity must fit. The context @@ -321,6 +428,9 @@ receiver.balloon_alert(receiver, "needs a real rig!") to_chat(receiver, span_warning("The autosurgeon chokes. This needs a real rig.")) return FALSE + // Even a forced window cannot seat chrome in a limb that is not there. + if(!cyberware_has_limb(ware, receiver)) + return FALSE if(!chrome.install_context_forced && !cyberware_insert_check(ware, receiver)) return FALSE chrome.clear_install_context() @@ -450,6 +560,65 @@ return FALSE return TRUE +/** + * A click made with a chrome ability armed belongs to the chrome, refused or + * not. tg's version returns FALSE on a refusal, and ClickOn() then carries on + * as an ordinary click: a held gun fires at whoever was out of reach, an empty + * hand punches whoever the ability would not take. So an armed ability takes + * the one click it is handed, and only that one. + * + * Chrome abilities have no button of their own to click off (chrome_hud.dm), + * so the aim must never outlive the click. A refusal (wrong target, out of + * reach, no line of sight) disarms: the ability's own balloon says why, and + * the next click is an ordinary one. A right-click disarms without trying. A + * button that has gone dark since it was armed (EMP, cooldown started + * elsewhere, stun) disarms too. None of these touch the cooldown. + * + * Only for a real click on an armed ability. Trigger() with a target (AI and + * scripted callers) pass through unchanged. + */ +/datum/action/cooldown/cyberware/InterceptClickOn(mob/living/clicker, params, atom/target) + var/armed = !isnull(clicker) && clicker.click_intercept == src + if(!armed) + return ..() + // The click is the player acting, so the idle drop no longer applies (a channel can outlast it). + stop_timer() + if(params && LAZYACCESS(params2list(params), RIGHT_CLICK)) + unset_click_ability(clicker, refund_cooldown = TRUE) + return TRUE + if(!IsAvailable(feedback = TRUE)) + unset_click_ability(clicker, refund_cooldown = TRUE) + return TRUE + if(..()) + return TRUE + // Refused. Only drop our own aim: a channel that slept may have let another ability arm meanwhile. + if(clicker.click_intercept == src) + unset_click_ability(clicker, refund_cooldown = TRUE) + return TRUE + +/// The timer that drops an aim left waiting. (Names reused on purpose: the CI build sits at DM's identifier limit.) +/datum/action/cooldown/cyberware/var/timeout_timer + +/datum/action/cooldown/cyberware/set_click_ability(mob/on_who) + . = ..() + stop_timer() + timeout_timer = addtimer(CALLBACK(src, PROC_REF(on_timeout)), 10 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/action/cooldown/cyberware/unset_click_ability(mob/on_who, refund_cooldown = TRUE) + stop_timer() + return ..() + +/datum/action/cooldown/cyberware/proc/stop_timer() + if(timeout_timer) + deltimer(timeout_timer) + timeout_timer = null + +/// Armed and never clicked: the aim drops, cooldown untouched. +/datum/action/cooldown/cyberware/proc/on_timeout() + timeout_timer = null + if(owner?.click_intercept == src) + unset_click_ability(owner, refund_cooldown = TRUE) + /** * Is there a clear arc from [here] to [destination]? Shared by every leg * ware that throws its owner through the air: Hopper Pistons, Meteor diff --git a/voidcrew/modules/cyberware/cyberware_component.dm b/voidcrew/modules/cyberware/cyberware_component.dm index ef1b1480118..e7f79a889ef 100644 --- a/voidcrew/modules/cyberware/cyberware_component.dm +++ b/voidcrew/modules/cyberware/cyberware_component.dm @@ -66,6 +66,8 @@ if(emp_timer) deltimer(emp_timer) emp_timer = null + var/obj/item/organ/ware = parent + ware?.cyberware_anim_clear() install_context_ref = null install_context_forced = FALSE return ..() @@ -101,6 +103,8 @@ settle_passives(old_owner) set_browned_out(FALSE) cyberware_reevaluate_brownout(old_owner) + var/obj/item/organ/ware = parent + ware.cyberware_anim_clear(old_owner) /// Signal proc for [COMSIG_CARBON_GAIN_ORGAN] and [COMSIG_CARBON_LOSE_ORGAN] /// on the bearer. Every installed ware listens with its own component, so a @@ -131,6 +135,8 @@ if(emp_timer) deltimer(emp_timer) emp_timer = addtimer(CALLBACK(src, PROC_REF(finish_emp_reboot)), CYBERWARE_EMP_DOWNTIME / max(severity, 1), TIMER_STOPPABLE) + // Static, arcs, then dark (cyberware_anim.dm). Only the picture staggers. + cyberware_emp_hit_fx(parent) /datum/component/cyberware/proc/finish_emp_reboot() emp_timer = null @@ -141,6 +147,9 @@ var/obj/item/organ/ware = parent if(ware.owner && !(ware.organ_flags & ORGAN_FAILING)) ware.owner.balloon_alert(ware.owner, "[ware.name] back online") + cyberware_emp_reboot_fx(ware) + else + ware.owner?.update_body_parts() /// Cradle tune-up: cancels any pending EMP reboot and repairs the organ /// outright. Cannot fix a brownout, that is a load problem, not damage. @@ -149,9 +158,11 @@ deltimer(emp_timer) emp_timer = null emp_down = FALSE + emp_look = null var/obj/item/organ/ware = parent ware.set_organ_damage(0) update_failing() + ware.owner?.update_body_parts() /// Settles ORGAN_FAILING from our two failure sources. Careful not to clear /// a failure earned through organ damage: only lift the flag while the organ @@ -415,6 +426,9 @@ GLOBAL_LIST_INIT(installed_capacity_costs, list( newly_tripped = TRUE else any_recovered = TRUE + if(newly_tripped || any_recovered) + // Worn chrome stutters down or back up (cyberware_anim2.dm). + cyberware_brownout_fx(target, newly_tripped) if(newly_tripped) target.balloon_alert(target, "chrome brownout!") to_chat(target, span_boldwarning("Your chrome goes dark.")) diff --git a/voidcrew/modules/cyberware/cyberware_scan.dm b/voidcrew/modules/cyberware/cyberware_scan.dm index 8a7b1c3315e..05dc2b89ae7 100644 --- a/voidcrew/modules/cyberware/cyberware_scan.dm +++ b/voidcrew/modules/cyberware/cyberware_scan.dm @@ -22,6 +22,11 @@ * - Resolution belongs to the optics, not to the reader: the street-tier * Nightshade counts signatures, everything above it names them. * + * The reader sees the result drawn over the subject, for their eyes only + * (cyberware_scan_overlay.dm). Nothing goes to their chat. Bystanders see the + * reader's eyes flicker; the subject sees their own body flinch red and gets + * the chat line naming the reader. + * * The action rides the shared /datum/action/cooldown/cyberware bridge, so a * browned-out or EMP-scrambled set of eyes reads nothing at all. */ @@ -33,6 +38,14 @@ /// robotic that isn't parlor chrome. #define CYBERWARE_SCAN_UNRATED_COLOR "#8fa1a8" +// Reach and pacing belong to the optics, so a scout model can read further or +// faster. The defaults are the read's stock numbers. +/obj/item/organ/eyes/robotic/cyberware + /// How far this set of eyes can read a body, in tiles, with line of sight. + var/chrome_read_range = CYBERWARE_SCAN_RANGE + /// Cooldown on this set of eyes' chrome read. + var/chrome_read_cooldown = CYBERWARE_SCAN_COOLDOWN + // ---- Readout ----------------------------------------------------------- /** @@ -64,41 +77,90 @@ return "[text]" /** - * Builds the report for a body at the given resolution and hands back the - * styled lines. Split out from the action so the Cradle, an admin tool or a - * unit test can ask the same question without a pair of eyes involved. + * The short name a readout shows for one piece of hardware: no \improper, + * and no "(left)" or "(right)" on paired arm chrome. */ -/proc/cyberware_scan_readout(mob/living/carbon/target, resolution = CYBERWARE_SCAN_ITEMIZED) +/proc/cyberware_scan_label(obj/item/organ/ware) + var/static/regex/side_suffix = regex(@"\s*\((left|right)\)$", "i") + return side_suffix.Replace(format_text(ware.name), "") + +/** + * What a read resolves on a body, as data. The overlay + * (cyberware_scan_overlay.dm) draws it and cyberware_scan_readout() prints it, + * so both agree on what the bus can and can't see. + * + * Returns an associative list: `name` (the visible name), `count`, `load`, + * `capacity`, `resolution` and `entries`, one list per signature in organ + * order. At itemized resolution an entry carries `label`, `name`, `icon`, + * `icon_state`, `color`, `tier` (0 when unrated), `rated` and `offline`. At + * silhouette resolution entries are empty: the bus counts them and nothing + * else. + */ +/proc/cyberware_scan_report(mob/living/carbon/target, resolution = CYBERWARE_SCAN_ITEMIZED) RETURN_TYPE(/list) - . = list() if(!iscarbon(target)) - return - var/accent = CYBERWARE_COLOR_TIER_2 + return null // Masks and helmets beat the bus: a body reads as whatever it looks like. var/subject = target.name if(ishuman(target)) var/mob/living/carbon/human/human_target = target subject = human_target.get_visible_name() - . += cyberware_scan_line("CHROME READ: [uppertext(subject)]", accent) var/list/found = cyberware_scannable_hardware(target) - if(!length(found)) + var/list/entries = list() + for(var/obj/item/organ/ware as anything in found) + if(resolution < CYBERWARE_SCAN_ITEMIZED) + entries += list(list()) + continue + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + entries += list(list( + "label" = cyberware_scan_label(ware), + "name" = ware.name, + "icon" = ware.icon, + "icon_state" = ware.icon_state, + // Anything that isn't parlor chrome has no tier to report. + "color" = chrome ? cyberware_tier_color(chrome.tier) : CYBERWARE_SCAN_UNRATED_COLOR, + "tier" = chrome ? chrome.tier : 0, + "rated" = !!chrome, + "offline" = !!(ware.organ_flags & ORGAN_FAILING), + )) + return list( + "name" = subject, + "count" = length(found), + "load" = get_chrome_load(target), + "capacity" = get_chrome_capacity(target), + "resolution" = resolution, + "entries" = entries, + ) + +/** + * Builds the report for a body at the given resolution and hands back the + * styled lines. Split out from the action so the Cradle, an admin tool or a + * unit test can ask the same question without a pair of eyes involved. + */ +/proc/cyberware_scan_readout(mob/living/carbon/target, resolution = CYBERWARE_SCAN_ITEMIZED) + RETURN_TYPE(/list) + . = list() + var/list/report = cyberware_scan_report(target, resolution) + if(!report) + return + var/accent = CYBERWARE_COLOR_TIER_2 + . += cyberware_scan_line("CHROME READ: [uppertext(report["name"])]", accent) + + if(!report["count"]) . += cyberware_scan_line("NO HARDWARE SIGNATURE", accent) return - var/load = get_chrome_load(target) - var/capacity = get_chrome_capacity(target) - . += cyberware_scan_line("SIGNATURES [length(found)] · NEURAL LOAD [load]/[capacity]", accent) + var/load = report["load"] + var/capacity = report["capacity"] + . += cyberware_scan_line("SIGNATURES [report["count"]] · NEURAL LOAD [load]/[capacity]", accent) if(resolution < CYBERWARE_SCAN_ITEMIZED) . += cyberware_scan_line("BUS RESOLUTION TOO LOW: IDENTITIES UNRESOLVED", accent) else - for(var/obj/item/organ/ware as anything in found) - var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) - var/status = (ware.organ_flags & ORGAN_FAILING) ? " · OFFLINE" : "" - // Anything that isn't parlor chrome has no tier to report. - var/rating = chrome ? "T[chrome.tier]" : "UNRATED" - var/line_color = chrome ? cyberware_tier_color(chrome.tier) : CYBERWARE_SCAN_UNRATED_COLOR - . += cyberware_scan_line("» [uppertext(ware.name)] · [rating][status]", line_color) + for(var/list/entry as anything in report["entries"]) + var/status = entry["offline"] ? " · OFFLINE" : "" + var/rating = entry["rated"] ? (list("STREET", "PRO", "MILITARY", "LEGEND")[clamp(entry["tier"], 1, 4)]) : "UNRATED" + . += cyberware_scan_line("» [uppertext(entry["name"])] · [rating][status]", entry["color"]) if(load > capacity) . += cyberware_scan_line("LOAD OVER CEILING: SUBJECT BROWNED OUT", CYBERWARE_COLOR_TIER_3) @@ -106,12 +168,41 @@ /datum/action/cooldown/cyberware/chrome_read name = "Chrome Read" - desc = "Read a body's hardware bus: what's installed, what tier it is, and how loaded their nervous system is. They'll feel you do it." + desc = "Read the chrome in a body you can see. They'll feel you do it." button_icon = 'icons/mob/actions/actions_items.dmi' button_icon_state = "scan" ranged_mousepointer = 'icons/effects/mouse_pointers/scan_target.dmi' cooldown_time = CYBERWARE_SCAN_COOLDOWN click_to_activate = TRUE + /// The readout this read is drawing for our bearer. A new read replaces it. + var/datum/chrome_readout/current_readout + +/datum/action/cooldown/cyberware/chrome_read/New(Target, original = TRUE) + . = ..() + var/obj/item/organ/eyes/robotic/cyberware/optics = organ + if(istype(optics)) + cooldown_time = optics.chrome_read_cooldown + +/datum/action/cooldown/cyberware/chrome_read/Destroy() + QDEL_NULL(current_readout) + return ..() + +/// Swaps whatever we were showing for a fresh readout. A null report draws +/// the scatter. +/datum/action/cooldown/cyberware/chrome_read/proc/show_readout(mob/living/carbon/bearer, mob/living/carbon/subject, list/report) + QDEL_NULL(current_readout) + var/datum/chrome_readout/readout = new(bearer, subject, report, organ) + if(QDELETED(readout)) + return + current_readout = readout + RegisterSignal(readout, COMSIG_QDELETING, PROC_REF(on_readout_deleted)) + +/// Signal proc for [COMSIG_QDELETING] on our readout: it ended on its own +/// (expired, target gone, bearer logged out), so stop holding it. +/datum/action/cooldown/cyberware/chrome_read/proc/on_readout_deleted(datum/source) + SIGNAL_HANDLER + if(source == current_readout) + current_readout = null /datum/action/cooldown/cyberware/chrome_read/Activate(atom/target) var/obj/item/organ/eyes/robotic/cyberware/optics = organ @@ -128,36 +219,44 @@ if(bearer.is_blind()) bearer.balloon_alert(bearer, "optics dark!") return FALSE - if(!self_read && !can_see(bearer, subject, CYBERWARE_SCAN_RANGE)) + if(!self_read && !can_see(bearer, subject, optics.chrome_read_range)) bearer.balloon_alert(bearer, "no line of sight!") return FALSE - // Ghostskin scatters the handshake the same way it scatters NPC targeting. + // Ghostskin scatters the handshake the same way it scatters NPC targeting: + // the readout breaks up and drops without a lock. if(HAS_TRAIT(subject, TRAIT_CYBER_CAMO)) - bearer.balloon_alert(bearer, "signature scatter!") - to_chat(bearer, cyberware_scan_line("CHROME READ: SIGNAL SCATTER, NO LOCK", CYBERWARE_COLOR_TIER_3)) + show_readout(bearer, subject, null) + if(!self_read) + chrome_read_eye_glint(bearer) playsound(bearer, 'sound/machines/terminal/terminal_error.ogg', 20, TRUE) StartCooldown() return TRUE - var/list/report = cyberware_scan_readout(subject, optics.chrome_scan_resolution) - to_chat(bearer, jointext(report, "
")) + // The read draws over the subject for the reader alone; nothing goes to chat. + var/list/report = cyberware_scan_report(subject, optics.chrome_scan_resolution) + show_readout(bearer, subject, report) playsound(bearer, 'sound/machines/terminal/terminal_processing.ogg', 20, TRUE) if(!self_read) // The tell: anyone close enough sees the readout scroll across the eyes. // The subject only sits that message out when they're getting the - // louder one below instead. - var/subject_feels_it = length(cyberware_scannable_hardware(subject)) + // louder one below instead. The reader never gets it: it's their eyes. + chrome_read_eye_glint(bearer) + var/subject_feels_it = report["count"] bearer.visible_message( span_notice("[bearer]'s irises flicker with a scrolling readout."), vision_distance = 3, - ignored_mobs = subject_feels_it ? list(subject) : null, + ignored_mobs = subject_feels_it ? list(subject, bearer) : list(bearer), ) - // The subject's own hardware reports the unauthorized handshake. + // The subject's own hardware reports the unauthorized handshake: it + // flinches on their screen, and their chat names the reader. if(subject_feels_it) - if(can_see(subject, bearer, CYBERWARE_SCAN_RANGE)) + chrome_read_flinch(subject) + if(can_see(subject, bearer, optics.chrome_read_range)) to_chat(subject, span_warning("[bearer] is reading your hardware. Every piece you have just answered a handshake you never authorized.")) else to_chat(subject, span_warning("Your hardware answers a handshake you never authorized. Someone nearby is reading you.")) + // Whoever is read can answer it: a bounty NPC turns and tells the reader off (voidcrew/modules/bounties/bounty_chrome.dm) + SEND_SIGNAL(subject, COMSIG_CARBON_CHROME_READ, bearer) StartCooldown() return TRUE diff --git a/voidcrew/modules/cyberware/cyberware_scan_overlay.dm b/voidcrew/modules/cyberware/cyberware_scan_overlay.dm new file mode 100644 index 00000000000..9881ce2c342 --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_scan_overlay.dm @@ -0,0 +1,890 @@ +/** + * # Chrome read overlay + * + * What a chrome read (cyberware_scan.dm) looks like to the reader. Corner + * brackets snap onto the subject and lock while a red outline of their body + * pulses inside them. Then a panel opens beside the subject: their visible + * name, one socket per piece of hardware framed in its tier colour, a short + * name list and a load bar. The outline fades, the panel holds, and the + * brackets release as the panel fades out. + * + * - Street optics (silhouette resolution) fill the sockets with static and + * name nothing. + * - A Ghostskin subject breaks the lock: the brackets hunt, the outline + * flickers into static, the header garbles and "NO LOCK" flashes before it + * all drops. That version stays where the lock was tried instead of + * following someone the optics can't hold. + * - A body with no hardware gets the header and "no chrome". + * + * Every image is a client image shown to the reader alone, on + * ABOVE_LIGHTING_PLANE: it draws over darkness, and a flash or blindfold still + * hides it because blindness lives on FULLSCREEN_PLANE above it. The images are + * parented to the SUBJECT, so they follow them without any server work and, + * just like the subject, are never drawn through a wall. That is on purpose: + * the read needs line of sight to start, and the readout must not turn into a + * free wallhack for its whole hold. (/datum/cyberware_hud_mark is the opposite + * case: it parks on the viewer's turf to draw THROUGH walls.) + * + * All motion is animate() on the reader's client. The server builds the + * images once and runs one timer. + */ + +/// Itemized optics on a body with hardware. +#define READOUT_ITEMIZED "itemized" +/// Street optics: counts and load, nothing named. +#define READOUT_SILHOUETTE "silhouette" +/// Ghostskin: the lock breaks. +#define READOUT_SCATTER "scatter" +/// Nothing on the bus. +#define READOUT_CLEAN "clean" + +// Timeline, in deciseconds from the click. +/// Brackets snap (0-3) and lock (3-7) while the outline pulses; the panel opens here. +#define READOUT_LOCK_TIME 8.5 +/// Header fade-in plus the panel unfolding. +#define READOUT_OPEN_TIME 4.5 +#define READOUT_HOLD_TIME 60 +#define READOUT_CLEAN_HOLD_TIME 30 +#define READOUT_FADE_TIME 6 +/// A scattered read drops at this point, without ever locking. +#define READOUT_SCATTER_DROP_AT 18 +#define READOUT_SCATTER_FADE_TIME 3 + +// Geometry, in pixels from the subject's tile origin (y up). +/// Left edge of a panel opening on the right. +#define READOUT_PANEL_X 37 +/// Top edge of the panel. +#define READOUT_PANEL_TOP 45 +#define READOUT_SOCKETS_PER_ROW 6 +#define READOUT_MAX_SOCKETS 18 +#define READOUT_MAX_NAME_LINES 8 +#define READOUT_MIN_WIDTH 108 +#define READOUT_MAX_WIDTH 160 +#define READOUT_BAR_SEGMENTS 20 +/// The socket icon is 32 px drawn into an 18 px socket. +#define READOUT_SOCKET_ICON_SCALE 0.5625 + +// Colours. +/// The HUD frame: brackets, panel border, leader line. +#define READOUT_HUD_COLOR CYBERWARE_COLOR_TIER_2 +/// The targeting outline. A deep crimson, kept apart from the brighter +/// red-orange of "offline" and the pink of tier 3. +#define READOUT_LOCK_COLOR "#f01d3a" +#define READOUT_LOCK_FILL_COLOR "#c8102a" +#define READOUT_PANEL_COLOR "#060e12" +#define READOUT_NAME_COLOR "#e6fbff" +#define READOUT_DIM_COLOR "#8fa1a8" +#define READOUT_OFFLINE_COLOR "#ff5a4a" +#define READOUT_OFFLINE_NAME_COLOR "#7d8a90" +#define READOUT_STATIC_FRAME_COLOR "#6c7c83" +#define READOUT_GLINT_COLOR "#e9feff" + +#define READOUT_ICON 'voidcrew/modules/cyberware/icons/chrome_read.dmi' +#define READOUT_BAR_ICON 'voidcrew/modules/cyberware/icons/chrome_read_bar.dmi' + +/datum/chrome_readout + /// The only person this is drawn for. + var/mob/living/viewer + /// The body being read. + var/mob/living/target + /// The optics doing the reading. Pulling them ends the readout. + var/obj/item/organ/optics + /// What the images hang off: the subject, or for a scatter the turf they + /// stood on when the lock failed. + var/atom/anchor + /// READOUT_* look. + var/mode + /// The cyberware_scan_report() being drawn. Null for a scatter. + var/list/report + /// Every image, in draw order. + var/list/image/images = list() + /// One image per socket drawn. + var/list/image/sockets = list() + /// One image per name line drawn, "and N more" included. + var/list/image/name_lines = list() + /// image -> list of what play() needs to animate it: its kind, start time, + /// role ("lock", "panel", "socket", "name", "load") and kind-specific data. + var/list/cues = list() + /// The client the images went to. Held because a mob that logged out has none. + var/client/shown_to + /// Expiry timer. + var/timer_id + /// Deciseconds from creation until everything starts fading, and how long that takes. + var/fade_at + var/fade_time + /// Whole lifetime, deciseconds. + var/lifetime + /// Panel geometry. + var/panel_x + var/panel_width + var/panel_height + /// TRUE when the panel opens on the subject's left: the reader is to their right, or the right has no room. + var/flipped = FALSE + /// Pixel offset every image carries. Zero on the subject; on a turf it lines + /// the scatter up with where the subject was drawn. + var/base_w = 0 + var/base_z = 0 + +/datum/chrome_readout/New(mob/living/new_viewer, mob/living/new_target, list/new_report, obj/item/organ/new_optics, animated = TRUE) + . = ..() + viewer = new_viewer + target = new_target + report = new_report + optics = new_optics + if(isnull(report)) + mode = READOUT_SCATTER + else if(!report["count"]) + mode = READOUT_CLEAN + else if(report["resolution"] < CYBERWARE_SCAN_ITEMIZED) + mode = READOUT_SILHOUETTE + else + mode = READOUT_ITEMIZED + + if(mode == READOUT_SCATTER) + anchor = get_turf(target) + base_w = target.pixel_x + target.pixel_w + base_z = target.pixel_y + target.pixel_z + else + anchor = target + if(isnull(anchor)) + qdel(src) + return + + RegisterSignals(target, list(COMSIG_QDELETING, COMSIG_MOVABLE_Z_CHANGED), PROC_REF(end_readout)) + RegisterSignal(viewer, COMSIG_MOB_LOGOUT, PROC_REF(end_readout)) + if(viewer != target) + RegisterSignal(viewer, COMSIG_QDELETING, PROC_REF(end_readout)) + if(optics) + RegisterSignals(optics, list(COMSIG_ORGAN_REMOVED, COMSIG_QDELETING), PROC_REF(end_readout)) + + switch(mode) + if(READOUT_SCATTER) + fade_at = READOUT_SCATTER_DROP_AT + fade_time = READOUT_SCATTER_FADE_TIME + if(READOUT_CLEAN) + fade_at = READOUT_LOCK_TIME + READOUT_OPEN_TIME + READOUT_CLEAN_HOLD_TIME + fade_time = READOUT_FADE_TIME + else + fade_at = READOUT_LOCK_TIME + READOUT_OPEN_TIME + READOUT_HOLD_TIME + fade_time = READOUT_FADE_TIME + lifetime = fade_at + fade_time + 2 + + build_lock() + build_panel() + if(viewer.client) + shown_to = viewer.client + shown_to.images += images + // Animate only once the client holds the images, as balloon alerts and runechat do. + if(animated) + play() + timer_id = addtimer(CALLBACK(src, PROC_REF(expire)), lifetime, TIMER_STOPPABLE | TIMER_CLIENT_TIME | TIMER_DELETE_ME) + +/datum/chrome_readout/Destroy(force) + if(timer_id) + deltimer(timer_id) + timer_id = null + shown_to?.images -= images + shown_to = null + if(target) + UnregisterSignal(target, list(COMSIG_QDELETING, COMSIG_MOVABLE_Z_CHANGED)) + if(viewer) + UnregisterSignal(viewer, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT)) + if(optics) + UnregisterSignal(optics, list(COMSIG_ORGAN_REMOVED, COMSIG_QDELETING)) + images.Cut() + sockets.Cut() + name_lines.Cut() + cues.Cut() + viewer = null + target = null + optics = null + anchor = null + report = null + return ..() + +/// The hold and fade ran out. +/datum/chrome_readout/proc/expire() + timer_id = null + qdel(src) + +/** + * Signal proc for the ways a readout ends early: the subject deleted or moved + * to another z, the reader deleted or logged out (a new client starts with no + * images, and a ghost must see nothing), or the optics pulled or deleted. A + * subject who dies keeps theirs; it is a snapshot, and reading a corpse is fair. + */ +/datum/chrome_readout/proc/end_readout(datum/source) + SIGNAL_HANDLER + qdel(src) + +// ---- Building ---------------------------------------------------------- + +/// A blank image on the readout's plane, filed for showing and animating. +/datum/chrome_readout/proc/add_image(icon_file, icon_state, role, kind, at = 0) + var/image/drawn = icon_file ? image(icon_file, anchor, icon_state) : image(loc = anchor) + drawn.layer = ABOVE_ALL_MOB_LAYER + length(images) * 0.001 + // KEEP_APART: humans are KEEP_TOGETHER, and the readout must not be pulled + // into their group (their colour, alpha, height filters or lying turn). + drawn.appearance_flags = RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | KEEP_APART | PIXEL_SCALE + // Without this a click anywhere on the panel lands on the subject. + drawn.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + SET_PLANE_EXPLICIT(drawn, ABOVE_LIGHTING_PLANE, anchor) + drawn.pixel_w = base_w + drawn.pixel_z = base_z + images += drawn + cues[drawn] = list("role" = role, "kind" = kind, "at" = at) + return drawn + +/// A flat rectangle: the white pixel state scaled to size. x/y is the bottom left. +/datum/chrome_readout/proc/add_quad(x, y, width, height, quad_color, quad_alpha, role, kind, at = 0) + var/image/quad = add_image(READOUT_ICON, "px", role, kind, at) + quad.color = quad_color + quad.alpha = quad_alpha + quad.transform = chrome_readout_quad(x, y, width, height) + return quad + +/** + * One line of maptext. `cap_row` is how many pixels below the panel's top edge + * the capitals start. The box is taller than the line and vertically centred, + * so the font's line height never shifts the text: Grand9K 6pt draws a 12 px + * cell with caps 3 px down it, TinyUnicode 12pt a 13 px cell with caps 6 px down. + */ +/datum/chrome_readout/proc/add_text(html, x, cap_row, width, tiny, role, kind, at = 0) + var/image/line = add_image(null, null, role, kind, at) + line.maptext = html + line.maptext_x = x + line.maptext_width = width + if(tiny) + line.maptext_height = 17 + line.maptext_y = READOUT_PANEL_TOP - 9 - cap_row + else + line.maptext_height = 16 + line.maptext_y = READOUT_PANEL_TOP - 11 - cap_row + return line + +/// Brackets, the red outline pulse, and for a scatter the static it breaks into. +/datum/chrome_readout/proc/build_lock() + // Draft C's reticle box: three pixels clear of a standing human sprite. + var/left = 4 + var/right = 26 + var/top = 31 + var/bottom = -2 + if(target.body_position == LYING_DOWN) + left = -2 + right = 33 + top = 26 + bottom = 4 + for(var/corner in list("nw", "ne", "sw", "se")) + var/west = findtext(corner, "w") + var/north = findtext(corner, "n") + var/image/bracket = add_image(READOUT_ICON, "bracket_[corner]", "lock", "bracket") + // The corner pixel sits at canvas column 1 or 30, row 30 or 1. + bracket.pixel_w += (west ? left - 1 : right - 30) + bracket.pixel_z += (north ? top - 30 : bottom - 1) + bracket.color = (mode == READOUT_SCATTER) ? "#ffffff" : READOUT_HUD_COLOR + cues[bracket]["out_w"] = west ? -1 : 1 + cues[bracket]["out_z"] = north ? 1 : -1 + + // The outline pulse, taken from the subject's own look at this moment. + var/image/fill = add_body_copy("lock", "lock_body") + var/list/fill_rgb = rgb2num(READOUT_LOCK_FILL_COLOR) + var/static/icon/stripes_mask = icon(READOUT_ICON, "stripes") + apply_filters(fill, list( + // Flat crimson at a third of the body's alpha... + list("type" = "color", "color" = list(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, fill_rgb[1] / 255, fill_rgb[2] / 255, fill_rgb[3] / 255, 0.33, 0, 0, 0, 0)), + // ...on every third row. + list("type" = "alpha", "icon" = stripes_mask), + )) + cues[fill]["roll"] = 2 + var/image/edge = add_body_copy("lock", "lock_body") + apply_filters(edge, chrome_read_edge_filters(READOUT_LOCK_COLOR)) + edge.alpha = 230 + + if(mode == READOUT_SCATTER) + // What the outline breaks into: tg's human-shaped static and a glitch strip. + var/image/noise = add_image('icons/effects/effects.dmi', "static", "lock", "static", 5) + noise.dir = target.dir + noise.transform = target.transform + noise.color = "#a8f3fb" + noise.alpha = 191 + cues[noise]["jitter"] = 2 + var/image/glitch = add_image('icons/effects/bitrunning.dmi', "glitch", "lock", "static", 5) + glitch.color = CYBERWARE_COLOR_TIER_3 + cues[glitch]["jitter"] = 3 + return + + // Two echoes of the outline swell out from it while the lock holds. + for(var/echo_at in list(3, 6)) + var/image/echo = add_body_copy("lock", "echo", echo_at) + apply_filters(echo, chrome_read_edge_filters(READOUT_LOCK_COLOR)) + var/matrix/start = matrix(target.transform) + var/matrix/finish = matrix(target.transform) + finish.Scale(1.25) + var/matrix/midway = matrix(target.transform) + midway.Scale(1.12) + cues[echo]["from"] = start + cues[echo]["to"] = finish + // Held mid-swell, so a frame captured without animation shows one. + echo.transform = midway + echo.alpha = 110 + +/// A copy of the subject's current look on the readout's plane. +/datum/chrome_readout/proc/add_body_copy(role, kind, at = 0) + var/image/copy = chrome_read_body_copy(target, anchor) + copy.layer = ABOVE_ALL_MOB_LAYER + length(images) * 0.001 + images += copy + cues[copy] = list("role" = role, "kind" = kind, "at" = at) + return copy + +/// Sets an image's filters from param lists, keeping the lists for anything that inspects the readout. +/datum/chrome_readout/proc/apply_filters(image/drawn, list/specs) + var/list/built = list() + for(var/list/spec as anything in specs) + built += filter(arglist(spec)) + drawn.filters = built + cues[drawn]["filters"] = specs + +/// The panel: frame, leader, header, sockets, names and load bar. +/datum/chrome_readout/proc/build_panel() + var/static/datum/font/header_font = new /datum/font/grand9k/size_6pt() + var/static/datum/font/list_font = new /datum/font/tiny_unicode/size_12pt() + var/open_at = (mode == READOUT_SCATTER) ? 5 : READOUT_LOCK_TIME + var/top = READOUT_PANEL_TOP + + // What goes in it. + var/list/entries = list() + var/socket_count = 0 + if(mode == READOUT_ITEMIZED) + entries = sort_entries(report["entries"]) + socket_count = min(length(entries), READOUT_MAX_SOCKETS) + else if(mode == READOUT_SILHOUETTE) + socket_count = min(report["count"], READOUT_MAX_SOCKETS) + var/rows = CEILING(socket_count / READOUT_SOCKETS_PER_ROW, 1) + + var/list/lines = list() + if(mode == READOUT_ITEMIZED) + var/shown = length(entries) + if(shown > READOUT_MAX_NAME_LINES) + shown = READOUT_MAX_NAME_LINES - 1 + for(var/i in 1 to shown) + var/list/entry = entries[i] + lines += list(list("label" = entry["label"], "color" = entry["color"], "offline" = entry["offline"])) + if(shown < length(entries)) + lines += list(list("label" = "and [length(entries) - shown] more", "color" = null, "offline" = FALSE)) + + var/list/noise + var/header_text + if(mode == READOUT_SCATTER) + var/visible_name = ishuman(target) ? target.get_visible_name() : target.name + noise = list() + for(var/i in 1 to 8) + noise += chrome_readout_noise(clamp(length_char(visible_name), 6, 16)) + header_text = noise[1] + else + header_text = report["name"] + + // Width from the real glyph metrics, never the client's MeasureText(). + var/status_width = 0 + if(mode == READOUT_CLEAN) + status_width = list_font.get_metrics("no chrome") + 8 + else if(mode == READOUT_SCATTER) + status_width = header_font.get_metrics("NO LOCK") + 8 + var/width = 5 + header_font.get_metrics(header_text) + 6 + status_width + if(socket_count) + width = max(width, min(socket_count, READOUT_SOCKETS_PER_ROW) * 24 + 2, 22 + READOUT_BAR_SEGMENTS * 4 + 5) + // A name line starts 9 px in and needs 4 px clear of the right border, + // counting the text outline. + var/offline_width = list_font.get_metrics(" offline") + for(var/list/line as anything in lines) + width = max(width, 9 + list_font.get_metrics(line["label"]) + (line["offline"] ? offline_width : 0) + 4) + width = clamp(width, READOUT_MIN_WIDTH, READOUT_MAX_WIDTH) + if(mode != READOUT_SCATTER) + header_text = chrome_readout_fit(header_text, header_font, width - 11 - status_width) + for(var/list/line as anything in lines) + line["label"] = chrome_readout_fit(line["label"], list_font, width - 13 - (line["offline"] ? offline_width : 0)) + + var/height = 13 + switch(mode) + if(READOUT_ITEMIZED) + height = 21 + 24 * rows + 7 * length(lines) + if(READOUT_SILHOUETTE) + height = 20 + 24 * rows + + // Open on the subject's far side from the reader, so the panel never lies over the reader or the ground + // between the two; on the near side only when the far side would run off the reader's screen. + var/turf/viewer_turf = get_turf(viewer) + var/turf/target_turf = get_turf(target) + flipped = FALSE + if(viewer_turf && target_turf) + // The left edge of the subject's tile on the reader's screen, in pixels from the middle of it. A zoom pans + // the screen through the client's own pixel offset. + var/tile_left = (target_turf.x - viewer_turf.x) * ICON_SIZE_X - ICON_SIZE_X / 2 - (viewer.client?.pixel_x || 0) + var/list/screen_pixels = view_to_pixels(viewer.client?.view || world.view) + var/half_screen = screen_pixels[1] / 2 + var/fits_right = tile_left + READOUT_PANEL_X + width <= half_screen + var/fits_left = tile_left - 5 - width >= -half_screen + flipped = target_turf.x < viewer_turf.x + if(flipped && !fits_left && fits_right) + flipped = FALSE + else if(!flipped && !fits_right && fits_left) + flipped = TRUE + panel_x = flipped ? (-5 - width) : READOUT_PANEL_X + panel_width = width + panel_height = height + + // Frame. The back and sides unfold down from the header. + var/image/back = add_quad(panel_x, top - height, width, height, READOUT_PANEL_COLOR, 205, "panel", "unfold", open_at) + cues[back]["from"] = chrome_readout_quad(panel_x, top - 11, width, 11) + add_quad(panel_x, top - 1, width, 1, READOUT_HUD_COLOR, 120, "panel", "fade", open_at) + var/image/border_bottom = add_quad(panel_x, top - height, width, 1, READOUT_HUD_COLOR, 120, "panel", "unfold", open_at) + cues[border_bottom]["from"] = chrome_readout_quad(panel_x, top - 11, width, 1) + var/image/border_left = add_quad(panel_x, top - height, 1, height, READOUT_HUD_COLOR, 120, "panel", "unfold", open_at) + cues[border_left]["from"] = chrome_readout_quad(panel_x, top - 11, 1, 11) + var/image/border_right = add_quad(panel_x + width - 1, top - height, 1, height, READOUT_HUD_COLOR, 120, "panel", "unfold", open_at) + cues[border_right]["from"] = chrome_readout_quad(panel_x + width - 1, top - 11, 1, 11) + add_quad(panel_x, top - 11, 2, 11, READOUT_HUD_COLOR, 255, "panel", "fade", open_at) + var/image/leader = add_image(READOUT_ICON, flipped ? "leader_flip" : "leader", "panel", "fade", open_at) + leader.pixel_w += flipped ? -5 : 5 + leader.pixel_z += 10 + + // Header: whatever name the body shows. + if(mode == READOUT_SCATTER) + var/image/garbled = add_text(chrome_readout_header_html(html_encode(header_text), "#9fb7bd"), panel_x + 5, 2, width - 9, FALSE, "panel", "noise", open_at) + var/list/noise_html = list() + for(var/noise_text in noise) + noise_html += chrome_readout_header_html(html_encode(noise_text), "#9fb7bd") + cues[garbled]["noise"] = noise_html + add_text(chrome_readout_header_html("NO LOCK", CYBERWARE_COLOR_TIER_3, "right"), panel_x + 5, 2, width - 9, FALSE, "panel", "blink", open_at + 1.5) + return + add_text(chrome_readout_header_html(html_encode(header_text), READOUT_NAME_COLOR), panel_x + 5, 2, width - 9, FALSE, "panel", "fade", open_at) + if(mode == READOUT_CLEAN) + add_text(chrome_readout_tiny_html("no chrome", "right"), panel_x + 5, 2, width - 9, TRUE, "panel", "fade", open_at + 1) + return + + // Sockets, wrapping six to a row. + for(var/i in 1 to socket_count) + var/column = (i - 1) % READOUT_SOCKETS_PER_ROW + var/row = round((i - 1) / READOUT_SOCKETS_PER_ROW) + var/image/socket + var/mutable_appearance/inlay + if(mode == READOUT_ITEMIZED) + var/list/entry = entries[i] + socket = add_image(READOUT_ICON, entry["offline"] ? "chip_blink" : "chip", "socket", "socket", open_at + 1.8 + (i - 1) * 0.5) + socket.color = entry["offline"] ? READOUT_OFFLINE_COLOR : entry["color"] + // Smoothly downscaled, so no PIXEL_SCALE on the ware itself. + inlay = mutable_appearance(entry["icon"], entry["icon_state"], appearance_flags = RESET_COLOR) + if(entry["offline"]) + inlay.color = list(0.29, 0.29, 0.29, 0.56, 0.56, 0.56, 0.11, 0.11, 0.11) + else + socket = add_image(READOUT_ICON, "chip", "socket", "socket", open_at + 1.8 + (i - 1) * 0.5) + socket.color = READOUT_STATIC_FRAME_COLOR + inlay = mutable_appearance('icons/effects/effects.dmi', "static_base", alpha = 110, appearance_flags = RESET_COLOR | PIXEL_SCALE) + inlay.color = "#bfbfbf" + inlay.transform = matrix(READOUT_SOCKET_ICON_SCALE, 0, 0, 0, READOUT_SOCKET_ICON_SCALE, 0) + socket.overlays += inlay + // The chip state's 20 px socket starts six pixels into its canvas. + socket.pixel_w += panel_x + 3 + column * 24 - 6 + socket.pixel_z += top - 33 - row * 24 - 6 + sockets += socket + + // Names, each with its tier as a small swatch. + var/names_top = 12 + 24 * rows + for(var/i in 1 to length(lines)) + var/list/line = lines[i] + var/cap_row = names_top + 7 * (i - 1) + var/inner + if(isnull(line["color"])) + inner = "[html_encode(line["label"])]" + else if(line["offline"]) + inner = "[html_encode(line["label"])] offline" + else + inner = "[html_encode(line["label"])]" + var/image/name_line = add_text(chrome_readout_tiny_html(inner), panel_x + 9, cap_row, width - 10, TRUE, "name", "fade", open_at + 2.6 + (i - 1) * 0.5) + if(!isnull(line["color"])) + var/mutable_appearance/swatch = mutable_appearance(READOUT_ICON, "bullet", appearance_flags = RESET_COLOR) + swatch.color = line["color"] + swatch.pixel_w = panel_x + 4 + swatch.pixel_z = top - 4 - cap_row + name_line.overlays += swatch + name_lines += name_line + + // Load, as a bar. Over the ceiling it pins full in the offline colour. + var/bar_row = names_top + (length(lines) ? 7 * length(lines) + 2 : 0) + var/load = report["load"] + var/capacity = max(report["capacity"], 1) + var/over = load > capacity + var/filled = over ? READOUT_BAR_SEGMENTS : clamp(round(load / capacity * READOUT_BAR_SEGMENTS), 0, READOUT_BAR_SEGMENTS) + var/image/bar = add_image(READOUT_BAR_ICON, "fill_[filled]", "load", "bar", open_at + 3.5) + bar.color = over ? READOUT_OFFLINE_COLOR : READOUT_HUD_COLOR + bar.underlays += mutable_appearance(READOUT_BAR_ICON, "track", appearance_flags = RESET_COLOR) + bar.pixel_w += panel_x + 22 + bar.pixel_z += top - 3 - bar_row + bar.maptext = chrome_readout_tiny_html("load") + bar.maptext_x = -18 + bar.maptext_y = -5 + bar.maptext_width = 20 + bar.maptext_height = 17 + cues[bar]["fill"] = filled + +/// Highest tier first, unrated last; ties keep organ order. +/datum/chrome_readout/proc/sort_entries(list/entries) + var/list/sorted = list() + for(var/rank in list(CYBERWARE_TIER_4, CYBERWARE_TIER_3, CYBERWARE_TIER_2, CYBERWARE_TIER_1, 0)) + for(var/list/entry as anything in entries) + if(entry["tier"] == rank) + sorted += list(entry) + return sorted + +// ---- Animation --------------------------------------------------------- + +/// Hands every image its timeline. Called once, after the client has them. +/datum/chrome_readout/proc/play() + for(var/image/drawn as anything in images) + var/list/cue = cues[drawn] + switch(cue["kind"]) + if("fade") + play_fade(drawn, cue["at"]) + if("unfold") + play_unfold(drawn, cue) + if("socket") + play_socket(drawn, cue["at"]) + if("bar") + play_bar(drawn, cue) + if("bracket") + play_bracket(drawn, cue) + if("lock_body") + play_lock_body(drawn, cue) + if("echo") + play_echo(drawn, cue) + if("static") + play_static(drawn, cue) + if("noise") + play_noise(drawn, cue) + if("blink") + play_blink(drawn, cue["at"]) + +/datum/chrome_readout/proc/play_fade(image/drawn, at, fade_in = 0.8) + var/hold_alpha = drawn.alpha + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + animate(alpha = hold_alpha, time = fade_in) + animate(alpha = hold_alpha, time = max(fade_at - at - fade_in, 0)) + animate(alpha = 0, time = fade_time) + +/datum/chrome_readout/proc/play_unfold(image/drawn, list/cue) + var/at = cue["at"] + var/hold_alpha = drawn.alpha + var/matrix/full = drawn.transform + drawn.alpha = 0 + drawn.transform = cue["from"] + animate(drawn, alpha = 0, time = at) + animate(alpha = hold_alpha, time = 0.8) + animate(transform = full, time = 2.2, easing = CUBIC_EASING | EASE_OUT) + animate(alpha = hold_alpha, time = max(fade_at - at - 3, 0)) + animate(alpha = 0, time = fade_time) + +/// Sockets land white and settle into their tier colour. +/datum/chrome_readout/proc/play_socket(image/drawn, at) + var/hold_color = drawn.color + drawn.alpha = 0 + drawn.color = "#ffffff" + animate(drawn, alpha = 0, time = at) + animate(alpha = 255, time = 0.6) + animate(color = hold_color, time = 1) + animate(alpha = 255, time = max(fade_at - at - 1.6, 0)) + animate(alpha = 0, time = fade_time) + +/// The bar fills a segment at a time. +/datum/chrome_readout/proc/play_bar(image/drawn, list/cue) + var/at = cue["at"] + var/filled = cue["fill"] + drawn.alpha = 0 + drawn.icon_state = "fill_0" + animate(drawn, alpha = 0, time = at) + animate(alpha = 255, time = 1) + for(var/step in 1 to filled) + animate(icon_state = "fill_[step]", time = 4 / filled) + animate(alpha = 255, time = max(fade_at - at - 1 - (filled ? 4 : 0), 0)) + animate(alpha = 0, time = fade_time) + +/// Snap in from wide with an overshoot, flash through the lock, hold, then +/// release outward as everything fades. A scatter never locks: the brackets hunt. +/datum/chrome_readout/proc/play_bracket(image/drawn, list/cue) + var/final_w = drawn.pixel_w + var/final_z = drawn.pixel_z + var/out_w = cue["out_w"] + var/out_z = cue["out_z"] + var/hold_color = drawn.color + drawn.pixel_w = final_w + out_w * 16 + drawn.pixel_z = final_z + out_z * 16 + drawn.alpha = 0 + drawn.color = "#ffffff" + animate(drawn, pixel_w = final_w, pixel_z = final_z, alpha = 255, time = 3, easing = BACK_EASING | EASE_OUT) + if(mode == READOUT_SCATTER) + var/elapsed = 3 + while(elapsed < fade_at) + var/step = min(1, fade_at - elapsed) + animate(pixel_w = final_w + rand(-3, 3), pixel_z = final_z + rand(-3, 3), alpha = pick(255, 255, 120), time = step, easing = JUMP_EASING | EASE_IN) + elapsed += step + else + for(var/flash in 1 to 6) + animate(color = ISODD(flash) ? hold_color : "#ffffff", time = 4 / 6, easing = JUMP_EASING | EASE_IN) + animate(color = hold_color, time = max(fade_at - 7, 0), easing = JUMP_EASING | EASE_IN) + animate(pixel_w = final_w + out_w * 10, pixel_z = final_z + out_z * 10, alpha = 0, time = fade_time, easing = CUBIC_EASING | EASE_IN) + +/// The red outline: in as the brackets land, held through the lock, out as the +/// panel opens. On a scatter it flickers and slips instead, then gives way to static. +/datum/chrome_readout/proc/play_lock_body(image/drawn, list/cue) + var/hold_alpha = drawn.alpha + var/base = drawn.pixel_w + drawn.alpha = 0 + animate(drawn, alpha = 0, time = 2) + animate(alpha = hold_alpha, time = 2) + if(mode == READOUT_SCATTER) + for(var/flicker in 1 to 8) + animate(alpha = pick(hold_alpha, hold_alpha, round(hold_alpha * 0.3), 0), pixel_w = base + rand(-2, 2), time = 0.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, pixel_w = base, time = 2) + else + animate(alpha = hold_alpha, time = READOUT_LOCK_TIME - 4) + animate(alpha = 0, time = 4.5) + if(cue["roll"]) + // The stripes crawl down the body, a row at a time. + animate(drawn.filters[cue["roll"]], y = -1, time = 0.8, loop = -1, easing = JUMP_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + animate(y = -2, time = 0.8, easing = JUMP_EASING | EASE_IN) + animate(y = 0, time = 0.8, easing = JUMP_EASING | EASE_IN) + +/// One outline echo swelling out and fading. +/datum/chrome_readout/proc/play_echo(image/drawn, list/cue) + drawn.alpha = 0 + drawn.transform = cue["from"] + animate(drawn, alpha = 0, time = cue["at"]) + animate(alpha = 204, time = 0) + animate(alpha = 0, transform = cue["to"], time = 5.5, easing = CUBIC_EASING | EASE_OUT) + +/// Scatter static: shivers in place until the read drops. +/datum/chrome_readout/proc/play_static(image/drawn, list/cue) + var/at = cue["at"] + var/jitter = cue["jitter"] + var/hold_alpha = drawn.alpha + var/base = drawn.pixel_w + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + animate(alpha = hold_alpha, time = 1.5) + var/elapsed = at + 1.5 + while(elapsed < fade_at) + var/step = min(0.5, fade_at - elapsed) + animate(alpha = hold_alpha, pixel_w = base + rand(-jitter, jitter), time = step, easing = JUMP_EASING | EASE_IN) + elapsed += step + animate(alpha = 0, time = fade_time) + +/// Scatter header: the name never resolves. +/datum/chrome_readout/proc/play_noise(image/drawn, list/cue) + var/at = cue["at"] + var/list/noise_html = cue["noise"] + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + animate(alpha = 255, time = 0.8) + var/elapsed = at + 0.8 + var/frame = 0 + while(elapsed < fade_at) + var/step = min(1, fade_at - elapsed) + animate(maptext = noise_html[(frame % length(noise_html)) + 1], time = step) + elapsed += step + frame++ + animate(alpha = 0, time = fade_time) + +/// "NO LOCK", three beats on and one off. +/datum/chrome_readout/proc/play_blink(image/drawn, at) + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + var/elapsed = at + while(elapsed + 2 <= fade_at) + animate(alpha = 255, time = 1.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = 0.5, easing = JUMP_EASING | EASE_IN) + elapsed += 2 + animate(alpha = 255, time = max(fade_at - elapsed, 0), easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = fade_time) + +// ---- Helpers ----------------------------------------------------------- + +/// Transform that draws the 32 px white state as a rectangle with its bottom left at x, y. +/proc/chrome_readout_quad(x, y, width, height) + return matrix(width / 32, 0, x + width / 2 - 16, 0, height / 32, y + height / 2 - 16) + +/// Grand9K header line (the map's default maptext font). +/proc/chrome_readout_header_html(text, text_color, align = "left") + return MAPTEXT("
[text]
") + +/// TinyUnicode list line. `inner` is already HTML. +/proc/chrome_readout_tiny_html(inner, align = "left") + return MAPTEXT_TINY_UNICODE("
[inner]
") + +/// Garbage of the given length for a header that won't resolve. Plain text; encode it for maptext. +/proc/chrome_readout_noise(length) + var/static/list/glyphs = list("#", "%", "&", "@", "$", "/", "\\", "|", "<", ">", "*") + var/garbage = "" + for(var/i in 1 to length) + garbage += pick(glyphs) + return garbage + +/// Trims text to fit max_width pixels in the given font, marking the cut. +/proc/chrome_readout_fit(text, datum/font/font, max_width) + if(font.get_metrics(text) <= max_width) + return text + var/trimmed = text + while(length_char(trimmed) > 1 && font.get_metrics("[trimmed]..") > max_width) + trimmed = copytext_char(trimmed, 1, length_char(trimmed)) + return "[trimmed].." + +/** + * Filter chain that turns a copy of a body into just its one-pixel outline in + * `edge_color`. The body goes black, gains a white outline, and a colour matrix + * then keys alpha off red: the white ring stays, the black body vanishes, and + * nothing is added where the image was already clear. + */ +/proc/chrome_read_edge_filters(edge_color) + var/list/edge_rgb = rgb2num(edge_color) + return list( + list("type" = "color", "color" = list(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0)), + list("type" = "outline", "size" = 1, "color" = "#ffffff", "flags" = OUTLINE_SHARP | OUTLINE_SQUARE), + list("type" = "color", "color" = list(edge_rgb[1] / 255, edge_rgb[2] / 255, edge_rgb[3] / 255, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)), + ) + +/** + * A client image wearing `body`'s current look, hung on `attach_to`, flattened + * out of everything that would tie it to the body's own rendering: no colour, + * alpha or filters of theirs, and on the readout's plane. Parented to the body + * itself it takes no pixel offset of its own (it inherits theirs); parked on a + * turf it keeps theirs so it lines up. + * + * Emissive overlays inside the copy keep their own plane and land exactly on + * the body's own emissives. + */ +/proc/chrome_read_body_copy(mob/living/body, atom/attach_to = body) + var/image/copy = image(loc = attach_to) + copy.appearance = body.appearance + copy.appearance_flags = KEEP_TOGETHER | KEEP_APART | RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | PIXEL_SCALE + copy.color = null + copy.alpha = 255 + copy.filters = null + copy.blend_mode = BLEND_DEFAULT + copy.render_target = null + copy.render_source = null + copy.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + if(attach_to == body) + copy.pixel_x = 0 + copy.pixel_y = 0 + copy.pixel_w = 0 + copy.pixel_z = 0 + copy.layer = ABOVE_ALL_MOB_LAYER + SET_PLANE_EXPLICIT(copy, ABOVE_LIGHTING_PLANE, attach_to) + return copy + +// ---- The subject and the bystanders ------------------------------------ + +/** + * The subject's own warning: their body flinches red twice, on their screen + * only. The chat line naming the reader is sent by the action. + */ +/proc/chrome_read_flinch(mob/living/subject) + var/client/subject_client = subject?.client + if(!subject_client) + return + var/image/flinch = chrome_read_flinch_image(subject) + subject_client.images += flinch + animate(flinch, alpha = 0, time = 1) + animate(alpha = 255, time = 0) + animate(alpha = 255, time = 1) + animate(alpha = 0, time = 0.6) + animate(alpha = 0, time = 0.4) + animate(alpha = 200, time = 0) + animate(alpha = 200, time = 0.8) + animate(alpha = 0, time = 1.5) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(remove_image_from_client), flinch, subject_client), 1 SECONDS, TIMER_CLIENT_TIME) + +/// The flinch image: the body in tier-three pink, a faint fill under a hard outline. +/proc/chrome_read_flinch_image(mob/living/subject) + var/image/flinch = chrome_read_body_copy(subject) + var/list/flinch_rgb = rgb2num(CYBERWARE_COLOR_TIER_3) + var/list/specs = chrome_read_edge_filters(CYBERWARE_COLOR_TIER_3) + // Same outline, but the body keeps a faint fill: alpha is 0.27 inside and + // 0.95 on the ring. + specs[3] = list("type" = "color", "color" = list(0, 0, 0, 0.68, 0, 0, 0, 0, 0, 0, 0, 0, flinch_rgb[1] / 255, flinch_rgb[2] / 255, flinch_rgb[3] / 255, 0.27, 0, 0, 0, 0)) + var/list/built = list() + for(var/list/spec as anything in specs) + built += filter(arglist(spec)) + flinch.filters = built + return flinch + +/** + * The tell anyone looking at the reader can see: their eyes flicker. It rides + * the reader through vis_contents, so it shows to whoever sees them. + */ +/proc/chrome_read_eye_glint(mob/living/carbon/reader) + var/mutable_appearance/glint = chrome_read_glint_appearance(reader) + if(!glint) + return + var/atom/movable/visual = reader.flick_overlay_view(glint, 0.5 SECONDS) + if(!visual) + return + visual.vis_flags |= VIS_INHERIT_DIR + animate(visual, alpha = 255, time = 1) + animate(alpha = 0, time = 0.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 255, time = 1, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = 0.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 255, time = 1, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = 1) + +/// The reader's eyes, lit. Null when there are no eyes to light or something covers them. +/proc/chrome_read_glint_appearance(mob/living/carbon/reader) + if(!ishuman(reader)) + return null + var/mob/living/carbon/human/human_reader = reader + var/obj/item/organ/eyes/eyes = human_reader.get_organ_slot(ORGAN_SLOT_EYES) + if(isnull(eyes?.eye_icon_state)) + return null + if(human_reader.check_obscured_slots() & ITEM_SLOT_EYES) + return null + var/eye_state = eyes.eye_icon_state + var/mutable_appearance/glint = mutable_appearance('icons/mob/human/human_face.dmi', "[eye_state]_l") + glint.overlays += mutable_appearance('icons/mob/human/human_face.dmi', "[eye_state]_r") + glint.overlays += emissive_appearance('icons/mob/human/human_face.dmi', "[eye_state]_l", reader) + glint.overlays += emissive_appearance('icons/mob/human/human_face.dmi', "[eye_state]_r", reader) + glint.color = READOUT_GLINT_COLOR + glint.dir = reader.dir + var/obj/item/bodypart/head/head = human_reader.get_bodypart(BODY_ZONE_HEAD) + head?.worn_face_offset?.apply_offset(glint) + return glint + +#undef READOUT_ITEMIZED +#undef READOUT_SILHOUETTE +#undef READOUT_SCATTER +#undef READOUT_CLEAN +#undef READOUT_LOCK_TIME +#undef READOUT_OPEN_TIME +#undef READOUT_HOLD_TIME +#undef READOUT_CLEAN_HOLD_TIME +#undef READOUT_FADE_TIME +#undef READOUT_SCATTER_DROP_AT +#undef READOUT_SCATTER_FADE_TIME +#undef READOUT_PANEL_X +#undef READOUT_PANEL_TOP +#undef READOUT_SOCKETS_PER_ROW +#undef READOUT_MAX_SOCKETS +#undef READOUT_MAX_NAME_LINES +#undef READOUT_MIN_WIDTH +#undef READOUT_MAX_WIDTH +#undef READOUT_BAR_SEGMENTS +#undef READOUT_SOCKET_ICON_SCALE +#undef READOUT_HUD_COLOR +#undef READOUT_LOCK_COLOR +#undef READOUT_LOCK_FILL_COLOR +#undef READOUT_PANEL_COLOR +#undef READOUT_NAME_COLOR +#undef READOUT_DIM_COLOR +#undef READOUT_OFFLINE_COLOR +#undef READOUT_OFFLINE_NAME_COLOR +#undef READOUT_STATIC_FRAME_COLOR +#undef READOUT_GLINT_COLOR +#undef READOUT_ICON +#undef READOUT_BAR_ICON diff --git a/voidcrew/modules/cyberware/cyberware_stealth.dm b/voidcrew/modules/cyberware/cyberware_stealth.dm index a56766082b9..9e1aee21738 100644 --- a/voidcrew/modules/cyberware/cyberware_stealth.dm +++ b/voidcrew/modules/cyberware/cyberware_stealth.dm @@ -4,14 +4,39 @@ * The Ghostskin Weave (ware_military.dm) fades its bearer to an alpha shimmer, * which fools humans but means nothing to NPC AI, basic mobs target by * reference, not by pixels. This file is the AI half of the camo: a trait the - * weave holds while active, and a chained override of the basic-mob targeting - * strategy that refuses camo'd targets beyond point-blank range. + * weave holds while active, one shared question (cyberware_camo_hides()), and + * the hooks that ask it for everything that hunts the bearer by sight. * - * The override lives HERE, in a cyberware-owned file, rather than in the + * Everything that grants TRAIT_CYBER_CAMO is hidden from: + * - basic mobs and human NPCs (the targeting strategy below, which every + * strategy subtype inherits through its own `..()` calls: the human NPC + * strategy, pirate crews and boarders, bounty criminals and their crews, + * ruin troopers and hermits, patrols, the club's people), + * - simple_animal hostiles (hermits, ice demons, polar bears, ...), + * - every porta_turret subtype: ruin and Syndicate turrets, hull defense + * turrets, player-built turrets, trader and player outpost turrets, including + * a player an outpost has marked, + * and, through one-line calls in the files that own them, the scripted target + * picks that never go through a /basic strategy: the club's people + * (mafia_target_ok()), the lich's thralls and the outpost experiment forms + * (their form's can_target()). + * + * What camo does NOT hide from: anything within CYBERWARE_CAMO_SPOT_RANGE + * tiles, the mobs in GLOB.cyberware_camo_piercing_types (megafauna, elites + * and the bosses that are still basic mobs), and every human NPC of boss rank + * (npc_rank HUMAN_NPC_RANK_BOSS: faction pirate bosses, the NT patrol's + * director, bounty bosses, the kingpin, the club's don). Ship weapons that + * target ships, not people, are not covered here. The street Flickerskin + * never grants the trait at all. + * + * The overrides live HERE, in a cyberware-owned file, rather than in the * shared AI files: defining the same proc a second time is this fork's blessed * way to patch upstream behavior (the later .dme include wraps the earlier - * definition, and `..()` calls straight into it). Nothing outside this module - * changes on disk. + * definition, and `..()` calls straight into it). That only works for a proc + * whose first definition is included BEFORE this file, so the hooks for the + * voidcrew files that include after it (the lich, the club, the experiment + * forms) are a single call to cyberware_camo_hides() in the proc that picks + * the target. * * TRAIT_CYBER_CAMO itself lives in voidcrew/_DEFINES/cyberware.dm, because * the chrome read (cyberware_scan.dm) is included ahead of this file and also @@ -23,6 +48,49 @@ /// fauna pack point-blank is still a bad idea. The PvP/PvE counterplay floor. #define CYBERWARE_CAMO_SPOT_RANGE 2 +/// Mobs that see straight through optical camo, whatever it is: megafauna, +/// mining elites and the bosses and mini bosses of the ruins, lairs, outposts +/// and vestiges that are still basic mobs. A typecache, so subtypes count. +/// Bosses that are people (human NPCs) see through it by rank instead, in +/// cyberware_camo_hides(). +GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( + /mob/living/simple_animal/hostile/megafauna, + /mob/living/basic/trooper/pirate/faction/boss, + /mob/living/simple_animal/hostile/asteroid/elite, + /mob/living/basic/boss, + /mob/living/basic/lich, + /mob/living/basic/hoarfrost_matriarch, + /mob/living/basic/bounty_lair_boss, + /mob/living/basic/outpost_experiment/horror, + /mob/living/basic/alien/queen/large/derelict, + /mob/living/basic/vestige_oracle, + /mob/living/basic/vestige_mutant, + /mob/living/basic/vestige_warframe, +))) + +/** + * Whether optical camo hides `target` from `viewer` right now: the target holds + * TRAIT_CYBER_CAMO, the viewer is further off than CYBERWARE_CAMO_SPOT_RANGE and + * the viewer is not one of the mobs that sees through it (a piercing type, or a + * human NPC of boss rank). `viewer` is whatever + * is doing the looking (a mob, a turret, a creature picking its next victim). + * Anything that is not a living mob is never hidden. + */ +/proc/cyberware_camo_hides(mob/living/target, atom/viewer) + if(!isliving(target) || !HAS_TRAIT(target, TRAIT_CYBER_CAMO)) + return FALSE + if(!viewer) + return TRUE + if(get_dist(viewer, target) <= CYBERWARE_CAMO_SPOT_RANGE) + return FALSE + if(is_type_in_typecache(viewer, GLOB.cyberware_camo_piercing_types)) + return FALSE + if(ishumannpc(viewer)) + var/mob/living/carbon/human/npc/person = viewer + if(person.npc_rank >= HUMAN_NPC_RANK_BOSS) + return FALSE + return TRUE + /** * Chained override of the stock basic targeting strategy (base definition: * code/datums/ai/basic_mobs/targeting_strategies/basic_targeting_strategy.dm). @@ -35,10 +103,49 @@ . = ..() if(!.) return - if(!isliving(the_target)) - return . - if(!HAS_TRAIT(the_target, TRAIT_CYBER_CAMO)) - return . - if(get_dist(living_mob, the_target) <= CYBERWARE_CAMO_SPOT_RANGE) - return . - return FALSE + if(cyberware_camo_hides(the_target, living_mob)) + return FALSE + return . + +/** + * The same for simple_animal hostiles, whose single gate is CanAttack() (base + * definition: code/modules/mob/living/simple_animal/hostile/hostile.dm). They + * ask it when they pick a target and again every tick they chase one, so a + * camo field that goes up mid-chase makes them lose the target. + */ +/mob/living/simple_animal/hostile/CanAttack(atom/the_target) + . = ..() + if(!.) + return + if(cyberware_camo_hides(the_target, src)) + return FALSE + return . + +/** + * Every porta_turret, however it finds its targets (the stock scan, the hull + * defense turret's, the outpost's), hands the list it wants to shoot at to + * tryToShootAt(). Anyone camo'd and out of spot range is dropped from it. A + * turret left with nothing behaves as one that found nothing (the cover goes + * back down) instead of standing up for a target it cannot see. + */ +/obj/machinery/porta_turret/tryToShootAt(list/atom/movable/targets) + var/hid_someone = FALSE + for(var/mob/living/candidate in targets.Copy()) + if(cyberware_camo_hides(candidate, src)) + targets -= candidate + hid_someone = TRUE + if(hid_someone && !length(targets)) + if(!always_up) + popDown() + return FALSE + return ..() + +/** + * Drops the bearer's camo field, if it is up. For the abilities that hurt someone without going + * through an attack the field listens for: a lunge, a landing. Same collapse as any other + * contact. + */ +/proc/cyberware_camo_drop(mob/living/carbon/bearer, reason = "field contact") + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = bearer?.get_organ_slot(ORGAN_SLOT_CYBERWARE_SKIN) + if(istype(weave)) + weave.break_camo(bearer, reason = reason) diff --git a/voidcrew/modules/cyberware/cyberware_zoom.dm b/voidcrew/modules/cyberware/cyberware_zoom.dm new file mode 100644 index 00000000000..1dff620378b --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_zoom.dm @@ -0,0 +1,244 @@ +/** + * # Optics zoom + * + * Every set of chrome optics (/obj/item/organ/eyes/robotic/cyberware and its + * subtypes) can zoom: one toggle button pans the bearer's camera toward the + * cursor, using tg's scope component (code/datums/components/scope.dm), the + * same one binoculars and rifle scopes use. How far it reaches is the optics' + * optics_zoom, passed straight through as the scope's range_modifier; better + * optics reach further, and the cheap ones slow their bearer while they look. + * + * The button rides the /datum/action/cooldown/cyberware bridge, so browned-out, + * EMP-scrambled or failing eyes can't zoom, and the chrome wheel collects it + * like any other chrome button. + * + * Zoom is look-only: + * - A zoomed bearer can't fire a gun (COMSIG_MOB_TRYING_TO_FIRE_GUN is refused, + * and a burst already under autofire stops when the zoom starts). + * - Their clicks on the zoomed view reach nothing on the map. The scope's + * fullscreen cursor catcher already eats them; check_click_intercept() below + * takes them before any click intercept or ClickOn() sees them. + * + * The zoom drops on its own when the eyes stop working or the bearer can't + * look: EMP, brownout or damage failure (chrome_passives_off()), the eyes + * coming out, blindness, a flash, a stun, knockdown or paralysis, any stat + * change (crit, sleep, death), and everything the scope already stops for + * (no client, climbing into a locker or a mech, the organ moving). + */ + +/obj/item/organ/eyes/robotic/cyberware + /// How far the zoom pans, as the scope's range_modifier: 1 reaches as far + /// again as the edge of the normal view, 2 twice that. 0 means no zoom. + var/optics_zoom = 1 + /// Slowdown while zoomed (a multiplicative_slowdown). 0 for none. + var/zoom_walk_penalty = 1 + /// Throttles the "can't aim" balloon, so autofire and click spam don't stack it. + COOLDOWN_DECLARE(zoom_aim_warning) + /// Throttles the iris tell, so flicking the zoom on and off can't flood chat. + COOLDOWN_DECLARE(zoom_tell) + +/// Gives these optics their zoom: the scope that does the panning, and the +/// button. Called from Initialize() on the optics base (cyberware_base.dm). +/obj/item/organ/eyes/robotic/cyberware/proc/setup_optics_zoom() + if(optics_zoom <= 0) + return + // ZOOM_METHOD_ITEM_ACTION with no item_action_type: the scope adds no button + // and no examine line of its own, and only zooms when we tell it to. + AddComponent(/datum/component/scope, range_modifier = optics_zoom, zoom_method = ZOOM_METHOD_ITEM_ACTION) + add_item_action(/datum/action/cooldown/cyberware/optics_zoom) + +/// Whether our bearer is looking through these optics' zoom right now. +/obj/item/organ/eyes/robotic/cyberware/proc/is_zoomed() + return owner && HAS_TRAIT_FROM(owner, TRAIT_CYBER_ZOOMED, REF(src)) + +/// Starts the zoom for our bearer. Returns TRUE if they are now zoomed. +/obj/item/organ/eyes/robotic/cyberware/proc/start_zoom() + var/mob/living/carbon/bearer = owner + if(!iscarbon(bearer) || is_zoomed()) + return FALSE + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + var/datum/component/scope/scope = GetComponent(/datum/component/scope) + if(!scope || !chrome?.is_running()) + return FALSE + if(bearer.is_blind()) + bearer.balloon_alert(bearer, "optics dark!") + return FALSE + if(!isturf(bearer.loc)) + bearer.balloon_alert(bearer, "can't see out!") + return FALSE + if(HAS_TRAIT(bearer, TRAIT_INCAPACITATED)) + return FALSE + // The scope refuses a clientless bearer and anyone already scoped (a rifle, + // binoculars), with its own balloon for the second. + if(!scope.zoom(bearer)) + return FALSE + engage_zoom(bearer) + return TRUE + +/** + * Our half of a zoom, once the scope is panning: the trait, the stop hooks, + * the gun block and the walk penalty. Split from start_zoom() because the scope + * needs a client, and a unit test has none. + */ +/obj/item/organ/eyes/robotic/cyberware/proc/engage_zoom(mob/living/carbon/bearer) + ADD_TRAIT(bearer, TRAIT_CYBER_ZOOMED, REF(src)) + // The scope stops itself for a few reasons of its own (no client, the bearer + // climbing into something); it drops TRAIT_USER_SCOPED when it does. + RegisterSignal(bearer, SIGNAL_REMOVETRAIT(TRAIT_USER_SCOPED), PROC_REF(on_scope_lost)) + RegisterSignal(bearer, COMSIG_MOB_TRYING_TO_FIRE_GUN, PROC_REF(on_zoomed_fire)) + RegisterSignal(bearer, COMSIG_MOB_STATCHANGE, PROC_REF(on_zoomed_stat_change)) + RegisterSignal(bearer, COMSIG_MOB_FLASHED, PROC_REF(on_zoomed_flashed)) + RegisterSignal(bearer, COMSIG_LIVING_STATUS_APPLIED, PROC_REF(on_zoomed_status)) + RegisterSignals(bearer, list(COMSIG_LIVING_STATUS_STUN, COMSIG_LIVING_STATUS_KNOCKDOWN, COMSIG_LIVING_STATUS_PARALYZE), PROC_REF(on_zoomed_stunned)) + if(zoom_walk_penalty) + bearer.add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom, multiplicative_slowdown = zoom_walk_penalty) + // Autofire fires through process_fire() and never asks the signal above, so + // a burst already rolling when the zoom starts is stopped here instead. + for(var/obj/item/gun/held in bearer.held_items) + var/datum/component/automatic_fire/autofire = held.GetComponent(/datum/component/automatic_fire) + autofire?.stop_autofiring() + // The tell, the chrome read's pattern: only people close enough see it. + if(COOLDOWN_FINISHED(src, zoom_tell)) + COOLDOWN_START(src, zoom_tell, 5 SECONDS) + bearer.visible_message(span_notice("[bearer]'s irises whir and narrow."), vision_distance = 3, ignored_mobs = bearer) + update_zoom_button() + +/// Stops the zoom, scope and all. Safe to call when nothing is zoomed. +/obj/item/organ/eyes/robotic/cyberware/proc/stop_zoom(mob/living/carbon/bearer) + if(!bearer) + return + var/datum/component/scope/scope = GetComponent(/datum/component/scope) + // Only ever stop OUR scope: the fullscreen it clears is shared by every scope. + if(scope?.tracker && HAS_TRAIT_FROM(bearer, TRAIT_USER_SCOPED, REF(scope))) + scope.stop_zooming(bearer) // drops TRAIT_USER_SCOPED, so on_scope_lost() runs + disengage_zoom(bearer) + +/// Undoes engage_zoom(). Idempotent: the passive hook calls it on every flip. +/obj/item/organ/eyes/robotic/cyberware/proc/disengage_zoom(mob/living/carbon/bearer) + if(!bearer) + return + REMOVE_TRAIT(bearer, TRAIT_CYBER_ZOOMED, REF(src)) + UnregisterSignal(bearer, list( + SIGNAL_REMOVETRAIT(TRAIT_USER_SCOPED), + COMSIG_MOB_TRYING_TO_FIRE_GUN, + COMSIG_MOB_STATCHANGE, + COMSIG_MOB_FLASHED, + COMSIG_LIVING_STATUS_APPLIED, + COMSIG_LIVING_STATUS_STUN, + COMSIG_LIVING_STATUS_KNOCKDOWN, + COMSIG_LIVING_STATUS_PARALYZE, + )) + bearer.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom) + update_zoom_button() + +/// The zoom button shows green while zoomed (the cooldown action's active look). +/obj/item/organ/eyes/robotic/cyberware/proc/update_zoom_button() + for(var/datum/action/cooldown/cyberware/optics_zoom/zoom in actions) + zoom.build_all_button_icons(UPDATE_BUTTON_STATUS) + +/// One short balloon when a zoomed bearer tries to shoot. +/obj/item/organ/eyes/robotic/cyberware/proc/refuse_zoomed_aim(mob/living/carbon/bearer) + if(!COOLDOWN_FINISHED(src, zoom_aim_warning)) + return + COOLDOWN_START(src, zoom_aim_warning, 1 SECONDS) + bearer.balloon_alert(bearer, "too zoomed in to aim!") + +// ---- Stop hooks ---------------------------------------------------------- + +/obj/item/organ/eyes/robotic/cyberware/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + // EMP reboot, brownout, damage failure and removal all come through here. + stop_zoom(bearer) + +/obj/item/organ/eyes/robotic/cyberware/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + stop_zoom(organ_owner) + return ..() + +/// Signal proc for [SIGNAL_REMOVETRAIT] of TRAIT_USER_SCOPED: the scope stopped. +/obj/item/organ/eyes/robotic/cyberware/proc/on_scope_lost(mob/living/carbon/source) + SIGNAL_HANDLER + disengage_zoom(source) + +/// Signal proc for [COMSIG_MOB_TRYING_TO_FIRE_GUN]: no shooting through the zoom. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_fire(mob/living/carbon/source, obj/item/gun/gun, atom/target, flag, params) + SIGNAL_HANDLER + refuse_zoomed_aim(source) + return COMPONENT_CANCEL_GUN_FIRE + +/// Signal proc for [COMSIG_MOB_STATCHANGE]: crit, sleep and death end the zoom. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_stat_change(mob/living/carbon/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != CONSCIOUS) + stop_zoom(source) + +/// Signal proc for [COMSIG_MOB_FLASHED]. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_flashed(mob/living/carbon/source) + SIGNAL_HANDLER + stop_zoom(source) + +/// Signal proc for [COMSIG_LIVING_STATUS_APPLIED]: going blind ends the zoom. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_status(mob/living/carbon/source, datum/status_effect/effect) + SIGNAL_HANDLER + if(istype(effect, /datum/status_effect/grouped/blindness)) + stop_zoom(source) + +/// Signal proc for stun, knockdown and paralysis. Mirrors the scope's own check. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_stunned(mob/living/carbon/source, amount = 0, ignore_canstun = FALSE) + SIGNAL_HANDLER + if(amount > 0) + stop_zoom(source) + +// ---- Clicking through the zoom ------------------------------------------- + +/** + * A click on the zoomed view, swallowed. Returns FALSE only for a click that + * isn't on our zoom, which the caller then handles as normal. + */ +/obj/item/organ/eyes/robotic/cyberware/proc/zoom_click(mob/living/carbon/bearer, atom/movable/screen/fullscreen/cursor_catcher/scope/tracker) + var/datum/component/scope/scope = GetComponent(/datum/component/scope) + if(!is_zoomed() || !scope || scope.tracker != tracker) + return FALSE + if(isgun(bearer.get_active_held_item())) + refuse_zoomed_aim(bearer) + return TRUE + +/** + * Zoomed clicks go to zoom_click() before the client's and the mob's click + * intercepts, so an armed ability never receives the scope's screen object as + * its target. The trait check keeps this to one lookup for everyone else. + */ +/mob/living/carbon/check_click_intercept(params, atom/A) + if(HAS_TRAIT(src, TRAIT_CYBER_ZOOMED) && istype(A, /atom/movable/screen/fullscreen/cursor_catcher/scope)) + var/obj/item/organ/eyes/robotic/cyberware/optics = get_organ_slot(ORGAN_SLOT_EYES) + if(istype(optics) && optics.zoom_click(src, A)) + return TRUE + return ..() + +// ---- The button ----------------------------------------------------------- + +/datum/action/cooldown/cyberware/optics_zoom + name = "Optic Zoom" + desc = "Focus your optics on something far off." + // tg's binoculars: reads as "look far", and stays clear of the Deadeye's crosshair. + button_icon = 'icons/obj/devices/tool.dmi' + button_icon_state = "binoculars" + +/datum/action/cooldown/cyberware/optics_zoom/chrome_wheel_name() + return "Zoom" + +/datum/action/cooldown/cyberware/optics_zoom/Activate(atom/target) + var/obj/item/organ/eyes/robotic/cyberware/optics = organ + if(!istype(optics) || !optics.owner) + return FALSE + if(optics.is_zoomed()) + optics.stop_zoom(optics.owner) + return TRUE + return optics.start_zoom() + +/datum/action/cooldown/cyberware/optics_zoom/is_action_active(atom/movable/screen/movable/action_button/current_button) + var/obj/item/organ/eyes/robotic/cyberware/optics = organ + return istype(optics) && optics.is_zoomed() + +/// The walk tax on cheap optics while they're zoomed. Strength set per optics. +/datum/movespeed_modifier/cyberware_zoom + variable = TRUE diff --git a/voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi b/voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi new file mode 100644 index 00000000000..8fb824341d4 Binary files /dev/null and b/voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi differ diff --git a/voidcrew/modules/cyberware/icons/chrome_hud.dmi b/voidcrew/modules/cyberware/icons/chrome_hud.dmi new file mode 100644 index 00000000000..2b65076978c Binary files /dev/null and b/voidcrew/modules/cyberware/icons/chrome_hud.dmi differ diff --git a/voidcrew/modules/cyberware/icons/chrome_read.dmi b/voidcrew/modules/cyberware/icons/chrome_read.dmi new file mode 100644 index 00000000000..9b3c41a776f Binary files /dev/null and b/voidcrew/modules/cyberware/icons/chrome_read.dmi differ diff --git a/voidcrew/modules/cyberware/icons/chrome_read_bar.dmi b/voidcrew/modules/cyberware/icons/chrome_read_bar.dmi new file mode 100644 index 00000000000..d35b976b5a2 Binary files /dev/null and b/voidcrew/modules/cyberware/icons/chrome_read_bar.dmi differ diff --git a/voidcrew/modules/cyberware/icons/chrome_wheel.dmi b/voidcrew/modules/cyberware/icons/chrome_wheel.dmi new file mode 100644 index 00000000000..a75f826549d Binary files /dev/null and b/voidcrew/modules/cyberware/icons/chrome_wheel.dmi differ diff --git a/voidcrew/modules/cyberware/icons/chrome_wheel_ring.dmi b/voidcrew/modules/cyberware/icons/chrome_wheel_ring.dmi new file mode 100644 index 00000000000..014ab351c38 Binary files /dev/null and b/voidcrew/modules/cyberware/icons/chrome_wheel_ring.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware.dmi b/voidcrew/modules/cyberware/icons/cyberware.dmi index 81028cb0dab..4b1b0b62816 100644 Binary files a/voidcrew/modules/cyberware/icons/cyberware.dmi and b/voidcrew/modules/cyberware/icons/cyberware.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim.dmi new file mode 100644 index 00000000000..f0b9f5ebf02 Binary files /dev/null and b/voidcrew/modules/cyberware/icons/cyberware_anim.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi new file mode 100644 index 00000000000..2e8607e159e Binary files /dev/null and b/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi new file mode 100644 index 00000000000..8004507e5b0 Binary files /dev/null and b/voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi new file mode 100644 index 00000000000..e5d4d0fa35d Binary files /dev/null and b/voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi new file mode 100644 index 00000000000..354d6891846 Binary files /dev/null and b/voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_ground.dmi b/voidcrew/modules/cyberware/icons/cyberware_ground.dmi new file mode 100644 index 00000000000..3762c0e948b Binary files /dev/null and b/voidcrew/modules/cyberware/icons/cyberware_ground.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi index 8b0d00c3981..3b03a18474e 100644 Binary files a/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi and b/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi index d06dac3ed36..6311eeb6592 100644 Binary files a/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi and b/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi b/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi index ce1bf56b5a1..b18b988f8b8 100644 Binary files a/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi and b/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi differ diff --git a/voidcrew/modules/cyberware/icons/cyberware_worn.dmi b/voidcrew/modules/cyberware/icons/cyberware_worn.dmi index 0e7aaa2e187..8b0bc67e783 100644 Binary files a/voidcrew/modules/cyberware/icons/cyberware_worn.dmi and b/voidcrew/modules/cyberware/icons/cyberware_worn.dmi differ diff --git a/voidcrew/modules/cyberware/ripperdoc_catalog.dm b/voidcrew/modules/cyberware/ripperdoc_catalog.dm index 4dfd8a8e01a..fb69e6550ec 100644 --- a/voidcrew/modules/cyberware/ripperdoc_catalog.dm +++ b/voidcrew/modules/cyberware/ripperdoc_catalog.dm @@ -8,10 +8,12 @@ * * Balance shape mirrors Vex's counter: credits buy the street and pro-utility * ware, vouchers gate everything that changes how you fight. Splice's intake - * ledger buys back ONLY the ladder-base rungs, the street chrome a customer - * outgrows, at half list, credits only, so climbing a slot ladder feels like - * climbing and never becomes an arbitrage loop (you can't refund vouchers, and - * a removed piece resold at 50% is always a loss). + * ledger buys back every piece he sells for credits (owner D7), at half list, + * credits only, so climbing a slot ladder feels like climbing and never becomes + * an arbitrage loop (voucher chrome bought at a counter is never bought back, + * and a removed piece resold at 50% is always a loss). Found chrome with no + * trade-in row (voucher-shelf pieces and loot-only ones) sells on four price + * bands instead, which refuse anything bought at a counter. * * Splice is not the only source any more. The roster also rides the zone loot * tables (modules/loot/themes/) at the tier that matches what he charges for @@ -73,6 +75,33 @@ /datum/shop_sku/ripperdoc/rockjaw, /datum/shop_sku/ripperdoc/fixers, /datum/shop_sku/ripperdoc/dermal_mesh, + /datum/shop_sku/ripperdoc/ghostskin_street, + /datum/shop_sku/ripperdoc/mantis_street, + /datum/shop_sku/ripperdoc/governor_street, + /datum/shop_sku/ripperdoc/redline_street, + /datum/shop_sku/ripperdoc/cascade_street, + // The phase 2 street models, street passives and optics. + /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_sku/ripperdoc/cuffer_wrists, + /datum/shop_sku/ripperdoc/medic_palms, + /datum/shop_sku/ripperdoc/subvocal_mic, + /datum/shop_sku/ripperdoc/fixers_street, + /datum/shop_sku/ripperdoc/muffler_ear, + /datum/shop_sku/ripperdoc/clotter_node, + /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_sku/ripperdoc/coolant_street, + /datum/shop_sku/ripperdoc/frostline, + /datum/shop_sku/ripperdoc/atlas_street, + /datum/shop_sku/ripperdoc/doppler_street, + /datum/shop_sku/ripperdoc/second_wind_deep, + /datum/shop_sku/ripperdoc/slipwire_street, + /datum/shop_sku/ripperdoc/hopper_street, + /datum/shop_sku/ripperdoc/adrenal_pump, + /datum/shop_sku/ripperdoc/gorilla_street, + /datum/shop_sku/ripperdoc/rockjaw_deepcore, + /datum/shop_sku/ripperdoc/ronin_holdout, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/hardweave, // ---- Pro Chrome (mixed) ---- /datum/shop_sku/ripperdoc/angler, /datum/shop_sku/ripperdoc/hemoglass, @@ -90,11 +119,20 @@ /datum/shop_sku/ripperdoc/atlas, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/monowire, + /datum/shop_sku/ripperdoc/monowire_live, /datum/shop_sku/ripperdoc/rigger, /datum/shop_sku/ripperdoc/slabskin, /datum/shop_sku/ripperdoc/mantis, + /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/ghostskin, /datum/shop_sku/ripperdoc/lazarus, + /datum/shop_sku/ripperdoc/governor_milspec, + /datum/shop_sku/ripperdoc/redline_milspec, + /datum/shop_sku/ripperdoc/cascade_milspec, + /datum/shop_sku/ripperdoc/hopper_milspec, + /datum/shop_sku/ripperdoc/slipwire_milspec, + /datum/shop_sku/ripperdoc/deadeye_milspec, + /datum/shop_sku/ripperdoc/lookout, // ---- Legend Chrome (the chase, always listed) ---- /datum/shop_sku/ripperdoc/governor_delete, /datum/shop_sku/ripperdoc/redline, @@ -104,12 +142,58 @@ /datum/shop_sku/ripperdoc/buster_rockets, ) buyback_types = list( - // The trade-in ladder: only the street rungs a customer outgrows. + // The trade-in ladder: every piece on the shelf priced in credits. /datum/shop_buyback/ripperdoc/nightshade, /datum/shop_buyback/ripperdoc/shock_coils, /datum/shop_buyback/ripperdoc/second_wind, /datum/shop_buyback/ripperdoc/dermal_mesh, /datum/shop_buyback/ripperdoc/scrapper, + // The cyberware lines' street models (ware_line_*.dm). + /datum/shop_buyback/ripperdoc/ghostskin_street, + /datum/shop_buyback/ripperdoc/mantis_street, + /datum/shop_buyback/ripperdoc/governor_street, + /datum/shop_buyback/ripperdoc/redline_street, + /datum/shop_buyback/ripperdoc/cascade_street, + // The phase 2 street models, street passives and optics. + /datum/shop_buyback/ripperdoc/spyglass, + /datum/shop_buyback/ripperdoc/cuffer_wrists, + /datum/shop_buyback/ripperdoc/medic_palms, + /datum/shop_buyback/ripperdoc/subvocal_mic, + /datum/shop_buyback/ripperdoc/fixers_street, + /datum/shop_buyback/ripperdoc/muffler_ear, + /datum/shop_buyback/ripperdoc/clotter_node, + /datum/shop_buyback/ripperdoc/assay_optics, + /datum/shop_buyback/ripperdoc/coolant_street, + /datum/shop_buyback/ripperdoc/frostline, + /datum/shop_buyback/ripperdoc/atlas_street, + /datum/shop_buyback/ripperdoc/doppler_street, + /datum/shop_buyback/ripperdoc/second_wind_deep, + /datum/shop_buyback/ripperdoc/slipwire_street, + /datum/shop_buyback/ripperdoc/hopper_street, + /datum/shop_buyback/ripperdoc/adrenal_pump, + /datum/shop_buyback/ripperdoc/gorilla_street, + /datum/shop_buyback/ripperdoc/rockjaw_deepcore, + /datum/shop_buyback/ripperdoc/ronin_holdout, + /datum/shop_buyback/ripperdoc/longsight, + /datum/shop_buyback/ripperdoc/hardweave, + // The rest of the credit shelf (owner D7). + /datum/shop_buyback/ripperdoc/hopper, + /datum/shop_buyback/ripperdoc/coolant, + /datum/shop_buyback/ripperdoc/fixers, + /datum/shop_buyback/ripperdoc/rockjaw, + /datum/shop_buyback/ripperdoc/gecko, + /datum/shop_buyback/ripperdoc/hemoglass, + /datum/shop_buyback/ripperdoc/angler, + /datum/shop_buyback/ripperdoc/registry_uplink, + /datum/shop_buyback/ripperdoc/cargo_cavity, + /datum/shop_buyback/ripperdoc/gastro, + /datum/shop_buyback/ripperdoc/chromatic_dermis, + /datum/shop_buyback/burnt_chrome, + // Found chrome that has no trade-in row, by price band. + /datum/shop_buyback/found_chrome/fitted, + /datum/shop_buyback/found_chrome/pro, + /datum/shop_buyback/found_chrome/military, + /datum/shop_buyback/found_chrome/legend, ) trader_lines = list( TRADER_LINE_GREETING = list( @@ -444,7 +528,7 @@ /datum/shop_sku/ripperdoc/cascade zone_permission = ZONE_PERMISSION_ELITE category = "Legend Chrome" - desc = "A Sandevistan reflex lattice in a sealed surgical case." + desc = "A full-spine reflex lattice in a sealed surgical case." item_path = /obj/item/organ/cyberimp/cyberware/cascade price_vouchers = 8 stock_min = 1 @@ -476,11 +560,21 @@ // ========================================================================= // SPLICE'S INTAKE LEDGER: the trade-in ladder -// Half list, credits only, street rungs only. Removed chrome resold here is +// Half list, credits only, credit-priced chrome only. Removed chrome resold here is // always a loss (you can't refund vouchers, and 50% never beats buying up the // ladder), so this reads as "trade up" and never as an arbitrage loop. +// +// The ripperdoc trade-in is the one ledger that takes chrome bought over a +// counter (owner decision, 2026-11-07): every other buyer and every export +// refuses anything carrying the trader-goods mark (trade/resale.dm). It holds +// because each entry pays half the lowest list price of the piece and a special +// takes at most 30% off, so a bought piece always sells back at a loss. The +// voidcrew_ripperdoc_resale unit test pins both halves of that. // ========================================================================= +/datum/shop_buyback/ripperdoc + buys_shop_stock = TRUE + /datum/shop_buyback/ripperdoc/nightshade name = "Nightshade Optics (pulled)" desc = "Old low-light eyes. Splice takes them off your hands when you move up to a targeting rig." @@ -492,7 +586,7 @@ /datum/shop_buyback/ripperdoc/shock_coils name = "Shock Coils (pulled)" - desc = "Reflex legs, lightly used. Trade-in credit toward the jump pistons." + desc = "Reflex legs, lightly used." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/shock_coils pay_credits = 250 @@ -501,9 +595,11 @@ /datum/shop_buyback/ripperdoc/second_wind name = "Second Wind Bladder (pulled)" - desc = "A used air reserve. Worth half toward something with a longer bladder." + desc = "A used air reserve." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/second_wind + // The Deep-lung is a subtype with its own, higher entry (ware_models_b.dm). + match_subtypes = FALSE pay_credits = 450 demand_min = 2 demand_max = 3 @@ -513,6 +609,8 @@ desc = "Light plating you've outgrown. Splice recycles the weave toward the heavy slab." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/dermal_mesh + // The Hardweave is a subtype with its own, higher entry (ware_models_b.dm). + match_subtypes = FALSE pay_credits = 1000 demand_min = 1 demand_max = 2 @@ -526,3 +624,244 @@ pay_credits = 200 demand_min = 2 demand_max = 4 + +// ========================================================================= +// THE REST OF THE CREDIT SHELF (owner D7): every piece he sells for credits +// comes back at half list, credits only. Exact types, so a ladder rung above +// or below (Spring calves, Vaulter, Tinker's, Deepcore, Heat-sink) keeps its +// own entry or none. Ratchet's subtypes carry his wording. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/hopper + name = "Hopper piston calves (pulled)" + desc = "Piston calves, still sprung." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/hopper + match_subtypes = FALSE + pay_credits = 1800 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/coolant + name = "Coolant Loop lattice (pulled)" + desc = "A drained coolant lattice." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/coolant + match_subtypes = FALSE + pay_credits = 1800 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/fixers + name = "Fixer's Fingers (pulled)" + desc = "A fingertip tool suite, pulled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers + match_subtypes = FALSE + pay_credits = 900 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/rockjaw + name = "Rockjaw drill fist (pulled)" + desc = "A drill fist with plenty of bit left." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/gecko + name = "Gecko Grip palm pads (pulled)" + desc = "Palm pads, a bit worn." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/gecko + match_subtypes = FALSE + pay_credits = 450 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/hemoglass + name = "Hemoglass filter (pulled)" + desc = "A blood filter, flushed." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/hemoglass + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/angler + name = "Harpoon Spool (pulled)" + desc = "A wrist spool and its line." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/angler + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/registry_uplink + name = "Registry uplink (pulled)" + desc = "A registry uplink, pulled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/registry_uplink + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/cargo_cavity + name = "Cargo Cavity (pulled)" + desc = "An empty rib compartment." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/cargo_cavity + match_subtypes = FALSE + pay_credits = 500 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/gastro + name = "Gastro Reactor (pulled)" + desc = "A used stomach liner." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/gastro + match_subtypes = FALSE + pay_credits = 200 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/chromatic_dermis + name = "Chromatic Dermis ink suite (pulled)" + desc = "A sheet of circuit ink, peeled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/chromatic_dermis + match_subtypes = FALSE + pay_credits = 200 + demand_min = 2 + demand_max = 3 + +/** + * Dead chrome off a dead (or opened-up) human NPC (/obj/item/burnt_chrome, human_npc.dm). Scrap money only: + * 50 cr is a fifth of the cheapest piece Splice sells for credits, so burning out bought chrome in a captive + * to sell the husk is always a loss, and the round's demand caps it. Not a ripperdoc trade-in row: those + * buy back shelf stock, and no shelf sells burnt chrome. + */ +/datum/shop_buyback/burnt_chrome + name = "Burnt-out chrome" + desc = "Dead implants. Splice strips them for the parts that didn't melt." + category = "Scrap" + item_path = /obj/item/burnt_chrome + pay_credits = 50 + demand_min = 4 + demand_max = 8 + +/datum/shop_buyback/burnt_chrome/street_doc + desc = "Dead implants. Ratchet melts them down for the metal." + +// ========================================================================= +// FOUND CHROME: the pieces no trade-in row takes +// Voucher-shelf chrome and loot-only chrome, out of caches or off the dead. +// Priced by band against the shelf: 1-voucher pieces pay 400 cr, 2-voucher +// pieces 900 cr (a paired arm counts as half its pair), 3-voucher and dearer +// pieces 1 voucher, the loot-only Piledriver 2. Each band names exact types, +// none of which any trade-in row matches, so no piece fits two rows. +// +// Not a /datum/shop_buyback/ripperdoc subtype on purpose: those take shop +// stock and pay credits only (voidcrew_ripperdoc_resale pins that). These +// refuse anything bought at a counter, so a voucher payout here only ever +// buys chrome nobody paid a shop for. +// ========================================================================= + +/datum/shop_buyback/found_chrome + category = "Found Chrome" + item_path = /obj/item/organ + icon_override = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + /// Exact chrome types this band pays for + var/list/wanted_chrome + +/datum/shop_buyback/found_chrome/buyback_wanted_kind(obj/item/offered) + return (offered.type in wanted_chrome) && !shop_buyback_inside_body(offered) + +/datum/shop_buyback/found_chrome/matches(obj/item/offered) + if(!..() || !(offered.type in wanted_chrome)) + return FALSE + var/obj/item/organ/ware = offered + return !(ware.organ_flags & ORGAN_FAILING) + +/// The stock refusal keys off item_path, which here is every organ (buyback_listed.dm). +/datum/shop_buyback/found_chrome/get_denial_reason(mob/living/user) + return buyback_listed_denial(src, user) + +/datum/shop_buyback/found_chrome/fitted + name = "fitted chrome" + desc = "Icepicks, sonar, field optics, grave hooks. Clean them up and they sell again." + icon_state_override = "icepick" + wanted_chrome = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/icepick, + /obj/item/organ/cyberimp/cyberware/doppler, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber, + /obj/item/organ/eyes/robotic/cyberware/prospector, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook, + ) + pay_credits = 400 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/found_chrome/pro + name = "pro chrome" + desc = "Combat optics, reflex wiring, one arm of a matched pair. Splice knows who's shopping." + icon_state_override = "mantis" + wanted_chrome = list( + /obj/item/organ/eyes/robotic/cyberware/deadeye, + /obj/item/organ/cyberimp/cyberware/slipwire, + /obj/item/organ/cyberimp/cyberware/dead_channel, + /obj/item/organ/cyberimp/cyberware/atlas, + /obj/item/organ/cyberimp/cyberware/gorilla_arms, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left, + ) + pay_credits = 900 + demand_min = 1 + demand_max = 3 + +/datum/shop_buyback/found_chrome/military + name = "military chrome" + desc = "Monowire, gun arms, slab plating, milspec builds. The big names too, if they still work." + icon_state_override = "ronin" + wanted_chrome = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/bunker_buster, + /obj/item/organ/cyberimp/cyberware/rigger, + /obj/item/organ/cyberimp/cyberware/slabskin, + /obj/item/organ/cyberimp/cyberware/ghostskin, + /obj/item/organ/cyberimp/cyberware/lazarus, + /obj/item/organ/eyes/robotic/cyberware/lookout, + /obj/item/organ/eyes/robotic/cyberware/deadeye/milspec, + /obj/item/organ/cyberimp/cyberware/slipwire/milspec, + /obj/item/organ/cyberimp/cyberware/hopper/milspec, + /obj/item/organ/cyberimp/cyberware/governor_delete/milspec, + /obj/item/organ/cyberimp/cyberware/redline/milspec, + /obj/item/organ/cyberimp/cyberware/cascade/milspec, + /obj/item/organ/cyberimp/cyberware/governor_delete, + /obj/item/organ/cyberimp/cyberware/redline, + /obj/item/organ/cyberimp/cyberware/cascade, + ) + pay_vouchers = 1 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/found_chrome/legend + name = "Piledriver" + desc = "Splice has never had one on the bench. He'd like to." + icon_state_override = "piledriver" + wanted_chrome = list(/obj/item/organ/cyberimp/cyberware/piledriver) + pay_vouchers = 2 + demand_min = 1 + demand_max = 1 diff --git a/voidcrew/modules/cyberware/street_doc_catalog.dm b/voidcrew/modules/cyberware/street_doc_catalog.dm new file mode 100644 index 00000000000..fbad68fc114 --- /dev/null +++ b/voidcrew/modules/cyberware/street_doc_catalog.dm @@ -0,0 +1,286 @@ +/** + * # The Back Bay: Ratchet, street ripperdoc + * + * Quartermain's chrome counter: a vendor stall in a storage bay off the depot's + * upper landing, with its own Chrome Cradle. Ratchet is a loader mechanic who + * started putting chrome in people, and the shelf shows it: street chrome only, + * nothing that needs a voucher. + * + * He lists the SAME /datum/shop_sku/ripperdoc types Splice does, not copies. + * Each shop builds its own SKU instances, so his stock is his own, but the + * price of a piece can never drift between the two counters. That matters + * because the cradle quotes one price per organ type + * (build_cyberware_price_index() in cradle_ui.dm), and because + * voidcrew_zone_shops/catalog pins the reputation gate of every SKU type that + * exists, so a parallel set of street-doc SKU types would need its own + * entries there for no gain. + * + * His intake ledger takes everything Splice's does at the same pay, even the + * credit chrome only Splice sells + * (subtypes of Splice's buybacks, with his own wording). Like Splice's, and + * unlike every other ledger, it takes chrome bought at a counter and pulled + * again (buys_shop_stock on /datum/shop_buyback/ripperdoc, ripperdoc_catalog.dm). + * Of Splice's found-chrome bands he runs only the two that pay credits: his + * counter never deals in vouchers either way. + * + * Catalog and NPC only: the organs live in ware_*.dm, the cradle in + * chrome_cradle.dm, the room on the Quartermain map + * (trader_outpost_outfitter.dmm). The NPC is a subtype of Splice's so the + * cradle's install patter (play_ripperdoc_bark()) comes out of his mouth. + */ + +/** + * Ratchet's look: a black robotics jumpsuit under a leather wader bib, the + * kind you hose down. Insulated gloves because half the job is wiring, welding + * goggles pushed up because the other half is welding, a hand-rolled cigarette, + * and a hand drill he uses on loaders and patients alike. Kept clear of Splice + * (no scrubs, no leather jacket) and of Sawbones (no head mirror). + * Purely cosmetic, the NPC snapshot only copies appearance. + */ +/datum/outfit/street_doc_ratchet + name = "Street ripperdoc" + uniform = /obj/item/clothing/under/rank/rnd/roboticist + suit = /obj/item/clothing/suit/apron/waders + gloves = /obj/item/clothing/gloves/color/yellow + shoes = /obj/item/clothing/shoes/workboots + glasses = /obj/item/clothing/glasses/welding/up + mask = /obj/item/cigarette/rollie + r_hand = /obj/item/screwdriver/power + +/datum/outpost_shop/vendor/street_doc + outpost_name = "\improper The Back Bay" + outpost_desc = "The depot's ripperdoc, in a storage bay he rents by the week." + trader_name = "Ratchet" + trader_outfit = /datum/outfit/street_doc_ratchet + trader_gender = MALE + trader_voice_pack = "goon.speak_3" + trader_voice_pitch = 0.88 + // A SKU whose category is missing here never renders on the storefront + // (voidcrew_shop_catalog checks it). Add the line models' category if it + // is not "Street Chrome". + categories = list( + "Street Chrome", + ) + sku_types = list( + // ---- Street Chrome (credits), Splice's own SKU types ---- + /datum/shop_sku/ripperdoc/chromatic_dermis, + /datum/shop_sku/ripperdoc/gastro, + /datum/shop_sku/ripperdoc/shock_coils, + /datum/shop_sku/ripperdoc/nightshade, + /datum/shop_sku/ripperdoc/scrapper, + /datum/shop_sku/ripperdoc/gecko, + /datum/shop_sku/ripperdoc/second_wind, + /datum/shop_sku/ripperdoc/cargo_cavity, + /datum/shop_sku/ripperdoc/rockjaw, + /datum/shop_sku/ripperdoc/fixers, + /datum/shop_sku/ripperdoc/dermal_mesh, + // ---- Street models from the cyberware lines (credit-priced T2) ---- + // CYBERWARE LINES: T2 SKUS START + /datum/shop_sku/ripperdoc/ghostskin_street, + /datum/shop_sku/ripperdoc/mantis_street, + /datum/shop_sku/ripperdoc/governor_street, + /datum/shop_sku/ripperdoc/redline_street, + /datum/shop_sku/ripperdoc/cascade_street, + // Phase 2: the street models, street passives and optics. Nothing + // priced in vouchers belongs here. + /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_sku/ripperdoc/cuffer_wrists, + /datum/shop_sku/ripperdoc/medic_palms, + /datum/shop_sku/ripperdoc/subvocal_mic, + /datum/shop_sku/ripperdoc/fixers_street, + /datum/shop_sku/ripperdoc/muffler_ear, + /datum/shop_sku/ripperdoc/clotter_node, + /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_sku/ripperdoc/coolant_street, + /datum/shop_sku/ripperdoc/frostline, + /datum/shop_sku/ripperdoc/atlas_street, + /datum/shop_sku/ripperdoc/doppler_street, + /datum/shop_sku/ripperdoc/second_wind_deep, + /datum/shop_sku/ripperdoc/slipwire_street, + /datum/shop_sku/ripperdoc/hopper_street, + /datum/shop_sku/ripperdoc/adrenal_pump, + /datum/shop_sku/ripperdoc/gorilla_street, + /datum/shop_sku/ripperdoc/rockjaw_deepcore, + /datum/shop_sku/ripperdoc/ronin_holdout, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/hardweave, + // CYBERWARE LINES: T2 SKUS END + ) + buyback_types = list( + /datum/shop_buyback/burnt_chrome/street_doc, + /datum/shop_buyback/ripperdoc/nightshade/street_doc, + /datum/shop_buyback/ripperdoc/shock_coils/street_doc, + /datum/shop_buyback/ripperdoc/second_wind/street_doc, + /datum/shop_buyback/ripperdoc/dermal_mesh/street_doc, + /datum/shop_buyback/ripperdoc/scrapper/street_doc, + // CYBERWARE LINES: T2 BUYBACKS START + /datum/shop_buyback/ripperdoc/ghostskin_street/street_doc, + /datum/shop_buyback/ripperdoc/mantis_street/street_doc, + /datum/shop_buyback/ripperdoc/governor_street/street_doc, + /datum/shop_buyback/ripperdoc/redline_street/street_doc, + /datum/shop_buyback/ripperdoc/cascade_street/street_doc, + /datum/shop_buyback/ripperdoc/spyglass/street_doc, + /datum/shop_buyback/ripperdoc/cuffer_wrists/street_doc, + /datum/shop_buyback/ripperdoc/medic_palms/street_doc, + /datum/shop_buyback/ripperdoc/subvocal_mic/street_doc, + /datum/shop_buyback/ripperdoc/fixers_street/street_doc, + /datum/shop_buyback/ripperdoc/muffler_ear/street_doc, + /datum/shop_buyback/ripperdoc/clotter_node/street_doc, + /datum/shop_buyback/ripperdoc/assay_optics/street_doc, + /datum/shop_buyback/ripperdoc/coolant_street/street_doc, + /datum/shop_buyback/ripperdoc/frostline/street_doc, + /datum/shop_buyback/ripperdoc/atlas_street/street_doc, + /datum/shop_buyback/ripperdoc/doppler_street/street_doc, + /datum/shop_buyback/ripperdoc/second_wind_deep/street_doc, + /datum/shop_buyback/ripperdoc/slipwire_street/street_doc, + /datum/shop_buyback/ripperdoc/hopper_street/street_doc, + /datum/shop_buyback/ripperdoc/adrenal_pump/street_doc, + /datum/shop_buyback/ripperdoc/gorilla_street/street_doc, + /datum/shop_buyback/ripperdoc/rockjaw_deepcore/street_doc, + /datum/shop_buyback/ripperdoc/ronin_holdout/street_doc, + /datum/shop_buyback/ripperdoc/longsight/street_doc, + /datum/shop_buyback/ripperdoc/hardweave/street_doc, + // CYBERWARE LINES: T2 BUYBACKS END + // The rest of Splice's credit shelf, which Ratchet buys back even where he + // doesn't sell it (owner D7). + /datum/shop_buyback/ripperdoc/hopper/street_doc, + /datum/shop_buyback/ripperdoc/coolant/street_doc, + /datum/shop_buyback/ripperdoc/fixers/street_doc, + /datum/shop_buyback/ripperdoc/rockjaw/street_doc, + /datum/shop_buyback/ripperdoc/gecko/street_doc, + /datum/shop_buyback/ripperdoc/hemoglass/street_doc, + /datum/shop_buyback/ripperdoc/angler/street_doc, + /datum/shop_buyback/ripperdoc/registry_uplink/street_doc, + /datum/shop_buyback/ripperdoc/cargo_cavity/street_doc, + /datum/shop_buyback/ripperdoc/gastro/street_doc, + /datum/shop_buyback/ripperdoc/chromatic_dermis/street_doc, + // Found chrome with no trade-in row, on Splice's credit bands only. + /datum/shop_buyback/found_chrome/fitted/street_doc, + /datum/shop_buyback/found_chrome/pro/street_doc, + ) + trader_lines = list( + TRADER_LINE_GREETING = list( + "Shut the door, you're letting the good air out.", + "Chair's free. The chrome isn't.", + "Don't mind the bucket.", + "You want it fancy, the Undertow's that way. You want it today, sit down.", + ), + TRADER_LINE_SALE = list( + "Done. Lie down whenever you're ready.", + "Good pick. I've put in a hundred of those. Most of them still work.", + "Wipe the slab before you lie on it.", + "Pleasure doing business.", + "There you go. Don't tell Sarge what you paid.", + ), + TRADER_LINE_REFUSAL = list( + "Your ship's on the list. Can't help you.", + "Not today. Sarge would have my bay.", + "No. Go square it with the depot first.", + ), + TRADER_LINE_WARNING = list( + "Easy. The turrets in here don't miss.", + "Do that again and the whole depot stops serving you.", + "I patch people up. I'd rather not start with you.", + ), + TRADER_LINE_AGGRESSION = list( + "Out of my bay!", + "Sarge! Got one for you!", + "Wrong guy to swing at.", + ), + TRADER_LINE_IDLE = list( + "That cradle came off a wreck. Don't ask whose.", + "Splice has the nicer chair. I've got the shorter flight.", + "I used to fix loaders. An arm's just a small loader.", + "The drain in the floor? Don't worry about the drain.", + "Most of my customers come back. Some of them on purpose.", + "The dockhands keep asking for discounts. I keep saying no.", + ), + TRADER_LINE_RESTOCK = list( + "Convoy's in. New chrome, some of it barely used.", + "Fresh crate. Still smells like the last owner.", + "Restocked. Get in before the dockhands do.", + ), + ) + +// ========================================================================= +// RATCHET'S INTAKE LEDGER +// Splice's ledger entries with Ratchet's wording. Same goods, same pay. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/nightshade/street_doc + desc = "Used low-light eyes." + +/datum/shop_buyback/ripperdoc/shock_coils/street_doc + desc = "Used calf pistons." + +/datum/shop_buyback/ripperdoc/second_wind/street_doc + desc = "A used air bladder." + +/datum/shop_buyback/ripperdoc/dermal_mesh/street_doc + desc = "Used subdermal plating." + +/datum/shop_buyback/ripperdoc/scrapper/street_doc + desc = "One used knuckle plate, either hand." + +/datum/shop_buyback/ripperdoc/ghostskin_street/street_doc + desc = "A used camo weave. Patchy, like they all are." + +/datum/shop_buyback/ripperdoc/mantis_street/street_doc + desc = "One used blade housing, either arm." + +/datum/shop_buyback/ripperdoc/governor_street/street_doc + desc = "A used governor patch. The tape's still on it." + +/datum/shop_buyback/ripperdoc/redline_street/street_doc + desc = "A used rage core. Smells burnt." + +/datum/shop_buyback/ripperdoc/cascade_street/street_doc + desc = "A used spinal rig, held together with tape." + +/datum/shop_buyback/ripperdoc/hopper/street_doc + desc = "Used piston calves." + +/datum/shop_buyback/ripperdoc/coolant/street_doc + desc = "A coolant lattice, drained." + +/datum/shop_buyback/ripperdoc/fixers/street_doc + desc = "Used fingertip tools." + +/datum/shop_buyback/ripperdoc/rockjaw/street_doc + desc = "A used drill fist." + +/datum/shop_buyback/ripperdoc/gecko/street_doc + desc = "Worn palm pads." + +/datum/shop_buyback/ripperdoc/hemoglass/street_doc + desc = "A used blood filter." + +/datum/shop_buyback/ripperdoc/angler/street_doc + desc = "A wrist harpoon, line and all." + +/datum/shop_buyback/ripperdoc/registry_uplink/street_doc + desc = "A used registry uplink." + +/datum/shop_buyback/ripperdoc/cargo_cavity/street_doc + desc = "An empty rib pocket." + +/datum/shop_buyback/ripperdoc/gastro/street_doc + desc = "A stomach liner, scrubbed. Mostly." + +/datum/shop_buyback/ripperdoc/chromatic_dermis/street_doc + desc = "Old circuit ink." + +/datum/shop_buyback/found_chrome/fitted/street_doc + desc = "Tool arms and field optics, pulled. He'll find them a new home." + +/datum/shop_buyback/found_chrome/pro/street_doc + desc = "Combat chrome, pulled. One blade or one fist is fine." + +// ========================================================================= +// THE NPC +// ========================================================================= + +/// Ratchet behind his counter. A ripperdoc subtype, so the cradle in his bay +/// talks through him during installs. +/mob/living/basic/outpost_trader/ripperdoc/street_doc + shop_type = /datum/outpost_shop/vendor/street_doc diff --git a/voidcrew/modules/cyberware/ware_arm_utility.dm b/voidcrew/modules/cyberware/ware_arm_utility.dm index 1c64088dd28..4b0bdaeec19 100644 --- a/voidcrew/modules/cyberware/ware_arm_utility.dm +++ b/voidcrew/modules/cyberware/ware_arm_utility.dm @@ -29,7 +29,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw name = "\improper Rockjaw drill fist" - desc = "A compact mining drill that folds out of the forearm. Anything it cracks gets vacuumed straight into an internal hopper, so you aren't crawling around the floor picking up your own ore." + desc = "A compact mining drill that folds out of the forearm, with a hopper that swallows whatever it cracks." icon_state = "rockjaw" chrome_load = 2 tier = CYBERWARE_TIER_1 @@ -58,7 +58,7 @@ /// diamond drill (0.2). The speed is part of what the 1,200 cr buys. /obj/item/pickaxe/drill/cyberware name = "rockjaw drill" - desc = "The business end of a Rockjaw drill fist. It only comes off the arm at a Chrome Cradle." + desc = "The business end of a Rockjaw drill fist." toolspeed = 0.5 /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/Destroy() @@ -160,7 +160,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers name = "\improper Fixer's Fingers" - desc = "A fingertip omnitool suite. Driver, wrench and cutters fold out of the knuckles, and the servo tendons behind them make everything you do with your hands quicker." + desc = "A fingertip omnitool suite. Driver, wrench and cutters fold out of the knuckles on quick servo tendons." icon_state = "fixers" chrome_load = 2 tier = CYBERWARE_TIER_1 @@ -170,6 +170,9 @@ /obj/item/wrench/cyborg, /obj/item/wirecutters/cyborg, ) + /// The action-speed modifier the servo tendons apply while the ware is running. + /// The model in the line (ware_line_models_a.dm) swaps in a weaker one. + var/datum/actionspeed_modifier/speed_modifier = /datum/actionspeed_modifier/cyberware_fixers // The tool-speed bonus rides the failing-gated passive layer (BAL-4): servo // tendons with an EMP reboot or a brownout are just fingers until the ware @@ -177,10 +180,10 @@ // idempotent and needs no applied-state guard. /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/chrome_passives_on(mob/living/carbon/bearer) . = ..() - bearer?.add_actionspeed_modifier(/datum/actionspeed_modifier/cyberware_fixers) + bearer?.add_actionspeed_modifier(speed_modifier) /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/chrome_passives_off(mob/living/carbon/bearer) . = ..() - bearer?.remove_actionspeed_modifier(/datum/actionspeed_modifier/cyberware_fixers) + bearer?.remove_actionspeed_modifier(speed_modifier) #undef CYBERWARE_ROCKJAW_SWEEP_DELAY diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index db50e79d8c2..58e909208b7 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -19,13 +19,19 @@ #define CYBERWARE_CASCADE_WINDOW (8 SECONDS) /// The price. #define CYBERWARE_CASCADE_CRASH (6 SECONDS) -/// Ranged dodge chance while moving during the window (shared arbiter; -/// highest-wins with Slipwire's 15, never additive). -#define CYBERWARE_CASCADE_DODGE_CHANCE 40 +/// Dodge chance during the window (shared arbiter; highest-wins with +/// Slipwire's 15, never additive): ranged fire while moving, blows any time. +#define CYBERWARE_CASCADE_DODGE_CHANCE 60 +/// Crit roll bonus for the bearer's own aimed hits during the window (crit_roll.dm). +#define CYBERWARE_CASCADE_CRIT_BONUS 18 /// Stamina dumped by the crash. #define CYBERWARE_CASCADE_CRASH_STAMINA 80 /// Degrees the afterimage hue moves per step of the trail. #define CYBERWARE_CASCADE_HUE_STEP 25 +/// Melee-cooldown multiplier during the window (lower is faster). +#define CYBERWARE_CASCADE_MELEE_MULT 0.5 +/// Starting opacity of a trail afterimage. +#define CYBERWARE_CASCADE_AFTERIMAGE_ALPHA 200 /** * # Cascade Lattice (T4, chest, OS slot, load 12) @@ -33,18 +39,24 @@ * The sandevistan-class flagship. Eight seconds where the world happens at * your speed: a full -1 gait (with reagent-modifier immunity for the * duration, no meth stacking, no sub-tick movement), melee cooldowns - * halved, and 40% ranged dodge while moving through the shared arbiter in - * ware_pro_combat.dm. Everyone nearby sees it: afterimages peel off every + * halved, 60% dodge through the shared arbiter in ware_pro_combat.dm (ranged + * fire while moving, blows whether moving or not), and +18 on the crit roll + * for the bearer's own aimed hits (crit_roll.dm). Everyone nearby sees it: afterimages peel off every * step, your victim's screen never had a chance, and the activation is an * audible time-tear. * * Then the heat bill: six seconds of crash, heavy stamina dump, leaden * slowdown, zero dodge. An EMP during the window skips straight to the * crash and re-arms the full sixty-second cooldown. + * + * Every number the window and the crash run on is a var here, so the lesser + * models in ware_line_sandy.dm retune by setting vars and swapping the action + * for a slower one. The status effects copy the numbers off the organ when the + * button is pressed and hold no reference back to it. */ /obj/item/organ/cyberimp/cyberware/cascade name = "\improper Cascade lattice" - desc = "A full-spine lattice of superconducting myelin, a real sandevistan, not one of the knockoffs. For eight seconds at a time you move and nobody else really does. Then all the heat it just made has to go somewhere, and it goes into you." + desc = "A full-spine lattice of superconducting myelin, the real thing, not one of the knockoffs. The bill comes afterward." icon_state = "cascade" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_OS @@ -54,6 +66,30 @@ aug_overlay = "cascade" emissive_overlay = TRUE actions_types = list(/datum/action/cooldown/cyberware/cascade_surge) + /// How long the window lasts. + var/window_duration = CYBERWARE_CASCADE_WINDOW + /// How long the crash lasts once the window ends. + var/crash_duration = CYBERWARE_CASCADE_CRASH + /// Stamina dumped on the bearer by the crash. + var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA + /// Dodge chance during the window: ranged fire while moving, blows any time. + var/window_dodge = CYBERWARE_CASCADE_DODGE_CHANCE + /// Crit roll bonus for the bearer's own aimed hits during the window. + var/window_crit_bonus = CYBERWARE_CASCADE_CRIT_BONUS + /// The gait the window grants. + var/datum/movespeed_modifier/window_speed = /datum/movespeed_modifier/cyberware_cascade + /// Melee-cooldown multiplier during the window (lower is faster). + var/window_melee_mult = CYBERWARE_CASCADE_MELEE_MULT + /// The bearer's screen tint during the window. + var/datum/client_colour/window_colour = /datum/client_colour/cyberware_cascade + /// A trail afterimage is left on every Nth step. + var/afterimage_interval = 1 + /// Starting opacity of a trail afterimage. + var/afterimage_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA + /// The status alert shown while the window runs. + var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade + /// What the bearer reads on activation. + var/online_message = "CASCADE ONLINE." /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -83,7 +119,7 @@ /datum/action/cooldown/cyberware/cascade_surge name = "Cascade Surge" - desc = "Eight seconds of overwhelming speed: melee cooldowns halved, 40% chance to dodge ranged fire while moving. Then six seconds of crash. An EMP mid-window crashes you instantly." + desc = "Eight seconds of overwhelming speed: melee cooldowns halved, hits land harder, and a 60% chance to dodge blows, or ranged fire on the move. Then six seconds of crash." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_cascade" cooldown_time = 60 SECONDS @@ -97,7 +133,8 @@ surger.balloon_alert(surger, "lattice still cycling!") return FALSE StartCooldown() - surger.apply_status_effect(/datum/status_effect/cyberware_cascade_window) + // The lattice hands the window its numbers (an organ-less call falls back to the T4 defaults). + surger.apply_status_effect(/datum/status_effect/cyberware_cascade_window, organ) return TRUE /** @@ -112,40 +149,86 @@ alert_type = /atom/movable/screen/alert/status_effect/cyberware_cascade /// Hue of the next afterimage, in degrees. Starts random so no two surges match. var/trail_hue = 0 + // The numbers below default to the T4 lattice and are overwritten from the + // organ that opened the window (see on_creation). Copied, never referenced. + /// Dodge chance while the window lives: ranged fire while moving, blows any time. + var/dodge_chance = CYBERWARE_CASCADE_DODGE_CHANCE + /// Crit roll bonus for the bearer's own aimed hits while the window lives. + var/crit_bonus = CYBERWARE_CASCADE_CRIT_BONUS + /// The gait the window grants. + var/datum/movespeed_modifier/speed_type = /datum/movespeed_modifier/cyberware_cascade + /// Melee-cooldown multiplier (lower is faster). + var/melee_mult = CYBERWARE_CASCADE_MELEE_MULT + /// The bearer's screen tint. + var/datum/client_colour/colour_type = /datum/client_colour/cyberware_cascade + /// A trail afterimage is left on every Nth step. + var/afterimage_interval = 1 + /// Starting opacity of a trail afterimage. + var/afterimage_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA + /// What the bearer reads on activation. + var/online_message = "CASCADE ONLINE." + /// The crash this window rolls into. + var/crash_duration = CYBERWARE_CASCADE_CRASH + var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA + /// Steps taken so far, for the afterimage cadence. + var/steps_taken = 0 + +/// Optional second argument: the lattice whose numbers this window runs on. +/datum/status_effect/cyberware_cascade_window/on_creation(mob/living/new_owner, obj/item/organ/cyberimp/cyberware/cascade/lattice) + if(istype(lattice)) + duration = lattice.window_duration + alert_type = lattice.window_alert + dodge_chance = lattice.window_dodge + crit_bonus = lattice.window_crit_bonus + speed_type = lattice.window_speed + melee_mult = lattice.window_melee_mult + colour_type = lattice.window_colour + afterimage_interval = lattice.afterimage_interval + afterimage_alpha = lattice.afterimage_alpha + online_message = lattice.online_message + crash_duration = lattice.crash_duration + crash_stamina = lattice.crash_stamina + return ..() /datum/status_effect/cyberware_cascade_window/on_apply() trail_hue = rand(0, 359) - owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade) + owner.add_movespeed_modifier(speed_type) owner.add_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) - cyberware_register_dodge_source(owner, id, CALLBACK(src, PROC_REF(get_dodge_chance))) + cyberware_register_dodge_source(owner, id, CALLBACK(src, PROC_REF(get_dodge_chance)), melee = TRUE) + owner.hv_crit_set_attacker_bonus(id, crit_bonus) RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_move)) - owner.add_client_colour(/datum/client_colour/cyberware_cascade, id) + owner.add_client_colour(colour_type, id) playsound(owner, 'sound/effects/magic/timeparadox2.ogg', 70, TRUE) owner.visible_message( span_boldwarning("[owner] fractures into afterimages. Something under [owner.p_their()] skin is running far too fast!"), - span_boldnotice("CASCADE ONLINE. Eight seconds."), + span_boldnotice(online_message), ) owner.balloon_alert(owner, "cascade online") + cyberware_cascade_window_fx(owner) return TRUE /datum/status_effect/cyberware_cascade_window/on_remove() - owner.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade) + owner.remove_movespeed_modifier(speed_type) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_unregister_dodge_source(owner, id) + owner.hv_crit_set_attacker_bonus(id, null) UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) - owner.apply_status_effect(/datum/status_effect/cyberware_cascade_crash) + owner.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash_duration, crash_stamina) + if(!QDELETED(owner)) + cyberware_cascade_window_end_fx(owner) return ..() -/// Melee cooldowns halved for the window (changeNext_move multiplies these in). +/// Melee cooldowns scaled for the window (changeNext_move multiplies these in). /datum/status_effect/cyberware_cascade_window/nextmove_modifier() - return 0.5 + return melee_mult -/// Dodge-source callback for the shared arbiter: flat 40 while the window -/// lives (the arbiter itself enforces moving-only and point-blank rules). +/// Dodge-source callback for the shared arbiter: flat chance while the window +/// lives (the arbiter itself enforces the moving-only and point-blank rules +/// for shots, and the pinned rules for blows). /datum/status_effect/cyberware_cascade_window/proc/get_dodge_chance() - return CYBERWARE_CASCADE_DODGE_CHANCE + return dodge_chance /// Signal proc for [COMSIG_MOVABLE_MOVED]: one afterimage per step, the /// telegraph everyone in the fight can read. Each step shifts the hue, so the @@ -154,16 +237,19 @@ SIGNAL_HANDLER if(!isturf(old_loc)) return - new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue) + steps_taken++ + if(steps_taken % afterimage_interval) // cheaper chrome leaves a sparser trail + return + new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue, afterimage_alpha) trail_hue = (trail_hue + CYBERWARE_CASCADE_HUE_STEP) % 360 /// One afterimage in the Cascade trail, washed in a single hue. /obj/effect/temp_visual/decoy/fading/cascade duration = 8 -/obj/effect/temp_visual/decoy/fading/cascade/Initialize(mapload, atom/mimiced_atom, hue = 0) +/obj/effect/temp_visual/decoy/fading/cascade/Initialize(mapload, atom/mimiced_atom, hue = 0, start_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA) . = ..() - alpha = 200 + alpha = start_alpha color = cascade_tint_matrix(hue) animate(src, alpha = 0, time = duration) // restart the parent's fade from the new alpha @@ -185,19 +271,34 @@ /atom/movable/screen/alert/status_effect/cyberware_cascade name = "Cascade Window" - desc = "The lattice has the world in slow motion. Make it count. Six seconds of crash follows either way." + desc = "The lattice has the world in slow motion. Make it count." icon_state = "radiation_shield" +/// The lesser models' alert (ware_line_sandy.dm), with no crash timer in the text. +/atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket + name = "Reflex Window" + desc = "The rig has the world in slow motion. Make it count." + /// The bill. No dodge source, no speed, the opposite of all of it. /datum/status_effect/cyberware_cascade_crash id = "cyberware_cascade_crash" duration = CYBERWARE_CASCADE_CRASH tick_interval = STATUS_EFFECT_NO_TICK alert_type = null + /// Stamina dumped on the bearer when the crash lands. + var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA + +/// Optional arguments: crash length and stamina dump, from the window that ended. +/datum/status_effect/cyberware_cascade_crash/on_creation(mob/living/new_owner, crash_duration, stamina_dump) + if(isnum(crash_duration)) + duration = crash_duration + if(isnum(stamina_dump)) + crash_stamina = stamina_dump + return ..() /datum/status_effect/cyberware_cascade_crash/on_apply() owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade_crash) - owner.adjustStaminaLoss(CYBERWARE_CASCADE_CRASH_STAMINA) + owner.adjustStaminaLoss(crash_stamina) owner.add_client_colour(/datum/client_colour/cyberware_cascade_crash, id) playsound(owner, 'sound/machines/warning-buzzer.ogg', 50, TRUE) owner.visible_message( @@ -205,6 +306,7 @@ span_userdanger("The lattice dumps its heat. Your whole body seizes up and everything slows to a crawl."), ) owner.balloon_alert(owner, "cascade crash!") + cyberware_cascade_crash_fx(owner) return TRUE /datum/status_effect/cyberware_cascade_crash/on_remove() @@ -212,6 +314,8 @@ owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) owner.balloon_alert(owner, "systems restabilized") + if(!QDELETED(owner)) + cyberware_cascade_crash_end_fx(owner) return ..() /datum/movespeed_modifier/cyberware_cascade @@ -250,6 +354,8 @@ #define CYBERWARE_REDLINE_CRASH_STAMINA 100 /// Crash slowdown duration. #define CYBERWARE_REDLINE_CRASH (5 SECONDS) +/// The outline a bearer wears through the window. +#define CYBERWARE_REDLINE_OUTLINE_COLOR "#ff2a2a" /** * # Redline Core (T4, chest, OS slot, load 10) @@ -271,7 +377,7 @@ */ /obj/item/organ/cyberimp/cyberware/redline name = "\improper Redline core" - desc = "A combat governor rebuilt to fail open. Twelve seconds with every safety limit switched off at once, announced by a chest speaker nobody told you was in there." + desc = "A combat governor rebuilt to fail open, announced by a chest speaker nobody told you was in there." icon_state = "redline" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_OS @@ -281,6 +387,29 @@ aug_overlay = "redline" emissive_overlay = TRUE actions_types = list(/datum/action/cooldown/cyberware/redline_burn) + // The burn's tuning lives on the organ so a cheaper or rougher model (see + // ware_line_berserk.dm) retunes it by setting vars. The defaults below ARE + // the Redline core's numbers. The defines stay as those defaults. + /// Length of the burn window. + var/burn_window = CYBERWARE_REDLINE_WINDOW + /// Incoming brute/burn multiplier during the window. + var/burn_damage_mult = CYBERWARE_REDLINE_DAMAGE_MULT + /// Flat bonus to unarmed strikes during the window. + var/burn_melee_bonus = CYBERWARE_REDLINE_MELEE_BONUS + /// Whether the window grants stun and baton immunity. + var/burn_stun_immune = TRUE + /// Roar reach in tiles. 0 keeps the announcement but staggers nobody. + var/burn_roar_range = CYBERWARE_REDLINE_ROAR_RANGE + /// Crash slowdown duration. + var/burn_crash = CYBERWARE_REDLINE_CRASH + /// Stamina dumped by the crash. + var/burn_crash_stamina = CYBERWARE_REDLINE_CRASH_STAMINA + /// Cooldown on the Redline Burn button. + var/burn_cooldown = 90 SECONDS + /// Button tooltip. Null keeps the Redline Burn action's own text. + var/burn_action_desc + /// Status alert text while the window is open. Null keeps the alert's own text. + var/burn_alert_desc /obj/item/organ/cyberimp/cyberware/redline/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -312,12 +441,22 @@ /datum/action/cooldown/cyberware/redline_burn name = "Redline Burn" - desc = "Twelve seconds of stun immunity, 40% damage resistance and harder fists, opened with a staggering roar. Ends in a stamina collapse. An EMP mid-window collapses you instantly." + desc = "Twelve seconds of stun immunity, 40% damage resistance and harder fists, opened with a staggering roar. Ends in a collapse." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_redline" cooldown_time = 90 SECONDS click_to_activate = FALSE +/datum/action/cooldown/cyberware/redline_burn/New(Target, original = TRUE) + . = ..() + // The core this button belongs to sets its own cooldown and tooltip. + var/obj/item/organ/cyberimp/cyberware/redline/core = organ + if(!istype(core)) + return + cooldown_time = core.burn_cooldown + if(core.burn_action_desc) + desc = core.burn_action_desc + /datum/action/cooldown/cyberware/redline_burn/Activate(atom/target) if(!isliving(owner)) return FALSE @@ -326,7 +465,7 @@ burner.balloon_alert(burner, "core still venting!") return FALSE StartCooldown() - burner.apply_status_effect(/datum/status_effect/cyberware_redline_window) + burner.apply_status_effect(/datum/status_effect/cyberware_redline_window, organ) return TRUE /** @@ -382,47 +521,93 @@ /// roll, so the next arming is skipped. See suppress_topup() for why this /// can't just clear the armed state. var/topup_suppressed = FALSE + // Tuning. The defaults are the Redline core's own numbers; on_creation() + // replaces them with the installed core's when one is passed in. + /// Incoming brute/burn multiplier. + var/damage_mult = CYBERWARE_REDLINE_DAMAGE_MULT + /// Flat bonus to unarmed strikes. + var/melee_bonus = CYBERWARE_REDLINE_MELEE_BONUS + /// Whether the window grants stun and baton immunity. + var/stun_immune = TRUE + /// Roar reach in tiles, 0 for the announcement only. + var/roar_range = CYBERWARE_REDLINE_ROAR_RANGE + /// Handed to the crash this window rolls into. + var/crash_duration = CYBERWARE_REDLINE_CRASH + var/crash_stamina = CYBERWARE_REDLINE_CRASH_STAMINA + /// Replaces the alert's description, if set. + var/alert_desc + +/// Pass the core whose numbers this burn runs on. Without one the window uses +/// the Redline core's own. +/datum/status_effect/cyberware_redline_window/on_creation(mob/living/new_owner, obj/item/organ/cyberimp/cyberware/redline/core) + if(istype(core)) + duration = core.burn_window + damage_mult = core.burn_damage_mult + melee_bonus = core.burn_melee_bonus + stun_immune = core.burn_stun_immune + roar_range = core.burn_roar_range + crash_duration = core.burn_crash + crash_stamina = core.burn_crash_stamina + alert_desc = core.burn_alert_desc + . = ..() + if(. && linked_alert && alert_desc) + linked_alert.desc = alert_desc /datum/status_effect/cyberware_redline_window/on_apply() // The anti-stun implant's buff pattern: traits plus damage-slowdown immunity. - owner.add_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) + if(stun_immune) + owner.add_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) owner.add_movespeed_mod_immunities(id, /datum/movespeed_modifier/damage_slowdown) if(ishuman(owner)) var/mob/living/carbon/human/human_owner = owner - human_owner.physiology.brute_mod *= CYBERWARE_REDLINE_DAMAGE_MULT - human_owner.physiology.burn_mod *= CYBERWARE_REDLINE_DAMAGE_MULT + human_owner.physiology.brute_mod *= damage_mult + human_owner.physiology.burn_mod *= damage_mult if(iscarbon(owner)) var/mob/living/carbon/carbon_owner = owner for(var/obj/item/bodypart/arm/limb in carbon_owner.bodyparts) - limb.unarmed_damage_low += CYBERWARE_REDLINE_MELEE_BONUS - limb.unarmed_damage_high += CYBERWARE_REDLINE_MELEE_BONUS + limb.unarmed_damage_low += melee_bonus + limb.unarmed_damage_high += melee_bonus buffed_parts += limb RegisterSignal(owner, COMSIG_LIVING_EARLY_UNARMED_ATTACK, PROC_REF(on_early_unarmed_attack)) RegisterSignal(owner, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_unarmed_attack)) owner.add_client_colour(/datum/client_colour/cyberware_redline, id) + // Everyone can see a bearer is burning: a red outline for the whole window, gone when it ends or they die + owner.add_filter(id, 2, outline_filter(1, CYBERWARE_REDLINE_OUTLINE_COLOR)) + RegisterSignal(owner, COMSIG_LIVING_DEATH, PROC_REF(on_bearer_death)) owner.balloon_alert(owner, "REDLINE") INVOKE_ASYNC(src, PROC_REF(roar)) + // Red-hot nodes, red eyes, a red light (cyberware_anim2.dm). + cyberware_redline_window_fx(owner) return TRUE +/// The burn's outline goes out with its bearer +/datum/status_effect/cyberware_redline_window/proc/on_bearer_death(mob/living/source, gibbed) + SIGNAL_HANDLER + source.remove_filter(id) + /datum/status_effect/cyberware_redline_window/on_remove() - owner.remove_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) + if(stun_immune) + owner.remove_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/damage_slowdown) if(ishuman(owner)) var/mob/living/carbon/human/human_owner = owner - human_owner.physiology.brute_mod /= CYBERWARE_REDLINE_DAMAGE_MULT - human_owner.physiology.burn_mod /= CYBERWARE_REDLINE_DAMAGE_MULT + human_owner.physiology.brute_mod /= damage_mult + human_owner.physiology.burn_mod /= damage_mult for(var/obj/item/bodypart/arm/limb as anything in buffed_parts) if(QDELETED(limb)) continue - limb.unarmed_damage_low -= CYBERWARE_REDLINE_MELEE_BONUS - limb.unarmed_damage_high -= CYBERWARE_REDLINE_MELEE_BONUS + limb.unarmed_damage_low -= melee_bonus + limb.unarmed_damage_high -= melee_bonus buffed_parts.Cut() - UnregisterSignal(owner, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK)) + UnregisterSignal(owner, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK, COMSIG_LIVING_DEATH)) + owner.remove_filter(id) bypassed_victim = null bypassed_penetration = 0 owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) - owner.apply_status_effect(/datum/status_effect/cyberware_redline_crash) + owner.apply_status_effect(/datum/status_effect/cyberware_redline_crash, crash_duration, crash_stamina) + if(!QDELETED(owner)) + cyberware_redline_window_end_fx(owner) return ..() /** @@ -436,11 +621,19 @@ return playsound(owner, 'sound/effects/magic/demon_attack1.ogg', 90, TRUE) playsound(owner, 'sound/effects/magic/repulse.ogg', 60, TRUE) + if(roar_range <= 0) + // A core with no roar behind it still announces itself, just doesn't shake anything. + owner.visible_message( + span_boldwarning("[owner]'s eyes flood red and the speaker in [owner.p_their()] chest blares!"), + span_boldwarning("Your chest speaker blares."), + ) + return owner.visible_message( span_boldwarning("[owner]'s eyes flood red and a speaker-drone ROAR shakes the deck!"), span_boldwarning("You ROAR."), ) - for(var/mob/living/victim in oview(CYBERWARE_REDLINE_ROAR_RANGE, owner)) + cyberware_redline_roar_fx(owner, roar_range) + for(var/mob/living/victim in oview(roar_range, owner)) if(victim.stat == DEAD) continue if(cyberware_is_ally(owner, victim)) @@ -551,7 +744,7 @@ return var/target_zone = victim.get_random_valid_zone(owner.zone_selected) victim.apply_damage( - CYBERWARE_REDLINE_MELEE_BONUS, + melee_bonus, BRUTE, target_zone, victim.run_armor_check(target_zone, MELEE, armour_penetration = penetration, silent = TRUE), @@ -561,7 +754,7 @@ /atom/movable/screen/alert/status_effect/cyberware_redline name = "Redline Burn" - desc = "Every safety limit off at once. Stuns don't land, hits don't hurt much, and your fists hit a great deal harder. You collapse when it ends." + desc = "Every safety limit is off at once." icon_state = "radiation_shield" /datum/status_effect/cyberware_redline_crash @@ -569,16 +762,29 @@ duration = CYBERWARE_REDLINE_CRASH tick_interval = STATUS_EFFECT_NO_TICK alert_type = null + /// Stamina dumped on apply. + var/stamina_dump = CYBERWARE_REDLINE_CRASH_STAMINA + +/// The window hands over its core's crash length and stamina dump. Without +/// them the crash is the Redline core's own. +/datum/status_effect/cyberware_redline_crash/on_creation(mob/living/new_owner, new_duration, new_stamina_dump) + if(!isnull(new_duration)) + duration = new_duration + if(!isnull(new_stamina_dump)) + stamina_dump = new_stamina_dump + return ..() /datum/status_effect/cyberware_redline_crash/on_apply() owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_redline_crash) - owner.adjustStaminaLoss(CYBERWARE_REDLINE_CRASH_STAMINA) + owner.adjustStaminaLoss(stamina_dump) owner.add_client_colour(/datum/client_colour/cyberware_redline_crash, id) owner.visible_message( span_warning("[owner] sags as the red drains out of [owner.p_their()] eyes."), span_userdanger("The core slams shut and your legs go out from under you."), ) owner.balloon_alert(owner, "redline collapse!") + // The outline gutters out, the charge earths through the deck, the nodes cool, the body slumps (cyberware_anim2.dm). + cyberware_redline_crash_fx(owner) return TRUE /datum/status_effect/cyberware_redline_crash/on_remove() @@ -586,6 +792,8 @@ owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) owner.balloon_alert(owner, "core recovered") + if(!QDELETED(owner)) + cyberware_redline_crash_end_fx(owner) return ..() /datum/movespeed_modifier/cyberware_redline_crash @@ -684,7 +892,7 @@ */ /obj/item/organ/cyberimp/cyberware/piledriver name = "\improper Meteor Piledriver frame" - desc = "Prototype launch pistons sleeved over both legs, running into a gyroscopic landing computer that aims you at the floor. It wants an open lane to throw you down, and everyone underneath gets a shadow to look at before you get there." + desc = "Prototype launch pistons sleeved over both legs, running into a gyroscopic landing computer that aims you at the floor." icon_state = "piledriver" zone = BODY_ZONE_L_LEG slot = ORGAN_SLOT_CYBERWARE_LEGS @@ -706,7 +914,9 @@ desc = "Something very heavy is about to land on this exact spot." icon = 'icons/mob/telegraphing/telegraph_96x96.dmi' icon_state = "target_largebox" - layer = BELOW_MOB_LAYER + // Painted on the floor: under whoever stands in it, on every row it covers. + plane = FLOOR_PLANE + layer = LOWER_RUNE_LAYER pixel_x = -32 pixel_y = -32 alpha = 0 @@ -718,11 +928,13 @@ /datum/action/cooldown/cyberware/piledriver_leap name = "Meteor Leap" - desc = "Launch onto any tile you can see with a clear arc to it, or onto somebody standing on one. Walls, windows and shut doors stop the jump. A shadow warns everyone underneath first. A direct hit crushes whoever is still there: heavy brute, broken bones, flat on the deck. Everything within two tiles is knocked flat and thrown." + desc = "Launch onto a tile you can see, or onto somebody standing on one. A direct hit crushes whoever is still there, and everything within two tiles is knocked flat." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_piledriver" cooldown_time = 60 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body does not get to leap out of it. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE /datum/action/cooldown/cyberware/piledriver_leap/Activate(atom/target) if(!isliving(owner)) @@ -732,6 +944,10 @@ var/turf/here = get_turf(jumper) if(isnull(destination) || isnull(here) || destination == here) return FALSE + // The landing crushes whoever is under it, so it is not for a pacifist. + if(HAS_TRAIT(jumper, TRAIT_PACIFISM)) + to_chat(jumper, span_warning("You don't want to harm other living beings!")) + return FALSE if(jumper.buckled || !isturf(jumper.loc)) jumper.balloon_alert(jumper, "can't launch from here!") return FALSE @@ -749,6 +965,8 @@ if(arc_blocker(here, destination)) jumper.balloon_alert(jumper, "no room for the arc!") return FALSE + // The shadow goes down and the landing hurts someone. + cyberware_camo_drop(jumper) StartCooldown() // Liftoff: locked in for the whole arc, committed, like everything // with this much telegraph. The TILE is what gets locked, never the mob, @@ -757,15 +975,15 @@ jumper.face_atom(destination) var/launch_message = "You launch. The landing computer handles the rest." if(isliving(target)) - launch_message = "You launch. The landing computer locks the tile [target] is standing on, if [target.p_they()] move[target.p_s()], you come down on empty deck." + launch_message = "You launch. The landing computer locks onto [target]." jumper.visible_message( span_boldwarning("[jumper]'s leg pistons CRACK the deck and [jumper.p_they()] launch[jumper.p_es()] skyward!"), span_warning(launch_message), ) playsound(here, 'sound/effects/gravhit.ogg', 70, TRUE) - new /obj/effect/temp_visual/mook_dust(here) new /obj/effect/temp_visual/cyberware_piledriver_shadow(destination) - animate(jumper, pixel_z = 64, time = CYBERWARE_PILEDRIVER_AIRTIME * 0.5, flags = ANIMATION_RELATIVE, easing = QUAD_EASING|EASE_OUT) + // Up off the deck and over to the mark; the body stays on its tile until it lands (cyberware_anim2.dm). + cyberware_leap_takeoff_fx(jumper, here, destination, CYBERWARE_PILEDRIVER_AIRTIME, 64, 64) addtimer(CALLBACK(src, PROC_REF(land), destination), CYBERWARE_PILEDRIVER_AIRTIME) return TRUE @@ -783,7 +1001,7 @@ if(QDELETED(jumper)) return jumper.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), REF(src)) - animate(jumper, pixel_z = -64, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) + cyberware_leap_touchdown_fx(jumper) if(jumper.stat == DEAD) return // The marked tile can stop existing mid-flight (someone blows the floor out @@ -812,7 +1030,8 @@ crushed += pinned jumper.forceMove(final) playsound(final, 'sound/effects/meteorimpact.ogg', 90, TRUE) - new /obj/effect/temp_visual/mook_dust(final) + cyberware_grey_dust(final) + cyberware_piledriver_impact_fx(final, CYBERWARE_PILEDRIVER_IMPACT_RANGE) for(var/mob/living/victim as anything in crushed) crush_victim(jumper, victim) for(var/mob/living/victim in oview(CYBERWARE_PILEDRIVER_IMPACT_RANGE, final)) @@ -899,6 +1118,7 @@ #undef CYBERWARE_REDLINE_ROAR_RANGE #undef CYBERWARE_REDLINE_CRASH_STAMINA #undef CYBERWARE_REDLINE_CRASH +#undef CYBERWARE_REDLINE_OUTLINE_COLOR #undef CYBERWARE_PILEDRIVER_RANGE #undef CYBERWARE_PILEDRIVER_AIRTIME #undef CYBERWARE_PILEDRIVER_IMPACT_RANGE diff --git a/voidcrew/modules/cyberware/ware_line_berserk.dm b/voidcrew/modules/cyberware/ware_line_berserk.dm new file mode 100644 index 00000000000..6bdde5d925d --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_berserk.dm @@ -0,0 +1,91 @@ +/** + * # The Berserk line: cheaper cores for the Redline slot + * + * Two earlier models of the Redline core. Same chest OS slot as the Redline + * (and Cascade), so fitting one evicts the other. Same burn, retuned: every + * number lives in a var on /obj/item/organ/cyberimp/cyberware/redline + * (ware_legend.dm) and a model sets its own here. + * + * | | window | damage | punch | stun immune | roar | crash | cooldown | load | + * |---|---|---|---|---|---|---|---|---| + * | street | 8 s | x0.8 | +3 | no | none | 6 s | 90 s | 4 | + * | milspec | 10 s | x0.7 | +5 | yes | 4 tiles | 5 s | 105 s | 8 | + * | Redline | 12 s | x0.6 | +8 | yes | 5 tiles | 5 s | 90 s | 10 | + * + * The chest speaker announcement stays on every model: it is the telegraph. + * The street core has no roar to follow it, so it announces and staggers + * nobody. Crash stamina stays at the Redline's 100 on all three. + * + * The street core cycles as fast as the Redline itself (final balance pass, + * review-balance change 5): at 120 s it was the worst T2 OS pick. What keeps it + * the bottom rung is the short, soft window, no stun immunity and no roar. + */ + +/// Street: bench-built, sold for credits. Burns for less time, softens less and +/// does not hold off stuns. +/obj/item/organ/cyberimp/cyberware/redline/street + name = "\improper Hothead core" + desc = "A combat governor soldered together on a back-room bench out of parts from two others. The chest speaker rattles when it runs." + icon_state = "redline_street" + chrome_load = 4 + tier = CYBERWARE_TIER_2 + aug_overlay = "redline_street" + burn_window = 8 SECONDS + burn_damage_mult = 0.8 + burn_melee_bonus = 3 + burn_stun_immune = FALSE + burn_roar_range = 0 + burn_crash = 6 SECONDS + burn_cooldown = 90 SECONDS + burn_action_desc = "Eight seconds of damage resistance and harder fists, announced by the chest speaker. Ends in a collapse." + burn_alert_desc = "The safeties are loosened, not gone." + +/// Milspec: surplus assault issue. Close to the Redline, a size smaller. +/obj/item/organ/cyberimp/cyberware/redline/milspec + name = "\improper Vanguard core" + desc = "A surplus assault governor, still stencilled with someone else's unit number. The chest speaker is military grade, which means loud." + icon_state = "redline_milspec" + chrome_load = 8 + tier = CYBERWARE_TIER_3 + aug_overlay = "redline_milspec" + burn_window = 10 SECONDS + burn_damage_mult = 0.7 + burn_melee_bonus = 5 + burn_stun_immune = TRUE + burn_roar_range = 4 + burn_crash = 5 SECONDS + burn_cooldown = 90 SECONDS + burn_action_desc = "Ten seconds of stun immunity, 30% damage resistance and harder fists, opened with a staggering roar. Ends in a collapse." + burn_alert_desc = "The safeties are off." + +// ========================================================================= +// SPLICE SKUs (wired into the vendors at integration, not here) +// ========================================================================= + +/datum/shop_sku/ripperdoc/redline_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/redline/street + price_credits = 3000 + +/datum/shop_sku/ripperdoc/redline_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/redline/milspec + price_vouchers = 4 + +// ========================================================================= +// TRADE-IN: the street model only +// Half list, credits only, exact type. The milspec core is paid for in +// vouchers and is never bought back. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/redline_street + name = "Hothead Core (pulled)" + desc = "A pulled rage governor, scorched where the safeties used to be." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/redline/street + match_subtypes = FALSE + pay_credits = 1500 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/cyberware/ware_line_camo.dm b/voidcrew/modules/cyberware/ware_line_camo.dm new file mode 100644 index 00000000000..21561b3900f --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_camo.dm @@ -0,0 +1,67 @@ +/** + * # Camo line: the street weave + * + * The cheap rung under the Ghostskin Weave (ware_military_body.dm), on the same + * skin slot, so installing one evicts the other. It reuses the whole Ghostskin + * field, signals, break triggers and all, and only retunes the vars the parent + * exposes: a shorter, patchier fade that fools players and never mobs. + * + * This file is included BEFORE ware_military_body.dm, so it cannot use the + * CYBERWARE_GHOSTSKIN_* defines (they are defined and #undef'd over there). + * Every number below is a plain var override instead. + * + * What hides_from_mobs = FALSE buys, in practice: no TRAIT_CYBER_CAMO, so + * cyberware_stealth.dm never refuses the bearer as a target (basic mobs, + * simple_animal hostiles, turrets and the scripted hunters it names) and + * cyberware_scan.dm never scatters a chrome read on them. All of it is keyed + * on that one trait. + */ + +/obj/item/organ/cyberimp/cyberware/ghostskin/street + name = "\improper Flickerskin weave" + desc = "A budget subdermal weave of refraction cells, mostly salvage. Switched on, you go blurry at the edges, which is not the same as gone." + icon_state = "ghostskin_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + actions_types = list(/datum/action/cooldown/cyberware/ghostskin_cloak/street) + // Mostly faded, but never as faint as the full weave. Moving flares it + // well above that. + field_alpha = 60 + ripple_alpha = 170 + field_duration = 7 SECONDS + hides_from_mobs = FALSE + +/obj/item/organ/cyberimp/cyberware/ghostskin/street/engage_feedback(mob/living/carbon/wearer) + playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 35, TRUE) + wearer.visible_message( + span_warning("[wearer] flickers and goes patchy!"), + span_notice("The weave stutters on and smears the light around you. Not all of it."), + ) + +/datum/action/cooldown/cyberware/ghostskin_cloak/street + desc = "Seven seconds of patchy optical camo." + cooldown_time = 45 SECONDS + +// ========================================================================= +// SPLICE SKUS +// Not listed on any vendor from here: the lead adds the path to Splice's +// sku_types and to the street doc's, and the buyback to buyback_types. +// ========================================================================= + +/datum/shop_sku/ripperdoc/ghostskin_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/ghostskin/street + price_credits = 2400 + stock_min = 1 + stock_max = 2 + +/// Trade-in at half list, credits only, like the other street rungs. +/datum/shop_buyback/ripperdoc/ghostskin_street + name = "Flickerskin weave (pulled)" + desc = "A patchy camo weave you've outgrown." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/ghostskin/street + pay_credits = 1200 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/cyberware/ware_line_governor.dm b/voidcrew/modules/cyberware/ware_line_governor.dm new file mode 100644 index 00000000000..9de41c9a7fa --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_governor.dm @@ -0,0 +1,75 @@ +/** + * # The Governor line + * + * Two cheaper rungs under Governor Delete (ware_legend.dm), so a crew can buy + * a little headroom early and climb to the real thing. All three sit in the + * chest OS slot, so fitting one evicts another, and they evict Cascade and + * Redline too: the cost of headroom is the operating-system slot, same as the + * parent. Load is 0 on every rung. + * + * The parent already reads its bonus from `chrome_capacity_bonus`, so the + * rungs only retune that var and `tier`; there is no behaviour of their own. + * Delete has no downside beyond the slot, so there is none to scale down. + * + * Swapping down is safe by construction. The install gate nets the evicted + * governor's bonus out (installed_capacity_check), so a swap that would leave + * the body over its new ceiling is refused up front with the incumbent still + * online. Pulling a governor out is never blocked: whatever is then over the + * ceiling browns out until load drops, like any other capacity loss. + * + * Rung bonus limit load tier + * Patch +5 25 0 T2 (street, sold for credits) + * Bypass +10 30 0 T3 (military, sold for vouchers) + * Delete +15 35 0 T4 (ware_legend.dm) + */ + +/// T2: a hand-fitted patch over the limiter instead of a clean removal. +/obj/item/organ/cyberimp/cyberware/governor_delete/street + name = "\improper Governor Patch" + desc = "A neural governor with the limiter taped over rather than removed. The tape has seen some things." + icon_state = "governor_delete_street" + chrome_load = 0 + chrome_capacity_bonus = 5 + tier = CYBERWARE_TIER_2 + +/// T3: the limiter jumpered out of the circuit, surplus-grade work. +/obj/item/organ/cyberimp/cyberware/governor_delete/milspec + name = "\improper Governor Bypass" + desc = "A neural governor with its limiter jumpered out of the circuit. The surplus stencil is still on the casing." + icon_state = "governor_delete_milspec" + chrome_load = 0 + chrome_capacity_bonus = 10 + tier = CYBERWARE_TIER_3 + +// ========================================================================= +// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them +// to the Chop Shop's sku_types (and the street doc's, for the Patch). +// ========================================================================= + +/datum/shop_sku/ripperdoc/governor_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/street + price_credits = 2500 + +/datum/shop_sku/ripperdoc/governor_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/milspec + price_vouchers = 3 + +// ========================================================================= +// TRADE-IN: the Patch is the rung a customer outgrows, half list in credits. +// Not listed on any buyback_types yet, same integration pass as above. The +// Bypass costs vouchers, which can't be refunded, so it has no entry. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/governor_street + name = "Governor Patch (pulled)" + desc = "A taped-over governor, pulled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/street + match_subtypes = FALSE + pay_credits = 1250 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/cyberware/ware_line_mantis.dm b/voidcrew/modules/cyberware/ware_line_mantis.dm new file mode 100644 index 00000000000..2505e971bf1 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_mantis.dm @@ -0,0 +1,182 @@ +/** + * # Mantis Blades line: the Shiv and Scorch models + * + * Two more ways to buy the same forearm blades (ware_military_arms.dm): + * - Shiv Blades, the street model. A duller blade, no lunge, light on the + * nervous system, sold for credits in the yellow zone and bought back. + * - Scorch Blades, the fire model. Mantis in every number, but every cut + * sets the target alight and the lunge lights them harder. + * + * Both are subtypes of the Mantis housing, so they share its arm hardware slot: + * installing one evicts the other. Both ship as cased pairs (left and right), + * like the parent. The tuning rides vars on the housing (lunge_range, + * lunge_bonus, lunge_fire_stacks and friends), so a model retunes by setting + * them, not by copying the lunge. + */ + +// ---- Fire -------------------------------------------------------------- + +/** + * Puts fire stacks on a living target and lights them. The only fire this + * line makes. It never touches the attacker and never lights a turf: any + * heat the surroundings get is what a burning mob already gives off. Corpses + * are left alone, so a body is never cooked out from under a revive or a salvage. + * Returns TRUE if the target caught fire just now. + */ +/proc/cyberware_scorch_target(mob/living/victim, stacks) + if(!istype(victim) || QDELETED(victim) || victim.stat == DEAD || stacks <= 0) + return FALSE + victim.adjust_fire_stacks(stacks) + return victim.ignite_mob() + +// ---- Blades -------------------------------------------------------------- + +/** + * The Shiv's blade. The same hardlight on a worse emitter: dull grey, and it + * hits softer. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street + name = "shiv blade" + desc = "A dull grey sliver of hardlight with no shine to it. It still cuts." + icon_state = "mantis_street" + base_icon_state = "mantis_street" + inhand_icon_state = "mantis_street_guard" + force = 14 + +/** + * The Scorch's blade. Cuts like a Mantis, then lights what it cut. The edge runs + * orange and the blade throws a little firelight while it is out. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch + name = "scorch blade" + desc = "A forearm's length of hardlight gone orange along the edge. The air above it shimmers." + icon_state = "mantis_scorch" + base_icon_state = "mantis_scorch" + inhand_icon_state = "mantis_scorch_guard" + light_range = 2 + light_power = 1 + light_color = LIGHT_COLOR_FIRE + /// Fire stacks each landed cut puts on its target. + var/fire_stacks_per_hit = 2 + +// The glow is only for a blade that is out. Stowed inside the housing it is dark. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/equipped(mob/user, slot, initial = FALSE) + . = ..() + set_light_on(TRUE) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/dropped(mob/user, silent = FALSE) + . = ..() + set_light_on(FALSE) + +// Only a cut that landed burns (the parent's attack() decides that), never the wielder. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/on_cut_landed(mob/living/target, mob/living/user) + . = ..() + // Embers off the cut; the blade's impact is the slash (cyberware_anim2.dm). + cyberware_scorch_trail_fx(user, target) + if(cyberware_scorch_target(target, fire_stacks_per_hit)) + log_combat(user, target, "set alight", src, "(scorch blade)") + +// ---- Shiv Blades (street) ------------------------------------------------ + +/** + * # Shiv Blades (T2, arms, arm hardware slots, load 2 per arm / 4 the pair) + * + * The street answer to Mantis Blades: the same hardlight forearm housing built + * from salvage. The blade is weaker and there is no lunge, so no button besides + * the blade toggle. Cheap in credits and light on the nervous system, and Splice + * takes a pulled housing back toward the real thing. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street + name = "\improper Shiv Blade housing (right)" + desc = "A forearm housing with the serial plate filed off and an emitter wired in by someone who skipped the manual." + icon_state = "mantis_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + aug_overlay = "mantis_street" + items_to_create = list(/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street) + actions_types = list(/datum/action/item_action/organ_action/toggle/toolkit) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left + name = "\improper Shiv Blade housing (left)" + zone = BODY_ZONE_L_ARM + slot = ORGAN_SLOT_LEFT_ARM_AUG + +/obj/item/cyberware_pair_case/mantis_street + name = "\improper Shiv Blades case" + desc = "A scuffed clamshell case with two forearm housings jammed into mismatched foam." + ware_types = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left, + ) + +// ---- Scorch Blades (fire) ------------------------------------------------ + +/** + * # Scorch Blades (T3, arms, arm hardware slots, load 4 per arm / 8 the pair) + * + * Mantis Blades with a heat sink grafted onto the emitter rail. The blade, the + * lunge and the cooldown are the Mantis numbers. What is added is fire: each cut + * leaves the target burning, and the lunge lights them harder. It heats the + * wrist, but never sets the wearer alight. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch + name = "\improper Scorch Blade housing (right)" + desc = "A forearm housing with a heat sink grafted onto the emitter rail. The wrist runs warm even with nothing out." + icon_state = "mantis_scorch" + chrome_load = 4 + tier = CYBERWARE_TIER_3 + aug_overlay = "mantis_scorch" + items_to_create = list(/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch) + lunge_fire_stacks = 4 + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left + name = "\improper Scorch Blade housing (left)" + zone = BODY_ZONE_L_ARM + slot = ORGAN_SLOT_LEFT_ARM_AUG + +/obj/item/cyberware_pair_case/mantis_scorch + name = "\improper Scorch Blades case" + desc = "A heavy clamshell case, scorched at the corners, with two forearm housings set in heat-proof foam." + ware_types = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left, + ) + +// ---- The parlor's shelf -------------------------------------------------- + +/datum/shop_sku/ripperdoc/mantis_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + name = "Shiv Blades (pair)" + desc = "A cased pair of cheap forearm blades." + item_path = /obj/item/cyberware_pair_case/mantis_street + price_credits = 2400 + stock_min = 1 + stock_max = 2 + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/ripperdoc/mantis_scorch + zone_permission = ZONE_PERMISSION_ELITE + category = "Military Chrome" + name = "Scorch Blades (pair)" + desc = "A cased pair of forearm blades with heat sinks on the emitter rails." + item_path = /obj/item/cyberware_pair_case/mantis_scorch + price_vouchers = 4 + stock_min = 1 + stock_max = 1 + convoys_per_unit = 3 + fixed_price = TRUE + contract_reward = FALSE + +/// Half the credit price of one housing, either side, as the other street rungs +/// pay. Credits only, so turning a pair around is always a loss. +/datum/shop_buyback/ripperdoc/mantis_street + name = "Shiv Blade housing (pulled)" + desc = "A secondhand forearm housing, either side." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street + match_subtypes = TRUE + pay_credits = 600 + demand_min = 2 + demand_max = 4 diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm new file mode 100644 index 00000000000..01f1147c904 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -0,0 +1,418 @@ +/** + * # Cheaper and harder models of six wares + * + * Seven new models, each a subtype of the ware it sits under in the same slot, + * so fitting one evicts the other. The parent's tuning lives in vars (the Hopper + * leap, the Gorilla punches, the Fixer's tool-speed modifier), and a model + * retunes by setting them. + * + * model parent tier load price where + * Spring calves Hopper pistons T1 2 1,800 cr yellow + * Vaulter pistons Hopper pistons T3 4 3 vouchers advanced + * Brawler myomers Gorilla Arms T2 2 per arm 2,000 cr the pair yellow + * Tinker's fingers Fixer's Fingers T1 1 700 cr public + * Deepcore drill fist Rockjaw T2 3 2,400 cr public + * Holdout housing Popup Ronin T2 3 2,400 cr yellow + * + * Leg ladder: Shock Coils, Spring calves, Hopper, Vaulter, Meteor Piledriver. + * The Vaulter carries the Coils' soft landing and dry footing, which the Hopper + * itself never had, so stepping up to it costs no safety. + * + * Sold at Splice's counter, and the credit-priced ones at the street doc's + * (ripperdoc_catalog.dm, street_doc_catalog.dm). All of them also ride the + * zone loot tables (modules/loot/themes/). + */ + +// ========================================================================= +// SPRING CALVES (street Hopper) +// ========================================================================= + +/** + * # Spring calves (T1, legs, load 2) + * + * The cheap way into a jump: coil springs on the calves instead of pistons. A + * short hop that takes a while to wind back up. Nothing else. + */ +/obj/item/organ/cyberimp/cyberware/hopper/street + name = "\improper Spring calves" + desc = "Industrial coil springs sleeved over both calves and bolted straight to the shin. They squeak." + icon_state = "hopper_street" + chrome_load = 2 + tier = CYBERWARE_TIER_1 + aug_overlay = "hopper_street" + leap_range = 3 + leap_cooldown = 14 SECONDS + leap_action_desc = "Leap up to three tiles over gaps, tables and people." + +// ========================================================================= +// VAULTER PISTONS (milspec Hopper) +// ========================================================================= + +/** + * # Vaulter pistons (T3, legs, load 4) + * + * Military leg pistons: a longer jump on a shorter clock, with shock plates + * under the heel. Keeps the Shock Coils' soft landing and dry footing, minus the + * Coils' knockdown and stun trims, which stay with the Coils. + */ +/obj/item/organ/cyberimp/cyberware/hopper/milspec + name = "\improper Vaulter pistons" + desc = "Decommissioned military leg pistons, shock-plated at the heel. Somebody ground the serial number off and left the warning stencil." + icon_state = "hopper_milspec" + chrome_load = 4 + tier = CYBERWARE_TIER_3 + aug_overlay = "hopper_milspec" + organ_traits = list(TRAIT_NO_SLIP_WATER) + leap_range = 5 + leap_cooldown = 6 SECONDS + leap_action_desc = "Leap up to five tiles over gaps, tables and people." + +/obj/item/organ/cyberimp/cyberware/hopper/milspec/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + . = ..() + RegisterSignal(organ_owner, COMSIG_LIVING_Z_IMPACT, PROC_REF(on_z_impact)) + +/obj/item/organ/cyberimp/cyberware/hopper/milspec/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(organ_owner, COMSIG_LIVING_Z_IMPACT) + +/// Signal proc for [COMSIG_LIVING_Z_IMPACT]: the Shock Coils' soft landing. Big +/// multi-level drops still stagger. +/obj/item/organ/cyberimp/cyberware/hopper/milspec/proc/on_z_impact(datum/source, levels, turf/fell_on) + SIGNAL_HANDLER + if(organ_flags & ORGAN_FAILING) + return NONE + new /obj/effect/temp_visual/mook_dust(fell_on) + cyberware_ink_pulse(owner, CYBERWARE_INK_SOFT) + if(levels >= 2) + owner.adjust_staggered_up_to(STAGGERED_SLOWDOWN_LENGTH * levels, 10 SECONDS) + owner.visible_message( + span_notice("[owner] lands on [fell_on] with a piston hiss."), + span_notice("Your heel plates soak up the impact."), + ) + return ZIMPACT_CANCEL_DAMAGE | ZIMPACT_NO_MESSAGE | ZIMPACT_NO_SPIN + +// ========================================================================= +// BRAWLER MYOMERS (street Gorilla Arms) +// ========================================================================= + +/** + * # Brawler myomers (T2, arms, arm hardware slots, load 2 per arm / 4 the pair) + * + * Gorilla Arms bought cheap: lighter punches, slower, a lighter smash on + * structures (still enough to dent an airlock, plain or reinforced). Rock still + * breaks. A grabbed victim is never hurled; a grab-click on the floor or a table + * slams them, lighter and on a longer clock than the Gorilla's + * (hyperviolence_gorilla/gorilla_punch.dm). No haymaker, no flex. + * + * Both arms sync into their own set bonus (cyberware_brawler below), never the + * Gorilla's, so one of these beside a real Gorilla arm syncs nothing. The synced + * pair forces powered airlocks slower than the Gorilla, gets a lighter ground + * pound with no daze (gorilla_pound.dm), and has the limb rip as its only + * finisher (gorilla_finisher.dm). Trader outpost doors refuse a pry or a punch. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/street + name = "\improper Brawler myomers (right)" + desc = "Off-the-shelf myomer strips grafted along the forearm. You don't look any stronger for it. You just stop losing arm wrestles." + icon_state = "gorilla_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + aug_overlay = "gorilla_street_right" + punch_damage = 14 + structure_damage = BRAWLER_STRUCTURE_DAMAGE + punch_cooldown_time = 5 SECONDS + smash_cooldown_time = 1.5 SECONDS + hurl_range = 0 + pair_bonus = /datum/status_effect/organ_set_bonus/cyberware_brawler + // No hurl, haymaker or flex; the pair's pound and finisher are its own (hyperviolence_gorilla/) + heavy_moves = FALSE + slam_floor_damage = BRAWLER_SLAM_FLOOR_DAMAGE + slam_floor_stamina = BRAWLER_SLAM_FLOOR_STAMINA + slam_table_damage = BRAWLER_SLAM_TABLE_DAMAGE + slam_table_stamina = BRAWLER_SLAM_TABLE_STAMINA + slam_knockdown = BRAWLER_SLAM_KNOCKDOWN + slam_table_break = BRAWLER_SLAM_TABLE_BREAK + grab_move_cooldown = BRAWLER_GRAB_MOVE_COOLDOWN + slam_overhead = FALSE + slam_full_fx = FALSE + fist_word = "myomer-driven" + snap_strain = 4 + punch_wound_bonus = BRAWLER_PUNCH_WOUND_BONUS + impact_state = "impact_brawler" + +/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left + name = "\improper Brawler myomers (left)" + zone = BODY_ZONE_L_ARM + slot = ORGAN_SLOT_LEFT_ARM_AUG + aug_overlay = "gorilla_street_left" + +/** + * The synced Brawler pair: forces powered airlocks slower than the Gorilla and grants the light ground + * pound. No flex. Its own id, so gorilla_synced() never counts it and a mixed pair syncs into neither. + */ +/datum/status_effect/organ_set_bonus/cyberware_brawler + parent_type = /datum/status_effect/organ_set_bonus/cyberware_gorilla + id = "organ_set_bonus_cyberware_brawler" + bonus_activate_text = span_notice("Your myomers sync up.") + bonus_deactivate_text = span_notice("Your myomers fall out of sync.") + pry_time = BRAWLER_PRY_TIME + pound_type = /datum/action/cooldown/cyberware/gorilla_pound/brawler + flexes = FALSE + +/obj/item/cyberware_pair_case/gorilla_arms/street + name = "\improper Brawler myomers case" + desc = "A scuffed clamshell case with two bundles of myomer strips zip-tied into the foam." + ware_types = list( + /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left, + ) + +// ========================================================================= +// TINKER'S FINGERS (street Fixer's Fingers) +// ========================================================================= + +/// Tinker's fingers' servo speed-up: a tenth off every timed action, to the +/// Fixer's quarter. Its own key, so it never replaces the Fixer's. +/datum/actionspeed_modifier/cyberware_fixers/street + multiplicative_slowdown = -0.1 + +/** + * # Tinker's fingers (T1, arm aug, load 1) + * + * The same three tools in the knuckles and cheaper servos behind them. A green + * crew's way into the arm slot the Fixer's Fingers hold at yellow. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street + name = "\improper Tinker's fingers" + desc = "A fingertip tool suite in a housing that has been opened and closed more times than the warranty allows. Driver, wrench and cutters, all slightly loose." + icon_state = "fixers_street" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + speed_modifier = /datum/actionspeed_modifier/cyberware_fixers/street + +// ========================================================================= +// DEEPCORE DRILL FIST (heavy Rockjaw) +// ========================================================================= + +/// The Deepcore's drill: between the Rockjaw's drill (0.5) and the diamond +/// drill (0.2). +/obj/item/pickaxe/drill/cyberware/deepcore + name = "deepcore drill" + desc = "A long drill bit with a diamond collar, wired straight into the forearm. It isn't coming off without a surgeon." + toolspeed = 0.3 + +/** + * # Deepcore drill fist (T2, arm aug, load 3) + * + * The Rockjaw with a heavier drill on the arm. The ore hopper and its button + * are the Rockjaw's own. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore + name = "\improper Deepcore drill fist" + desc = "A heavy drill folded into the forearm, built for rock the usual drills give up on." + icon_state = "rockjaw_deepcore" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + items_to_create = list(/obj/item/pickaxe/drill/cyberware/deepcore) + +// ========================================================================= +// HOLDOUT HOUSING (street Popup Ronin) +// ========================================================================= + +/** + * The holdout's cylinder: six chambers of ordinary .38, thumbed in one round at + * a time. A speedloader feeds it a round per click, not the whole cylinder. + */ +/obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout + name = "holdout cylinder" + multiload = FALSE + +/** + * The gun the housing unfolds: a snub-nosed revolver on the stock chassis. NODROP + * while deployed comes from the toolkit Extend, so it can't be disarmed; Retract + * and EMP stow it. It leaves the shop loaded, and every reload after that is by + * hand, round by round. + */ +/obj/item/gun/ballistic/revolver/cyberware_holdout + name = "holdout revolver" + desc = "A snub-nosed revolver small enough to fold into a forearm. Uses .38 Special rounds." + icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' + icon_state = "ronin_holdout" + inhand_icon_state = "ronin_holdout" + lefthand_file = 'voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi' + righthand_file = 'voidcrew/modules/cyberware/icons/cyberware_righthand.dmi' + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout + fire_sound = 'sound/items/weapons/gun/revolver/shot.ogg' + fire_delay = 0.4 SECONDS + /// How long each round takes to thumb into the cylinder. + var/reload_time = 1 SECONDS + +/// TRUE when a chamber is empty or holds a spent casing, so a round can go in. +/obj/item/gun/ballistic/revolver/cyberware_holdout/proc/cylinder_has_room() + if(!magazine) + return FALSE + for(var/slot in magazine.stored_ammo) + if(isnull(slot)) + return TRUE + // A path is a round the cylinder hasn't spawned yet, which is a live one. + if(!istype(slot, /obj/item/ammo_casing)) + continue + var/obj/item/ammo_casing/casing = slot + if(!casing.loaded_projectile) + return TRUE + return FALSE + +// Thumbing a round in takes a moment. A full cylinder is refused without a wait. +/obj/item/gun/ballistic/revolver/cyberware_holdout/load_gun(obj/item/ammo, mob/living/user) + if(user && cylinder_has_room() && !do_after(user, reload_time, target = src)) + return FALSE + return ..() + +/** + * # Holdout housing (T2, one arm, aug slot, load 3) + * + * A fold-out forearm revolver for the yellow zone: six shots of ordinary .38, + * loaded by hand. Anyone who looks at the bearer sees the housing in the arm. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout + name = "\improper Holdout housing" + desc = "A forearm housing with a snub-nosed revolver folded inside. Not much to look at, which is the idea." + icon_state = "ronin_holdout" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + aug_overlay = "ronin_holdout" + items_to_create = list(/obj/item/gun/ballistic/revolver/cyberware_holdout) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/on_mob_insert(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + RegisterSignal(arm_owner, COMSIG_ATOM_EXAMINE, PROC_REF(on_owner_examined)) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(arm_owner, COMSIG_ATOM_EXAMINE) + +/// Signal proc for [COMSIG_ATOM_EXAMINE] on the bearer: the housing shows in the +/// arm to anyone who looks, suit or no suit. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/proc/on_owner_examined(mob/living/carbon/source, mob/user, list/examine_list) + SIGNAL_HANDLER + examine_list += span_notice("[source.p_They()] [source.p_have()] a revolver housing set into [source.p_their()] [parse_zone(zone)].") + +// ========================================================================= +// SPLICE SKUS: on the Chop Shop's sku_types (ripperdoc_catalog.dm), and the +// credit-priced ones on the Back Bay's too (street_doc_catalog.dm). +// ========================================================================= + +/datum/shop_sku/ripperdoc/hopper_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/hopper/street + price_credits = 1800 + +/datum/shop_sku/ripperdoc/hopper_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/hopper/milspec + price_vouchers = 3 + +/datum/shop_sku/ripperdoc/gorilla_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + name = "Brawler Myomers (pair)" + desc = "A cased pair of cheap myomer forearms." + item_path = /obj/item/cyberware_pair_case/gorilla_arms/street + price_credits = 2000 + stock_min = 1 + stock_max = 2 + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/ripperdoc/fixers_street + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street + price_credits = 700 + +/datum/shop_sku/ripperdoc/rockjaw_deepcore + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore + price_credits = 2400 + +/datum/shop_sku/ripperdoc/ronin_holdout + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + desc = "A snub-nosed revolver folded into a chrome forearm." + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout + price_credits = 2400 + stock_min = 1 + stock_max = 2 + fixed_price = TRUE + contract_reward = FALSE + +// ========================================================================= +// INTAKE LEDGERS: half the shelf price, credits only, like the other street +// rungs. The Vaulter costs vouchers, which can't be refunded, so it has no +// entry. Ratchet's subtypes carry his wording and are listed beside Splice's. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/hopper_street + name = "Spring calves (pulled)" + desc = "Used calf springs. They squeak." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/hopper/street + match_subtypes = FALSE + pay_credits = 900 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/hopper_street/street_doc + desc = "A used pair of calf springs." + +/datum/shop_buyback/ripperdoc/gorilla_street + name = "Brawler myomers (pulled)" + desc = "One myomer arm, either side." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/gorilla_arms/street + match_subtypes = TRUE + pay_credits = 500 + demand_min = 2 + demand_max = 4 + +/datum/shop_buyback/ripperdoc/gorilla_street/street_doc + desc = "One used myomer arm, either side." + +/datum/shop_buyback/ripperdoc/fixers_street + name = "Tinker's fingers (pulled)" + desc = "A used fingertip tool suite." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street + match_subtypes = FALSE + pay_credits = 350 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/fixers_street/street_doc + desc = "A used set of fingertip tools, a bit loose." + +/datum/shop_buyback/ripperdoc/rockjaw_deepcore + name = "Deepcore drill fist (pulled)" + desc = "A used drill fist. The bit's still sharp." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/rockjaw_deepcore/street_doc + desc = "A used drill fist, ore hopper and all." + +/datum/shop_buyback/ripperdoc/ronin_holdout + name = "Holdout housing (pulled)" + desc = "A used revolver housing." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/ronin_holdout/street_doc + desc = "A used revolver housing, cylinder and all." diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm new file mode 100644 index 00000000000..303ff54f346 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -0,0 +1,132 @@ +/** + * # The Cascade line: street and service-grade reflex rigs + * + * Two cheaper ways into the chest OS slot's speed core, both subtypes of the + * Cascade Lattice (ware_legend.dm) so they share its slot, its EMP rule, its + * crash-on-removal rule and its window code. Installing any of the three + * evicts the other two, and the Redline family sits in the same slot, so a + * crew climbs this ladder by swapping in the next rung. + * + * Each rung trades window strength for a worse bill: the lower the model, the + * slower the gait, the thinner the dodge and the melee boost, and the longer + * and nastier the crash. Window length stays nearly flat. The telegraphs are + * the same at every rung (the activation, the afterimages, the crash), just + * duller and sparser on the cheap chrome. + * + * model window gait dodge crit melee crash stamina cooldown load + * street 6 s -0.5 30 +9 x0.75 8 s 90 120 s 6 + * service 7 s -0.75 45 +14 x0.6 7 s 90 90 s 9 + * Cascade T4 8 s -1 60 +18 x0.5 6 s 80 60 s 12 + * + * The dodge covers ranged fire while moving and blows whether moving or not + * (the shared arbiter in ware_pro_combat.dm). The crit column is added to the + * aimed-crit roll of the bearer's own hits (crit_roll.dm): on a 25+ damage + * hit's 1 to 90 roll that is about +10, +15 and +20 points of chance. + * + * Sold at Splice's counter. The street model is credits, behind the yellow + * step, and trades in at the Intake ledger; the service model is vouchers + * behind the advanced step. + */ + +// ========================================================================= +// STREET MODEL (T2) +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/cascade/street + name = "spliced reflex rig" + desc = "A spinal reflex rig cobbled together from salvaged parts and a lot of tape. The scorch marks came with it." + icon_state = "cascade_street" + chrome_load = 6 + tier = CYBERWARE_TIER_2 + aug_overlay = "cascade_street" + actions_types = list(/datum/action/cooldown/cyberware/cascade_surge/street) + window_duration = 6 SECONDS + crash_duration = 8 SECONDS + crash_stamina = 90 + window_dodge = 30 + window_crit_bonus = 9 + window_speed = /datum/movespeed_modifier/cyberware_cascade/street + window_melee_mult = 0.75 + window_colour = /datum/client_colour/cyberware_cascade/street + afterimage_interval = 3 + afterimage_alpha = 120 + window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket + online_message = "REFLEX RIG ONLINE." + +/datum/action/cooldown/cyberware/cascade_surge/street + name = "Reflex Surge" + desc = "Six seconds of speed: melee cooldowns cut by a quarter, hits land harder, and a 30% chance to dodge blows, or ranged fire on the move. Then eight seconds of crash." + cooldown_time = 120 SECONDS + +/datum/movespeed_modifier/cyberware_cascade/street + multiplicative_slowdown = -0.5 + +/// The street rig's screen: the lattice's tint, a quarter as strong. +/datum/client_colour/cyberware_cascade/street + color = list(1.025, 0, 0.02, 0.0125, 1.0125, 0.025, 0.025, 0, 1.05) + +// ========================================================================= +// SERVICE MODEL (T3) +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/cascade/milspec + name = "service reflex lattice" + desc = "A decommissioned military reflex lattice. The serial plate has been ground off, but the stencilling on the casing is still there." + icon_state = "cascade_milspec" + chrome_load = 9 + tier = CYBERWARE_TIER_3 + aug_overlay = "cascade_milspec" + actions_types = list(/datum/action/cooldown/cyberware/cascade_surge/milspec) + window_duration = 7 SECONDS + crash_duration = 7 SECONDS + crash_stamina = 90 + window_dodge = 45 + window_crit_bonus = 14 + window_speed = /datum/movespeed_modifier/cyberware_cascade/milspec + window_melee_mult = 0.6 + window_colour = /datum/client_colour/cyberware_cascade/milspec + afterimage_interval = 2 + afterimage_alpha = 160 + window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket + online_message = "LATTICE ONLINE." + +/datum/action/cooldown/cyberware/cascade_surge/milspec + name = "Reflex Surge" + desc = "Seven seconds of speed: melee cooldowns cut by 40%, hits land harder, and a 45% chance to dodge blows, or ranged fire on the move. Then seven seconds of crash." + cooldown_time = 90 SECONDS + +/datum/movespeed_modifier/cyberware_cascade/milspec + multiplicative_slowdown = -0.75 + +/// The service lattice's screen: the lattice's tint, half as strong. +/datum/client_colour/cyberware_cascade/milspec + color = list(1.05, 0, 0.04, 0.025, 1.025, 0.05, 0.05, 0, 1.1) + +// ========================================================================= +// SPLICE SKUs and INTAKE LEDGER +// The lead wires these into /datum/outpost_shop/vendor/ripperdoc (sku_types, +// buyback_types) and the zone loot tables at integration. +// ========================================================================= + +/datum/shop_sku/ripperdoc/cascade_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/cascade/street + price_credits = 3600 + +/datum/shop_sku/ripperdoc/cascade_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/cascade/milspec + price_vouchers = 4 + +/// Half list, credits only, like the rest of the ladder's street rungs. +/datum/shop_buyback/ripperdoc/cascade_street + name = "Spliced Reflex Rig (pulled)" + desc = "A taped-together spinal rig. Splice strips it for parts toward a proper lattice." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/cascade/street + match_subtypes = FALSE + pay_credits = 1800 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/cyberware/ware_line_widowline.dm b/voidcrew/modules/cyberware/ware_line_widowline.dm new file mode 100644 index 00000000000..d01e197a7ef --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_widowline.dm @@ -0,0 +1,234 @@ +/** + * # Widowline line: the Arc model + * + * The Widowline monowire (ware_military_arms.dm) with a capacitor stack on the + * spool. Everything the plain wire does still happens, and every landed hit + * adds a shock on top: + * + * - Living targets take a little burn and a short stagger. The current goes in + * through the wire itself, so it is run with SHOCK_NOGLOVES and insulated + * gloves do nothing. Everything else tg's shock code already honours still + * applies: TRAIT_SHOCKIMMUNE, insulating suits and species or physiology + * shock coefficients. A target the shock can't reach takes only the plain + * cut: no burn, no stagger, no arc. + * - Silicons, mechs and robotic mobs (MOB_ROBOTIC) take a heavy burn bonus. + * The plain wire is nearly useless against them (its demolition_mod), and + * this is what makes up for it. Silicons shrug electrocute_act off by signal, + * so they get the bonus alone. + * - A turret, a ship laser turret, a console or a camera is knocked out for a + * few seconds. Never a raw emp_act: an EMP blacks out an APC for a minute, + * pops airlocks open, drains a laser turret's cell and scrambles a + * porta-turret's targeting flags. Each family is cut through its own + * machinery (the Icepick Jack's precedent), consoles lose power briefly, and + * anything that answers tg's on_saboteur() hook (cameras) goes through that. + * Outpost property and the trader outposts' concourse are immune. + * + * The through-cut is untouched: the body behind takes the plain cut, not the shock. + */ + +/// Burn of the shock itself, on top of the cut and the wire's own charge. +#define CYBERWARE_ARC_SHOCK_BURN 5 +/// Stagger a landed shock leaves, and the most it stacks to. +#define CYBERWARE_ARC_STAGGER (2 SECONDS) +#define CYBERWARE_ARC_STAGGER_CAP (4 SECONDS) +/// Burn bonus on silicons, mechs and robotic mobs. +#define CYBERWARE_ARC_ROBOTIC_BONUS 25 +/// How long a shorted turret, console or camera stays dark. +#define CYBERWARE_ARC_JAM_DURATION (5 SECONDS) +/// Gap before the same wire can short another machine. +#define CYBERWARE_ARC_JAM_RECHARGE (15 SECONDS) + +/** + * The wire. Plain Widowline numbers (force, burn, cleave, reach, armour + * penetration, demolition_mod) are inherited as they are; the vars here are + * the shock rider only. All of it keys off afterattack(), which only runs for + * a swing that actually landed, on a mob or on an object that can be hit. + */ +/obj/item/melee/cyberware_monowire/live + name = "\improper Widowline Arc monowire" + desc = "A weighted spool of monomolecular line with a capacitor stack feeding it. It smells of ozone and ticks like a cooling engine." + icon_state = "monowire_live" + inhand_icon_state = "monowire_live" + /// Burn of the shock on a living target. + var/shock_burn = CYBERWARE_ARC_SHOCK_BURN + /// Stagger a landed shock leaves. + var/stagger_time = CYBERWARE_ARC_STAGGER + /// Most stagger the shock stacks to. + var/stagger_cap = CYBERWARE_ARC_STAGGER_CAP + /// Burn bonus on silicons, mechs and robotic mobs. + var/robotic_bonus = CYBERWARE_ARC_ROBOTIC_BONUS + /// How long a shorted machine stays down. + var/jam_duration = CYBERWARE_ARC_JAM_DURATION + /// Gap before this wire can short another machine. + var/jam_recharge = CYBERWARE_ARC_JAM_RECHARGE + /// The stack recharging after a machine was shorted. + COOLDOWN_DECLARE(jam_cooldown) + +/obj/item/melee/cyberware_monowire/live/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) + . = ..() + if(!isliving(user) || QDELETED(target) || target == user) + return + // The lash out to what it hit is every wire's (ware_military_arms.dm); the shock is this one's. + if(isliving(target)) + discharge_into_mob(target, user) + else if(ismecha(target)) + discharge_into_mech(target, user) + else if(ismachinery(target)) + discharge_into_machine(target, user) + +/// On a body while the Arc's charge goes into it: no shock shake (do_jitter_animation()). +#define TRAIT_ARC_NO_SHAKE "arc_no_shake" + +/mob/living/do_jitter_animation(jitter_amount = 100) + if(HAS_TRAIT(src, TRAIT_ARC_NO_SHAKE)) + return + return ..() + +/// The shock on whatever it went into: a small crackle where the wire touched +/// (cyberware_anim2.dm). The lash is the picture, never a bolt or a shower of sparks. +/obj/item/melee/cyberware_monowire/live/proc/arc_visuals(atom/target, mob/living/user) + cyberware_arc_crackle_fx(user, target, lash_zone || check_zone(user?.zone_selected)) + playsound(target, SFX_SPARKS, 50, TRUE) + cyberware_ink_pulse(user, CYBERWARE_INK_SOFT) + +/** + * The shock into a living target. Returns TRUE if anything landed. + * Dead and shock-immune targets are skipped entirely; see the file header for + * what counts as shock-immune. + */ +/obj/item/melee/cyberware_monowire/live/proc/discharge_into_mob(mob/living/target, mob/living/user) + if(target.stat == DEAD || HAS_TRAIT(target, TRAIT_SHOCKIMMUNE)) + return FALSE + var/jolted = FALSE + var/is_silicon = issilicon(target) + // No stun, no message, no skeleton flash, no jitter: the lash and its + // crackle are the tell, and the stagger is the whole effect on the body. + // tg's shock shakes a body for over two seconds whatever jitter it is + // given, so the shake is held off while the charge goes in. + if(!is_silicon) + ADD_TRAIT(target, TRAIT_ARC_NO_SHAKE, REF(src)) + jolted = target.electrocute_act(shock_burn, user, 1, SHOCK_NOGLOVES | SHOCK_NOSTUN | SHOCK_SUPPRESS_MESSAGE | SHOCK_NO_HUMAN_ANIM, 0, 0, 0) ? TRUE : FALSE + REMOVE_TRAIT(target, TRAIT_ARC_NO_SHAKE, REF(src)) + if(is_silicon || (target.mob_biotypes & MOB_ROBOTIC)) + var/bonus_zone = target.get_random_valid_zone(user.zone_selected) + target.apply_damage( + robotic_bonus, + BURN, + bonus_zone, + target.run_armor_check(bonus_zone, ENERGY, armour_penetration = armour_penetration, silent = TRUE), + ) + jolted = TRUE + if(!jolted) + return FALSE + arc_visuals(target, user) + if(!QDELETED(target)) + target.adjust_staggered_up_to(stagger_time, stagger_cap) + return TRUE + +/// The burn bonus into a mech's armour. The pilot is not touched. +/obj/item/melee/cyberware_monowire/live/proc/discharge_into_mech(obj/vehicle/sealed/mecha/mech, mob/living/user) + if(QDELETED(mech) || mech.atom_integrity <= 0 || HAS_TRAIT(mech, TRAIT_OUTPOST_PROPERTY) || is_trader_outpost_protected(mech)) + return FALSE + mech.take_damage(robotic_bonus, BURN, ENERGY, FALSE, get_dir(mech, user), armour_penetration) + arc_visuals(mech, user) + return TRUE + +/** + * Shorts a machine out for a few seconds, if it is one that can be. Returns + * TRUE if it went dark. The recharge only starts on a machine that actually + * went dark, so a wasted swing at a vending machine costs nothing. + */ +/obj/item/melee/cyberware_monowire/live/proc/discharge_into_machine(obj/machinery/machine, mob/living/user) + if(QDELETED(machine) || HAS_TRAIT(machine, TRAIT_OUTPOST_PROPERTY) || is_trader_outpost_protected(machine)) + return FALSE + if(!COOLDOWN_FINISHED(src, jam_cooldown) || !jam_machine(machine)) + // Still crackles, goes nowhere. + do_sparks(1, TRUE, machine) + return FALSE + COOLDOWN_START(src, jam_cooldown, jam_recharge) + arc_visuals(machine, user) + machine.visible_message(span_warning("[machine] crackles and goes dark.")) + return TRUE + +/// Cuts the machine through its own family's machinery. Returns TRUE if it was knocked out. +/obj/item/melee/cyberware_monowire/live/proc/jam_machine(obj/machinery/machine) + if(machine.machine_stat & BROKEN) + return FALSE + if(istype(machine, /obj/machinery/porta_turret)) + // Outpost sanctuary turrets are immune, as against the Icepick Jack. + if(istype(machine, /obj/machinery/porta_turret/outpost)) + return FALSE + var/obj/machinery/porta_turret/turret = machine + if(!COOLDOWN_FINISHED(turret, disabled_time)) + return FALSE + turret.set_disabled(jam_duration) + return TRUE + if(istype(machine, /obj/machinery/ship_combat/laser_turret)) + var/obj/machinery/ship_combat/laser_turret/laser = machine + // Its fire cooldown is also its normal reload; never shorten that. + if(COOLDOWN_TIMELEFT(laser, fire_cooldown) >= jam_duration) + return FALSE + COOLDOWN_START(laser, fire_cooldown, jam_duration) + return TRUE + if(istype(machine, /obj/machinery/computer)) + return cyberware_arc_power_cut(machine, jam_duration) + // Cameras answer here; anything else with no answer is left alone. + return !!machine.on_saboteur(src, jam_duration) + +/** + * Drops a machine's power for a moment, the same flag an area blackout sets + * and the same way back: power_change() works the real state out again. + * Returns FALSE for a machine that is already down or draws no power. + */ +/proc/cyberware_arc_power_cut(obj/machinery/machine, duration) + if(machine.use_power == NO_POWER_USE || (machine.machine_stat & (NOPOWER | BROKEN | MAINT | EMPED))) + return FALSE + machine.set_machine_stat(machine.machine_stat | NOPOWER) + SEND_SIGNAL(machine, COMSIG_MACHINERY_POWER_LOST) + machine.update_appearance() + machine.set_light(0) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_arc_power_restore), machine), duration) + return TRUE + +/// End of a power cut. Global so it survives the machine being deleted meanwhile. +/proc/cyberware_arc_power_restore(obj/machinery/machine) + if(QDELETED(machine)) + return + // power_change() leaves a broken machine's flags alone, so a machine that + // broke while it was dark would stay unpowered after it was repaired. + if(machine.machine_stat & BROKEN) + machine.set_machine_stat(machine.machine_stat & ~NOPOWER) + machine.power_change() + +/** + * # Widowline Arc spool housing (T3, one arm, aug slot, load 6) + * + * The Widowline spool housing, stepped up. Same slot, same arm, so installing + * one evicts the other. Sold at the Chop Shop for vouchers and never bought + * back, like the plain Widowline. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live + name = "\improper Widowline Arc spool housing" + desc = "A wrist spool wound with monomolecular line, with a capacitor stack bolted on where the safety used to be." + icon_state = "monowire_live" + chrome_load = 6 + items_to_create = list(/obj/item/melee/cyberware_monowire/live) + +/datum/shop_sku/ripperdoc/monowire_live + zone_permission = ZONE_PERMISSION_ELITE + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live + price_vouchers = 5 + stock_min = 1 + stock_max = 1 + convoys_per_unit = 3 + fixed_price = TRUE + contract_reward = FALSE + +#undef CYBERWARE_ARC_SHOCK_BURN +#undef CYBERWARE_ARC_STAGGER +#undef CYBERWARE_ARC_STAGGER_CAP +#undef CYBERWARE_ARC_ROBOTIC_BONUS +#undef CYBERWARE_ARC_JAM_DURATION +#undef CYBERWARE_ARC_JAM_RECHARGE +#undef TRAIT_ARC_NO_SHAKE diff --git a/voidcrew/modules/cyberware/ware_line_zoom.dm b/voidcrew/modules/cyberware/ware_line_zoom.dm new file mode 100644 index 00000000000..e826a570699 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_zoom.dm @@ -0,0 +1,210 @@ +/** + * # The optics line: zoom by tier + * + * Every chrome optic zooms (cyberware_zoom.dm). Reach climbs with the tier, and + * the dedicated scout optics reach half a step further than combat or survey + * optics of the same tier. The cheap ones slow their bearer while zoomed. + * + * model tier load zoom walk tax read other + * Spyglass 1 1 1.0 1.0 silhouette nothing + * Assay 1 1 1.0 1.0 silhouette short ore pulse, no caches + * Nightshade 1 1 1.0 1.0 silhouette unchanged + * Prospector 2 2 1.5 0.5 itemized unchanged + * Deadeye 2 3 1.5 0.5 itemized unchanged + * Longsight 2 2 2.0 none itemized, 9 nothing + * Marksman link 3 4 2.0 none itemized 4 tracked shots, 10 tiles, 18 s + * Lookout 3 3 2.5 none itemized, 11 8 s read, low-light + * + * Binoculars (4) stay the reach ceiling, since they cost both hands. All of + * these ride ORGAN_SLOT_EYES, so installing one replaces whatever eyes the + * bearer had. Spyglass, Longsight and Lookout are their own types on the optics + * base; Assay optics subtype the Prospector and the Marksman link the Deadeye, + * so they keep their parents' ability code. + */ + +// ========================================================================= +// SPYGLASS OPTICS (T1) +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/spyglass + name = "spyglass optics" + desc = "Cheap chrome eyes with a telescoping lens behind each iris. They see a long way and not much else." + icon_state = "spyglass" + eye_color_left = "#7a6a4f" + eye_color_right = "#7a6a4f" + iris_overlay = null + chrome_load = 1 + tier = CYBERWARE_TIER_1 + chrome_scan_resolution = CYBERWARE_SCAN_SILHOUETTE + optics_zoom = 1 + zoom_walk_penalty = 1 + +// ========================================================================= +// ASSAY OPTICS (T1), the Prospector's street model +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/prospector/street + name = "assay optics" + desc = "Surveyor eyes off a mining co-op's bargain shelf. The pulse is short and the lenses are scratched, but ore still lights up through the rock." + icon_state = "prospector_street" + eye_color_left = "#8a6a1f" + eye_color_right = "#8a6a1f" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + chrome_scan_resolution = CYBERWARE_SCAN_SILHOUETTE + actions_types = list( + /datum/action/cooldown/cyberware/prospector_pulse/assay, + /datum/action/cooldown/cyberware/chrome_read, + ) + optics_zoom = 1 + zoom_walk_penalty = 1 + pulse_range = 5 + pulse_marks_caches = FALSE + +/datum/action/cooldown/cyberware/prospector_pulse/assay + desc = "Ping ore veins through rock." + cooldown_time = 15 SECONDS + +// ========================================================================= +// LONGSIGHT OPTICS (T2 scout) +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/longsight + name = "longsight optics" + desc = "Scout optics with a long lens stack behind each iris. They pick out a face across a hangar bay." + icon_state = "longsight" + eye_color_left = "#3f6f8f" + eye_color_right = "#3f6f8f" + iris_overlay = null + chrome_load = 2 + tier = CYBERWARE_TIER_2 + optics_zoom = 2 + zoom_walk_penalty = 0 + chrome_read_range = 9 + +// ========================================================================= +// MARKSMAN LINK (T3), the Deadeye's service model +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec + name = "marksman link" + desc = "Service-grade targeting optics, the unit stencil still on the housing. The coprocessor is a generation newer than the Deadeye's." + icon_state = "deadeye_milspec" + chrome_load = 4 + tier = CYBERWARE_TIER_3 + actions_types = list( + /datum/action/cooldown/cyberware/deadeye_tag/marksman, + /datum/action/cooldown/cyberware/chrome_read, + ) + optics_zoom = 2 + zoom_walk_penalty = 0 + tag_range = 10 + tag_shots = 4 + tag_duration = 15 SECONDS + +/datum/action/cooldown/cyberware/deadeye_tag/marksman + name = "Marksman Tag" + desc = "Tag a hostile in view. Your next four shots track them." + cooldown_time = 18 SECONDS + +// ========================================================================= +// LOOKOUT OPTICS (T3 scout) +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/lookout + name = "lookout optics" + desc = "Military spotter eyes with a low-light amplifier. Made for lying on a ridge and watching a camp for hours." + icon_state = "lookout" + eye_color_left = "#2d4a2a" + eye_color_right = "#2d4a2a" + iris_overlay = null + // The Nightshade's dark floor, without its green. + lighting_cutoff = LIGHTING_CUTOFF_MEDIUM + chrome_load = 3 + tier = CYBERWARE_TIER_3 + optics_zoom = 2.5 + zoom_walk_penalty = 0 + chrome_read_range = 11 + chrome_read_cooldown = 8 SECONDS + +// ========================================================================= +// SPLICE SKUs and INTAKE LEDGER +// Listed on /datum/outpost_shop/vendor/ripperdoc (sku_types, buyback_types) +// and on the street doc's lists (the credit-priced three and their +// /street_doc trade-ins). The optics also ride the zone loot tables. +// ========================================================================= + +/datum/shop_sku/ripperdoc/spyglass + category = "Street Chrome" + desc = "A pair of chrome telescoping optics." + item_path = /obj/item/organ/eyes/robotic/cyberware/spyglass + price_credits = 400 + stock_min = 1 + stock_max = 3 + +/datum/shop_sku/ripperdoc/assay_optics + category = "Street Chrome" + desc = "A pair of budget surveyor optics." + item_path = /obj/item/organ/eyes/robotic/cyberware/prospector/street + price_credits = 900 + +/datum/shop_sku/ripperdoc/longsight + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + desc = "A pair of long-lens scout optics." + item_path = /obj/item/organ/eyes/robotic/cyberware/longsight + price_credits = 2400 + +/datum/shop_sku/ripperdoc/deadeye_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/eyes/robotic/cyberware/deadeye/milspec + price_vouchers = 3 + +/datum/shop_sku/ripperdoc/lookout + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/eyes/robotic/cyberware/lookout + price_vouchers = 3 + +/// Half list, credits only, like the rest of the ladder's credit rungs. +/datum/shop_buyback/ripperdoc/spyglass + name = "Spyglass Optics (pulled)" + desc = "Cheap zoom eyes. Splice takes them toward a better pair." + category = "Trade-In" + item_path = /obj/item/organ/eyes/robotic/cyberware/spyglass + match_subtypes = FALSE + pay_credits = 200 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/assay_optics + name = "Assay Optics (pulled)" + desc = "Budget surveyor eyes, lenses scratched." + category = "Trade-In" + item_path = /obj/item/organ/eyes/robotic/cyberware/prospector/street + match_subtypes = FALSE + pay_credits = 450 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/longsight + name = "Longsight Optics (pulled)" + desc = "Scout eyes, lenses intact. Splice strips the glass for a spotter rig." + category = "Trade-In" + item_path = /obj/item/organ/eyes/robotic/cyberware/longsight + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +// Ratchet's ledger: the same entries in his words, same pay. + +/datum/shop_buyback/ripperdoc/spyglass/street_doc + desc = "Used zoom eyes." + +/datum/shop_buyback/ripperdoc/assay_optics/street_doc + desc = "Used survey eyes. Scratched, like they all are." + +/datum/shop_buyback/ripperdoc/longsight/street_doc + desc = "Used scout eyes." diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index cb46862d5ea..eef698851ac 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -23,7 +23,7 @@ */ /obj/item/cyberware_pair_case name = "cyberware pair case" - desc = "A foam-lined clamshell case for a matched set of chrome. Crack it open in hand." + desc = "A foam-lined clamshell case for a matched set of chrome." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' icon_state = "ware_case" w_class = WEIGHT_CLASS_NORMAL @@ -39,7 +39,7 @@ . = ..() if(length(contents)) . += span_notice("Nestled in the foam: [english_list(contents)].") - . += span_notice("Use it in hand to crack it open.") + /obj/item/cyberware_pair_case/attack_self(mob/user, modifiers) . = ..() @@ -61,7 +61,7 @@ /obj/item/cyberware_pair_case/gorilla_arms name = "\improper Gorilla Arms case" - desc = "A heavy clamshell case stenciled with a fist. Two myomer lattices inside, one per arm. The Cradle installs the pair in one sitting." + desc = "A heavy clamshell case stenciled with a fist. Two myomer lattices inside, one per arm." ware_types = list( /obj/item/organ/cyberimp/cyberware/gorilla_arms, /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, @@ -114,7 +114,7 @@ */ /obj/item/organ/cyberimp/cyberware/gorilla_arms name = "\improper Gorilla Arms myomer lattice (right)" - desc = "A myomer weave anchored bone-deep through the arm. You don't look any stronger for it; you just start pulling closed airlocks open with your bare hands." + desc = "A myomer weave anchored bone-deep through the arm. You don't look any stronger for it; doors just stop being your problem." icon_state = "gorilla" zone = BODY_ZONE_R_ARM slot = ORGAN_SLOT_RIGHT_ARM_AUG @@ -128,6 +128,23 @@ aug_overlay = "gorilla_right" /// Rate limit on this arm's empowered hits. COOLDOWN_DECLARE(slam_cooldown) + // The defines above are the Gorilla defaults. They live in vars so the other + // models of the line (ware_line_models_a.dm) can retune by setting them. + /// Flat brute per empowered punch. + var/punch_damage = CYBERWARE_GORILLA_PUNCH_DAMAGE + /// Damage per structure/machine smash. + var/structure_damage = CYBERWARE_GORILLA_STRUCTURE_DAMAGE + /// Gap between empowered punches, per arm. + var/punch_cooldown_time = CYBERWARE_GORILLA_PUNCH_COOLDOWN + /// Gap between structure smashes / rock breaks, per arm. + var/smash_cooldown_time = CYBERWARE_GORILLA_SMASH_COOLDOWN + /// How far an aggressively-grabbed victim gets hurled. 0 or less: no hurl, + /// the grabbed victim takes the ordinary slam. + var/hurl_range = CYBERWARE_GORILLA_HURL_RANGE + /// Whether a matched pair of these pries closed airlocks (the set bonus). + var/pries_doors = TRUE + /// The set bonus a matched pair of this model syncs into. Each model has its own, so a mixed pair syncs into neither. + var/pair_bonus = /datum/status_effect/organ_set_bonus/cyberware_gorilla /obj/item/organ/cyberimp/cyberware/gorilla_arms/left name = "\improper Gorilla Arms myomer lattice (left)" @@ -137,7 +154,11 @@ /obj/item/organ/cyberimp/cyberware/gorilla_arms/Initialize(mapload) . = ..() - AddElement(/datum/element/organ_set_bonus, /datum/status_effect/organ_set_bonus/cyberware_gorilla) + if(pries_doors && pair_bonus) + AddElement(/datum/element/organ_set_bonus, pair_bonus) + // Each real arm winds up a haymaker; both share the pistons' cooldown (hyperviolence_gorilla/gorilla_haymaker.dm) + if(heavy_moves) + add_item_action(/datum/action/cooldown/cyberware/gorilla_haymaker) /** * Worn-chrome overlay hook. Gorilla Arms replace the limb outright, hand @@ -146,12 +167,11 @@ * sits well above CYBERWARE_WORN_LAYER, so a plate drawn only at the chrome * layer gets painted back over by bare knuckles. * - * One image cannot fix that. The hand already draws above UNIFORM_LAYER, so - * anything stacked over the hand is also stacked over the jumpsuit, and the - * chrome layer exists precisely to stay under worn clothing. Instead we do what - * tg's own toolkit augments do (augments_arms.dm's hand_state): the arm plate - * keeps the chrome layer and hides under sleeves, and a separate "_hand" plate - * rides at the hand's own layer, where gloves still cover it like real skin. + * Lifting the whole arm plate above the hand would also lift it over the worn + * ID. Instead we do what tg's own toolkit augments do (augments_arms.dm's + * hand_state): the arm plate keeps the chrome layer, over the sleeve, and a + * separate "_hand" plate rides at the hand's own layer, where gloves still + * cover it like real skin. * * The sheet may ship without the hand plate. Then this is a no-op and the arm * plate renders alone, exactly as it did before. @@ -198,7 +218,28 @@ source.do_attack_animation(rock, ATTACK_EFFECT_SMASH) playsound(rock, 'sound/effects/meteorimpact.ogg', 30, TRUE) rock.gets_drilled(source, 1) - COOLDOWN_START(src, slam_cooldown, CYBERWARE_GORILLA_SMASH_COOLDOWN) + COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) + return COMPONENT_CANCEL_ATTACK_CHAIN + + // Someone held in an aggressive grab, brought down on the floor or a table (hyperviolence_gorilla/gorilla_punch.dm). + // Before the structure smash, so a table click while grabbing slams them instead of caving the table in. + switch(try_slam(source, target, active_hand)) + if(GORILLA_SLAM_DONE) + COOLDOWN_START(src, slam_cooldown, punch_cooldown_time) + return COMPONENT_CANCEL_ATTACK_CHAIN + if(GORILLA_SLAM_PASS) + return NONE + + // A trader outpost's doors hold against a fist as they do against a pry, and trying counts against you + // (hyperviolence_gorilla/gorilla_pry.dm). + if(istype(target, /obj/machinery/door) && gorilla_pry_refused(target)) + source.changeNext_move(CLICK_CD_MELEE) + source.do_attack_animation(target, ATTACK_EFFECT_SMASH) + playsound(target, 'sound/items/weapons/tap.ogg', 50, TRUE) + target.balloon_alert(source, "it won't budge!") + gorilla_pry_aggression(target, source) + source.log_message("punched [target] at a trader outpost, located at [loc_name(target)].", LOG_GAME) + COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN // Structures, girders, machines cave in. Loose items are beneath us. @@ -213,11 +254,12 @@ span_danger("[source] caves [smashed] in with a piston-driven fist!"), span_danger("You cave [smashed] in!"), ) - smashed.take_damage(CYBERWARE_GORILLA_STRUCTURE_DAMAGE, BRUTE, MELEE) - COOLDOWN_START(src, slam_cooldown, CYBERWARE_GORILLA_SMASH_COOLDOWN) + smashed.take_damage(structure_damage, BRUTE, MELEE) + COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN - if(!isliving(target)) + // A pacifist's punch at a living thing is the ordinary punch, which refuses it. + if(!isliving(target) || HAS_TRAIT(source, TRAIT_PACIFISM)) return NONE if(!source.can_unarmed_attack()) return COMPONENT_SKIP_ATTACK @@ -227,37 +269,19 @@ if(ishuman(living_target)) var/mob/living/carbon/human/human_target = living_target - if(human_target.check_block(source, CYBERWARE_GORILLA_PUNCH_DAMAGE, "[source]'s piston punch")) + if(human_target.check_block(source, punch_damage, "[source]'s piston punch")) source.do_attack_animation(living_target) playsound(living_target.loc, 'sound/items/weapons/punchmiss.ogg', 25, TRUE, -1) log_combat(source, living_target, "attempted to piston-punch", "gorilla arms") return COMPONENT_CANCEL_ATTACK_CHAIN - // The one damage line. Flat, wounding, honest. - var/grabbed_hurl = living_target.pulledby == source && source.grab_state >= GRAB_AGGRESSIVE - source.do_attack_animation(living_target, ATTACK_EFFECT_SMASH) - playsound(living_target.loc, 'sound/items/weapons/punch1.ogg', 40, TRUE, -1) - var/target_zone = living_target.get_random_valid_zone(source.zone_selected) - var/armor_block = living_target.run_armor_check(target_zone, MELEE, armour_penetration = active_hand.unarmed_effectiveness) - living_target.apply_damage(CYBERWARE_GORILLA_PUNCH_DAMAGE, BRUTE, target_zone, armor_block, wound_bonus = 10) - - if(source.body_position != LYING_DOWN) - var/atom/throw_target = get_edge_target_turf(living_target, source.dir) - if(grabbed_hurl) - living_target.safe_throw_at(throw_target, CYBERWARE_GORILLA_HURL_RANGE, 2, source) - living_target.visible_message( - span_danger("[source] hurls [living_target] across the room!"), - span_userdanger("[source] hurls you across the room!"), - ) - else - living_target.throw_at(throw_target, 1, rand(1, 4), source, gentle = TRUE) - living_target.visible_message( - span_danger("[source] slams [living_target] with a piston-driven fist!"), - span_userdanger("[source] slams you with a piston-driven fist!"), - ) - to_chat(source, span_danger("You slam [living_target]!")) - log_combat(source, living_target, "piston-punched", "gorilla arms") - COOLDOWN_START(src, slam_cooldown, CYBERWARE_GORILLA_PUNCH_COOLDOWN) + // Someone in crit or stunned, with the aimed zone picking the finisher (hyperviolence_gorilla/gorilla_finisher.dm). + if(gorilla_try_finisher(source, living_target)) + return COMPONENT_CANCEL_ATTACK_CHAIN + + // The one damage line, through #509, with the knockback on the safe path (hyperviolence_gorilla/gorilla_punch.dm). + piston_hit(source, living_target, active_hand) + COOLDOWN_START(src, slam_cooldown, punch_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN /** @@ -270,23 +294,42 @@ id = "organ_set_bonus_cyberware_gorilla" organs_needed = 2 required_biotype = NONE - bonus_activate_text = span_notice("Your Gorilla Arms sync up. You can force closed airlocks by hand now. Walk up and pull, out of combat mode.") - bonus_deactivate_text = span_notice("Your arms fall out of sync. Doors stay shut again.") + bonus_activate_text = span_notice("Your Gorilla Arms sync up.") + bonus_deactivate_text = span_notice("Your arms fall out of sync.") + /// How long the pair takes to force a powered airlock. + var/pry_time = GORILLA_PRY_TIME + /// The ground pound chip the pair grants. + var/pound_type = /datum/action/cooldown/cyberware/gorilla_pound + /// Whether the arms flex as they sync. + var/flexes = TRUE /datum/status_effect/organ_set_bonus/cyberware_gorilla/enable_bonus(obj/item/organ/inserted_organ) . = ..() if(!.) return - owner.AddElement(/datum/element/door_pryer, pry_time = 4 SECONDS, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + // The pry refuses trader outpost doors and plays the strain (hyperviolence_gorilla/gorilla_pry.dm) + owner.AddElement(/datum/element/door_pryer/gorilla, pry_time = pry_time, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + // The ground pound chip, and the flex as the arms come online (hyperviolence_gorilla/gorilla_pound.dm) + grant_pound() + if(flexes) + gorilla_flex(owner) /datum/status_effect/organ_set_bonus/cyberware_gorilla/disable_bonus(obj/item/organ/removed_organ) . = ..() - owner.RemoveElement(/datum/element/door_pryer, pry_time = 4 SECONDS, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + owner.RemoveElement(/datum/element/door_pryer/gorilla, pry_time = pry_time, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + take_pound() + +/datum/status_effect/organ_set_bonus/cyberware_gorilla/on_remove() + take_pound() + return ..() // ========================================================================= // MANTIS BLADES // ========================================================================= +// These four are the Mantis defaults. The housing reads them into vars so the +// other models in the line (ware_line_mantis.dm) can retune by setting vars. + /// Lunge reach in tiles. #define CYBERWARE_MANTIS_LUNGE_RANGE 5 /// Bonus brute the arriving lunge lands PER BLADE (cap 10 per ADDENDUM 2). @@ -296,6 +339,10 @@ /// How long a connecting lunge floors its target. Above the module's usual /// 1-1.5s band because the lunge is telegraphed and on a 20 second clock. #define CYBERWARE_MANTIS_LUNGE_KNOCKDOWN (2 SECONDS) +/// How long a shown-line dash (dash_aim) takes per tile: a real run down the line, one tick a step. +#define CYBERWARE_MANTIS_LUNGE_STEP (0.5) +/// How long tg's attack lunge takes to reach its target (do_attack_animation()): the arrival's cut lands no sooner. +#define CYBERWARE_MANTIS_LUNGE_THRUST (0.1 SECONDS) /** * The blade itself: hardlight, ~20 force (cap 22), sharp, wounding. Only @@ -305,13 +352,14 @@ * * Every sprite comes off the cyberware sheets rather than the stock hardlight * sword: this is a long blade running forward past the hand out of a forearm - * housing, not something anybody is gripping. The held sprite is blade only. - * The housing is already on the bearer's arm as the organ's aug_overlay, and - * drawing a second one in the hand put a metal brick over their bicep. + * housing, not something anybody is gripping. The held sprite is the blade + * along the forearm. The organ's worn overlay (the housing with the blade + * folded in) hides while the blade is out, see hide_worn_while_deployed. * - * base_icon_state comes along for the ride purely so it cannot disagree with - * icon_state; update_icon_state() only consults it when sword_color_icon is - * set, which it never is here. + * base_icon_state names the blade's model for its held poses: the held sprite + * is "[base_icon_state]_[pose]" (voidcrew/modules/mantis_gore/), the raised + * guard while nothing else is going on. update_icon_state() only consults it + * when sword_color_icon is set, which it never is here. */ /obj/item/melee/energy/blade/hardlight/cyberware_mantis name = "mantis blade" @@ -319,12 +367,41 @@ icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' icon_state = "mantis" base_icon_state = "mantis" - inhand_icon_state = "mantis" - lefthand_file = 'voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi' - righthand_file = 'voidcrew/modules/cyberware/icons/cyberware_righthand.dmi' + inhand_icon_state = "mantis_guard" + lefthand_file = MANTIS_LEFTHAND_ICON + righthand_file = MANTIS_RIGHTHAND_ICON force = 20 armour_penetration = 10 wound_bonus = 10 + /// Set by afterattack() during attack()'s parent call, which only a swing that + /// connected reaches. attack()'s own FALSE can't tell a cut from a pacifist's + /// pulled swing or a signal-skipped one. The Widowline does the same. + var/swing_landed = FALSE + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/attack(mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) + // A head swing at someone dying is the scissor, not a cut (mantis_gore/mantis_scissor.dm). + if(mantis_swing_taken(target, user)) + return TRUE + swing_landed = FALSE + // What the cut does to the body is read while the hit resolves, so its lines join the attack line. + mantis_watch_cut(target, user) + . = ..() + mantis_unwatch_cut(target) + var/landed = swing_landed + swing_landed = FALSE + // Only a cut that landed counts: not a blocked or cancelled swing, not a pulled + // one. Never the wielder, whoever is in the way. + if(. || !landed || QDELETED(target) || target == user) + return + on_cut_landed(target, user) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) + . = ..() + swing_landed = TRUE + +/// A swing with this blade connected with [target]. The line's models add to it. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/on_cut_landed(mob/living/target, mob/living/user) + return /** * # Mantis Blades (T3, arms, aug slots, load 3 per arm / 6 the pair) @@ -343,6 +420,8 @@ chrome_load = 3 tier = CYBERWARE_TIER_3 aug_overlay = "mantis" + // The worn housing sprite carries the folded blade, so it hides while the blade is out. + hide_worn_while_deployed = TRUE items_to_create = list(/obj/item/melee/energy/blade/hardlight/cyberware_mantis) // A blade sliding out of a forearm housing and snapping back into it, the // cursed katana's pair (mining_loot/cursed_katana.dm), not a baton's ratchet. @@ -352,6 +431,17 @@ /datum/action/item_action/organ_action/toggle/toolkit, /datum/action/cooldown/cyberware/mantis_lunge, ) + /// Lunge reach in tiles. + var/lunge_range = CYBERWARE_MANTIS_LUNGE_RANGE + /// Bonus brute the arriving lunge lands per blade out. + var/lunge_bonus = CYBERWARE_MANTIS_LUNGE_BONUS + /// Delay between the wind-up and the dash. + var/lunge_telegraph = CYBERWARE_MANTIS_LUNGE_TELEGRAPH + /// How long a connecting lunge floors its target. + var/lunge_knockdown = CYBERWARE_MANTIS_LUNGE_KNOCKDOWN + /// Fire stacks the arriving lunge puts on its target (and lights them + /// with). 0 for no fire. The most of any blade that arrives lands, not the sum. + var/lunge_fire_stacks = 0 /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left name = "\improper Mantis Blade housing (left)" @@ -369,32 +459,46 @@ /datum/action/cooldown/cyberware/mantis_lunge name = "Mantis Lunge" - desc = "Dash up to five tiles onto a target with the blade leading, cutting them open and dropping them on arrival. The blade must be extended. With both blades out, both land." + desc = "Dash up to five tiles onto a target, cutting them open and dropping them on arrival." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_mantis_lunge" cooldown_time = 20 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body, or one with its hands tied, does not lunge. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE | AB_CHECK_HANDS_BLOCKED + /// Set by a bearer that showed its line before pressing (a bounty NPC's tell): the next press runs down + /// that line to this tile with real movement (dash()) instead of the ordinary lunge. Spent by the press. + var/turf/dash_aim /** - * How many mantis blades this owner has out right now. Every housing grants - * exactly one of these buttons, so counting the buttons whose blade is - * extended counts the blades that will arrive. + * The housings whose blade is out right now and will arrive with the lunge. + * Every lunging housing grants exactly one of these buttons, so walking the + * buttons whose blade is extended finds the blades. A model with no lunge + * button never lands in this list. */ -/datum/action/cooldown/cyberware/mantis_lunge/proc/count_extended_blades() +/datum/action/cooldown/cyberware/mantis_lunge/proc/get_extended_housings() + var/list/housings = list() if(!owner) - return 0 - var/blades = 0 - for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge_action in owner.actions) + return housings + // Chrome abilities have no button and so never join mob.actions (chrome_hud.dm). + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge_action in get_chrome_wheel_actions(owner)) var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = lunge_action.organ if(istype(housing) && housing.blade_extended()) - blades++ - return blades + housings += housing + return housings + +/// How many mantis blades this owner has out right now. +/datum/action/cooldown/cyberware/mantis_lunge/proc/count_extended_blades() + return length(get_extended_housings()) /datum/action/cooldown/cyberware/mantis_lunge/Activate(atom/target) var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ if(!istype(housing) || !isliving(owner)) return FALSE var/mob/living/lunger = owner + if(HAS_TRAIT(lunger, TRAIT_PACIFISM)) + to_chat(lunger, span_warning("You don't want to harm other living beings!")) + return FALSE if(!housing.blade_extended()) lunger.balloon_alert(lunger, "extend the blade first!") return FALSE @@ -405,15 +509,22 @@ lunger.balloon_alert(lunger, "no target!") return FALSE var/mob/living/victim = target + var/turf/aim = dash_aim + dash_aim = null if(victim == lunger || victim.stat == DEAD) lunger.balloon_alert(lunger, "no target!") return FALSE - if(get_dist(lunger, victim) > CYBERWARE_MANTIS_LUNGE_RANGE || !can_see(lunger, victim, CYBERWARE_MANTIS_LUNGE_RANGE)) + if(get_dist(lunger, victim) > housing.lunge_range || !can_see(lunger, victim, housing.lunge_range)) lunger.balloon_alert(lunger, "out of reach!") return FALSE + // The blade is about to be used on someone. + cyberware_camo_drop(lunger) + // A shown line out of reach is no line: the ordinary lunge + if(aim && (aim.z != lunger.z || get_dist(lunger, aim) > housing.lunge_range)) + aim = null // One lunge clock across both housings: start every mantis_lunge button // this owner has, ours included. - for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge_action in lunger.actions) + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge_action in get_chrome_wheel_actions(lunger)) lunge_action.StartCooldown() // The telegraph: readable wind-up before the dash lands. lunger.face_atom(victim) @@ -422,58 +533,193 @@ span_boldwarning("[lunger] drops low, mantis blade[both_blades ? "s" : ""] laid flat along [lunger.p_their()] [both_blades ? "arms" : "arm"]!"), span_warning("You coil for the lunge."), ) + // The blades go flat along the arms for the wind-up (mantis_gore/mantis_poses.dm). + mantis_pose_blades(get_extended_housings(), MANTIS_POSE_LUNGE) playsound(lunger, 'sound/items/weapons/sear.ogg', 50, TRUE) - addtimer(CALLBACK(src, PROC_REF(do_lunge), WEAKREF(victim)), CYBERWARE_MANTIS_LUNGE_TELEGRAPH) + addtimer(CALLBACK(src, PROC_REF(do_lunge), WEAKREF(victim), null, FALSE, aim), housing.lunge_telegraph) return TRUE -/// The dash itself: walk the line to the target with a decoy per tile, -/// stop adjacent, land the bonus cut once per blade and floor them. -/datum/action/cooldown/cyberware/mantis_lunge/proc/do_lunge(datum/weakref/victim_ref) +/// The dash itself: walk the line to the target, stop adjacent, land the bonus +/// cut once per blade and floor them. [range] is how far it may reach (a charged +/// leap reaches further, mantis_gore/mantis_charge.dm); [charged] is TRUE for a +/// leap out of a crouch. With a shown line ([aim], from dash_aim) it runs down +/// that line instead (dash()). +/datum/action/cooldown/cyberware/mantis_lunge/proc/do_lunge(datum/weakref/victim_ref, range, charged = FALSE, turf/aim) var/mob/living/lunger = owner - if(QDELETED(lunger) || lunger.incapacitated || !isturf(lunger.loc)) + if(QDELETED(lunger)) + return + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ + if(!istype(housing)) return + // The blades go back to the guard however the dash ends. + var/list/posed = get_extended_housings() + if(lunger.incapacitated || !isturf(lunger.loc)) + mantis_pose_blades(posed, MANTIS_POSE_GUARD) + return + if(isnull(range)) + range = housing.lunge_range var/mob/living/victim = victim_ref?.resolve() if(QDELETED(victim) || victim.stat == DEAD) lunger.balloon_alert(lunger, "target lost!") + mantis_pose_blades(posed, MANTIS_POSE_GUARD) + return + if(aim) + if(aim.z != lunger.z) + lunger.balloon_alert(lunger, "target lost!") + mantis_pose_blades(posed, MANTIS_POSE_GUARD) + return + INVOKE_ASYNC(src, PROC_REF(dash), lunger, victim_ref, aim, range, charged, posed) return + // The ordinary lunge: walk the line to the target with a decoy per tile and stop adjacent var/turf/victim_turf = get_turf(victim) - if(!victim_turf || get_dist(lunger, victim_turf) > CYBERWARE_MANTIS_LUNGE_RANGE + 1) + if(!victim_turf || get_dist(lunger, victim_turf) > range + 1) lunger.balloon_alert(lunger, "target lost!") + mantis_pose_blades(posed, MANTIS_POSE_GUARD) return playsound(lunger, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) - for(var/turf/step_turf as anything in get_line(get_turf(lunger), victim_turf)) - if(step_turf == get_turf(lunger)) + var/turf/start = get_turf(lunger) + var/list/passed = list() + for(var/turf/step_turf as anything in get_line(start, victim_turf)) + if(step_turf == start) continue if(step_turf == victim_turf) break if(step_turf.is_blocked_turf(exclude_mobs = TRUE)) break - new /obj/effect/temp_visual/decoy/fading/halfsecond(lunger.loc, lunger) + // Never over a drop, into lava or out into space: the dash stops at the edge. + if(mantis_lunge_unsafe_turf(step_turf)) + break + passed += lunger.loc lunger.forceMove(step_turf) + // Hardlight afterimages and a streak down the line, and the body glides into place (mantis_gore/mantis_lunge_fx.dm). + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/lunge_blade = housing.active_item + var/glide = mantis_lunge_dash_fx(lunger, start, passed, charged, istype(lunge_blade) ? lunge_blade.base_icon_state : "mantis") if(!lunger.Adjacent(victim)) lunger.balloon_alert(lunger, "fell short!") + // The blades stay flat while the body is still gliding in. + mantis_lunge_settle(posed, glide) return + land_lunge(lunger, victim, housing, lunge_blade, charged, posed, start, glide) + +/** + * A shown-line dash (dash_aim, a bounty NPC's tell): a real run down the line it showed, a tile a tick with + * a decoy per tile, up to the tile before `aim`. Anything that stops a walker stops it there: a wall, a + * window, a shut door, a standing body, a stun or a grab. The lunge lands only where the line said: on a + * target still on the marked tile, or one who stepped into its way, next to it where it stops. Anyone who + * stepped off the line is missed. It goes no further than [range] tiles, never over a drop, lava or + * space, and leaves the lunge's afterimages and streak behind it (mantis_gore/mantis_lunge_fx.dm). + * [posed] are the blades laid flat for the wind-up: they go back to the guard however it ends. + */ +/datum/action/cooldown/cyberware/mantis_lunge/proc/dash(mob/living/lunger, datum/weakref/victim_ref, turf/aim, range, charged = FALSE, list/posed) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ + if(!istype(housing)) + mantis_pose_blades(posed, MANTIS_POSE_GUARD) + return + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/lunge_blade = housing.active_item + var/model = istype(lunge_blade) ? lunge_blade.base_icon_state : "mantis" + var/turf/start = get_turf(lunger) + var/turf/stopped_at + var/dash_dir = get_dir(start, aim) + var/steps = 0 + playsound(lunger, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) + for(var/turf/step_turf as anything in get_line(start, aim)) + if(step_turf == start) + continue + if(steps >= range || mantis_lunge_unsafe_turf(step_turf)) + break + if(step_turf == aim || QDELETED(lunger) || lunger.incapacitated || lunger.body_position == LYING_DOWN || !isturf(lunger.loc) || lunger.pulledby?.grab_state > GRAB_PASSIVE) + break + var/blocked = FALSE + for(var/mob/living/body in step_turf) + if(body.density) + blocked = TRUE + break + if(blocked) + stopped_at = step_turf + break + var/turf/from = lunger.loc + lunger.set_glide_size(DELAY_TO_GLIDE_SIZE(CYBERWARE_MANTIS_LUNGE_STEP)) + if(!lunger.Move(step_turf, get_dir(from, step_turf)) || lunger.loc != step_turf) + break + steps++ + // The lunge's wake on the tile it just left: hardlight afterimages and the streak. + mantis_dash_ghost(WEAKREF(lunger), WEAKREF(from), dash_dir, steps / (range + 1), model) + sleep(CYBERWARE_MANTIS_LUNGE_STEP) + if(QDELETED(lunger) || lunger.stat != CONSCIOUS) + mantis_pose_blades(posed, MANTIS_POSE_GUARD) + return + var/mob/living/victim = victim_ref?.resolve() + var/turf/victim_turf = get_turf(victim) + if(QDELETED(victim) || victim.stat == DEAD || (victim_turf != aim && victim_turf != stopped_at) || !lunger.Adjacent(victim)) + lunger.balloon_alert(lunger, "fell short!") + mantis_pose_blades(posed, MANTIS_POSE_GUARD) + return + // The run was real movement: the body is already there. + land_lunge(lunger, victim, housing, lunge_blade, charged, posed, null, 0) + +/// The arrival: the bonus cut once per blade, and they go down. [housing] is the housing that lunged and +/// [lunge_blade] its blade, [charged] is TRUE for a leap out of a crouch, and [posed] are the blades laid +/// flat for the wind-up. [from] is where the ordinary lunge set out, for the impale's glide in; a shown-line +/// dash ran here on its own feet and gives none. [glide] is how long the body still takes to glide in +/// (mantis_lunge_dash_fx()): the impact, the knockdown and the blades' return to the guard wait for it, and for +/// the body's thrust at the target when it is already there. +/datum/action/cooldown/cyberware/mantis_lunge/proc/land_lunge(mob/living/lunger, mob/living/victim, obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing, obj/item/melee/energy/blade/hardlight/cyberware_mantis/lunge_blade, charged = FALSE, list/posed, turf/from, glide = 0) // Both housings deployed means both blades arrive. Floored at one: a blade // stowed during the 0.3s telegraph shouldn't eat the whole cooldown for // nothing when the dash still connected. - var/blades = max(count_extended_blades(), 1) + var/list/blade_housings = get_extended_housings() + if(!length(blade_housings)) + blade_housings = list(housing) + var/blades = length(blade_housings) + // Judged as they arrive, before the arrival cut: someone already hurt or down. + var/skewer = mantis_can_impale(lunger, victim, blade_housings) lunger.face_atom(victim) - lunger.do_attack_animation(victim, ATTACK_EFFECT_SLASH) + lunger.do_attack_animation(victim, no_effect = TRUE) + // The cut lands as the body does, and not before its thrust has reached them. + var/lands_in = max(glide, CYBERWARE_MANTIS_LUNGE_THRUST) + // The blade's own impact over the target (both blades for a pounce), and a burst of light and dust (mantis_gore/mantis_lunge_fx.dm). + mantis_lunge_arrival_fx(lunger, victim, istype(lunge_blade) ? lunge_blade.impact_state : "impact_mantis", charged && blades > 1, lands_in) + // The arrival counts toward the lunger's kill (mantis_gore/mantis_cuts.dm). + mantis_note_hit(victim, lunger) // One armor roll for the arrival, reused per blade. Both land in the same // instant on the same plate, and it keeps the armor message to one line. var/armor_block = victim.run_armor_check(BODY_ZONE_CHEST, MELEE) - for(var/i in 1 to blades) + var/lunge_fire = 0 + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/blade_housing as anything in blade_housings) victim.apply_damage( - CYBERWARE_MANTIS_LUNGE_BONUS, + blade_housing.lunge_bonus, BRUTE, BODY_ZONE_CHEST, armor_block, wound_bonus = 10, sharpness = SHARP_EDGED, ) - victim.Knockdown(CYBERWARE_MANTIS_LUNGE_KNOCKDOWN) - if(blades > 1) + lunge_fire = max(lunge_fire, blade_housing.lunge_fire_stacks) + if(lunge_fire) + cyberware_scorch_target(victim, lunge_fire) + // Both blades into someone already hurt: held up on them, then ripped free (mantis_gore/mantis_impale.dm). + // The knockdown waits for the rip. + if(skewer && !QDELETED(victim) && victim.stat != DEAD && mantis_start_impale(src, lunger, victim, blade_housings, housing.lunge_knockdown, from, charged ? 2 : MANTIS_LUNGE_GLIDE)) + log_combat(lunger, victim, "mantis-lunged and impaled", "mantis blades ([blades] landed)") + playsound(victim, 'sound/effects/wounds/pierce3.ogg', 70, TRUE) + return + // The blades stay flat through the glide and come back to the guard as the body lands, and they go down + // then, with the impact (mantis_gore/mantis_lunge_fx.dm). + mantis_lunge_settle(posed, lands_in) + mantis_lunge_knockdown(victim, housing.lunge_knockdown, lands_in) + if(charged) + if(blades > 1) + victim.visible_message( + span_danger("[lunger] springs at [victim] and shuts both blades on [victim.p_them()], dropping [victim.p_them()]!"), + span_userdanger("[lunger] springs at you. Both blades land at once and your legs go out from under you!"), + ) + else + victim.visible_message( + span_danger("[lunger] springs at [victim], opens [victim.p_them()] up and puts [victim.p_them()] down!"), + span_userdanger("[lunger] springs at you. The blade is already in you, and the floor comes up fast!"), + ) + else if(blades > 1) victim.visible_message( span_danger("[lunger] flickers across the gap and shuts both blades on [victim] like a trap, dropping [victim.p_them()]!"), span_userdanger("[lunger] flickers across the gap. Both blades land at once and your legs go out from under you!"), @@ -483,7 +729,7 @@ span_danger("[lunger] flickers across the gap, opens [victim] up on arrival and puts [victim.p_them()] down!"), span_userdanger("[lunger] flickers across the gap. The blade is already in you, and the floor comes up fast!"), ) - log_combat(lunger, victim, "mantis-lunged", "mantis blades ([blades] landed)") + log_combat(lunger, victim, charged ? "mantis-leapt" : "mantis-lunged", "mantis blades ([blades] landed)") playsound(victim, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) // ========================================================================= @@ -506,14 +752,16 @@ * * The damage splits 3:1. The cut is brute and carries the wounding; the charge * running down the line is burn, flat and unwounding. Both halves ignore armor - * and both halves cleave. + * and both halves carry through. * - * Every landed hit cleaves through mobs adjacent to the victim with line of - * sight, allies filtered out, for CYBERWARE_MONOWIRE_CLEAVE_MULT of both. + * Every landed hit carries on into one body at most: the one standing right + * behind the victim along the swing, allies filtered out, for + * CYBERWARE_MONOWIRE_CLEAVE_MULT of both, never taking a limb. The + * through-cut, severs and the garrote live in voidcrew/modules/hyperviolence/wire/. */ /obj/item/melee/cyberware_monowire name = "\improper Widowline monowire" - desc = "A weighted spool of monomolecular line, run live. At rest it's a glitter in the air. Swung, it goes through plate and the person wearing it in the same motion, and through whoever is standing next to them." + desc = "A weighted spool of monomolecular line, run live. At rest it's a glitter in the air." icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' icon_state = "monowire" inhand_icon_state = "monowire" @@ -533,65 +781,57 @@ attack_verb_continuous = list("garrotes", "flenses", "lashes", "bisects") attack_verb_simple = list("garrote", "flense", "lash", "bisect") hitsound = 'voidcrew/sound/monowire1.ogg' + /// Cleave fraction of the wire's damage dealt to secondary targets. + var/cleave_mult = CYBERWARE_MONOWIRE_CLEAVE_MULT + /// Burn the charged line adds on top of the cut, every hit. + var/charge_burn = CYBERWARE_MONOWIRE_BURN + /// Set by afterattack() during attack()'s parent call. Only a swing that + /// connected gets that far, and attack()'s own return can't tell a hit from + /// a pacifist's pulled swing or a signal-skipped one (both return FALSE). + var/swing_landed = FALSE /obj/item/melee/cyberware_monowire/attack(mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) // The parent plays hitsound partway through the swing, so pick which of the // line's two voices we are before handing off. hitsound = pick('voidcrew/sound/monowire1.ogg', 'voidcrew/sound/monowire2.ogg') + swing_landed = FALSE + // The cut rides the hit: clean severs and the through-cut land + // while tg resolves it, so their lines join its message (hyperviolence/wire/wire_cut.dm). + wire_cut_begin(target, user) . = ..() - // A truthy return means the swing was blocked or cancelled outright, no - // cut landed, so nothing burns and nothing carries through. - if(. || QDELETED(target) || !isliving(user)) + var/landed = swing_landed + swing_landed = FALSE + // No cut landed (blocked, cancelled, pulled), so nothing burns. + if(. || !landed || QDELETED(target) || !isliving(user)) + wire_cut_end(target) return // The charge on the line, on top of the cut. Silent armor check: the parent - // already announced the penetration for this same swing. + // already announced the penetration for this same swing. Still part of the clean + // cut: a wound the cut just opened throws no blood when the charge lands on it. var/burn_zone = target.get_random_valid_zone(user.zone_selected) target.apply_damage( - CYBERWARE_MONOWIRE_BURN, + charge_burn, BURN, burn_zone, target.run_armor_check(burn_zone, MELEE, armour_penetration = armour_penetration, silent = TRUE), ) + wire_cut_end(target) - var/cleaved_anyone = FALSE - for(var/mob/living/nearby in orange(1, target)) - if(nearby == user || nearby == target || nearby.stat == DEAD) - continue - if(!can_see(target, nearby, 1)) - continue - if(cyberware_is_ally(user, nearby)) - continue - // One roll for both halves: it is one pass of the same wire. - var/cleave_block = nearby.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = armour_penetration) - nearby.apply_damage( - force * CYBERWARE_MONOWIRE_CLEAVE_MULT, - BRUTE, - BODY_ZONE_CHEST, - cleave_block, - wound_bonus = wound_bonus, - sharpness = SHARP_EDGED, - ) - nearby.apply_damage( - CYBERWARE_MONOWIRE_BURN * CYBERWARE_MONOWIRE_CLEAVE_MULT, - BURN, - BODY_ZONE_CHEST, - cleave_block, - ) - to_chat(nearby, span_userdanger("[user]'s monowire carries through into you!")) - cleaved_anyone = TRUE - if(cleaved_anyone) - user.visible_message( - span_danger("The wire sings through everything beside [target]!"), - span_danger("The wire carries through the whole cluster."), - ) - playsound(target, 'sound/items/weapons/bladeslice.ogg', 50, TRUE) +/obj/item/melee/cyberware_monowire/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) + . = ..() + swing_landed = TRUE + if(!isliving(user) || QDELETED(target) || target == user) + return + // The wire lashes out of the hand to what it hit (hyperviolence/wire/wire_held.dm), standing in for the held wire. + wire_lash_at(user, target, lash_zone) + lash_zone = null /** * # Widowline Monowire (T3, one arm, aug slot, load 5) * - * A single-arm spool housing deploying the wire above. Reach two, hits the - * crowd around whatever it lands on, and armor does nothing to slow it down. + * A single-arm spool housing deploying the wire above. Reach two, carries on + * into whoever stands behind what it lands on, and armor does nothing to slow it down. * What it can't do is hard targets: a quarter demolition mod means walls, * doors and machines shrug it off. Bring it to bodies, not to bulkheads. */ @@ -602,7 +842,8 @@ chrome_load = 5 tier = CYBERWARE_TIER_3 items_to_create = list(/obj/item/melee/cyberware_monowire) - extend_sound = 'sound/items/weapons/batonextend.ogg' + extend_sound = 'voidcrew/sound/monowire_deploy.ogg' + retract_sound = 'voidcrew/sound/monowire_retract.ogg' // ========================================================================= // POPUP RONIN SMG @@ -634,7 +875,7 @@ */ /obj/item/ammo_box/magazine/cyberware_ronin name = "\improper Ronin flush-feed magazine (10x24mm)" - desc = "A flush-feed magazine keyed to the Popup Ronin's action and nothing else's. Holds twenty caseless rounds." + desc = "A flush-feed magazine keyed to the Popup Ronin's action and nothing else's." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' icon_state = "ronin_mag" base_icon_state = "ronin_mag" @@ -681,7 +922,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin name = "\improper Popup Ronin housing" - desc = "A forearm rig that unfolds a skeletal machine-pistol into your grip and folds it away again just as fast. The mag well only takes Ronin flush-feed magazines." + desc = "A forearm rig that unfolds a skeletal machine-pistol into your grip and folds it away again just as fast." icon_state = "ronin" chrome_load = 5 tier = CYBERWARE_TIER_3 @@ -723,7 +964,7 @@ /obj/item/ammo_casing/cyberware_buster name = "30mm shaped rocket" - desc = "A stubby shaped-charge rocket in the Buster's proprietary 30mm. All of the blast goes forward and stops at the first thing it hits." + desc = "A stubby shaped-charge rocket in the Buster's proprietary 30mm." icon_state = "low_yield_rocket" base_icon_state = "low_yield_rocket" caliber = CYBERWARE_CALIBER_BUSTER @@ -771,7 +1012,7 @@ */ /obj/item/gun/ballistic/revolver/cyberware_buster name = "\improper Bunker Buster micro-rocket pod" - desc = "A two-tube rocket pod that rides folded along the forearm. The munitions are shaped, so the blast goes into the target instead of the room, and instead of your hull." + desc = "A two-tube rocket pod that rides folded along the forearm." icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' icon_state = "bunker_buster" inhand_icon_state = "bunker_buster" @@ -797,7 +1038,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/bunker_buster name = "\improper Bunker Buster housing" - desc = "A reinforced forearm bay built around two rocket tubes, with a recoil bed where your radius used to be. Takes proprietary 30mm shaped rockets, so make both count." + desc = "A reinforced forearm bay built around two rocket tubes, with a recoil bed where your radius used to be." icon_state = "bunker_buster" chrome_load = 5 tier = CYBERWARE_TIER_3 @@ -815,6 +1056,8 @@ #undef CYBERWARE_MANTIS_LUNGE_BONUS #undef CYBERWARE_MANTIS_LUNGE_TELEGRAPH #undef CYBERWARE_MANTIS_LUNGE_KNOCKDOWN +#undef CYBERWARE_MANTIS_LUNGE_STEP +#undef CYBERWARE_MANTIS_LUNGE_THRUST #undef CYBERWARE_MONOWIRE_CLEAVE_MULT #undef CYBERWARE_MONOWIRE_BURN #undef CYBERWARE_CALIBER_RONIN diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index 6bd4b6d118f..ffd818d7dd5 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -10,7 +10,7 @@ // GHOSTSKIN WEAVE // ========================================================================= -/// Cloak alpha. NEVER 0: a shimmer stays readable point-blank. +/// Cloak alpha. NEVER 0: a faint outline stays readable point-blank. #define CYBERWARE_GHOSTSKIN_ALPHA 40 /// How long a camo field holds before it collapses on its own. #define CYBERWARE_GHOSTSKIN_DURATION (10 SECONDS) @@ -19,17 +19,17 @@ * # Ghostskin Weave (T3, chest, skin slot, load 5) * * A dermal mesh of refraction cells: ten seconds of optical camo on a - * 30-second clock. Players see a heat-shimmer outline (alpha 40, never full - * invisibility) that ripples brighter when you move; NPC targeting honors + * 30-second clock. Players see a faint outline (alpha 40, never full + * invisibility) that flashes brighter when you move; NPC targeting honors * TRAIT_CYBER_CAMO through the hook in cyberware_stealth.dm, but anything * within two tiles spots you anyway. The field collapses the instant you - * attack, shoot, get struck, get cuffed, or eat an EMP, the break-trigger - * list is the MOD stealth module's, plus gunfire (nothing uncounterable + * attack, shoot, throw, get struck, get cuffed, or eat an EMP, the break-trigger + * list is the MOD stealth module's, plus gunfire and throws (nothing uncounterable * fires from inside a cloak on this server). */ /obj/item/organ/cyberimp/cyberware/ghostskin name = "\improper Ghostskin weave" - desc = "A subdermal lattice of refraction cells. Switched on, you're a faint heat-shimmer and not much else. Swing at anyone and the field drops, mid-swing, out in the open." + desc = "A subdermal lattice of refraction cells. Switched on, you flicker out until there's barely anything of you left to see." icon_state = "ghostskin" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_SKIN @@ -41,11 +41,28 @@ var/camo_active = FALSE /// Timer for the field's natural collapse. var/camo_timer + /// Alpha the bearer fades to while the field is up. Never 0. + var/field_alpha = CYBERWARE_GHOSTSKIN_ALPHA + /// How long the field holds before it collapses on its own. + var/field_duration = CYBERWARE_GHOSTSKIN_DURATION + /// Alpha the bearer flashes up to for a step when moving under the field. + /// Must stay above field_alpha, motion is meant to make you MORE visible. + var/ripple_alpha = 90 + /// Whether the field takes the bearer out of NPC targeting and scatters a + /// chrome read (TRAIT_CYBER_CAMO). Cheaper weaves only fool players. + var/hides_from_mobs = TRUE /obj/item/organ/cyberimp/cyberware/ghostskin/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() break_camo(organ_owner, silent = TRUE) +/// The field runs on the weave: it collapses the moment the weave goes offline, +/// a brownout or damage, the same as an EMP drops it. +/obj/item/organ/cyberimp/cyberware/ghostskin/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(camo_active) + break_camo(bearer, reason = "weave offline", silent = (owner != bearer)) + /obj/item/organ/cyberimp/cyberware/ghostskin/emp_act(severity) . = ..() if(. & EMP_PROTECT_SELF) @@ -53,22 +70,26 @@ if(camo_active) break_camo(owner, reason = "EMP discharge") -/// Raises the field: shimmer alpha, camo trait, break triggers, expiry clock. +/// Raises the field: field alpha, camo trait, break triggers, expiry clock. /obj/item/organ/cyberimp/cyberware/ghostskin/proc/engage_camo(mob/living/carbon/wearer) if(camo_active || !wearer) return camo_active = TRUE - ADD_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) - animate(wearer, alpha = CYBERWARE_GHOSTSKIN_ALPHA, time = 1 SECONDS) - playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 60, TRUE) - wearer.visible_message( - span_warning("[wearer] ripples and thins to a heat-shimmer outline!"), - span_notice("The weave bends the light around you. Ten seconds."), - ) + if(hides_from_mobs) + ADD_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) + // Flickers down to field_alpha over 0.6 s (cyberware_anim.dm). The var holds field_alpha at once. + cloak_flicker_in(wearer) + engage_feedback(wearer) // The MOD stealth break list (modules_ninja.dm), minus bump-off, plus - // gunfire, shooting from inside a cloak breaks it here. - RegisterSignal(wearer, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_unarmed_attack)) + // gunfire and throwing: shooting or throwing from inside a cloak breaks it here. + // The early signal too: a fist that takes over the attack (Gorilla, Scrapper) cancels the + // chain before the late one is ever sent. + RegisterSignals(wearer, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK), PROC_REF(on_unarmed_attack)) RegisterSignal(wearer, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_bullet_act)) + RegisterSignal(wearer, COMSIG_LIVING_ELECTROCUTE_ACT, PROC_REF(on_shocked)) + // Being mauled by an animal, a mob, a mech or a xeno is being hit too: the + // field is what keeps those mobs off you (cyberware_stealth.dm), so a bite at + // point-blank range has to drop it. RegisterSignals(wearer, list( COMSIG_MOB_ITEM_ATTACK, COMSIG_ATOM_ATTACKBY, @@ -76,19 +97,38 @@ COMSIG_ATOM_HITBY, COMSIG_ATOM_HULK_ATTACK, COMSIG_ATOM_ATTACK_PAW, + COMSIG_ATOM_ATTACK_ANIMAL, + COMSIG_ATOM_ATTACK_BASIC_MOB, + COMSIG_ATOM_ATTACK_MECH, + COMSIG_MOB_ATTACK_ALIEN, COMSIG_CARBON_CUFF_ATTEMPTED, COMSIG_MOB_FIRED_GUN, + COMSIG_MOB_THROW, COMSIG_LIVING_DEATH, ), PROC_REF(on_break_trigger)) RegisterSignal(wearer, COMSIG_MOVABLE_MOVED, PROC_REF(on_camo_move)) - camo_timer = addtimer(CALLBACK(src, PROC_REF(on_field_expired)), CYBERWARE_GHOSTSKIN_DURATION, TIMER_STOPPABLE) + camo_timer = addtimer(CALLBACK(src, PROC_REF(on_field_expired)), field_duration, TIMER_STOPPABLE) + +/// The sound and chat lines that go with the field coming up. Split out so a +/// cheaper weave can tell it differently. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/engage_feedback(mob/living/carbon/wearer) + playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 60, TRUE) + wearer.visible_message( + span_warning("[wearer] flickers and fades almost out of sight!"), + span_notice("The weave bends the light around you."), + ) /obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_field_expired() camo_timer = null - break_camo(owner, reason = "field capacity spent") + break_camo(owner, reason = "field capacity spent", gentle = TRUE) -/// Collapses the field and returns the bearer to full visibility. -/obj/item/organ/cyberimp/cyberware/ghostskin/proc/break_camo(mob/living/carbon/wearer, reason, silent = FALSE) +/** + * Collapses the field and returns the bearer to full visibility. A [gentle] + * drop (the field ran out or was switched off) flickers back out; anything + * that knocks it down stutters back with sparks; a [silent] one just snaps + * back. + */ +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/break_camo(mob/living/carbon/wearer, reason, silent = FALSE, gentle = FALSE) if(!camo_active) return camo_active = FALSE @@ -99,6 +139,7 @@ return REMOVE_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) UnregisterSignal(wearer, list( + COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK, COMSIG_ATOM_BULLET_ACT, COMSIG_MOB_ITEM_ATTACK, @@ -107,16 +148,28 @@ COMSIG_ATOM_HITBY, COMSIG_ATOM_HULK_ATTACK, COMSIG_ATOM_ATTACK_PAW, + COMSIG_ATOM_ATTACK_ANIMAL, + COMSIG_ATOM_ATTACK_BASIC_MOB, + COMSIG_ATOM_ATTACK_MECH, + COMSIG_MOB_ATTACK_ALIEN, + COMSIG_LIVING_ELECTROCUTE_ACT, COMSIG_CARBON_CUFF_ATTEMPTED, COMSIG_MOB_FIRED_GUN, + COMSIG_MOB_THROW, COMSIG_LIVING_DEATH, COMSIG_MOVABLE_MOVED, )) - animate(wearer, alpha = 255, time = 0.5 SECONDS) - if(!silent) + // The way back to full view is the exit's own (cyberware_anim.dm). + if(silent) + cloak_reset(wearer) + else if(gentle) + cloak_flicker_out(wearer) + else + cloak_flicker_break(wearer) do_sparks(2, TRUE, wearer) + if(!silent) wearer.visible_message( - span_danger("[wearer] shimmers back into full view!"), + span_danger("[wearer] flickers back into full view!"), span_warning("The weave collapses[reason ? ", [reason]" : ""]. You are visible."), ) @@ -133,6 +186,15 @@ return break_camo(owner, reason = "field contact") +/// Signal proc for [COMSIG_LIVING_ELECTROCUTE_ACT]: a real jolt (a live door) +/// drops the field. Hallucinated shocks and +/// ones the gloves fully insulate don't. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_shocked(datum/source, shock_damage, shock_source, siemens_coeff, flags) + SIGNAL_HANDLER + if((flags & SHOCK_ILLUSION) || shock_damage <= 0 || siemens_coeff <= 0) + return + break_camo(owner, reason = "field shorted") + /// Signal proc for [COMSIG_ATOM_BULLET_ACT]: hostile rounds pop the field. /obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_bullet_act(datum/source, obj/projectile/projectile) SIGNAL_HANDLER @@ -140,19 +202,22 @@ return break_camo(owner, reason = "field disrupted") -/// Signal proc for [COMSIG_MOVABLE_MOVED]: the moving-shimmer ripple. +/// Signal proc for [COMSIG_MOVABLE_MOVED]: the moving flash. /// Motion makes you briefly MORE visible. Free counterplay for sharp eyes. /obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_camo_move(datum/source) SIGNAL_HANDLER var/mob/living/wearer = owner if(!wearer || !camo_active) return - animate(wearer, alpha = 90, time = 0.15 SECONDS) - animate(alpha = CYBERWARE_GHOSTSKIN_ALPHA, time = 0.35 SECONDS) + // Still flickering in; the bearer is more visible than this already. + if(world.time < cloak_enter_until) + return + animate(wearer, alpha = ripple_alpha, time = 0.15 SECONDS) + animate(alpha = field_alpha, time = 0.35 SECONDS) /datum/action/cooldown/cyberware/ghostskin_cloak name = "Ghostskin Field" - desc = "Ten seconds of optical camo. Attacking, shooting, taking a hit or an EMP drops the field. Anyone standing right next to you still sees you." + desc = "Ten seconds of optical camo." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_ghostskin" cooldown_time = 30 SECONDS @@ -164,7 +229,7 @@ return FALSE if(weave.camo_active) // Manual early collapse; the cooldown was already paid. - weave.break_camo(owner, reason = "disengaged") + weave.break_camo(owner, reason = "disengaged", gentle = TRUE) return TRUE StartCooldown() weave.engage_camo(owner) @@ -199,7 +264,7 @@ */ /obj/item/organ/cyberimp/cyberware/slabskin name = "\improper Slabskin plate" - desc = "Sintered ceramic scales grown into the skin in overlapping courses. Bullets leave scuffs and a noise like a dropped pan, and not a great deal else." + desc = "A keel and rib plates of sintered ceramic, grown into the skin of the chest and back. Bullets leave scuffs, when they don't bounce off with a noise like a dropped pan." icon_state = "slabskin" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -207,9 +272,12 @@ chrome_load = 6 tier = CYBERWARE_TIER_3 aug_overlay = "slabskin" + worn_under_clothes = TRUE /// TRUE while the plate armor is mixed into the bearer's physiology. /// Guards the failing-gated passive hooks against double add/subtract. var/plate_armor_applied = FALSE + /// Percent of bullets from the front that glance off, through the shared dodge arbiter. The top of the dermal ladder. + var/deflect_chance = 45 /obj/item/organ/cyberimp/cyberware/slabskin/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -217,6 +285,7 @@ /obj/item/organ/cyberimp/cyberware/slabskin/chrome_passives_on(mob/living/carbon/bearer) . = ..() + cyberware_register_deflect_source(bearer, REF(src), CALLBACK(src, PROC_REF(get_deflect_chance))) if(plate_armor_applied || !ishuman(bearer)) return plate_armor_applied = TRUE @@ -225,6 +294,7 @@ /obj/item/organ/cyberimp/cyberware/slabskin/chrome_passives_off(mob/living/carbon/bearer) . = ..() + cyberware_unregister_deflect_source(bearer, REF(src)) if(!plate_armor_applied) return plate_armor_applied = FALSE // reset before the validity skip, or the plate never re-arms after a bearer deletes @@ -237,6 +307,12 @@ . = ..() UnregisterSignal(organ_owner, COMSIG_MOB_APPLY_DAMAGE) +/// Deflect-source callback: nothing while failing. +/obj/item/organ/cyberimp/cyberware/slabskin/proc/get_deflect_chance() + if(organ_flags & ORGAN_FAILING) + return 0 + return deflect_chance + /** * Signal proc for [COMSIG_MOB_APPLY_DAMAGE]: the telegraph. Solid brute or * burn hits spark and ping off the plate where everyone can see them. @@ -285,7 +361,7 @@ */ /obj/item/organ/cyberimp/cyberware/lazarus name = "\improper Lazarus node" - desc = "A capacitor bank sunk fist-deep in the chest, wired straight across the heart. When you go down it spends a second charging up, then shocks you back onto your feet. Rebuilding that charge takes a lot longer." + desc = "A capacitor bank sunk fist-deep in the chest, wired straight across the heart. It has strong views on you staying down." icon_state = "lazarus" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_HEART_AID @@ -304,7 +380,7 @@ if(COOLDOWN_FINISHED(src, jolt_cooldown)) . += span_notice("The charge bank reads READY.") else - . += span_notice("The charge bank reads RECHARGING, [DisplayTimeText(COOLDOWN_TIMELEFT(src, jolt_cooldown))] left.") + . += span_notice("The charge bank reads RECHARGING.") /obj/item/organ/cyberimp/cyberware/lazarus/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -320,6 +396,7 @@ . = ..() jolting = FALSE jolt_generation++ + clear_charge_fx() /** * Signal proc for [COMSIG_MOB_STATCHANGE]: fires the windup on the @@ -328,6 +405,8 @@ */ /obj/item/organ/cyberimp/cyberware/lazarus/proc/on_stat_change(mob/living/carbon/source, new_stat, old_stat) SIGNAL_HANDLER + if(new_stat == DEAD) + clear_charge_fx() if(jolting) return var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) @@ -348,6 +427,8 @@ playsound(source, 'sound/machines/defib/defib_charge.ogg', 75, TRUE) source.set_jitter_if_lower(4 SECONDS) source.do_jitter_animation(200) + // Magenta charge and arcs crawling over the body (cyberware_anim2.dm). + play_charge_fx(source, CYBERWARE_LAZARUS_WINDUP) addtimer(CALLBACK(src, PROC_REF(jolt), jolt_generation, WEAKREF(source)), CYBERWARE_LAZARUS_WINDUP) /// The discharge: heal to 30%, clear the floor out from under every stun, @@ -357,6 +438,8 @@ if(!jolting || windup_generation != jolt_generation) return jolting = FALSE + // The windup is over whether or not the jolt fires: a patient who got up on their own keeps no glow. + clear_charge_fx() var/mob/living/carbon/patient = intended_patient?.resolve() if(QDELETED(src) || QDELETED(patient) || patient != owner) return @@ -390,6 +473,7 @@ patient.set_resting(FALSE, silent = TRUE, instant = TRUE) playsound(patient, 'sound/machines/defib/defib_zap.ogg', 100, TRUE) do_sparks(3, TRUE, patient) + play_jolt_fx(patient) patient.emote("gasp") patient.set_jitter_if_lower(20 SECONDS) patient.visible_message( @@ -402,11 +486,8 @@ // ATLAS FRAME // ========================================================================= -/// Held by a bearer whose skeleton simply does not break. Blocks the whole -/// bone wound series, dislocations, hairline fractures, compound fractures, -/// at the wound system's own gate, and nothing else. Every other wound series -/// lands on its bearer exactly as it lands on anyone. -#define TRAIT_CYBERWARE_UNBREAKABLE_BONES "cyberware_unbreakable_bones" +// TRAIT_CYBERWARE_UNBREAKABLE_BONES lives in voidcrew/_DEFINES/cyberware.dm +// so the street frame (ware_models_b.dm) can name it too. /** * # Atlas Frame (T3, chest, frame slot, load 4) @@ -423,7 +504,7 @@ */ /obj/item/organ/cyberimp/cyberware/atlas name = "\improper Atlas frame" - desc = "A titanium truss bolted through the spine and ribs. Nothing breaks your bones and nothing takes a limb off you. Everything softer than bone is still your problem, and anything you throw leaves your hand like it was fired." + desc = "A titanium truss bolted through the spine and ribs, so the skeleton stops being the weak point." icon_state = "atlas" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_FRAME @@ -431,15 +512,27 @@ chrome_load = 4 tier = CYBERWARE_TIER_3 organ_traits = list(TRAIT_NODISMEMBER, TRAIT_CYBERWARE_UNBREAKABLE_BONES) + /// Tiles added to the range of anything the bearer throws. With no speed + /// bonus either, the throw hook is never armed. + var/throw_range_bonus = 2 + /// Speed added to anything the bearer throws. + var/throw_speed_bonus = 1 + /// Whether a throw of the bearer by something else travels half as far. + var/resists_knockback = TRUE + /// The line examine adds on top of the organ's own. Null for none. + var/frame_examine = null /obj/item/organ/cyberimp/cyberware/atlas/examine(mob/user) . = ..() - . += span_notice("Covers the skeleton only: no dislocations, no fractures, and no losing a limb. Slashes, punctures, burns and bleeding are untouched.") + if(frame_examine) + . += span_notice(frame_examine) /obj/item/organ/cyberimp/cyberware/atlas/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() - RegisterSignal(organ_owner, COMSIG_MOB_THROW, PROC_REF(on_owner_throws)) - RegisterSignal(organ_owner, COMSIG_MOVABLE_PRE_THROW, PROC_REF(on_owner_thrown)) + if(throw_range_bonus || throw_speed_bonus) + RegisterSignal(organ_owner, COMSIG_MOB_THROW, PROC_REF(on_owner_throws)) + if(resists_knockback) + RegisterSignal(organ_owner, COMSIG_MOVABLE_PRE_THROW, PROC_REF(on_owner_thrown)) /obj/item/organ/cyberimp/cyberware/atlas/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -472,8 +565,8 @@ SIGNAL_HANDLER if(throw_args[4] != owner) // someone else's throw, somehow return - throw_args[2] += 2 // range - throw_args[3] += 1 // speed + throw_args[2] += throw_range_bonus // range + throw_args[3] += throw_speed_bonus // speed /obj/item/organ/cyberimp/cyberware/atlas/proc/clear_throw_hooks(list/candidates) for(var/atom/movable/candidate as anything in candidates) @@ -565,7 +658,7 @@ */ /obj/item/organ/cyberimp/cyberware/rigger name = "\improper Rigger socket" - desc = "A skull jack that patches a ship's helm straight into your head. Anywhere aboard, you can fly it, as long as the ship still has a helm console for the socket to talk to, and as long as you don't mind walking while you do it." + desc = "A skull jack that patches a ship's helm straight into your head." icon_state = "rigger" zone = BODY_ZONE_HEAD slot = ORGAN_SLOT_CYBERWARE_RIGGER @@ -580,7 +673,6 @@ /obj/item/organ/cyberimp/cyberware/rigger/examine(mob/user) . = ..() - . += span_notice("Opens the helm of whatever ship its bearer stands on, from anywhere on it. It borrows a real console. A hull with no helm left has nothing to answer with.") if(!owner) return if(!linked_ship) @@ -727,12 +819,12 @@ return FALSE if(!linked_ship) pilot.balloon_alert(pilot, "no hull to reach") - to_chat(pilot, span_warning("The socket casts around for a hull and finds solid ground. You have to be aboard a ship.")) + to_chat(pilot, span_warning("The socket casts around for a hull and finds solid ground.")) return FALSE var/obj/machinery/computer/helm/console = find_console() if(!console) pilot.balloon_alert(pilot, "no helm answering!") - to_chat(pilot, span_warning("Nothing aboard [linked_ship] answers the uplink. The socket borrows a helm console, and there isn't a working one left on the ship.")) + to_chat(pilot, span_warning("Nothing aboard [linked_ship] answers the uplink.")) return FALSE if(!console.check_crew_access(pilot)) return FALSE @@ -743,7 +835,7 @@ cyberware_ink_pulse(pilot, CYBERWARE_INK_HARD) pilot.visible_message( span_notice("The jack behind [pilot]'s ear lights up, and [pilot.p_their()] eyes go somewhere else."), - span_notice("The uplink catches [console] aboard [linked_ship]. You are flying the ship from here, at a walk."), + span_notice("The uplink catches [console] aboard [linked_ship]. You are flying the ship from here."), ) console.ui_interact(pilot) return TRUE @@ -778,7 +870,7 @@ /datum/action/cooldown/cyberware/rigger_uplink name = "Helm Uplink" - desc = "Open the ship's helm from wherever you're standing. You move at a walk while it's up, and it drops if you leave the hull, black out, or lose the socket." + desc = "Open the ship's helm from wherever you're standing. You walk slowly while it's up." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "rigger" cooldown_time = 5 SECONDS @@ -847,5 +939,4 @@ #undef CYBERWARE_LAZARUS_COOLDOWN #undef CYBERWARE_LAZARUS_WINDUP #undef CYBERWARE_LAZARUS_HEAL_TO -#undef TRAIT_CYBERWARE_UNBREAKABLE_BONES #undef CYBERWARE_RIGGER_SLOWDOWN diff --git a/voidcrew/modules/cyberware/ware_models_b.dm b/voidcrew/modules/cyberware/ware_models_b.dm new file mode 100644 index 00000000000..7de0991d93c --- /dev/null +++ b/voidcrew/modules/cyberware/ware_models_b.dm @@ -0,0 +1,315 @@ +/** + * # Street and service models, set B + * + * Cheaper and sideways models of the plating, frame, seal, nervous and ear + * wares. Every one is a subtype of the ware it undercuts and sits in the same + * slot, so fitting one evicts the other. The parents keep their numbers; the + * models only set the vars the parents expose. + * + * model parent slot load tier sold for + * Hardweave plating Dermal Mesh dermal 3 T2 3,000 cr (yellow) + * Strut frame Atlas Frame frame 2 T2 1,500 cr (yellow) + * Deep-lung bladder Second Wind seal 2 T2 1,800 cr + * Heat-sink lining Coolant Loops seal 1 T1 1,500 cr + * Frostline lining (new) seal 1 T1 1,500 cr + * Twitchwire Slipwire nervous 2 T1 1,800 cr (yellow) + * Weaver shunt Slipwire nervous 5 T3 3 vouchers (advanced) + * Thumper ear Heartbeat Dop. ears 1 T1 1,500 cr (yellow) + * + * The seal slot now holds five choices (Second Wind, Deep-lung, Heat-sink, + * Frostline, Coolant). It stays a single slot, so none of them combine: an + * air bladder and a cold lining never share a body. + * + * Dodge goes through the shared arbiter (ware_pro_combat.dm), which rolls the + * single highest live source, so the Twitchwire and the Weaver shunt can never + * stack with a Cascade window or each other. + * + * Splice's shelf, the street doc's shelf (ripperdoc_catalog.dm, + * street_doc_catalog.dm) and the zone loot tables list the SKUs, ledger + * entries and organs below. + */ + +// ========================================================================= +// HARDWEAVE PLATING (T2): the middle of the dermal ladder +// ========================================================================= + +/// Between the Mesh and the Slabskin Plate: more of the same, none of the +/// plate's bomb, fire and acid cover. +/datum/armor/cyberware_hardweave + melee = 20 + bullet = 20 + laser = 15 + energy = 10 + wound = 10 + +/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave + name = "\improper Hardweave plating" + desc = "Rib plates over the chest and back with a layered polymer weave sewn in below them, a size up from the usual mesh. Rounds that come in at an angle skip off. The scarring is the part nobody mentions." + icon_state = "dermal_mesh_hardweave" + aug_overlay = "dermal_mesh_hardweave" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + mesh_armor = /datum/armor/cyberware_hardweave + deflect_chance = 30 + +// ========================================================================= +// STRUT FRAME (T2): the bones and nothing else +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/atlas/street + name = "\improper Strut frame" + desc = "Steel struts bolted along the long bones. It holds you together and has no opinion about the rest." + icon_state = "atlas_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + // Bones only: limbs can still come off, throws are plain, and a shove + // carries you as far as it carries anyone. + organ_traits = list(TRAIT_CYBERWARE_UNBREAKABLE_BONES) + throw_range_bonus = 0 + throw_speed_bonus = 0 + resists_knockback = FALSE + frame_examine = null + +// ========================================================================= +// DEEP-LUNG BLADDER (T2): a longer breath +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/second_wind/deep + name = "\improper Deep-lung bladder" + desc = "An oversized air bladder packed in around the ribs, for people who spend their shifts on the wrong side of an airlock." + icon_state = "second_wind_deep" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + reserve = 360 SECONDS + max_reserve = 360 SECONDS + refill_rate = 24 SECONDS + +// ========================================================================= +// HEAT-SINK LINING (T1): the heat half of Coolant Loops +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/coolant/street + name = "\improper Heat-sink lining" + desc = "A lining of finned aluminium stitched in under the ribs, so the heat has somewhere to go. It ticks as it cools." + icon_state = "coolant_street" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + // Heat exposure and the fire scrub stay; burns land at full strength. + burn_mult = 1 + +// ========================================================================= +// FROSTLINE LINING (T1): the cold twin +// ========================================================================= + +/** + * # Frostline lining (T1, chest, seal slot, load 1) + * + * The mirror of the Coolant Loops' heat immunity: the cold stops hurting and + * nothing else changes. It does not breathe for you, so vacuum still kills + * through low pressure, and as the seal slot is single it never sits beside + * an air bladder. + */ +/obj/item/organ/cyberimp/cyberware/frostline + name = "\improper Frostline lining" + desc = "A quilted heating lining stitched in under the ribs, for work on planets that never get warm." + icon_state = "frostline" + zone = BODY_ZONE_CHEST + slot = ORGAN_SLOT_CYBERWARE_SEAL + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_RESISTCOLD) + +// ========================================================================= +// TWITCHWIRE (T1) and WEAVER SHUNT (T3): the rungs around the Slipwire +// ========================================================================= + +/// The Twitchwire's dodge: a flinch more than a sidestep. +/obj/item/organ/cyberimp/cyberware/slipwire/street + name = "\improper Twitchwire" + desc = "A cheap reflex splice into the spinal trunk. Your legs flinch a little before you tell them to." + icon_state = "slipwire_street" + chrome_load = 2 + tier = CYBERWARE_TIER_1 + dodge_chance = 8 + gait_modifier = null + +/obj/item/organ/cyberimp/cyberware/slipwire/milspec + name = "\improper Weaver shunt" + desc = "A military reflex shunt wired the length of the spine. It was built for people who get shot at for a living." + icon_state = "slipwire_milspec" + chrome_load = 5 + tier = CYBERWARE_TIER_3 + dodge_chance = 20 + gait_modifier = /datum/movespeed_modifier/cyberware_slipwire/weaver + +/datum/movespeed_modifier/cyberware_slipwire/weaver + multiplicative_slowdown = -0.15 + +// ========================================================================= +// THUMPER EAR (T1): short, loud sonar +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/doppler/street + name = "\improper Thumper ear" + desc = "A budget sonar transceiver socketed in behind the ear. It thumps like a bass drum from the inside." + icon_state = "doppler_street" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + sonar_range = 5 + contact_fade = 3 SECONDS + ping_volume_bonus = 10 + +// ========================================================================= +// SPLICE SKUS: on the Chop Shop's sku_types, and the credit ones on the +// street doc's too. +// ========================================================================= + +/datum/shop_sku/ripperdoc/hardweave + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave + price_credits = 3000 + +/datum/shop_sku/ripperdoc/atlas_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/atlas/street + price_credits = 1500 + +/datum/shop_sku/ripperdoc/second_wind_deep + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/second_wind/deep + price_credits = 1800 + +/datum/shop_sku/ripperdoc/coolant_street + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/coolant/street + price_credits = 1500 + +/datum/shop_sku/ripperdoc/frostline + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/frostline + price_credits = 1500 + +/datum/shop_sku/ripperdoc/slipwire_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/slipwire/street + price_credits = 1800 + +/datum/shop_sku/ripperdoc/slipwire_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/slipwire/milspec + price_vouchers = 3 + +/datum/shop_sku/ripperdoc/doppler_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/doppler/street + price_credits = 1500 + +// ========================================================================= +// TRADE-IN: half list, credits only, exact type. The Weaver shunt costs +// vouchers, which can't be refunded, so it has no entry. Each entry also has +// the street doc's own wording below. +// +// Names stay siblings of the parent entries (second_wind_deep, not +// second_wind/deep) so a model never inherits its parent's price. The +// parent's own entries (second_wind, dermal_mesh) still match these models +// through match_subtypes, at the parent's lower rate. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/hardweave + name = "Hardweave Plating (pulled)" + desc = "Heavy weave, cut out. Splice recycles it toward the plate." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave + match_subtypes = FALSE + pay_credits = 1500 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/atlas_street + name = "Strut Frame (pulled)" + desc = "A bolted-in bone frame, pulled. Splice strips it for the full truss." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/atlas/street + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/second_wind_deep + name = "Deep-Lung Bladder (pulled)" + desc = "A big air bladder, used. Splice patches and resells." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/second_wind/deep + match_subtypes = FALSE + pay_credits = 900 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/coolant_street + name = "Heat-Sink Lining (pulled)" + desc = "A finned lining, pulled. Splice melts it down toward the full loop." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/coolant/street + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/frostline + name = "Frostline Lining (pulled)" + desc = "A quilted heater lining, pulled. Splice resells these to the ice crews." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/frostline + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/slipwire_street + name = "Twitchwire (pulled)" + desc = "A cheap reflex splice, pulled. Splice trades it up toward the real shunt." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/slipwire/street + match_subtypes = FALSE + pay_credits = 900 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/doppler_street + name = "Thumper Ear (pulled)" + desc = "A loud little sonar, pulled. Splice takes it toward a quieter one." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/doppler/street + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +// ---- Ratchet's wording ---------------------------------------------------- + +/datum/shop_buyback/ripperdoc/hardweave/street_doc + desc = "Used armour weave. Heavy." + +/datum/shop_buyback/ripperdoc/atlas_street/street_doc + desc = "A used bone frame. Still rattles." + +/datum/shop_buyback/ripperdoc/second_wind_deep/street_doc + desc = "A used big air bladder." + +/datum/shop_buyback/ripperdoc/coolant_street/street_doc + desc = "A used heat-sink lining. Still warm." + +/datum/shop_buyback/ripperdoc/frostline/street_doc + desc = "A used heater lining." + +/datum/shop_buyback/ripperdoc/slipwire_street/street_doc + desc = "A used reflex splice. Twitchy." + +/datum/shop_buyback/ripperdoc/doppler_street/street_doc + desc = "A used sonar ear. Loud." diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 5fd06eb5f91..9a7e77c66a0 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -6,11 +6,17 @@ * * - /datum/component/cyberware_dodge: THE one projectile-dodge arbiter. * Slipwire registers a 15% source here, Cascade Lattice (ware_legend.dm) - * registers a 40% source during its window, and the component rolls the + * registers a 60% source during its window, and the component rolls the * single HIGHEST live source. Never additive: Slipwire + Cascade together - * is still 40%, not 49%. Any future dodge chrome must register a source + * is still 60%, not 75%. Any future dodge chrome must register a source * through cyberware_register_dodge_source() rather than hanging its own - * COMSIG_ATOM_PRE_BULLET_ACT handler. + * COMSIG_ATOM_PRE_BULLET_ACT handler. A source registered with melee = TRUE + * (the Cascade line's windows) also dodges blows, through tg's own + * check_block() via COMSIG_LIVING_CHECK_BLOCK, so a dodged blow is a real + * miss and never fake damage. The same arbiter rolls the dermal plating's + * bullet deflection (see cyberware_register_deflect_source()), capped so a + * dodge and a deflect together never stop more than CYBERWARE_AVOID_CAP + * percent of shots. * - cyberware_is_ally(): the crew filter for auto-targeting ware (Deadeye's * tag, Widowline's cleave). Allies are people who share a ship team with * you; NPC boarders standing on your deck are NOT allies, and rival players @@ -22,6 +28,12 @@ /// A dodge source only fires while its bearer moved within this window. #define CYBERWARE_DODGE_MOVE_WINDOW (1 SECONDS) +/// The shortest gap between two dodge lines from a bearer nobody is playing (an NPC under fire). +#define CYBERWARE_DODGE_LINE_GAP (1.5 SECONDS) +/// Dodge and deflect together never stop more than this percent of shots. +#define CYBERWARE_AVOID_CAP 60 +/// Stamina each deflected bullet costs the bearer, so focused fire still wears them down. +#define CYBERWARE_DEFLECT_STAMINA 5 /** * The one dodge roll for all cyberware. Lives on the mob; wares and status @@ -36,13 +48,26 @@ * fading decoy and a bullet-miss crack. Dodging reads to the shooter. * - COMPONENT_BULLET_PIERCED means the round keeps flying into whoever is * behind; accepted as flavor. + * - Melee sources dodge damaging blows (weapons, fists, claws and bites) + * whether moving or not, but never grabs, tackles or shoves (zero-damage + * attempts), and never once the bearer is pinned: lying down, stunned, + * cuffed, buckled, held in an aggressive grab or in a finisher's hold. + * A melee dodge blurs in place; it never steps the bearer out of reach. */ /datum/component/cyberware_dodge dupe_mode = COMPONENT_DUPE_UNIQUE /// key -> /datum/callback returning that source's live dodge chance. var/list/dodge_sources = list() + /// key -> /datum/callback for the sources that also dodge blows. Each is in dodge_sources too. + var/list/melee_sources = list() + /// key -> /datum/callback returning that source's live bullet-deflect chance (the dermal plating). + var/list/deflect_sources = list() + /// Dodge and deflect together never stop more than this percent of shots. + var/avoid_cap = CYBERWARE_AVOID_CAP /// world.time of the bearer's last move. var/last_move_time = 0 + /// world.time a dodge was last announced: an NPC under fire says it at most once every CYBERWARE_DODGE_LINE_GAP, so chat isn't flooded + var/last_message_at = -INFINITY /datum/component/cyberware_dodge/Initialize() if(!isliving(parent)) @@ -51,12 +76,15 @@ /datum/component/cyberware_dodge/RegisterWithParent() RegisterSignal(parent, COMSIG_ATOM_PRE_BULLET_ACT, PROC_REF(on_pre_bullet_act)) RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_LIVING_CHECK_BLOCK, PROC_REF(on_check_block)) /datum/component/cyberware_dodge/UnregisterFromParent() - UnregisterSignal(parent, list(COMSIG_ATOM_PRE_BULLET_ACT, COMSIG_MOVABLE_MOVED)) + UnregisterSignal(parent, list(COMSIG_ATOM_PRE_BULLET_ACT, COMSIG_MOVABLE_MOVED, COMSIG_LIVING_CHECK_BLOCK)) /datum/component/cyberware_dodge/Destroy() dodge_sources = null + melee_sources = null + deflect_sources = null return ..() /// Signal proc for [COMSIG_MOVABLE_MOVED]: timestamp the movement window. @@ -64,30 +92,146 @@ SIGNAL_HANDLER last_move_time = world.time -/// Signal proc for [COMSIG_ATOM_PRE_BULLET_ACT]: the actual dodge roll. +/** + * Signal proc for [COMSIG_ATOM_PRE_BULLET_ACT]: the dodge roll, then the + * deflect roll. One handler for both, so the cap on the two together holds + * no matter which chrome registered first. + */ /datum/component/cyberware_dodge/proc/on_pre_bullet_act(mob/living/source, obj/projectile/hitting_projectile, def_zone, piercing_hit, blocked) SIGNAL_HANDLER - if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) - return NONE - if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) - return NONE if(!istype(hitting_projectile) || !hitting_projectile.is_hostile_projectile()) return NONE + var/dodge_chance = live_dodge_chance(source, hitting_projectile) + if(dodge_chance > 0 && prob(dodge_chance)) + INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) + return COMPONENT_BULLET_PIERCED + var/deflect_chance = min(live_deflect_chance(source, hitting_projectile), cyberware_deflect_cap(dodge_chance, avoid_cap)) + if(deflect_chance <= 0 || !prob(deflect_chance)) + return NONE + glance(source, hitting_projectile) + return COMPONENT_BULLET_PIERCED + +/// The dodge chance against this shot right now: 0 unless the bearer is moving, upright and not point-blank. +/datum/component/cyberware_dodge/proc/live_dodge_chance(mob/living/source, obj/projectile/hitting_projectile) + if(!length(dodge_sources)) + return 0 + if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) + return 0 + if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) + return 0 // Point-blank shots always land: an adjacent shooter is inside the // reflex loop. This is the anti-"unhittable in a brawl" rule. var/atom/shooter = hitting_projectile.firer if(shooter && shooter != source && get_dist(source, shooter) <= 1) - return NONE - var/best_chance = 0 - for(var/key in dodge_sources) - var/datum/callback/chance_callback = dodge_sources[key] + return 0 + return best_chance(dodge_sources) + +/** + * The deflect chance against this shot right now. Only real bullets (and + * each buckshot pellet on its own, since every pellet is its own projectile) + * glance off; lasers, energy and everything else never do. The plate only + * covers what the bearer is facing: a shot into the back always lands. + */ +/datum/component/cyberware_dodge/proc/live_deflect_chance(mob/living/source, obj/projectile/hitting_projectile) + if(!length(deflect_sources)) + return 0 + if(!istype(hitting_projectile, /obj/projectile/bullet) || hitting_projectile.armor_flag != BULLET) + return 0 + if(source.stat == DEAD) + return 0 + if(cyberware_shot_from_behind(source, hitting_projectile)) + return 0 + return best_chance(deflect_sources) + +/** + * The real glance: the bullet's heading turns back off the plate at a random + * angle and the round keeps flying (COMPONENT_BULLET_PIERCED, the same exit + * tg's own reflect takes), so it can still hit whoever stands in its new + * path. It loses energy like any tg ricochet. The bearer stays in the + * bullet's impacted list, so it can't come back for them. + */ +/datum/component/cyberware_dodge/proc/glance(mob/living/source, obj/projectile/bullet) + bullet.set_angle(SIMPLIFY_DEGREES(bullet.angle + pick(-1, 1) * rand(100, 170))) + bullet.damage *= bullet.ricochet_decay_damage + bullet.stamina *= bullet.ricochet_decay_damage + INVOKE_ASYNC(src, PROC_REF(perform_glance), source, bullet.name) + +/// The visible and physical half of a glance: sparks, a ping, and the stamina the hit still costs. +/datum/component/cyberware_dodge/proc/perform_glance(mob/living/source, bullet_name) + if(QDELETED(source)) + return + do_sparks(1, TRUE, source) + playsound(source, SFX_RICOCHET, 60, TRUE) + source.adjustStaminaLoss(CYBERWARE_DEFLECT_STAMINA) + if(source.client || world.time - last_message_at >= CYBERWARE_DODGE_LINE_GAP) + last_message_at = world.time + source.visible_message( + span_danger("\The [bullet_name] glances off [source]!"), + span_danger("\The [bullet_name] glances off you!"), + ) + +/// The highest live chance among these sources. Never additive. +/datum/component/cyberware_dodge/proc/best_chance(list/sources) + . = 0 + for(var/key in sources) + var/datum/callback/chance_callback = sources[key] var/chance = chance_callback?.Invoke() if(isnum(chance)) - best_chance = max(best_chance, chance) + . = max(., chance) + +/// Signal proc for [COMSIG_LIVING_CHECK_BLOCK]: the melee dodge roll, from tg's own check_block(). +/datum/component/cyberware_dodge/proc/on_check_block(mob/living/source, atom/hit_by, damage, attack_text, attack_type, armour_penetration, damage_type) + SIGNAL_HANDLER + if(!length(melee_sources) || !cyberware_can_dodge_blow(source, hit_by, damage, attack_text, attack_type)) + return FAILED_BLOCK + var/best_chance = best_chance(melee_sources) if(best_chance <= 0 || !prob(best_chance)) - return NONE - INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) - return COMPONENT_BULLET_PIERCED + return FAILED_BLOCK + INVOKE_ASYNC(src, PROC_REF(perform_blow_dodge), source, attack_text) + return SUCCESSFUL_BLOCK + +/// The visible half of a dodged blow: the same blur decoy, a whiff, and a line. No step. +/datum/component/cyberware_dodge/proc/perform_blow_dodge(mob/living/source, attack_text) + if(QDELETED(source)) + return + new /obj/effect/temp_visual/decoy/fading/halfsecond(source.loc, source) + playsound(source, 'sound/items/weapons/punchmiss.ogg', 50, TRUE) + if(source.client || world.time - last_message_at >= CYBERWARE_DODGE_LINE_GAP) + last_message_at = world.time + source.visible_message( + span_danger("[source] blurs out of the way of [attack_text]!"), + span_danger("You blur out of the way of [attack_text]!"), + ) + +/** + * Whether the bearer can dodge this blow at all. Only damaging melee and + * unarmed blows from someone else; never while the bearer is down, stunned, + * cuffed, buckled, held tight, or in a finisher's hold, so grabs, finishers, + * the garrote and executions are never dodged once they have started. + */ +/proc/cyberware_can_dodge_blow(mob/living/bearer, atom/hit_by, damage, attack_text, attack_type) + if(attack_type != MELEE_ATTACK && attack_type != UNARMED_ATTACK) + return FALSE + if(!isnum(damage) || damage <= 0) + // tg checks a person's bare hands before it knows what they do, with no damage and the + // attacker's name as the text. Only a hand thrown in combat mode counts; a grab + // ("[name]'s grab"), a tackle and a friendly touch never do. + if(!isliving(hit_by)) + return FALSE + var/mob/living/attacker = hit_by + if(!attacker.combat_mode || attack_text != attacker.name) + return FALSE + if(hit_by == bearer || (isitem(hit_by) && hit_by.loc == bearer)) + return FALSE + if(bearer.stat != CONSCIOUS || bearer.body_position == LYING_DOWN || bearer.buckled) + return FALSE + if(HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || HAS_TRAIT(bearer, TRAIT_FLOORED) || HAS_TRAIT(bearer, TRAIT_RESTRAINED) || HAS_TRAIT(bearer, TRAIT_IMMOBILIZED)) + return FALSE + if(bearer.pulledby && bearer.pulledby.grab_state >= GRAB_AGGRESSIVE) + return FALSE + if(bearer.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + return TRUE /// The visible half of a successful dodge: blur decoy, bullet-miss crack, /// and a one-tile sidestep perpendicular to the shot when the floor allows. @@ -96,10 +240,12 @@ return new /obj/effect/temp_visual/decoy/fading/halfsecond(source.loc, source) playsound(source, SFX_BULLET_MISS, 60, TRUE) - source.visible_message( - span_danger("[source] blurs sideways out of [dodged]'s path!"), - span_danger("You blur out of [dodged]'s path!"), - ) + if(source.client || world.time - last_message_at >= CYBERWARE_DODGE_LINE_GAP) + last_message_at = world.time + source.visible_message( + span_danger("[source] blurs sideways out of [dodged]'s path!"), + span_danger("You blur out of [dodged]'s path!"), + ) var/incoming_dir = QDELETED(dodged) ? source.dir : dodged.dir var/list/side_dirs = shuffle(list(turn(incoming_dir, 90), turn(incoming_dir, -90))) for(var/step_dir in side_dirs) @@ -111,12 +257,17 @@ * arbiter component on first use. The callback is polled per incoming * projectile and must return the source's CURRENT chance, return 0 while * disabled (ORGAN_FAILING etc.) rather than unregistering per-flicker. + * With melee = TRUE the same chance also dodges blows (see on_check_block()). */ -/proc/cyberware_register_dodge_source(mob/living/bearer, key, datum/callback/chance_callback) +/proc/cyberware_register_dodge_source(mob/living/bearer, key, datum/callback/chance_callback, melee = FALSE) if(!istype(bearer) || !key || !chance_callback) return var/datum/component/cyberware_dodge/arbiter = bearer.LoadComponent(/datum/component/cyberware_dodge) arbiter.dodge_sources[key] = chance_callback + if(melee) + arbiter.melee_sources[key] = chance_callback + else + arbiter.melee_sources -= key /// Removes a keyed dodge source; the arbiter cleans itself up with the last one. /proc/cyberware_unregister_dodge_source(mob/living/bearer, key) @@ -124,9 +275,46 @@ if(!arbiter) return arbiter.dodge_sources -= key - if(!length(arbiter.dodge_sources)) + arbiter.melee_sources -= key + if(!length(arbiter.dodge_sources) && !length(arbiter.deflect_sources)) + qdel(arbiter) + +/** + * Adds (or refreshes) a keyed bullet-deflect source on the bearer, through + * the same arbiter as the dodge. The callback returns the source's CURRENT + * chance. Never additive: the highest live source rolls. + */ +/proc/cyberware_register_deflect_source(mob/living/bearer, key, datum/callback/chance_callback) + if(!istype(bearer) || !key || !chance_callback) + return + var/datum/component/cyberware_dodge/arbiter = bearer.LoadComponent(/datum/component/cyberware_dodge) + arbiter.deflect_sources[key] = chance_callback + +/// Removes a keyed deflect source; the arbiter cleans itself up with the last source of either kind. +/proc/cyberware_unregister_deflect_source(mob/living/bearer, key) + var/datum/component/cyberware_dodge/arbiter = bearer?.GetComponent(/datum/component/cyberware_dodge) + if(!arbiter) + return + arbiter.deflect_sources -= key + if(!length(arbiter.dodge_sources) && !length(arbiter.deflect_sources)) qdel(arbiter) +/** + * The most a deflect may roll after a dodge roll at dodge_chance missed, so + * that dodge and deflect together never stop more than avoid_cap + * percent of shots: dodge + (1 - dodge) * deflect <= cap. + */ +/proc/cyberware_deflect_cap(dodge_chance, avoid_cap = CYBERWARE_AVOID_CAP) + dodge_chance = clamp(dodge_chance, 0, 100) + if(dodge_chance >= avoid_cap) + return 0 + return (avoid_cap - dodge_chance) * 100 / (100 - dodge_chance) + +/// Whether this shot is travelling the way the bearer faces, so it strikes them in the back. +/proc/cyberware_shot_from_behind(mob/living/bearer, obj/projectile/shot) + var/offset = SIMPLIFY_DEGREES(shot.angle - dir2angle(bearer.dir) + 180) - 180 + return abs(offset) < 90 + // ---- Shared ally filter ------------------------------------------------ /** @@ -158,6 +346,9 @@ return TRUE if(!istype(user) || !istype(target)) return FALSE + // A bounty criminal's own companions and creatures (voidcrew/modules/bounties) + if(bounty_ai_same_gang(user, target)) + return TRUE var/datum/team/user_team = cyberware_crew_team(user) if(!user_team) return FALSE @@ -189,7 +380,7 @@ */ /obj/item/organ/eyes/robotic/cyberware/deadeye name = "\improper Deadeye link" - desc = "Milspec optics built around a target-processing coprocessor. Paint someone and the link walks your next few rounds onto them, whatever the barrel was actually pointed at." + desc = "Military optics built around a targeting coprocessor. It decides where your rounds go, and you aren't consulted." icon_state = "deadeye" chrome_load = 3 tier = CYBERWARE_TIER_2 @@ -197,6 +388,14 @@ /datum/action/cooldown/cyberware/deadeye_tag, /datum/action/cooldown/cyberware/chrome_read, ) + optics_zoom = 1.5 + zoom_walk_penalty = 0.5 + /// How far a tag can be planted, and how far homing keeps correcting. + var/tag_range = CYBERWARE_DEADEYE_TAG_RANGE + /// Homing shots granted per tag. + var/tag_shots = CYBERWARE_DEADEYE_TAG_SHOTS + /// How long a tag lasts, spent or not. + var/tag_duration = CYBERWARE_DEADEYE_TAG_DURATION /// Weakref to the currently tagged target. var/datum/weakref/tagged_ref /// Homing shots remaining on the current tag. @@ -217,14 +416,14 @@ /obj/item/organ/eyes/robotic/cyberware/deadeye/proc/start_tag(mob/living/target) end_tag(owner) tagged_ref = WEAKREF(target) - shots_left = CYBERWARE_DEADEYE_TAG_SHOTS + shots_left = tag_shots RegisterSignal(owner, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_owner_fired)) target.add_filter(CYBERWARE_DEADEYE_MARK_FILTER, 2, list("type" = "outline", "color" = "#ff3300", "size" = 1)) new /obj/effect/temp_visual/smartgun_target(get_turf(target)) playsound(owner, 'sound/machines/synth/synth_yes.ogg', 30, TRUE) owner.balloon_alert(owner, "target tagged") to_chat(target, span_userdanger("A red targeting glint settles on you!")) - tag_timer = addtimer(CALLBACK(src, PROC_REF(on_tag_expired)), CYBERWARE_DEADEYE_TAG_DURATION, TIMER_STOPPABLE) + tag_timer = addtimer(CALLBACK(src, PROC_REF(on_tag_expired)), tag_duration, TIMER_STOPPABLE) /obj/item/organ/eyes/robotic/cyberware/deadeye/proc/on_tag_expired() tag_timer = null @@ -255,7 +454,7 @@ if(!istype(projectile)) return var/mob/living/target = tagged_ref?.resolve() - if(QDELETED(target) || target.stat == DEAD || target.z != user.z || get_dist(user, target) > CYBERWARE_DEADEYE_TAG_RANGE) + if(QDELETED(target) || target.stat == DEAD || target.z != user.z || get_dist(user, target) > tag_range) end_tag(user) return projectile.set_homing_target(target) @@ -267,7 +466,7 @@ /datum/action/cooldown/cyberware/deadeye_tag name = "Deadeye Tag" - desc = "Tag a hostile in view. Your next three shots track them automatically. The tag refuses to take a crewmate." + desc = "Tag a hostile in view. Your next three shots track them." button_icon = 'icons/mob/actions/actions_items.dmi' button_icon_state = "sniper_zoom" cooldown_time = 25 SECONDS @@ -287,7 +486,7 @@ if(cyberware_is_ally(owner, victim)) owner.balloon_alert(owner, "friendly, tag refused!") return FALSE - if(!can_see(owner, victim, CYBERWARE_DEADEYE_TAG_RANGE)) + if(!can_see(owner, victim, optics.tag_range)) owner.balloon_alert(owner, "no line of sight!") return FALSE StartCooldown() @@ -309,17 +508,22 @@ * sidestep (never point-blank, never while browned out) plus a small * always-on gait boost. The dodge itself lives in the shared arbiter above; * this organ just contributes a source whose chance drops to zero the moment - * the ware is EMP-scrambled or browned out. + * the ware is EMP-scrambled or browned out. The chance and the gait boost are + * vars, so the street and service models (ware_models_b.dm) retune them. */ /obj/item/organ/cyberimp/cyberware/slipwire name = "\improper Slipwire reflex shunt" - desc = "A reflex arc spliced in parallel with the spinal trunk. While you're moving it sidesteps incoming fire on its own, roughly one shot in seven misses because your body moved before you told it to." + desc = "A reflex arc spliced in parallel with the spinal trunk. Your body gets out of the way before you've decided to." icon_state = "slipwire" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_NERVOUS w_class = WEIGHT_CLASS_SMALL chrome_load = 4 tier = CYBERWARE_TIER_2 + /// Percent of hostile projectiles dodged while the bearer is moving. + var/dodge_chance = CYBERWARE_SLIPWIRE_DODGE_CHANCE + /// The gait boost the ware adds while it runs. Null for none. + var/datum/movespeed_modifier/gait_modifier = /datum/movespeed_modifier/cyberware_slipwire /obj/item/organ/cyberimp/cyberware/slipwire/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -335,17 +539,19 @@ // add/remove is idempotent and needs no applied-state guard. /obj/item/organ/cyberimp/cyberware/slipwire/chrome_passives_on(mob/living/carbon/bearer) . = ..() - bearer?.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire) + if(gait_modifier) + bearer?.add_movespeed_modifier(gait_modifier) /obj/item/organ/cyberimp/cyberware/slipwire/chrome_passives_off(mob/living/carbon/bearer) . = ..() - bearer?.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire) + if(gait_modifier) + bearer?.remove_movespeed_modifier(gait_modifier) /// Dodge-source callback: dead weight while failing, 15% otherwise. /obj/item/organ/cyberimp/cyberware/slipwire/proc/get_dodge_chance() if(organ_flags & ORGAN_FAILING) return 0 - return CYBERWARE_SLIPWIRE_DODGE_CHANCE + return dodge_chance /datum/movespeed_modifier/cyberware_slipwire multiplicative_slowdown = -0.1 @@ -391,7 +597,7 @@ */ /obj/item/organ/cyberimp/cyberware/dead_channel name = "\improper Dead Channel pain editor" - desc = "A signal processor clamped over the pain nerves. You stop feeling injuries entirely, which keeps you upright and moving where anyone else would fold. The catch is that you can't tell how bad it's gotten. The colour draining out of the world is the only gauge you get." + desc = "A signal processor clamped over the pain nerves. Nothing hurts. That isn't the same as nothing being wrong." icon_state = "dead_channel" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_NERVOUS @@ -608,7 +814,7 @@ */ /obj/item/organ/cyberimp/cyberware/hopper name = "\improper Hopper piston calves" - desc = "Paired myomer pistons sleeved over both calves. Four tiles of flat jump on demand, over railings, tables and whoever's in the way. Flat is the operative word: they will not put you over a wall." + desc = "Paired myomer pistons sleeved over both calves, for clearing railings, tables and whoever's in the way." icon_state = "hopper" zone = BODY_ZONE_L_LEG slot = ORGAN_SLOT_CYBERWARE_LEGS @@ -622,14 +828,34 @@ tier = CYBERWARE_TIER_2 aug_overlay = "hopper" actions_types = list(/datum/action/cooldown/cyberware/hopper_leap) + // The models in the line (ware_line_models_a.dm) retune these by setting vars. + /// Leap distance in tiles. + var/leap_range = CYBERWARE_HOPPER_RANGE + /// Gap between leaps. The button reads it when it is built. + var/leap_cooldown = 8 SECONDS + /// Tooltip for the button when this model's numbers differ from the button's + /// own text. Null keeps the button's own. + var/leap_action_desc /datum/action/cooldown/cyberware/hopper_leap name = "Piston Leap" - desc = "Leap up to four tiles over gaps, tables and people, onto any open floor you can see with a clear lane to it. Walls, windows and shut doors stop the jump." + desc = "Leap up to four tiles over gaps, tables and people." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_hopper" cooldown_time = 8 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body does not get to leap out of it. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE + +/datum/action/cooldown/cyberware/hopper_leap/New(Target, original = TRUE) + . = ..() + // The pistons this button belongs to set its cooldown and tooltip. + var/obj/item/organ/cyberimp/cyberware/hopper/pistons = organ + if(!istype(pistons)) + return + cooldown_time = pistons.leap_cooldown + if(pistons.leap_action_desc) + desc = pistons.leap_action_desc /datum/action/cooldown/cyberware/hopper_leap/Activate(atom/target) if(!isliving(owner)) @@ -642,10 +868,12 @@ if(jumper.buckled || !isturf(jumper.loc)) jumper.balloon_alert(jumper, "can't jump from here!") return FALSE - if(get_dist(here, destination) > CYBERWARE_HOPPER_RANGE) + var/obj/item/organ/cyberimp/cyberware/hopper/pistons = organ + var/leap_range = istype(pistons) ? pistons.leap_range : CYBERWARE_HOPPER_RANGE + if(get_dist(here, destination) > leap_range) jumper.balloon_alert(jumper, "too far!") return FALSE - if(!can_see(jumper, destination, CYBERWARE_HOPPER_RANGE)) + if(!can_see(jumper, destination, leap_range)) jumper.balloon_alert(jumper, "no line of sight!") return FALSE if(destination.is_blocked_turf() || islava(destination) || ischasm(destination)) @@ -656,12 +884,13 @@ return FALSE StartCooldown() playsound(here, 'sound/items/weapons/punchmiss.ogg', 40, TRUE, -1) - new /obj/effect/temp_visual/decoy/fading/halfsecond(here, jumper) + var/obj/effect/temp_visual/decoy/fading/halfsecond/afterimage = new(here, jumper) jumper.visible_message( span_warning("[jumper]'s legs fire like pistons and [jumper.p_they()] vault[jumper.p_s()] into the air!"), span_notice("You fire the pistons and leap."), ) - animate(jumper, pixel_z = 20, time = CYBERWARE_HOPPER_AIRTIME, flags = ANIMATION_RELATIVE, easing = QUAD_EASING|EASE_OUT) + // A crouch, dust, and an arc over to the landing; the body stays on its tile until it lands (cyberware_anim2.dm). + cyberware_leap_takeoff_fx(jumper, here, destination, CYBERWARE_HOPPER_AIRTIME, 20, 26, afterimage) addtimer(CALLBACK(src, PROC_REF(land), destination), CYBERWARE_HOPPER_AIRTIME) return TRUE @@ -671,7 +900,7 @@ var/mob/living/jumper = owner if(QDELETED(jumper)) return - animate(jumper, pixel_z = -20, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) + cyberware_leap_touchdown_fx(jumper) var/turf/final = destination if(QDELETED(final) || final.is_blocked_turf()) final = null @@ -683,10 +912,13 @@ jumper.balloon_alert(jumper, "landing fouled!") return jumper.forceMove(final) - new /obj/effect/temp_visual/mook_dust(final) + cyberware_grey_dust(final) playsound(final, 'sound/effects/gravhit.ogg', 50, TRUE) #undef CYBERWARE_DODGE_MOVE_WINDOW +#undef CYBERWARE_DODGE_LINE_GAP +#undef CYBERWARE_AVOID_CAP +#undef CYBERWARE_DEFLECT_STAMINA #undef CYBERWARE_DEADEYE_TAG_RANGE #undef CYBERWARE_DEADEYE_TAG_SHOTS #undef CYBERWARE_DEADEYE_TAG_DURATION diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index 1c560463f0e..5bc0bbe3499 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -189,7 +189,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/icepick name = "\improper Icepick Jack" - desc = "A slim data spike folded into the wrist, tipped in monocrystal. Turret IFF buses and door motor controllers all run the same handful of protocols, and the spike knows every one of them. Outpost security hardware is the one exception; nobody has cracked that firmware." + desc = "A slim data spike folded into the wrist, tipped in monocrystal. Turrets and door motors both talk to it a little too easily." icon_state = "icepick" chrome_load = 3 tier = CYBERWARE_TIER_2 @@ -229,7 +229,7 @@ /datum/action/cooldown/cyberware/icepick/suppress name = "Icepick: Suppress" - desc = "Spike a hostile turret in line of sight to knock it offline for a while, or slowly force open a bolted or unpowered door you're standing at." + desc = "Spike a hostile turret in sight to knock it offline, or force open a bolted or unpowered door." // Both channels are on the same spike, so both need their own face on the // HUD: two identical buttons is a misclick waiting to happen. button_icon_state = "act_icepick_suppress" @@ -346,7 +346,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook name = "\improper Skyhook wrist winch" - desc = "A barbed anchor over a spooled monofilament winch, built through the wrist bones. Fire it into anything solid within seven tiles and hold on." + desc = "A barbed anchor over a spooled monofilament winch, built through the wrist bones. It goes into anything solid and reels you in after it." icon_state = "skyhook" chrome_load = 3 tier = CYBERWARE_TIER_2 @@ -359,6 +359,8 @@ button_icon_state = "skyhook" cooldown_time = 8 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body, or one with its hands tied, does not winch itself out. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE | AB_CHECK_HANDS_BLOCKED /// The zipline visual for the current pull. var/datum/beam/zipline /// Traits worn for the duration of the pull, per the grapple gun. @@ -431,7 +433,7 @@ */ /obj/item/organ/eyes/robotic/cyberware/prospector name = "\improper Prospector Suite optics" - desc = "Amber-ringed surveyor eyes. Blink the command and they thump out a resonance pulse: ore veins light up straight through the rock, and any cache transponders in range echo back with them. The handheld scanner needs line of sight. These don't." + desc = "Amber-ringed surveyor eyes that can see ore through solid rock. The pulse that does it makes your skull hum." icon_state = "prospector" eye_color_left = "#b8860b" eye_color_right = "#b8860b" @@ -442,6 +444,12 @@ /datum/action/cooldown/cyberware/prospector_pulse, /datum/action/cooldown/cyberware/chrome_read, ) + optics_zoom = 1.5 + zoom_walk_penalty = 0.5 + /// How far the resonance pulse reaches, through rock, in tiles. + var/pulse_range = CYBERWARE_PROSPECTOR_RANGE + /// Whether the pulse also flags zone loot caches. + var/pulse_marks_caches = TRUE /// Cache marks the last pulse raised, so pulling the optics takes them out /// with it instead of leaving blips on a client that no longer owns them. var/list/hud_marks = list() @@ -472,10 +480,14 @@ var/mob/living/carbon/bearer = organ.owner var/obj/item/organ/eyes/robotic/cyberware/prospector/optics = organ var/list/marks = istype(optics) ? optics.hud_marks : null - mineral_scan_pulse(get_turf(bearer), CYBERWARE_PROSPECTOR_RANGE, scanner = bearer) + var/pulse_range = istype(optics) ? optics.pulse_range : CYBERWARE_PROSPECTOR_RANGE + mineral_scan_pulse(get_turf(bearer), pulse_range, scanner = bearer) playsound(bearer, 'sound/machines/sonar-ping.ogg', 25, TRUE) + if(istype(optics) && !optics.pulse_marks_caches) + StartCooldown() + return TRUE var/caches = 0 - for(var/obj/structure/closet/crate/zone_loot/cache in range(CYBERWARE_PROSPECTOR_RANGE, bearer)) + for(var/obj/structure/closet/crate/zone_loot/cache in range(pulse_range, bearer)) // Marks go up through the rock the same way the ore reveal does, a // cache you can already see was never the thing worth pinging for. cyberware_hud_mark(bearer, cache, "#ffb347", 6 SECONDS, registry = marks) @@ -498,7 +510,7 @@ */ /obj/item/organ/cyberimp/cyberware/hemoglass name = "\improper Hemoglass filter" - desc = "A dialysis loop in borosilicate, teed into the vena cava. It strips poisons, chems and radiation out of your blood a little faster than most things can put them in. Party mode tells it to leave the recreational chems alone." + desc = "A dialysis loop in borosilicate, teed into the vena cava. It cleans your blood faster than most things can foul it. There's a switch for evenings when you'd rather it didn't." icon_state = "hemoglass" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_FILTER @@ -513,7 +525,7 @@ /obj/item/organ/cyberimp/cyberware/hemoglass/ui_action_click() party_mode = !party_mode - owner.balloon_alert(owner, party_mode ? "party mode, recreational channels open" : "filter scrubbing everything") + owner.balloon_alert(owner, party_mode ? "party mode" : "scrubbing everything") /obj/item/organ/cyberimp/cyberware/hemoglass/on_life(seconds_per_tick, times_fired) . = ..() @@ -542,13 +554,17 @@ * * Circulating coolant woven through the torso: heat exposure damage is * trait-gated off, burns hit at half strength, and open flame on you gets - * starved down fast. Deliberately NOT fireproof, enough flamer fuel still - * wins, which keeps the buyable MOD flamethrower an honest counter. Sits + * starved down fast. In practice that makes a human bearer fireproof: fire + * stacks only hurt a human through body heat (fire_stacks.dm harm_human()), + * and TRAIT_RESISTHEAT gates that damage off (_species.dm), so a burning + * bearer loses clothes, not skin. The cheap Heat-sink lining + * (ware_models_b.dm) carries the same trait. Burn damage dealt outright + * (a laser, say) still lands, halved here. Sits * on the seal ladder above Second Wind: pick your climate. */ /obj/item/organ/cyberimp/cyberware/coolant name = "\improper Coolant Loop lattice" - desc = "Kilometres of microbore coolant line threaded between muscle and skin: a full climate system for your body. You can work in lava country and walk out of fires that would cook anyone else. It eats a lot of chrome load to run." + desc = "Kilometres of microbore coolant line threaded between muscle and skin, which makes you a climate system with a pulse." icon_state = "coolant" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_SEAL @@ -556,6 +572,9 @@ chrome_load = 3 tier = CYBERWARE_TIER_2 organ_traits = list(TRAIT_RESISTHEAT) + /// Multiplier on the bearer's burn damage while running. 1 leaves burns alone + /// and applies no physiology mod at all. + var/burn_mult = 0.5 /// TRUE while the burn-halving physiology mod is applied. Guards the /// failing-gated passive hooks against double multiply/divide. var/coolant_mod_applied = FALSE @@ -567,11 +586,11 @@ // Physiology persists across species changes (physiology.dm:1). /obj/item/organ/cyberimp/cyberware/coolant/chrome_passives_on(mob/living/carbon/bearer) . = ..() - if(coolant_mod_applied || !ishuman(bearer)) + if(coolant_mod_applied || !ishuman(bearer) || burn_mult == 1) return coolant_mod_applied = TRUE var/mob/living/carbon/human/human_bearer = bearer - human_bearer.physiology.burn_mod *= 0.5 + human_bearer.physiology.burn_mod *= burn_mult /obj/item/organ/cyberimp/cyberware/coolant/chrome_passives_off(mob/living/carbon/bearer) . = ..() @@ -581,7 +600,7 @@ if(!ishuman(bearer) || QDELETED(bearer)) return var/mob/living/carbon/human/human_bearer = bearer - human_bearer.physiology.burn_mod /= 0.5 + human_bearer.physiology.burn_mod /= burn_mult /obj/item/organ/cyberimp/cyberware/coolant/on_life(seconds_per_tick, times_fired) . = ..() @@ -615,7 +634,7 @@ */ /obj/item/organ/cyberimp/cyberware/doppler name = "\improper Heartbeat Doppler" - desc = "A sonar transceiver socketed into the bone behind your ear. Anything alive and moving within nine tiles shows up through walls; anything holding still doesn't register at all. The ping is quiet, but it's out loud, so you're not the only one who knows it's running." + desc = "A sonar transceiver socketed into the bone behind your ear. It paints whatever is moving nearby, walls or no walls, and the ping is louder than the brochure said." icon_state = "doppler" zone = BODY_ZONE_HEAD slot = ORGAN_SLOT_CYBERWARE_EARS @@ -625,6 +644,12 @@ actions_types = list(/datum/action/item_action/organ_action/toggle) /// Whether the sonar is running. var/active = FALSE + /// How far the sonar reaches, in tiles. + var/sonar_range = CYBERWARE_DOPPLER_RANGE + /// How long a contact stays painted after its last step. + var/contact_fade = CYBERWARE_DOPPLER_FADE + /// Added to the ping's volume, on top of the distance falloff. + var/ping_volume_bonus = 0 /// Living things in range we hold movement and death hooks on. Assoc for /// the O(1) membership test; the values are dummies. var/list/watched = list() @@ -694,7 +719,7 @@ return next_sweep = world.time + CYBERWARE_DOPPLER_SWEEP var/list/in_range = list() - for(var/mob/living/contact in range(CYBERWARE_DOPPLER_RANGE, owner)) + for(var/mob/living/contact in range(sonar_range, owner)) if(contact == owner || contact.stat == DEAD) continue in_range[contact] = TRUE @@ -742,14 +767,14 @@ /obj/item/organ/cyberimp/cyberware/doppler/proc/paint_contact(mob/living/contact) if(!active || (organ_flags & ORGAN_FAILING) || QDELETED(owner) || QDELETED(contact)) return - if(contact.stat == DEAD || get_dist(owner, contact) > CYBERWARE_DOPPLER_RANGE || contact.z != owner.z) + if(contact.stat == DEAD || get_dist(owner, contact) > sonar_range || contact.z != owner.z) unwatch_contact(contact) return var/datum/cyberware_hud_mark/blip = contacts[contact] if(blip) - blip.refresh(CYBERWARE_DOPPLER_FADE) + blip.refresh(contact_fade) return - cyberware_hud_mark(owner, contact, "#9fd8ff", CYBERWARE_DOPPLER_FADE, "sonar_ping_small", contacts, contact) + cyberware_hud_mark(owner, contact, "#9fd8ff", contact_fade, "sonar_ping_small", contacts, contact) /// Signal proc for [COMSIG_MOVABLE_MOVED] on the bearer: the set of things /// close enough to hook changed. The blips re-aim themselves. @@ -792,7 +817,7 @@ if(nearest_mover == INFINITY) return // Louder as they close; audible around you either way. That's the deal. - var/ping_volume = clamp(45 - nearest_mover * 3, 15, 45) + var/ping_volume = clamp(45 - nearest_mover * 3, 15, 45) + ping_volume_bonus playsound(owner, 'sound/machines/sonar-ping.ogg', ping_volume, TRUE) if(nearest_mover <= 3) // close contact: the beep doubles up addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(playsound), owner, 'sound/machines/sonar-ping.ogg', ping_volume, TRUE), 0.4 SECONDS) @@ -812,7 +837,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber name = "\improper Graverobber's Jack" - desc = "An arm-mounted data spike that reads intel out of dead bodies. Spend three seconds on an adjacent corpse and nearby loot caches and hostile patrols get marked on your HUD." + desc = "An arm-mounted data spike for going through the implants of the recently dead." icon_state = "graverobber" chrome_load = 2 tier = CYBERWARE_TIER_2 @@ -821,12 +846,6 @@ /// with it instead of leaving blips on a client that no longer owns them. var/list/hud_marks = list() -/obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber/examine(mob/user) - . = ..() - . += span_notice("Once installed, use the Data-Spike the Dead ability on a dead body next to you. The read takes 3 seconds. It marks loot caches within [CYBERWARE_GRAVEROBBER_SWEEP_RANGE] tiles in amber and hostile patrols in red on your HUD for a few seconds.") - . += span_notice("Occasionally a read recovers a vault phrase, marking caches within [CYBERWARE_GRAVEROBBER_DEEP_SWEEP_RANGE] tiles instead.") - . += span_notice("Each body can only be read once, and the spike takes 20 seconds to recharge between reads.") - /obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) clear_marks() return ..() @@ -912,7 +931,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/angler name = "\improper Harpoon Spool" - desc = "A forearm winch spooled with monofilament under a barbed head. Fired, it drags loose gear and small creatures to you from six tiles out, anything heavier drags you instead." + desc = "A forearm winch spooled with monofilament under a barbed head. It reels in whatever it hits, or you toward it, depending on who's heavier." icon_state = "angler" chrome_load = 2 tier = CYBERWARE_TIER_2 @@ -920,7 +939,7 @@ /datum/action/cooldown/cyberware/harpoon name = "Harpoon Barb" - desc = "Fire the barb up to six tiles: loose items and small creatures come to you. Anything heavier drags you a tile toward it instead." + desc = "Fire the barb up to six tiles. Loose items and small creatures come to you; anything heavier pulls you toward it." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "angler" cooldown_time = 8 SECONDS @@ -928,7 +947,8 @@ /datum/action/cooldown/cyberware/harpoon/Activate(atom/target) var/mob/living/carbon/bearer = organ.owner - if(isturf(target) || QDELETED(target)) + // Clicking yourself fires the barb at nothing: a line from the bearer to the bearer. + if(isturf(target) || QDELETED(target) || target == bearer) bearer.balloon_alert(bearer, "barb skitters off!") return FALSE if(get_dist(bearer, target) > CYBERWARE_ANGLER_RANGE || !(target in view(CYBERWARE_ANGLER_RANGE, bearer))) diff --git a/voidcrew/modules/cyberware/ware_second_wind.dm b/voidcrew/modules/cyberware/ware_second_wind.dm index 6330c5ecf31..75a98197b43 100644 --- a/voidcrew/modules/cyberware/ware_second_wind.dm +++ b/voidcrew/modules/cyberware/ware_second_wind.dm @@ -41,15 +41,21 @@ /obj/item/organ/cyberimp/cyberware/second_wind name = "\improper Second Wind bladder" - desc = "A vacuum-cured polymer bladder plumbed around the lungs. When the air runs out it kicks in with a hiss and gives you about three minutes to do something about it, then refills off every breath you take normally. You'll know when it's running: the mask-breathing is loud and the indicator is hard to miss." + desc = "A vacuum-cured polymer bladder plumbed around the lungs. When the air runs out it takes over with a hiss." icon_state = "second_wind" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_SEAL w_class = WEIGHT_CLASS_SMALL chrome_load = 1 tier = CYBERWARE_TIER_1 - /// Breathable reserve remaining, in deciseconds of breathing covered. + /// Breathable reserve remaining, in deciseconds of breathing covered. A + /// model with a bigger bladder sets this to its max_reserve too, a var + /// initialiser can't read another var. var/reserve = CYBERWARE_SECOND_WIND_RESERVE + /// The most reserve the bladder holds. + var/max_reserve = CYBERWARE_SECOND_WIND_RESERVE + /// Reserve regained per normal breath in breathable air. + var/refill_rate = CYBERWARE_SECOND_WIND_REFILL /// TRUE while feeding the owner from reserve, for the engage/disengage beats. var/engaged = FALSE /// Whether the low-reserve warning has already fired this engagement, so @@ -65,7 +71,7 @@ /obj/item/organ/cyberimp/cyberware/second_wind/examine(mob/user) . = ..() var/seconds_left = round(reserve / (1 SECONDS)) - . += span_notice("The reserve gauge reads [seconds_left] seconds of air[reserve < CYBERWARE_SECOND_WIND_RESERVE ? ", refilling while its bearer breathes normally" : " (full)"].") + . += span_notice("The reserve gauge reads [seconds_left] seconds of air.") /obj/item/organ/cyberimp/cyberware/second_wind/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -116,15 +122,15 @@ if(environment_is_breathable(source)) if(engaged) set_engaged(source, FALSE) - if(reserve < CYBERWARE_SECOND_WIND_RESERVE) - reserve = min(reserve + CYBERWARE_SECOND_WIND_REFILL, CYBERWARE_SECOND_WIND_RESERVE) + if(reserve < max_reserve) + reserve = min(reserve + refill_rate, max_reserve) low_warned = FALSE return NONE if(reserve <= 0) if(engaged) set_engaged(source, FALSE) source.balloon_alert(source, "air reserve empty!") - to_chat(source, span_userdanger("Your Second Wind bladder collapses empty. You are breathing vacuum.")) + to_chat(source, span_userdanger("Your [format_text(name)] collapses empty. You are breathing vacuum.")) playsound(source, 'sound/machines/buzz/buzz-sigh.ogg', 40, TRUE) return NONE if(!engaged) @@ -133,7 +139,7 @@ update_alert(source) if(!low_warned && reserve <= CYBERWARE_SECOND_WIND_LOW) low_warned = TRUE - to_chat(source, span_warning("Your bladder gauge drops into the red, [round(reserve / (1 SECONDS))] seconds of air left.")) + to_chat(source, span_warning("Your bladder gauge drops into the red.")) playsound(source, 'sound/machines/buzz/buzz-two.ogg', 30, TRUE) // This IS a successful breath as far as the body is concerned. source.failed_last_breath = FALSE @@ -148,10 +154,10 @@ engaged = new_state if(engaged) INVOKE_ASYNC(source, TYPE_PROC_REF(/mob, emote), "gasp") - source.balloon_alert(source, "second wind engages") + source.balloon_alert(source, "[LOWER_TEXT(format_text(name))] engages") source.visible_message( span_notice("[source] takes a sharp breath, and something under [source.p_their()] ribs starts working."), - span_notice("SEAL ENGAGED. The bladder takes your breathing off you. You have [round(reserve / (1 SECONDS))] seconds of air."), + span_notice("SEAL ENGAGED. The bladder takes your breathing off you."), span_hear("You hear a pneumatic hiss and the rhythm of mask breathing."), vision_distance = COMBAT_MESSAGE_RANGE, ) @@ -160,7 +166,7 @@ breath_loop?.start(source) update_alert(source) return - source.balloon_alert(source, "second wind disengages") + source.balloon_alert(source, "[LOWER_TEXT(format_text(name))] disengages") to_chat(source, span_notice("SEAL RELEASED. You are breathing the room again.")) cyberware_ink_pulse(source, CYBERWARE_INK_SOFT) breath_loop?.stop() @@ -182,7 +188,9 @@ var/seconds_left = round(reserve / (1 SECONDS)) var/running_low = reserve <= CYBERWARE_SECOND_WIND_LOW indicator.color = running_low ? "#ffb347" : "#4dd8e6" - indicator.desc = "Your Second Wind bladder is doing your breathing for you. [seconds_left] second\s of reserve air left[running_low ? ". Find air." : "."]" + // Named for the bladder actually fitted, so a Deep-lung never reads as a Second Wind. + indicator.name = "[format_text(name)] engaged" + indicator.desc = "Your [format_text(name)] is doing your breathing for you. The gauge reads [seconds_left] second\s." /** * Whether the mob's surroundings hold enough oxygen to breathe unassisted: diff --git a/voidcrew/modules/cyberware/ware_street.dm b/voidcrew/modules/cyberware/ware_street.dm index 4ca0296974c..9fe5c186db2 100644 --- a/voidcrew/modules/cyberware/ware_street.dm +++ b/voidcrew/modules/cyberware/ware_street.dm @@ -68,7 +68,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/chromatic_dermis name = "\improper Chromatic Dermis ink suite" - desc = "Programmable tattoo ink and the spider-legged injector that lays it under the skin. The traces light up whenever the rest of your chrome does something: a punch landing, a tool folding out, the air bladder kicking in. Colour and pattern are set at a Chrome Cradle." + desc = "Programmable tattoo ink and the spider-legged injector that lays it under the skin. The traces light up whenever the rest of your chrome gets to work." icon_state = "chromatic_dermis" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_INK @@ -276,7 +276,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/eyes/robotic/cyberware/nightshade name = "\improper Nightshade optics" - desc = "Matte black eyes that render a dark room in green. Living bodies in range pick up a soft glow only you can see, so an unlit corridor isn't cover any more. They aren't thermals, so a flashbang hits you no harder than it would normal eyes. The diagnostic bus is the stock one: it counts how much chrome someone is carrying, but can't name any of it." + desc = "Matte black eyes that render a dark room in green. Living bodies pick up a soft glow only you can see." icon_state = "nightshade" eye_color_left = "#1d3b2a" eye_color_right = "#1d3b2a" @@ -409,7 +409,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/scrapper name = "\improper Scrapper's Knuckles" - desc = "Milled knuckle caps grafted along the metacarpals, right-arm fit. Your punches land harder, and against plating and machine housings they land about twice as hard again." + desc = "Milled knuckle caps grafted along the metacarpals, right-arm fit. Hinges and housings come off worse." icon_state = "scrapper" zone = BODY_ZONE_R_ARM slot = ORGAN_SLOT_RIGHT_ARM_AUG @@ -423,7 +423,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( aug_overlay = "scrapper_right" /obj/item/organ/cyberimp/cyberware/scrapper/left - desc = "Milled knuckle caps grafted along the metacarpals, left-arm fit. Your punches land harder, and against plating and machine housings they land about twice as hard again." + desc = "Milled knuckle caps grafted along the metacarpals, left-arm fit. Hinges and housings come off worse." zone = BODY_ZONE_L_ARM slot = ORGAN_SLOT_LEFT_ARM_AUG aug_overlay = "scrapper_left" @@ -458,6 +458,9 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/is_structure_target = ismachinery(target) || isstructure(target) if(!isliving(target) && !is_structure_target) return NONE + // A pacifist's punch at a living thing is the ordinary punch, which refuses it. + if(isliving(target) && HAS_TRAIT(source, TRAIT_PACIFISM)) + return NONE // Past the last "not our punch" guard: this swing is ours from here on, so // stand Redline's bypass top-up down before we take the chain away. The // roll below reads the hand's unarmed damage, which already carries the @@ -517,7 +520,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/storage/case/cyberware/scrapper name = "\improper Scrapper's Knuckles case" - desc = "A foam-lined chrome case holding a matched pair of Scrapper's Knuckles, one per arm. Each side installs separately, though the rig will do both in one sitting." + desc = "A foam-lined chrome case holding a matched pair of Scrapper's Knuckles, one per arm." /obj/item/storage/case/cyberware/scrapper/PopulateContents() new /obj/item/organ/cyberimp/cyberware/scrapper(src) @@ -535,7 +538,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/shock_coils name = "\improper Shock Coil calf pistons" - desc = "Spring-loaded pistons sistered along the calf bones. You get back on your feet much faster after a knockdown, keep your footing on wet decking, and walk away from drops that would break an ankle." + desc = "Spring-loaded pistons sistered along the calf bones, so the floor gets less say in things." icon_state = "shock_coils" zone = BODY_ZONE_L_LEG slot = ORGAN_SLOT_CYBERWARE_LEGS @@ -622,7 +625,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/gastro name = "\improper Gastro Reactor" - desc = "A ceramic-lined digester where your stomach used to be. Rot, floor scrapings, whatever was in the back of the fridge. It all burns the same, faster than a real gut and without the food poisoning." + desc = "A ceramic-lined digester where your stomach used to be. Rot, floor scrapings, whatever was in the back of the fridge: it all burns the same." icon_state = "gastro" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_GUT @@ -668,7 +671,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/cargo_cavity name = "\improper Cargo Cavity" - desc = "A shielded compartment plumbed into the ribcage, sized for one small item. Scanners read straight past it and a pat-down won't find it." + desc = "A shielded compartment plumbed into the ribcage, sized for one small item." icon_state = "cargo_cavity" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_STASH @@ -742,10 +745,14 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( * add/subtract pair per flip is the whole lifecycle; re-applying on species * gain would stack the armor. The pair rides the failing-gated passive hooks * (BAL-4): EMP-scrambled or browned-out mesh armors nothing. + * + * The dermal ladder (Mesh, Hardweave, Slabskin) also turns bullets: a + * passive deflect source in the shared dodge arbiter (ware_pro_combat.dm), + * 15/30/45% by rung, front arc only, rides the same passive hooks. */ /obj/item/organ/cyberimp/cyberware/dermal_mesh name = "\improper Dermal Mesh weave" - desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but knives and small arms hurt noticeably less and the skin over it doesn't tear so easily." + desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but now and then a round skips off it." icon_state = "dermal_mesh" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -753,13 +760,17 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( chrome_load = 2 tier = CYBERWARE_TIER_1 aug_overlay = "dermal_mesh" + worn_under_clothes = TRUE /// Armor mixed into the bearer's physiology while installed and running. var/datum/armor/mesh_armor = /datum/armor/cyberware_dermal_mesh /// TRUE while mesh_armor is mixed in; guards against double add/subtract. var/mesh_armor_applied = FALSE + /// Percent of bullets from the front that glance off, through the shared dodge arbiter. + var/deflect_chance = 15 /obj/item/organ/cyberimp/cyberware/dermal_mesh/chrome_passives_on(mob/living/carbon/bearer) . = ..() + cyberware_register_deflect_source(bearer, REF(src), CALLBACK(src, PROC_REF(get_deflect_chance))) if(mesh_armor_applied || !ishuman(bearer)) return mesh_armor_applied = TRUE @@ -768,6 +779,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/organ/cyberimp/cyberware/dermal_mesh/chrome_passives_off(mob/living/carbon/bearer) . = ..() + cyberware_unregister_deflect_source(bearer, REF(src)) if(!mesh_armor_applied) return mesh_armor_applied = FALSE // reset before the validity skip, see Shock Coils @@ -776,6 +788,12 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/mob/living/carbon/human/human_bearer = bearer human_bearer.physiology.armor = human_bearer.physiology.armor.subtract_other_armor(mesh_armor) +/// Deflect-source callback: nothing while failing. +/obj/item/organ/cyberimp/cyberware/dermal_mesh/proc/get_deflect_chance() + if(organ_flags & ORGAN_FAILING) + return 0 + return deflect_chance + // ---- 11. Gecko Grips --------------------------------------------------- /** @@ -807,7 +825,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/gecko name = "\improper Gecko Grip palm pads" - desc = "Setae pads laminated into the palms, running the same trick geckos do. Your hands clamp shut on whatever you're holding when you go down, so nobody strips your gun off you, and you can cross tables like flat ground and catch a ledge on the way into a chasm. Cuffs still work fine." + desc = "Setae pads laminated into the palms, running the same trick geckos do. Whatever you're holding stays held." icon_state = "gecko" zone = BODY_ZONE_R_ARM slot = ORGAN_SLOT_CYBERWARE_HANDS @@ -832,19 +850,32 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( RegisterSignal(organ_owner, COMSIG_MOB_EQUIPPED_ITEM, PROC_REF(on_item_equipped)) RegisterSignal(organ_owner, COMSIG_MOB_UNEQUIPPED_ITEM, PROC_REF(on_item_unequipped)) RegisterSignal(organ_owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) - passtable_on(organ_owner, REF(src)) // Whatever they were already holding when the pads went in. for(var/obj/item/held in organ_owner.held_items) guard_item(held) - if(catch_ready) - ADD_TRAIT(organ_owner, TRAIT_CHASM_STOPPER, REF(src)) /obj/item/organ/cyberimp/cyberware/gecko/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() drop_guards() UnregisterSignal(organ_owner, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM, COMSIG_MOVABLE_MOVED)) - passtable_off(organ_owner, REF(src)) - REMOVE_TRAIT(organ_owner, TRAIT_CHASM_STOPPER, REF(src)) + +// Tables as flat ground and the chasm catch ride the failing-gated passive layer: dark pads +// (EMP, brownout, damage) do neither. The catch used to stay armed through a pulse, and a +// bearer whose pads were dark could cross a chasm because the arming trait was never spent. +/obj/item/organ/cyberimp/cyberware/gecko/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + passtable_on(bearer, REF(src)) + if(catch_ready) + ADD_TRAIT(bearer, TRAIT_CHASM_STOPPER, REF(src)) + +/obj/item/organ/cyberimp/cyberware/gecko/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + passtable_off(bearer, REF(src)) + REMOVE_TRAIT(bearer, TRAIT_CHASM_STOPPER, REF(src)) /obj/item/organ/cyberimp/cyberware/gecko/Destroy() drop_guards() @@ -968,7 +999,9 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/organ/cyberimp/cyberware/gecko/proc/rearm_catch() catch_ready = TRUE - if(owner) + // Dark pads re-arm too, the trait goes back on with the passive layer. + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(owner && chrome?.passives_online) ADD_TRAIT(owner, TRAIT_CHASM_STOPPER, REF(src)) owner.balloon_alert(owner, "grips re-set") diff --git a/voidcrew/modules/cyberware/ware_street_passives.dm b/voidcrew/modules/cyberware/ware_street_passives.dm new file mode 100644 index 00000000000..d5a50c516eb --- /dev/null +++ b/voidcrew/modules/cyberware/ware_street_passives.dm @@ -0,0 +1,454 @@ +/** + * # Street passives + * + * Six cheap pieces of chrome that do one quiet thing each and have no button: + * + * - Clotter node (heart aux, T1): bleed slower. + * - Adrenal pump (heart aux, T2): answers a bad run of stamina damage once + * in a while. Shares the heart aux slot with the Clotter node. + * - Medic's palms (hands, T1): nitrile-glove speed without the glove. + * - Cuffer's wrists (hands, T1): quicker cuffing. Sideways to the Medic's + * palms and the Gecko Grip, which sit in the same slot. + * - Subvocal mic (larynx, T1): radio talk goes no further than the speaker. + * - Muffler ear (ears, T1): earmuff-grade protection from a bang. Sideways to + * the Heartbeat Doppler, same slot. + * + * Every effect rides the failing-gated passive layer (chrome_passives_on/off in + * cyberware_base.dm), so an EMP, a brownout or a damage failure switches it off + * and a tune-up hands it back. All tuning is in vars on the organ. + * + * The Subvocal mic needs two small hooks in the speech path. Both are chained + * overrides at the bottom of this file, nothing upstream is edited. + * + * SKUs and trade-ins are defined at the bottom. + */ + +/// Held by a mob whose radio talk is not overheard. Granted by the Subvocal mic. +#define TRAIT_CYBER_SUBVOCAL "cyber_subvocal" +/// The red the Adrenal pump leaves on its bearer for the length of the rush. +#define CYBERWARE_ADRENAL_FLUSH "#ff8f8f" + +// ========================================================================= +// CLOTTER NODE +// ========================================================================= + +/** + * # Clotter node (T1, chest, heart aux slot, load 1) + * + * Halves the blood the bearer loses. The multiplier is mixed into the human + * physiology, which survives species changes, so there is one multiply and one + * divide per flip, and the factor actually applied is remembered so retuning the + * var on a live organ cannot desync the pair. Rides the failing-gated passive + * hooks like the Dermal Mesh does. + */ +/obj/item/organ/cyberimp/cyberware/clotter_node + name = "\improper Clotter node" + desc = "A reservoir of clotting gel plumbed into the heart. Cuts stop sooner than they should." + icon_state = "clotter_node" + zone = BODY_ZONE_CHEST + slot = ORGAN_SLOT_CYBERWARE_HEART_AUX + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + /// Multiplier on the blood the bearer loses. + var/bleed_multiplier = 0.5 + /// The multiplier currently mixed into the bearer, or null when none is. + var/applied_bleed_multiplier + +/obj/item/organ/cyberimp/cyberware/clotter_node/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(!isnull(applied_bleed_multiplier) || !ishuman(bearer)) + return + var/mob/living/carbon/human/human_bearer = bearer + applied_bleed_multiplier = bleed_multiplier + human_bearer.physiology.bleed_mod *= applied_bleed_multiplier + +/obj/item/organ/cyberimp/cyberware/clotter_node/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(isnull(applied_bleed_multiplier)) + return + var/removed_multiplier = applied_bleed_multiplier + applied_bleed_multiplier = null // reset before the validity skip, see Shock Coils + if(!ishuman(bearer) || QDELETED(bearer)) + return + var/mob/living/carbon/human/human_bearer = bearer + human_bearer.physiology.bleed_mod /= removed_multiplier + +// ========================================================================= +// ADRENAL PUMP +// ========================================================================= + +/** + * # Adrenal pump (T2, chest, heart aux slot, load 2) + * + * When a run of stamina damage takes the bearer to the trigger level, the pump + * dumps a shot of adrenaline: part of the stamina loss comes off and the bearer + * is quicker on their feet for a few seconds. The tell is deliberate: a heartbeat + * thud anyone nearby can hear and a red flush on the bearer. + * + * The wait before it fires again is stored on the organ as the world.time it is + * ready at, so pulling the pump and refitting it never resets the wait. + * + * It answers damage from outside only. The crashes of the Cascade and Redline + * lines dump stamina as their own price, and a pump that took the sting out of + * that price would make the cheap heart ware a cure for the legend ware's bill, so + * the check runs one tick after the damage (the crash status is not on the bearer + * yet at the moment of the dump) and stands down while either crash is running. + */ +/obj/item/organ/cyberimp/cyberware/adrenal_pump + name = "\improper Adrenal pump" + desc = "A pump of synthetic adrenaline wired into the heart. The sort of thing a doctor tells you not to try at home." + icon_state = "adrenal_pump" + zone = BODY_ZONE_CHEST + slot = ORGAN_SLOT_CYBERWARE_HEART_AUX + w_class = WEIGHT_CLASS_SMALL + chrome_load = 2 + tier = CYBERWARE_TIER_2 + /// Stamina loss at or above which the pump fires. + var/trigger_stamina = 70 + /// Stamina loss taken off when it fires. + var/stamina_relief = 40 + /// How long the bearer is quicker for. + var/rush_duration = 3 SECONDS + /// How long the pump takes to charge again after firing. + var/recharge_time = 4 MINUTES + /// world.time at which the pump is ready to fire. Lives on the organ, so + /// removing and refitting it keeps the wait. + var/ready_at = 0 + /// TRUE while a firing check is queued, so one volley of damage queues one check. + var/discharge_queued = FALSE + +/obj/item/organ/cyberimp/cyberware/adrenal_pump/examine(mob/user) + . = ..() + . += span_notice("Its status light is [world.time < ready_at ? "amber" : "green"].") + +/obj/item/organ/cyberimp/cyberware/adrenal_pump/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + RegisterSignal(bearer, COMSIG_LIVING_STAMINA_UPDATE, PROC_REF(on_stamina_update), override = TRUE) + +/obj/item/organ/cyberimp/cyberware/adrenal_pump/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + discharge_queued = FALSE + if(isnull(bearer)) + return + UnregisterSignal(bearer, COMSIG_LIVING_STAMINA_UPDATE) + +/// Signal proc for [COMSIG_LIVING_STAMINA_UPDATE]: cheap tests only, the pump +/// itself fires from the queued check. +/obj/item/organ/cyberimp/cyberware/adrenal_pump/proc/on_stamina_update(mob/living/source) + SIGNAL_HANDLER + if(discharge_queued || source != owner || world.time < ready_at) + return + if(source.stat != CONSCIOUS || source.getStaminaLoss() < trigger_stamina) + return + discharge_queued = TRUE + addtimer(CALLBACK(src, PROC_REF(discharge)), 1) // next tick, see the header + +/// The queued check. Everything is tested again: the bearer may have recovered, +/// been knocked out, lost the chrome or be riding out a crash. +/obj/item/organ/cyberimp/cyberware/adrenal_pump/proc/discharge() + discharge_queued = FALSE + var/mob/living/carbon/bearer = owner + if(QDELETED(src) || QDELETED(bearer) || world.time < ready_at) + return + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(!chrome?.is_running()) + return + if(bearer.stat != CONSCIOUS || bearer.getStaminaLoss() < trigger_stamina) + return + if(bearer.has_status_effect(/datum/status_effect/cyberware_cascade_crash) || bearer.has_status_effect(/datum/status_effect/cyberware_redline_crash)) + return + // Spent first: the relief below re-enters the stamina signal. + ready_at = world.time + recharge_time + bearer.adjustStaminaLoss(-stamina_relief) + bearer.apply_status_effect(/datum/status_effect/cyberware_adrenal_rush, rush_duration) + playsound(bearer, 'sound/effects/singlebeat.ogg', 100, TRUE, 2) + bearer.visible_message( + span_warning("[bearer] flushes red, heart thudding loud enough to hear."), + span_userdanger("Your heart slams and the exhaustion drops away."), + ) + bearer.balloon_alert(bearer, "adrenaline!") + cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) + +/// The burst of speed, and the flush that shows it. +/datum/status_effect/cyberware_adrenal_rush + id = "cyberware_adrenal_rush" + duration = 3 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + alert_type = null + +/// Optional argument: how long the rush lasts, from the pump that fired it. +/datum/status_effect/cyberware_adrenal_rush/on_creation(mob/living/new_owner, rush_duration) + if(isnum(rush_duration)) + duration = rush_duration + return ..() + +/datum/status_effect/cyberware_adrenal_rush/on_apply() + owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_adrenal_rush) + owner.add_atom_colour(CYBERWARE_ADRENAL_FLUSH, TEMPORARY_COLOUR_PRIORITY) + return TRUE + +/datum/status_effect/cyberware_adrenal_rush/on_remove() + owner.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_adrenal_rush) + owner.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY, CYBERWARE_ADRENAL_FLUSH) + return ..() + +/datum/movespeed_modifier/cyberware_adrenal_rush + multiplicative_slowdown = -0.4 + +// ========================================================================= +// MEDIC'S PALMS +// ========================================================================= + +/** + * # Medic's palms (T1, arm, hands slot, load 1) + * + * The nitrile-glove pair of traits (quicker chemistry, quicker fireman carry) + * without taking up the glove slot. Same slot as the Gecko Grip and the Cuffer's + * wrists, so a body wears one of the three. + */ +/obj/item/organ/cyberimp/cyberware/medic_palms + name = "\improper Medic's palms" + desc = "Sterile palm plates with a better grip than any nitrile glove. Saves a trip to the glove dispenser." + icon_state = "medic_palms" + zone = BODY_ZONE_R_ARM + slot = ORGAN_SLOT_CYBERWARE_HANDS + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_FASTMED, TRAIT_QUICKER_CARRY) + +// ========================================================================= +// CUFFER'S WRISTS +// ========================================================================= + +/** + * # Cuffer's wrists (T1, arm, hands slot, load 1) + * + * Cuffs go on in three quarters of the time (TRAIT_FAST_CUFFING, the same trait + * as combat gloves). For bounty hunters and anyone else who ties people up for a + * living. + */ +/obj/item/organ/cyberimp/cyberware/cuffer_wrists + name = "\improper Cuffer's wrists" + desc = "Reinforced wrist actuators with a lot of practice at closing around other people's wrists." + icon_state = "cuffer_wrists" + zone = BODY_ZONE_R_ARM + slot = ORGAN_SLOT_CYBERWARE_HANDS + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_FAST_CUFFING) + +// ========================================================================= +// SUBVOCAL MIC +// ========================================================================= + +/** + * # Subvocal mic (T1, head, larynx slot, load 1) + * + * Radio talk is whispered at range 1 today, so the person next to you hears it. + * With the mic it goes no further than the speaker. The trait is the whole ware; + * the speech hooks at the bottom of this file read it. + */ +/obj/item/organ/cyberimp/cyberware/subvocal_mic + name = "\improper Subvocal mic" + desc = "A throat mic sutured to the vocal cords. Radio chatter goes out the wire instead of the room." + icon_state = "subvocal_mic" + zone = BODY_ZONE_HEAD + slot = ORGAN_SLOT_CYBERWARE_LARYNX + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_CYBER_SUBVOCAL) + +/// TRUE between a radio line the Subvocal mic quieted and the send_speech() call +/// that carries it, which follows straight on with no wait in between. +/mob/living/var/cyberware_subvocal_pending = FALSE + +/** + * Chained override of the human radio branch (base definition: + * code/modules/mob/living/carbon/human/human_say.dm, which sets the whisper + * range when it returns REDUCE_RANGE). A bearer's radio line is marked here; + * the send_speech() override below spends the mark. A line the radio swallowed + * (NOPASS) never reaches send_speech and is not marked. + */ +/mob/living/carbon/human/radio(message, list/message_mods = list(), list/spans, language) + cyberware_subvocal_pending = FALSE + . = ..() + if((. & REDUCE_RANGE) && !(. & NOPASS) && HAS_TRAIT(src, TRAIT_CYBER_SUBVOCAL)) + cyberware_subvocal_pending = TRUE + +/** + * Chained override of living speech output. A marked radio line is spoken at + * range 0: the bearer and anyone on their tile hear it, a neighbour one tile off + * gets the usual eavesdropper's garble, nobody else hears anything. The voice + * bark layer wraps this one and still plays its blip at the old range. + */ +/mob/living/send_speech(message_raw, message_range = 6, obj/source = src, bubble_type = bubble_icon, list/spans, datum/language/message_language = null, list/message_mods = list(), forced = null, tts_message, list/tts_filter) + if(cyberware_subvocal_pending) + cyberware_subvocal_pending = FALSE + message_range = 0 + return ..(message_raw, message_range, source, bubble_type, spans, message_language, message_mods, forced, tts_message, tts_filter) + +// ========================================================================= +// MUFFLER EAR +// ========================================================================= + +/** + * # Muffler ear (T1, head, ears slot, load 1) + * + * Takes one point off the intensity of a bang, which is exactly what earmuffs do + * (code/datums/components/earprotection.dm, sender soundbang_act in + * carbon_defense.dm). The listener is registered by the passive hooks, so an + * offline ear hears the bang in full. Same slot as the Heartbeat Doppler. + */ +/obj/item/organ/cyberimp/cyberware/muffler_ear + name = "\improper Muffler ear" + desc = "Baffles set into the ear canal. Earmuffs you can't leave on the bridge." + icon_state = "muffler_ear" + zone = BODY_ZONE_HEAD + slot = ORGAN_SLOT_CYBERWARE_EARS + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + /// Intensity taken off a bang. + var/bang_reduction = 1 + +/obj/item/organ/cyberimp/cyberware/muffler_ear/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + RegisterSignal(bearer, COMSIG_CARBON_SOUNDBANG, PROC_REF(on_soundbang), override = TRUE) + +/obj/item/organ/cyberimp/cyberware/muffler_ear/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + UnregisterSignal(bearer, COMSIG_CARBON_SOUNDBANG) + +/// Signal proc for [COMSIG_CARBON_SOUNDBANG]: the intensity rides in a list so +/// listeners can change it. +/obj/item/organ/cyberimp/cyberware/muffler_ear/proc/on_soundbang(datum/source, list/reflist) + SIGNAL_HANDLER + reflist[1] -= bang_reduction + +// ========================================================================= +// SHOP SKUS: on the Chop Shop's sku_types and on the street doc's. +// ========================================================================= + +/datum/shop_sku/ripperdoc/clotter_node + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/clotter_node + price_credits = 900 + +/datum/shop_sku/ripperdoc/adrenal_pump + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/adrenal_pump + price_credits = 2400 + +/datum/shop_sku/ripperdoc/medic_palms + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/medic_palms + price_credits = 600 + +/datum/shop_sku/ripperdoc/cuffer_wrists + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/cuffer_wrists + price_credits = 500 + +/datum/shop_sku/ripperdoc/subvocal_mic + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/subvocal_mic + price_credits = 600 + +/datum/shop_sku/ripperdoc/muffler_ear + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/muffler_ear + price_credits = 700 + +// ========================================================================= +// TRADE-IN: half list, credits only. Ratchet's entries (/street_doc) differ +// only in wording. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/clotter_node + name = "Clotter node (pulled)" + desc = "A used clotting-gel reservoir." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/clotter_node + match_subtypes = FALSE + pay_credits = 450 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/clotter_node/street_doc + desc = "A used gel reservoir. Still sticky." + +/datum/shop_buyback/ripperdoc/adrenal_pump + name = "Adrenal pump (pulled)" + desc = "A used adrenaline pump. Splice takes these off your hands." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/adrenal_pump + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/adrenal_pump/street_doc + desc = "A used heart pump. Handle it gently." + +/datum/shop_buyback/ripperdoc/medic_palms + name = "Medic's palms (pulled)" + desc = "Used sterile palm plates." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/medic_palms + match_subtypes = FALSE + pay_credits = 300 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/medic_palms/street_doc + desc = "Used palm plates. Washed, mostly." + +/datum/shop_buyback/ripperdoc/cuffer_wrists + name = "Cuffer's wrists (pulled)" + desc = "Used wrist actuators." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/cuffer_wrists + match_subtypes = FALSE + pay_credits = 250 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/cuffer_wrists/street_doc + desc = "Used wrist actuators. They've seen some arrests." + +/datum/shop_buyback/ripperdoc/subvocal_mic + name = "Subvocal mic (pulled)" + desc = "A used throat mic." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/subvocal_mic + match_subtypes = FALSE + pay_credits = 300 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/subvocal_mic/street_doc + desc = "A used throat mic. Don't ask what it heard." + +/datum/shop_buyback/ripperdoc/muffler_ear + name = "Muffler ear (pulled)" + desc = "Used ear baffles." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/muffler_ear + match_subtypes = FALSE + pay_credits = 350 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/muffler_ear/street_doc + desc = "Used ear baffles. Quiet ones." diff --git a/voidcrew/modules/drug_smuggling/mission.dm b/voidcrew/modules/drug_smuggling/mission.dm index 7255eb52cae..108fe1f2349 100644 --- a/voidcrew/modules/drug_smuggling/mission.dm +++ b/voidcrew/modules/drug_smuggling/mission.dm @@ -292,6 +292,7 @@ fail("No mission pad to deliver the formula chip to.") return formula = new(chip_turf) + mission_pad_delivery_effect(chip_turf) formula.recipe = recipe servant?.ship_notify("Formula chip for '[recipe.street_name]' delivered to your mission pad. Examine it for the shopping list.", "DRUG RUN", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) @@ -433,6 +434,8 @@ fail("Nowhere to deliver the finished batch, contract void.") return var/obj/item/mission_recovery/drug_product/product = new(product_turf) + if(!preferred_turf) + mission_pad_delivery_effect(product_turf) // the pad fallback: it arrives like any other delivery product.configure(recipe, purity_tier) bind_item(product) register_quest_atom(product) @@ -496,6 +499,10 @@ var/respawns_left = 1 /// Guard so notify_when_loaded() is never double-registered var/awaiting_load = FALSE + /// Where the current harvest scene was laid; a replacement grows near it + var/turf/scene_anchor + /// How far from the cache a replacement specimen may grow + var/regrow_radius = 6 /datum/ingredient_site/New(datum/mission/drug_run/mission, list/entry) ..() @@ -508,6 +515,7 @@ release_item(delete_uncollected = TRUE) QDEL_NULL(site_target) mission = null + scene_anchor = null return ..() /** @@ -554,6 +562,7 @@ site_target?.notify_when_loaded() return spawned = TRUE + scene_anchor = scene_turf new /obj/structure/ingredient_cache(scene_turf) var/obj/item/drug_ingredient/ingredient = new ingredient_type(get_nearby_open_turf(scene_turf)) watch_item(ingredient) @@ -608,7 +617,7 @@ site_target.notify_when_loaded() return if(respawns_left > 0) - var/turf/retry_turf = site_target.get_spawn_turf() + var/turf/retry_turf = find_regrowth_turf() if(retry_turf) respawns_left-- var/obj/item/drug_ingredient/replacement = new ingredient_type(retry_turf) @@ -639,6 +648,24 @@ if(mission.active) mission.push_site_waypoint(src) +/** + * Where a replacement specimen grows: clear ground a few tiles from the cache, + * out of the crew's sight when there is any. Falls back to the whole site when + * the camp has no room (or the anchor isn't on this site any more). + */ +/datum/ingredient_site/proc/find_regrowth_turf() + if(scene_anchor && site_target.contains_turf(scene_anchor)) + var/list/nearby = list() + for(var/turf/open/tile in RANGE_TURFS(regrow_radius, scene_anchor)) + if(isgroundlessturf(tile) || tile.is_blocked_turf(exclude_mobs = TRUE)) + continue + if(istype(get_area(tile), /area/shuttle) || !site_target.contains_turf(tile)) + continue + nearby += tile + if(length(nearby)) + return pick_unwatched_turf(nearby) + return site_target.get_spawn_turf() + /// Stops watching the live field ingredient; optionally removes it outright /datum/ingredient_site/proc/release_item(delete_uncollected = FALSE) var/obj/item/drug_ingredient/ingredient = item_ref?.resolve() diff --git a/voidcrew/modules/dynamic_events/events/portal_storm.dm b/voidcrew/modules/dynamic_events/events/portal_storm.dm index 98b221070a6..7dee9a0a9e1 100644 --- a/voidcrew/modules/dynamic_events/events/portal_storm.dm +++ b/voidcrew/modules/dynamic_events/events/portal_storm.dm @@ -179,12 +179,24 @@ if(!spawn_turf || !target_ship.is_aboard(spawn_turf)) continue var/datum/npc_spawn_context/context = new(target_ship, npc_spawn_zone, role, source_turf = npc_source_turf) + // People (human NPCs) are queued and step out of the portal as they are built, a body a tick. + if(ispath(mob_type, /mob/living/carbon/human/npc)) + spawn_human_npc(mob_type, spawn_turf, CALLBACK(src, PROC_REF(human_stepped_out)), context = context) + spawned_mobs++ + continue var/mob/living/new_hostile = spawn_npc_in_zone(mob_type, spawn_turf, context) if(QDELETED(new_hostile)) continue spawned_mobs++ spawn_effects(spawn_turf) +/// A queued boarder is built: the portal flashes where it steps out, if it is still aboard. +/datum/round_event/voidcrew/portal_storm/proc/human_stepped_out(mob/living/carbon/human/npc/boarder) + var/turf/spot = get_turf(boarder) + if(!spot || !target_valid() || !target_ship.is_aboard(spot)) + return + spawn_effects(spot) + /// Plays TG's portal flash and sound without allowing the shifted visual off-ship. /datum/round_event/voidcrew/portal_storm/proc/spawn_effects(turf/spawn_turf) if(!target_valid() || !spawn_turf || !target_ship.is_aboard(spawn_turf)) @@ -194,16 +206,17 @@ if(!target_ship.is_aboard(effect_turf)) effect_turf = spawn_turf var/appearance_index = GET_TURF_PLANE_OFFSET(effect_turf) + 1 - if(appearance_index <= length(storm_appearances)) + if(appearance_index <= length(storm_appearances) && storm_appearances[appearance_index]) effect_turf.flick_overlay_static(storm_appearances[appearance_index], 15) playsound(effect_turf, 'sound/effects/magic/lightningbolt.ogg', rand(80, 100), TRUE) -/// Syndicate commandos, with a stormtrooper occupying the optional boss slot. +/// Syndicate commandos, with a stormtrooper occupying the optional boss slot: people in sealed suits +/// (human NPCs, voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm), armed for the zone they board in. /datum/round_event/voidcrew/portal_storm/syndicate_shocktroop - boss_types = list(/mob/living/basic/trooper/syndicate/melee/space/stormtrooper = 2) + boss_types = list(/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/sword = 2) hostile_types = list( - /mob/living/basic/trooper/syndicate/melee/space = 8, - /mob/living/basic/trooper/syndicate/ranged/space = 2, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando = 8, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/gunner = 2, ) /// Hostile cult constructs using the same pulse and population limits. diff --git a/voidcrew/modules/gun_feel/bullet_speed.dm b/voidcrew/modules/gun_feel/bullet_speed.dm new file mode 100644 index 00000000000..65ed287ed31 --- /dev/null +++ b/voidcrew/modules/gun_feel/bullet_speed.dm @@ -0,0 +1,73 @@ +// Bullet speeds by caliber, after Shiptest. Only the plain gun calibers change: rockets, grenades, +// darts, arrows, rebar, foam, shrapnel, turrets and the bounty NPCs' own rounds keep their speeds. +// Damage is unchanged, so this is a straight buff to how hard bullets are to dodge. + +// Pistols and revolvers +/obj/projectile/bullet/c9mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/incendiary/c9mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/c10mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/incendiary/c10mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/n762 + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/a50ae + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/c38 + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/c357 + speed = GUN_FEEL_SPEED_PISTOL + +// SMGs +/obj/projectile/bullet/c45 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/c45 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/c46x30mm + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/c46x30mm + speed = GUN_FEEL_SPEED_RIFLE + +// Rifles and machine guns +/obj/projectile/bullet/a223 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/strilka310 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/a7mm + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/a7mm + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/c3d + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/lmg + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/fnx99 + speed = GUN_FEEL_SPEED_RIFLE + +// Shotguns +/obj/projectile/bullet/shotgun_slug + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_beanbag + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/incendiary/shotgun + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_stunslug + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_frag12 + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_breaching + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/scattershot + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/pellet/shotgun_buckshot + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/pellet/flechette + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/pellet/shotgun_rubbershot + speed = GUN_FEEL_SPEED_SHOTGUN_RUBBER + +// Sniper +/obj/projectile/bullet/p50 + speed = GUN_FEEL_SPEED_SNIPER diff --git a/voidcrew/modules/gun_feel/casings.dm b/voidcrew/modules/gun_feel/casings.dm new file mode 100644 index 00000000000..c0c5b43233e --- /dev/null +++ b/voidcrew/modules/gun_feel/casings.dm @@ -0,0 +1,91 @@ +// Flying casings. A casing a ballistic gun spits out (when firing or racking) leaves the right side of +// the gun, arcs, bounces and skids to a stop instead of popping onto the shooter's tile. +// After Shiptest's /obj/item/ammo_casing/on_eject(). The flight is /datum/component/movable_physics. + +/obj/item/ammo_casing + /// Set only while a gun is ejecting this casing: the direction it should fly, in degrees clockwise from north. + var/gun_feel_eject_angle + +/obj/item/gun/ballistic/handle_chamber(empty_chamber = TRUE, from_firing = TRUE, chamber_next_round = TRUE) + var/obj/item/ammo_casing/casing = chambered + if(istype(casing)) + casing.gun_feel_eject_angle = gun_feel_eject_angle() + . = ..() + if(istype(casing) && !QDELETED(casing)) + casing.gun_feel_eject_angle = null + +/// Out of the right side of the gun and a little back, from the holder's facing. +/obj/item/gun/ballistic/proc/gun_feel_eject_angle() + var/atom/holder = ismob(loc) ? loc : src + var/facing = dir2angle(holder.dir) + if(isnull(facing)) + facing = rand(0, 359) + return SIMPLIFY_DEGREES(facing + GUN_FEEL_CASING_EJECT_ANGLE + (rand() * 2 - 1) * GUN_FEEL_CASING_EJECT_SCATTER) + +/obj/item/ammo_casing/bounce_away(still_warm = FALSE, bounce_delay = 3) + if(!isnull(gun_feel_eject_angle)) + var/angle = gun_feel_eject_angle + gun_feel_eject_angle = null + if(gun_feel_launch(angle, still_warm)) + update_appearance() + return + return ..() + +/// Sends this casing flying. Returns FALSE (and the caller drops it the old way) if it can't fly here. +/obj/item/ammo_casing/proc/gun_feel_launch(angle, still_warm) + var/turf/floor = loc + if(!isturf(floor) || floor.density || !has_gravity(floor)) + return FALSE + if(length(SSmovable_physics.processing) >= GUN_FEEL_CASING_CAP) + return FALSE + pixel_x = base_pixel_x + rand(-4, 4) + pixel_y = base_pixel_y + rand(-4, 4) + var/speed = GUN_FEEL_CASING_SPEED_MIN + (GUN_FEEL_CASING_SPEED_MAX - GUN_FEEL_CASING_SPEED_MIN) * rand() + var/lift = GUN_FEEL_CASING_LIFT_MIN + (GUN_FEEL_CASING_LIFT_MAX - GUN_FEEL_CASING_LIFT_MIN) * rand() + var/datum/component/movable_physics/flight = AddComponent(/datum/component/movable_physics, speed, lift, angle, GUN_FEEL_CASING_START_HEIGHT, !!still_warm, TRUE) + return !QDELETED(flight) + +// ---- Resting casings ---- +// Casings on a floor lie at a random angle in 22.5 degree steps, as in Shiptest, so piles look tossed +// rather than lined up. A casing in a hand, a pocket or a magazine stands straight again. +// The spins (throwing, ejecting, bouncing) all end on the current transform, so they keep the angle. + +/obj/item/ammo_casing + /// FALSE for rounds too big to lie at a random angle, like rockets. + var/gun_feel_tumbles = TRUE + +/obj/item/ammo_casing/rocket + gun_feel_tumbles = FALSE + +/obj/item/ammo_casing/Initialize(mapload) + . = ..() + if(isturf(loc)) + gun_feel_lie_down() + +/obj/item/ammo_casing/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + var/on_floor = isturf(loc) + // Only when it lands from somewhere else or gets picked up; not on every step of a flight. + if(on_floor == isturf(old_loc)) + return + if(!on_floor) + gun_feel_stand_up() + return + gun_feel_lie_down() + // Tipped out of a gun, box or hand: land a little apart from the others. + if(!GetComponent(/datum/component/movable_physics)) + pixel_x = base_pixel_x + rand(-GUN_FEEL_CASING_DROP_SCATTER, GUN_FEEL_CASING_DROP_SCATTER) + pixel_y = base_pixel_y + rand(-GUN_FEEL_CASING_DROP_SCATTER, GUN_FEEL_CASING_DROP_SCATTER) + +/// Turns this casing to a random resting angle. +/obj/item/ammo_casing/proc/gun_feel_lie_down() + if(!gun_feel_tumbles) + return + var/matrix/resting = matrix() + resting.Turn(GUN_FEEL_CASING_ROTATION_STEP * rand(0, 15)) + transform = resting + +/// Stands this casing straight again, for hands, pockets and magazines. +/obj/item/ammo_casing/proc/gun_feel_stand_up() + if(gun_feel_tumbles) + transform = matrix() diff --git a/voidcrew/modules/gun_feel/flinch.dm b/voidcrew/modules/gun_feel/flinch.dm new file mode 100644 index 00000000000..498675e4457 --- /dev/null +++ b/voidcrew/modules/gun_feel/flinch.dm @@ -0,0 +1,59 @@ +// Hit flinch: a mob taking brute or burn damage from an attack jolts a few pixels along the blow and +// springs back. After Shiptest's shake_animation() in apply_damage(), but it only moves pixel_w and +// pixel_z, as a relative parallel animation that adds up to zero, the same way tg's shake_up_animation() +// works. So it never touches transform (lying down, size) and never stops or shifts any other animation. + +/mob/living + /// No new flinch starts until this world.time, so automatic fire never stacks them. + COOLDOWN_DECLARE(gun_feel_flinch_cooldown) + +/mob/living/apply_damage( + damage = 0, + damagetype = BRUTE, + def_zone = null, + blocked = 0, + forced = FALSE, + spread_damage = FALSE, + wound_bonus = 0, + exposed_wound_bonus = 0, + sharpness = NONE, + attack_direction = null, + attacking_item, + wound_clothing = TRUE, +) + . = ..() + // Only blows: something hit us, or came from a direction. Fire, cold and the like don't flinch. + if(. > 0 && (damagetype == BRUTE || damagetype == BURN) && (attacking_item || attack_direction)) + gun_feel_flinch(GUN_FEEL_FLINCH_BASE + . * GUN_FEEL_FLINCH_PER_DAMAGE, attack_direction) + +/** + * Jolts this mob's sprite a few pixels and lets it spring back. + * + * Arguments: + * * strength - pixels, rounded and capped at GUN_FEEL_FLINCH_MAX + * * push_dir - direction to jolt towards (the way the blow was travelling). Null picks a side. + * + * Returns TRUE if a flinch started. + */ +/mob/living/proc/gun_feel_flinch(strength, push_dir) + if(stat == DEAD || !isturf(loc) || !COOLDOWN_FINISHED(src, gun_feel_flinch_cooldown)) + return FALSE + var/pixels = clamp(round(strength, 1), 1, GUN_FEEL_FLINCH_MAX) + var/sign_x = 0 + var/sign_y = 0 + if(push_dir & EAST) + sign_x = 1 + else if(push_dir & WEST) + sign_x = -1 + if(push_dir & NORTH) + sign_y = 1 + else if(push_dir & SOUTH) + sign_y = -1 + if(!sign_x && !sign_y) + sign_x = pick(-1, 1) + var/shift_w = sign_x * pixels + var/shift_z = sign_y * max(1, round(pixels * GUN_FEEL_FLINCH_VERTICAL, 1)) + COOLDOWN_START(src, gun_feel_flinch_cooldown, GUN_FEEL_FLINCH_COOLDOWN) + animate(src, pixel_w = shift_w, pixel_z = shift_z, time = GUN_FEEL_FLINCH_OUT_TIME, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_PARALLEL | ANIMATION_RELATIVE) + animate(pixel_w = -shift_w, pixel_z = -shift_z, time = GUN_FEEL_FLINCH_BACK_TIME, easing = ELASTIC_EASING | EASE_OUT, flags = ANIMATION_RELATIVE) + return TRUE diff --git a/voidcrew/modules/gun_feel/gun_feel.dm b/voidcrew/modules/gun_feel/gun_feel.dm new file mode 100644 index 00000000000..c11c9ecc74c --- /dev/null +++ b/voidcrew/modules/gun_feel/gun_feel.dm @@ -0,0 +1,159 @@ +// Gun feel: a directional recoil kick, a muzzle flash and flying casings for every gun. +// The firing feel is modelled on Shiptest (shiptest-ss13/Shiptest), whose code is AGPL-3.0 like ours. +// +// Hooks, all chained onto the upstream procs (this file loads after code/, so these run first and call ..()): +// - shoot_live_shot(): kick the shooter's camera and show the muzzle flash, once per shot. +// The upstream random camera shake in shoot_live_shot() is removed (marked VOIDCREW EDIT in gun.dm). + +/obj/item/gun + /// Camera kick in tiles. Null picks a default from gun_feel_class. + var/recoil_kick + /// GUN_FEEL_CLASS_*. Null until first needed; a subtype may preset it. + var/gun_feel_class + /// TRUE once gun_feel_resolve() has filled in the values above. + var/gun_feel_resolved = FALSE + /// The short-lived light from the last muzzle flash. + var/obj/effect/dummy/lighting_obj/moblight/gun_feel_light + +/obj/item/gun/Destroy() + gun_feel_clear_light() + return ..() + +/// Picks this gun's class and fills in any feel values its type left unset. +/obj/item/gun/proc/gun_feel_resolve() + if(gun_feel_resolved) + return + gun_feel_resolved = TRUE + if(isnull(gun_feel_class)) + gun_feel_class = gun_feel_pick_class() + var/kick = 0 + switch(gun_feel_class) + if(GUN_FEEL_CLASS_PISTOL) + kick = GUN_FEEL_KICK_PISTOL + if(GUN_FEEL_CLASS_REVOLVER) + kick = GUN_FEEL_KICK_REVOLVER + if(GUN_FEEL_CLASS_SMG) + kick = GUN_FEEL_KICK_SMG + if(GUN_FEEL_CLASS_RIFLE) + kick = GUN_FEEL_KICK_RIFLE + if(GUN_FEEL_CLASS_SHOTGUN) + kick = GUN_FEEL_KICK_SHOTGUN + if(GUN_FEEL_CLASS_MARKSMAN) + kick = GUN_FEEL_KICK_MARKSMAN + if(GUN_FEEL_CLASS_HEAVY) + kick = GUN_FEEL_KICK_HEAVY + if(GUN_FEEL_CLASS_LAUNCHER) + kick = GUN_FEEL_KICK_LAUNCHER + if(isnull(recoil_kick)) + recoil_kick = kick + +/// Works out what kind of gun this is from its type, weight and size. +/obj/item/gun/proc/gun_feel_pick_class() + var/long_frame = weapon_weight >= WEAPON_MEDIUM || w_class >= WEIGHT_CLASS_BULKY + if(istype(src, /obj/item/gun/energy)) + return long_frame ? GUN_FEEL_CLASS_ENERGY_LONG : GUN_FEEL_CLASS_ENERGY + if(!istype(src, /obj/item/gun/ballistic) || istype(src, /obj/item/gun/ballistic/bow)) + return GUN_FEEL_CLASS_NONE + if(istype(src, /obj/item/gun/ballistic/rocketlauncher) || istype(src, /obj/item/gun/ballistic/revolver/grenadelauncher)) + return GUN_FEEL_CLASS_LAUNCHER + if(istype(src, /obj/item/gun/ballistic/shotgun)) + return GUN_FEEL_CLASS_SHOTGUN + if(istype(src, /obj/item/gun/ballistic/rifle)) + return GUN_FEEL_CLASS_MARKSMAN + if(istype(src, /obj/item/gun/ballistic/revolver)) + return GUN_FEEL_CLASS_REVOLVER + if(istype(src, /obj/item/gun/ballistic/automatic/pistol)) + return GUN_FEEL_CLASS_PISTOL + if(istype(src, /obj/item/gun/ballistic/automatic)) + if(weapon_weight >= WEAPON_HEAVY && w_class >= WEIGHT_CLASS_HUGE) + return GUN_FEEL_CLASS_HEAVY + if(long_frame) + return GUN_FEEL_CLASS_RIFLE + return GUN_FEEL_CLASS_SMG + return long_frame ? GUN_FEEL_CLASS_RIFLE : GUN_FEEL_CLASS_PISTOL + +// Classes and values for guns the type rules above would get wrong. +/obj/item/gun/ballistic/automatic/toy + gun_feel_class = GUN_FEEL_CLASS_NONE + +/obj/item/gun/ballistic/automatic/pistol/toy + gun_feel_class = GUN_FEEL_CLASS_NONE + +/obj/item/gun/ballistic/shotgun/toy + gun_feel_class = GUN_FEEL_CLASS_NONE + +/// Fires laser casings from a magazine; it should handle like a laser rifle, not a powder rifle. +/obj/item/gun/ballistic/automatic/laser + gun_feel_class = GUN_FEEL_CLASS_ENERGY_LONG + +/// A crossbow, not a rifle. +/obj/item/gun/ballistic/rifle/rebarxbow + recoil_kick = 0.06 + +// ---- Per shot ---- + +/obj/item/gun/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) + . = ..() + gun_feel_on_shot(user, pbtarget) + +/// Kick and flash for one shot that just left the barrel. +/obj/item/gun/proc/gun_feel_on_shot(mob/living/user, atom/target) + if(!isliving(user) || QDELETED(src) || tk_firing(user)) + return + gun_feel_resolve() + var/angle = gun_feel_fire_angle(user, target) + gun_feel_kick(user, angle) + if(gun_feel_wants_flash()) + gun_feel_muzzle_flash(user, angle) + +/// Direction of the shot in degrees clockwise from north. +/obj/item/gun/proc/gun_feel_fire_angle(mob/living/user, atom/target) + var/turf/shooter_turf = get_turf(user) + var/turf/target_turf = get_turf(target) + if(shooter_turf && target_turf && shooter_turf != target_turf) + return get_angle(shooter_turf, target_turf) + var/facing = dir2angle(user.dir) + return isnull(facing) ? 0 : facing + +/obj/item/gun/proc/gun_feel_kick(mob/living/user, angle) + if(!user.client) + return + var/strength = min(recoil_kick + max(recoil, 0) * GUN_FEEL_LEGACY_RECOIL_KICK, GUN_FEEL_MAX_KICK) + if(strength <= 0) + return + var/kick_angle = angle + 180 + (rand() * 2 - 1) * GUN_FEEL_RECOIL_DEVIATION + gun_feel_recoil_camera(user, strength + 1, strength * 2 + 1, strength, kick_angle) + +/** + * Jolts a mob's camera a short way along `angle` and lets it spring back. + * + * After Shiptest's recoil_camera(). It runs as a named, parallel animation sequence that only adds + * a temporary offset on top of whatever else animates the client (explosion shakes, choking), and + * every value it writes ends at the client's current resting pixel offset, so it never shifts the + * view for good. A newer kick replaces the last one instead of stacking on it. + * + * Arguments: + * * recoiler - the mob whose camera kicks + * * duration - deciseconds to reach the full kick + * * backtime - deciseconds to settle back + * * strength - distance in tiles + * * angle - direction of the kick in degrees clockwise from north + */ +/proc/gun_feel_recoil_camera(mob/recoiler, duration, backtime, strength, angle) + var/client/viewer = recoiler?.client + if(!viewer) + return + // Players who asked for screen shake to be toned down get no kick at all. + if(viewer.prefs?.read_preference(/datum/preference/toggle/screen_shake_darken)) + return + // A scope re-aims the camera every tick and would cut the kick off anyway. + if(HAS_TRAIT(recoiler, TRAIT_USER_SCOPED)) + return + var/kick_x = round(sin(angle) * strength * ICON_SIZE_X, 1) + var/kick_y = round(cos(angle) * strength * ICON_SIZE_Y, 1) + if(!kick_x && !kick_y) + return + var/rest_x = viewer.pixel_x + var/rest_y = viewer.pixel_y + animate(viewer, pixel_x = rest_x + kick_x, pixel_y = rest_y + kick_y, time = duration, easing = CUBIC_EASING | EASE_OUT, tag = GUN_FEEL_RECOIL_ANIM_TAG) + animate(pixel_x = rest_x, pixel_y = rest_y, time = backtime, easing = BACK_EASING | EASE_OUT) diff --git a/voidcrew/modules/gun_feel/impacts.dm b/voidcrew/modules/gun_feel/impacts.dm new file mode 100644 index 00000000000..f4b20981c77 --- /dev/null +++ b/voidcrew/modules/gun_feel/impacts.dm @@ -0,0 +1,201 @@ +// Bullets: the long glowing streak sprite, an impact sound that matches what was hit, and a whizz and +// flinch for anyone a bullet passes close to. After Shiptest's bullet look, bullet_hit_sfx() and the +// near-miss check in its scan_moved_turf(). No ricochets: a bullet that hits something stops as before. + +/obj/projectile/bullet + // Shiptest's streak. tg ships the same "gauss" state in this icon file. + icon_state = "gauss" + // gun_feel_impact_sound() picks the hit sound from what was hit instead. + // A round that sets its own hitsound (cannonball, sonic slug...) keeps it. + hitsound = null + hitsound_wall = null + /// Mobs this bullet has already whizzed past, so it whizzes once per mob. + var/list/gun_feel_whizzed + +/// .50 rounds draw a thicker streak. +/obj/projectile/bullet/p50 + icon_state = "gaussstrong" + +// A slug is one heavy round, not a pellet. Shiptest draws slugs as a streak too. +/obj/projectile/bullet/shotgun_slug + icon_state = "gauss" + +/obj/projectile/bullet/shotgun_slug/milspec + icon_state = "gauss" + +/obj/projectile/bullet/Destroy() + gun_feel_whizzed = null + return ..() + +// ---- Impact sounds ---- + +/atom + /// world.time until which a bullet hitting this makes no new impact sound (pellets hitting together). + var/tmp/gun_feel_impact_quiet_until = 0 + +/obj/projectile/bullet/on_hit(atom/target, blocked = 0, pierce_hit) + . = ..() + if(. != BULLET_ACT_BLOCK) + gun_feel_impact_sound(target, blocked) + +/// Plays the sound of this bullet hitting `target`: flesh, or the surface it is made of. +/obj/projectile/bullet/proc/gun_feel_impact_sound(atom/target, blocked = 0) + if(hitsound || hitsound_wall || QDELETED(target)) + return + if(world.time < target.gun_feel_impact_quiet_until) + return + var/surface = gun_feel_surface_of(target) + // Fully stopped by armour: it rings off the plate. + if(surface == GUN_FEEL_SURFACE_FLESH && blocked >= 100) + surface = GUN_FEEL_SURFACE_METAL + var/sound_key = GLOB.gun_feel_surface_sounds[surface] + if(!sound_key) + return + target.gun_feel_impact_quiet_until = world.time + GUN_FEEL_IMPACT_SOUND_WINDOW + var/volume + if(suppressed) + volume = GUN_FEEL_SUPPRESSED_HIT_VOLUME + else if(surface == GUN_FEEL_SURFACE_FLESH) + volume = GUN_FEEL_FLESH_HIT_VOLUME + else + // The same loudness upstream gave a bullet hitting a wall. + volume = clamp(vol_by_damage() + 20, GUN_FEEL_SUPPRESSED_HIT_VOLUME, 100) + playsound(src, sound_key, volume, TRUE, -1, frequency = GUN_FEEL_PITCH(GUN_FEEL_IMPACT_PITCH_SPREAD)) + +/// Surface name to sound effect key. +GLOBAL_LIST_INIT(gun_feel_surface_sounds, list( + GUN_FEEL_SURFACE_FLESH = SFX_GUN_FEEL_BULLET_FLESH, + GUN_FEEL_SURFACE_METAL = SFX_GUN_FEEL_HIT_METAL, + GUN_FEEL_SURFACE_GLASS = SFX_GUN_FEEL_HIT_GLASS, + GUN_FEEL_SURFACE_STONE = SFX_GUN_FEEL_HIT_STONE, + GUN_FEEL_SURFACE_WOOD = SFX_GUN_FEEL_HIT_WOOD, + GUN_FEEL_SURFACE_SNOW = SFX_GUN_FEEL_HIT_SNOW, +)) + +/// Types whose surface can't be read from their materials, by surface. Subtypes count too. +GLOBAL_LIST_INIT(gun_feel_surface_types, list( + GUN_FEEL_SURFACE_GLASS = typecacheof(list( + /obj/machinery/door/window, + /obj/structure/displaycase, + /obj/structure/table/glass, + /obj/structure/window, + )), + GUN_FEEL_SURFACE_WOOD = typecacheof(list( + /obj/structure/barricade/wooden, + /obj/structure/bookcase, + /obj/structure/chair/wood, + /obj/structure/closet/crate/wooden, + /obj/structure/dresser, + /obj/structure/flora/tree, + /obj/structure/table/wood, + )), + GUN_FEEL_SURFACE_STONE = typecacheof(list( + /obj/structure/flora/rock, + /obj/structure/statue, + /turf/closed/indestructible/rock, + /turf/closed/mineral, + /turf/closed/wall/rock, + /turf/open/misc, + )), + GUN_FEEL_SURFACE_SNOW = typecacheof(list( + /obj/structure/barricade/sandbags, + /turf/closed/wall/ice, + )), +)) + +/// Listed surface by type, worked out once per type. FALSE caches "not listed". +GLOBAL_LIST_EMPTY(gun_feel_surface_by_type) + +/proc/gun_feel_surface_of(atom/target) + if(isliving(target)) + var/mob/living/victim = target + return (victim.mob_biotypes & MOB_ROBOTIC) ? GUN_FEEL_SURFACE_METAL : GUN_FEEL_SURFACE_FLESH + // Known types first: some never set their own materials (tg's wooden table inherits the base table's iron). + var/listed = GLOB.gun_feel_surface_by_type[target.type] + if(isnull(listed)) + listed = gun_feel_listed_surface(target.type) || FALSE + GLOB.gun_feel_surface_by_type[target.type] = listed + if(listed) + return listed + // Otherwise go by what it's made of (a generic table built from wood or glass). + return gun_feel_surface_of_materials(target.custom_materials) || GUN_FEEL_SURFACE_METAL + +/// Surface for a type with no materials to go by. +/proc/gun_feel_surface_of_type(atom_type) + return gun_feel_listed_surface(atom_type) || GUN_FEEL_SURFACE_METAL + +/// Surface for a type on the lists above, or null if it isn't listed. +/proc/gun_feel_listed_surface(atom_type) + // Snowy and icy rock looks like stone by type, so check its name first. + if(ispath(atom_type, /turf/closed/mineral) || ispath(atom_type, /turf/closed/indestructible/rock) || ispath(atom_type, /turf/open/misc)) + var/type_name = "[atom_type]" + if(findtext(type_name, "snow") || findtext(type_name, "/ice") || findtext(type_name, "glacial")) + return GUN_FEEL_SURFACE_SNOW + for(var/surface in GLOB.gun_feel_surface_types) + var/list/types = GLOB.gun_feel_surface_types[surface] + if(types[atom_type]) + return surface + return null + +/// Surface for the main material in a custom_materials list, or null if there is none. +/proc/gun_feel_surface_of_materials(list/materials) + if(!length(materials)) + return null + var/main_type + var/most = 0 + for(var/material in materials) + var/amount = materials[material] + if(!isnum(amount) || amount <= most) + continue + var/datum/material/as_datum = material + var/material_type = ispath(material) ? material : (istype(as_datum) ? as_datum.type : null) + if(!material_type) + continue + main_type = material_type + most = amount + if(!main_type) + return null + if(ispath(main_type, /datum/material/wood) || ispath(main_type, /datum/material/bamboo) || ispath(main_type, /datum/material/paper) || ispath(main_type, /datum/material/cardboard)) + return GUN_FEEL_SURFACE_WOOD + if(ispath(main_type, /datum/material/glass) || ispath(main_type, /datum/material/alloy/plasmaglass) || ispath(main_type, /datum/material/alloy/titaniumglass) || ispath(main_type, /datum/material/alloy/plastitaniumglass) || ispath(main_type, /datum/material/diamond)) + return GUN_FEEL_SURFACE_GLASS + if(ispath(main_type, /datum/material/sandstone) || ispath(main_type, /datum/material/sand) || ispath(main_type, /datum/material/bone)) + return GUN_FEEL_SURFACE_STONE + if(ispath(main_type, /datum/material/snow)) + return GUN_FEEL_SURFACE_SNOW + if(ispath(main_type, /datum/material/meat)) + return GUN_FEEL_SURFACE_FLESH + return GUN_FEEL_SURFACE_METAL + +// ---- Near misses ---- + +/mob/living + /// Stops one mob hearing a whole shotgun blast whizz past as six separate bullets. + COOLDOWN_DECLARE(gun_feel_whizz_cooldown) + +/obj/projectile/bullet/scan_moved_turf() + . = ..() + if(!QDELETED(src) && !deletion_queued) + gun_feel_near_miss() + +/// Whizzes past and flinches every conscious mob next to this bullet that it hasn't already passed. +/// Suppressed rounds don't: a whizz would give away a shot nobody heard. +/obj/projectile/bullet/proc/gun_feel_near_miss() + if(suppressed) + return + var/turf/here = loc + if(!isturf(here) || !starting || get_dist(starting, here) < GUN_FEEL_NEAR_MISS_MIN_TRAVEL) + return + for(var/turf/nearby as anything in RANGE_TURFS(1, here)) + for(var/mob/living/bystander in nearby) + if(bystander == firer || bystander.stat != CONSCIOUS) + continue + if(LAZYFIND(gun_feel_whizzed, bystander) || impacted[bystander.weak_reference]) + continue + LAZYADD(gun_feel_whizzed, bystander) + if(!COOLDOWN_FINISHED(bystander, gun_feel_whizz_cooldown)) + continue + COOLDOWN_START(bystander, gun_feel_whizz_cooldown, GUN_FEEL_NEAR_MISS_COOLDOWN) + if(bystander.client) + bystander.playsound_local(here, SFX_GUN_FEEL_BULLET_MISS, GUN_FEEL_NEAR_MISS_VOLUME, TRUE, GUN_FEEL_PITCH(GUN_FEEL_IMPACT_PITCH_SPREAD)) + bystander.gun_feel_flinch(GUN_FEEL_NEAR_MISS_FLINCH, nearby == here ? null : get_dir(here, bystander)) diff --git a/voidcrew/modules/gun_feel/movable_physics.dm b/voidcrew/modules/gun_feel/movable_physics.dm new file mode 100644 index 00000000000..2a6c4bdc760 --- /dev/null +++ b/voidcrew/modules/gun_feel/movable_physics.dm @@ -0,0 +1,191 @@ +/** + * Movable physics + * + * Ported from Shiptest (shiptest-ss13/Shiptest, code/datums/components/movable_physics.dm and + * code/controllers/subsystem/processing/movable_physics.dm), AGPL-3.0 like this codebase. + * + * Throws a movable along the floor by its pixel offsets: it arcs up on pixel_z under gravity, + * bounces, slides to a stop, and steps into the next turf with Move() whenever its offset crosses + * a tile edge. Walls and anything else that refuses the Move() reflect it. + * + * Changes from Shiptest: wall reflections use the right axis, bounces lose speed instead of + * gaining it, offsets are kept as floats and written as whole pixels, and the component always + * deletes itself once the movable settles (or after MOVABLE_PHYSICS_TIMEOUT), so a settled movable + * costs nothing. It also lets go the moment anything else moves, picks up or throws the movable. + */ +/datum/component/movable_physics + /// Speed along the floor, in pixels per step. + var/horizontal_velocity = 0 + /// Speed up (positive) or down along pixel_z, in pixels per step. + var/vertical_velocity = 0 + /// Direction of travel along the floor, in degrees clockwise from north. + var/angle_of_movement = 0 + /// Current height above the floor, in pixels. + var/height = 0 + /// Offsets from the movable's base pixel position, kept unrounded between steps. + var/offset_x = 0 + var/offset_y = 0 + var/gravity = MOVABLE_PHYSICS_GRAVITY + var/air_drag = MOVABLE_PHYSICS_AIR_DRAG + var/ground_friction = MOVABLE_PHYSICS_GROUND_FRICTION + /// Sound for floor bounces. Null uses the turf's bullet_bounce_sound. + var/bounce_sound + /// A hot casing sizzles on the first landing if the floor is wet or icy. + var/hot = FALSE + /// Spin on launch and on each real bounce. + var/spin = TRUE + var/bounces = 0 + /// The movable's animate_movement before we started; Move() must not glide while we steer it by pixels. + var/cached_animate_movement + /// TRUE while we are the ones calling Move(), so on_moved() knows the move was ours. + var/moving_self = FALSE + /// world.time after which we give up and settle wherever we are. + var/give_up_at = 0 + +/datum/component/movable_physics/Initialize(horizontal_velocity = 0, vertical_velocity = 0, angle_of_movement = 0, start_height = 0, hot = FALSE, spin = TRUE, bounce_sound = null) + var/atom/movable/body = parent + if(!ismovable(body) || !isturf(body.loc)) + return COMPONENT_INCOMPATIBLE + src.horizontal_velocity = max(horizontal_velocity, 0) + src.vertical_velocity = vertical_velocity + src.angle_of_movement = SIMPLIFY_DEGREES(angle_of_movement) + src.hot = hot + src.spin = spin + src.bounce_sound = bounce_sound + height = max(start_height, 0) + offset_x = body.pixel_x - body.base_pixel_x + offset_y = body.pixel_y - body.base_pixel_y + cached_animate_movement = body.animate_movement + body.animate_movement = NO_STEPS + body.pixel_z = body.base_pixel_z + round(height) + give_up_at = world.time + MOVABLE_PHYSICS_TIMEOUT + if(spin) + body.SpinAnimation(speed = 0.4 SECONDS, loops = 2) + START_PROCESSING(SSmovable_physics, src) + +/datum/component/movable_physics/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_MOVABLE_PRE_THROW, PROC_REF(on_thrown)) + +/datum/component/movable_physics/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_MOVABLE_PRE_THROW)) + +/datum/component/movable_physics/Destroy(force) + STOP_PROCESSING(SSmovable_physics, src) + var/atom/movable/body = parent + if(!QDELETED(body)) + body.animate_movement = cached_animate_movement + body.pixel_z = body.base_pixel_z + if(isturf(body.loc)) + body.pixel_x = body.base_pixel_x + round(clamp(offset_x, -16, 16)) + body.pixel_y = body.base_pixel_y + round(clamp(offset_y, -16, 16)) + else + body.pixel_x = body.base_pixel_x + body.pixel_y = body.base_pixel_y + return ..() + +/// Anything other than our own stepping that moves the body (a hand, a broom, a conveyor, a shuttle) ends the flight. +/datum/component/movable_physics/proc/on_moved(atom/movable/source, atom/old_loc, dir, forced, list/old_locs, momentum_change) + SIGNAL_HANDLER + if(moving_self) + return + qdel(src) + +/datum/component/movable_physics/proc/on_thrown(atom/movable/source, list/arguments) + SIGNAL_HANDLER + qdel(src) + +/datum/component/movable_physics/process(seconds_per_tick) + var/atom/movable/body = parent + if(QDELETED(body) || !isturf(body.loc) || world.time >= give_up_at) + qdel(src) + return + + if(height > 0 || vertical_velocity > 0) + vertical_velocity -= gravity + height += vertical_velocity + if(height <= 0) + height = 0 + land(body) + + horizontal_velocity = max(0, horizontal_velocity - (height > 0 ? air_drag : ground_friction)) + if(horizontal_velocity) + offset_x += sin(angle_of_movement) * horizontal_velocity + offset_y += cos(angle_of_movement) * horizontal_velocity + if(!cross_tiles(body)) + return + + body.pixel_x = body.base_pixel_x + round(offset_x) + body.pixel_y = body.base_pixel_y + round(offset_y) + body.pixel_z = body.base_pixel_z + round(height) + + if(!horizontal_velocity && height <= 0 && !vertical_velocity) + qdel(src) + +/// Hit the floor: make a noise, lose speed, maybe hop again. +/datum/component/movable_physics/proc/land(atom/movable/body) + bounces++ + var/turf/floor = body.loc + if(bounces <= 2) + var/volume = bounces == 1 ? 20 : 10 + if(hot && bounces == 1 && floor.bullet_sizzle) + playsound(body, 'sound/items/tools/welder.ogg', volume, TRUE) + else + var/landing_sound = bounce_sound || floor.bullet_bounce_sound + if(landing_sound) + playsound(body, landing_sound, volume, TRUE) + vertical_velocity = -vertical_velocity * MOVABLE_PHYSICS_RESTITUTION + if(vertical_velocity < MOVABLE_PHYSICS_MIN_BOUNCE) + vertical_velocity = 0 + horizontal_velocity *= MOVABLE_PHYSICS_BOUNCE_KEEP + angle_of_movement = SIMPLIFY_DEGREES(angle_of_movement + rand(-15, 15)) + if(spin && vertical_velocity) + body.SpinAnimation(speed = 0.3 SECONDS, loops = 1) + +/// Steps into the next turf when an offset passes a tile edge, or glances off whatever is in the way. +/// Returns FALSE if we stopped existing or lost the body on the way. +/datum/component/movable_physics/proc/cross_tiles(atom/movable/body) + var/stepped = FALSE + if(offset_x > 16 || offset_x < -16) + var/step_dir = offset_x > 0 ? EAST : WEST + if(step_body(body, step_dir)) + offset_x += step_dir == EAST ? -32 : 32 + stepped = TRUE + else + offset_x = clamp(offset_x, -16, 16) + angle_of_movement = SIMPLIFY_DEGREES(360 - angle_of_movement) + horizontal_velocity *= MOVABLE_PHYSICS_WALL_KEEP + if(QDELETED(src) || QDELETED(body) || !isturf(body.loc)) + return FALSE + if(offset_y > 16 || offset_y < -16) + var/step_dir = offset_y > 0 ? NORTH : SOUTH + if(step_body(body, step_dir)) + offset_y += step_dir == NORTH ? -32 : 32 + stepped = TRUE + else + offset_y = clamp(offset_y, -16, 16) + angle_of_movement = SIMPLIFY_DEGREES(180 - angle_of_movement) + horizontal_velocity *= MOVABLE_PHYSICS_WALL_KEEP + if(QDELETED(src) || QDELETED(body) || !isturf(body.loc)) + return FALSE + // Into space or a gravityless room: stop steering and let ordinary drift carry it. + if(stepped && !body.has_gravity()) + qdel(src) + return FALSE + return TRUE + +/// One Move() into the next turf. Dense turfs and blockers refuse it, so the body never ends up inside a wall. +/datum/component/movable_physics/proc/step_body(atom/movable/body, step_dir) + var/turf/next = get_step(body, step_dir) + if(!next) + return FALSE + moving_self = TRUE + . = body.Move(next, step_dir, 0, FALSE) + moving_self = FALSE + +/// Fast processing for movable physics. Runs every 0.05 s while anything is in flight and idles otherwise. +PROCESSING_SUBSYSTEM_DEF(movable_physics) + name = "Movable Physics" + flags = SS_NO_INIT|SS_KEEP_TIMING + wait = 0.05 SECONDS + stat_tag = "MP" diff --git a/voidcrew/modules/gun_feel/muzzle_flash.dm b/voidcrew/modules/gun_feel/muzzle_flash.dm new file mode 100644 index 00000000000..cbf101e632b --- /dev/null +++ b/voidcrew/modules/gun_feel/muzzle_flash.dm @@ -0,0 +1,120 @@ +// Muzzle flash for powder guns: tg's muzzle_bullet sprite at the barrel, turned to the shot, plus a +// short cone of light thrown ahead of the shooter. After Shiptest's handle_muzzle_flash() and its +// directional gun light. Energy weapons already glow (their projectiles carry a light) and hitscan +// rounds already spawn a lit muzzle, so neither gets one. + +/obj/item/ammo_casing + /// FALSE for rounds that don't burn powder (darts, arrows, lenses, gumballs...). + var/gun_feel_flash = TRUE + +/obj/item/ammo_casing/energy + gun_feel_flash = FALSE + +/obj/item/ammo_casing/laser + gun_feel_flash = FALSE + +/obj/item/ammo_casing/foam_dart + gun_feel_flash = FALSE + +/obj/item/ammo_casing/arrow + gun_feel_flash = FALSE + +/obj/item/ammo_casing/rebar + gun_feel_flash = FALSE + +/obj/item/ammo_casing/harpoon + gun_feel_flash = FALSE + +/obj/item/ammo_casing/gumball + gun_feel_flash = FALSE + +/obj/item/ammo_casing/lollipop + gun_feel_flash = FALSE + +/obj/item/ammo_casing/pea + gun_feel_flash = FALSE + +/obj/item/ammo_casing/magic + gun_feel_flash = FALSE + +/obj/item/ammo_casing/syringegun + gun_feel_flash = FALSE + +/obj/item/ammo_casing/chemgun + gun_feel_flash = FALSE + +/obj/item/ammo_casing/dnainjector + gun_feel_flash = FALSE + +/// TRUE if the round that was just fired should flash. +/obj/item/gun/proc/gun_feel_wants_flash() + if(suppressed) + return FALSE + var/obj/item/ammo_casing/casing = chambered + if(!istype(casing) || !casing.gun_feel_flash || !casing.firing_effect_type) + return FALSE + var/obj/projectile/shot_type = casing.projectile_type + if(ispath(shot_type, /obj/projectile) && initial(shot_type.hitscan) && initial(shot_type.muzzle_type)) + return FALSE + return TRUE + +/obj/item/gun/proc/gun_feel_muzzle_flash(mob/living/user, angle) + if(!isturf(user.loc)) + return + new /obj/effect/temp_visual/gun_feel_muzzle_flash(user, angle) + if(QDELETED(gun_feel_light) || gun_feel_light.loc != user) + gun_feel_clear_light() + gun_feel_light = user.mob_light(GUN_FEEL_FLASH_LIGHT_RANGE, GUN_FEEL_FLASH_LIGHT_POWER, GUN_FEEL_FLASH_LIGHT_COLOR, null, /obj/effect/dummy/lighting_obj/moblight/gun_feel_muzzle) + if(QDELETED(gun_feel_light)) + gun_feel_light = null + return + RegisterSignal(gun_feel_light, COMSIG_QDELETING, PROC_REF(gun_feel_light_deleted)) + // Each shot pushes the light-off back, so automatic fire keeps one light lit instead of making one per shot. + addtimer(CALLBACK(src, PROC_REF(gun_feel_clear_light)), GUN_FEEL_FLASH_LIGHT_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) + +/// The flash light. Directional, like a flashlight: it follows the shooter's facing, and firing turns +/// the shooter to face the target, so the cone points down the line of fire. +/obj/effect/dummy/lighting_obj/moblight/gun_feel_muzzle + name = "muzzle flash lighting" + light_system = OVERLAY_LIGHT_DIRECTIONAL + +/obj/item/gun/proc/gun_feel_clear_light() + if(isnull(gun_feel_light)) + return + UnregisterSignal(gun_feel_light, COMSIG_QDELETING) + QDEL_NULL(gun_feel_light) + +/obj/item/gun/proc/gun_feel_light_deleted(datum/source) + SIGNAL_HANDLER + gun_feel_light = null + +/// The flash itself. It rides in the shooter's vis_contents so it stays at the barrel while they move. +/obj/effect/temp_visual/gun_feel_muzzle_flash + name = "muzzle flash" + icon = GUN_FEEL_FLASH_ICON + icon_state = GUN_FEEL_FLASH_ICON_STATE + layer = ABOVE_MOB_LAYER + duration = GUN_FEEL_FLASH_TIME + randomdir = FALSE + appearance_flags = KEEP_APART | RESET_COLOR | TILE_BOUND | PIXEL_SCALE + vis_flags = VIS_INHERIT_PLANE + +/obj/effect/temp_visual/gun_feel_muzzle_flash/Initialize(mapload, angle = 0) + . = ..() + var/side = sin(angle) + var/forward = cos(angle) + // The barrel: out to the side, up when aiming away from the viewer, down when aiming at them. + var/barrel_x = side * GUN_FEEL_FLASH_REACH_SIDE + var/barrel_y = GUN_FEEL_FLASH_HAND_HEIGHT + forward * (forward > 0 ? GUN_FEEL_FLASH_REACH_UP : GUN_FEEL_FLASH_REACH_DOWN) + // Push the sprite out so its bright core, not its centre, sits on the barrel. + pixel_x = round(barrel_x + side * GUN_FEEL_FLASH_HOTSPOT) + pixel_y = round(barrel_y + forward * GUN_FEEL_FLASH_HOTSPOT) + var/matrix/turned = matrix() + turned.Turn(angle) + transform = turned + // Aiming away from the viewer puts the barrel behind the shooter's body. + if(forward > 0.5) + layer = BELOW_MOB_LAYER + if(ismovable(loc)) + var/atom/movable/shooter = loc + shooter.vis_contents += src diff --git a/voidcrew/modules/gun_feel/npc_blood.dm b/voidcrew/modules/gun_feel/npc_blood.dm new file mode 100644 index 00000000000..3aec88f9264 --- /dev/null +++ b/voidcrew/modules/gun_feel/npc_blood.dm @@ -0,0 +1,31 @@ +// Human NPCs bleed when shot: blood splatters off them and sometimes onto the floor, as it does off +// players. Basic mobs default to no blood, so create_projectile_hit_effects() showed nothing at all. +// +// The blood is only for show. Basic mobs never run handle_blood(), so they don't lose blood over time, +// never die of blood loss and have no wounds. Troopers are deleted on death and leave a separate corpse. + +/mob/living/basic + /// TRUE for NPCs that should have blood like a person. Only organic, non-robotic mobs get it. + var/gun_feel_bleeds = FALSE + +/mob/living/basic/Initialize(mapload) + . = ..() + if(gun_feel_bleeds && !blood_volume && (mob_biotypes & MOB_ORGANIC) && !(mob_biotypes & MOB_ROBOTIC)) + blood_volume = BLOOD_VOLUME_NORMAL + +// Pirates, Russians, Nanotrasen, Syndicate and the rest of the trooper family. +/mob/living/basic/trooper + gun_feel_bleeds = TRUE + +// Voidcrew's other human NPCs. +/mob/living/basic/ambient_npc + gun_feel_bleeds = TRUE + +/mob/living/basic/mission_survivor + gun_feel_bleeds = TRUE + +/mob/living/basic/outpost_trader + gun_feel_bleeds = TRUE + +/mob/living/basic/outpost_kessler_staff + gun_feel_bleeds = TRUE diff --git a/voidcrew/modules/gun_feel/sound/attribution.txt b/voidcrew/modules/gun_feel/sound/attribution.txt new file mode 100644 index 00000000000..9610998ac49 --- /dev/null +++ b/voidcrew/modules/gun_feel/sound/attribution.txt @@ -0,0 +1,28 @@ +All sounds in this folder come from Shiptest (https://github.com/shiptest-ss13/Shiptest), path +sound/weapons/gun/, taken unmodified on 2026-11-07. Shiptest licenses its assets +CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/) unless otherwise indicated; they are +used here under that licence. Shiptest took most of them from other SS13 codebases, listed per file +below where a byte-identical copy was found in the source repository. + +Fire sounds +pistol/shot.ogg - Shiptest PR #1601 "Gun rework" (rye-rice). Same file as TerraGov-Marine-Corps sound/weapons/guns/fire/tgmc/kinetic/gun_p14.ogg, added by TGMC PR #13409 (XSlayer300), whose description states its new sounds are CC BY-NC-SA 3.0. +pistol/shot_alt.ogg - Shiptest PR #56 "Eris gun noises" (rye-rice). From CEV-Eris sound/weapons/guns/fire/cal/40revolver.ogg. +revolver/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/revolver_fire.ogg. +revolver/shot_light.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/weapons/guns/fire/revolver_light.ogg (TGMC PR #5261, XSlayer300). +smg/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/smg_fire.ogg. +rifle/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/batrifle_fire.ogg. +shotgun/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/shotgunp_fire.ogg. +sniper/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/sniper_fire.ogg. +l6/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/lmg_fire.ogg. + +Handling sounds +smg/smg_reload.ogg, smg/smg_unload.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/weapons/guns/interact/ (TGMC PR #2284, MoXuT0). +rifle/ar_reload.ogg, rifle/ar_unload.ogg, rifle/ar_cock.ogg - Shiptest PR #1601 (rye-rice). From CEV-Eris sound/weapons/guns/interact/batrifle_magin.ogg, batrifle_magout.ogg, batrifle_cock.ogg. +hmg/cm40_reload.ogg, hmg/cm40_unload.ogg - Shiptest PR #3238 (rye-rice), credited there to TerraGov-Marine-Corps. No byte-identical source copy found. + +Hit and near-miss sounds +hit/bullet_impact1-3.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/bullets/ (TGMC PR #636, forwardslashN). +hit/bullet_miss1-3.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/bullets/ (TGMC PR #636, forwardslashN). +hit/bullet_glass_01-07.ogg, hit/bullet_masonry_01-06.ogg, hit/bullet_metal_01-06.ogg, +hit/bullet_wood_01-06.ogg, hit/bullet_snow_01-06.ogg - Shiptest PR #1601 (rye-rice). From Skyrat-tg +modular_skyrat/modules/gunsgalore/sound/impact/impact_*.ogg (Skyrat-tg PR #11697 "The Impact Soundening", Gandalf2k15). diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg new file mode 100644 index 00000000000..30f6f1fb612 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg new file mode 100644 index 00000000000..472f98e9801 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_03.ogg new file mode 100644 index 00000000000..25c6df47a92 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_03.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_04.ogg new file mode 100644 index 00000000000..b525f665c41 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_04.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_05.ogg new file mode 100644 index 00000000000..89ff21723aa Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_05.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_06.ogg new file mode 100644 index 00000000000..26cacb99076 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_06.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg new file mode 100644 index 00000000000..110a45074d1 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg new file mode 100644 index 00000000000..4c0cd5b5eeb Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_impact2.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_impact2.ogg new file mode 100644 index 00000000000..bfffcfeadfa Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_impact2.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg new file mode 100644 index 00000000000..0356dc6c881 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg new file mode 100644 index 00000000000..1a04e7688d6 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg new file mode 100644 index 00000000000..770e9e242a7 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_03.ogg new file mode 100644 index 00000000000..c0eb0bf1323 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_03.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg new file mode 100644 index 00000000000..83cbc57ebd8 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg new file mode 100644 index 00000000000..6d9a6730465 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_06.ogg new file mode 100644 index 00000000000..2a982a56edb Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_06.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_01.ogg new file mode 100644 index 00000000000..1a706abd90c Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_01.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_02.ogg new file mode 100644 index 00000000000..ee938e2a593 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_02.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg new file mode 100644 index 00000000000..9ede9f16129 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg new file mode 100644 index 00000000000..bf6824c8cef Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg new file mode 100644 index 00000000000..a45e3d6204e Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_06.ogg new file mode 100644 index 00000000000..53877b5e8ce Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_06.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg new file mode 100644 index 00000000000..dfff45a0cb5 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg new file mode 100644 index 00000000000..54b11112802 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg new file mode 100644 index 00000000000..fbff6dde904 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_01.ogg new file mode 100644 index 00000000000..4da742bf146 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_01.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_02.ogg new file mode 100644 index 00000000000..21572daf13d Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_02.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_03.ogg new file mode 100644 index 00000000000..fb8e1dcb9da Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_03.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg new file mode 100644 index 00000000000..2bfb46d958b Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg new file mode 100644 index 00000000000..3752f95b3d6 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg new file mode 100644 index 00000000000..cac69cc7404 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg new file mode 100644 index 00000000000..559310853f9 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg new file mode 100644 index 00000000000..852c1d875cd Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg new file mode 100644 index 00000000000..440681e0da4 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_04.ogg new file mode 100644 index 00000000000..89ddd21e2bb Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_04.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg new file mode 100644 index 00000000000..3a66b3f32c8 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg new file mode 100644 index 00000000000..cf54f8cc8f5 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hmg/cm40_reload.ogg b/voidcrew/modules/gun_feel/sound/hmg/cm40_reload.ogg new file mode 100644 index 00000000000..490bbd4fe78 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hmg/cm40_reload.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg b/voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg new file mode 100644 index 00000000000..ae9e31aee53 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/l6/shot.ogg b/voidcrew/modules/gun_feel/sound/l6/shot.ogg new file mode 100644 index 00000000000..c64549c717d Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/l6/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/pistol/shot.ogg b/voidcrew/modules/gun_feel/sound/pistol/shot.ogg new file mode 100644 index 00000000000..1bbd95e405b Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/pistol/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg b/voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg new file mode 100644 index 00000000000..a24c7acc4f9 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/revolver/shot.ogg b/voidcrew/modules/gun_feel/sound/revolver/shot.ogg new file mode 100644 index 00000000000..91e480bd152 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/revolver/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg b/voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg new file mode 100644 index 00000000000..68b7fada6e4 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg b/voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg new file mode 100644 index 00000000000..ac02ed93c1d Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg b/voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg new file mode 100644 index 00000000000..6e557b892a9 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg b/voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg new file mode 100644 index 00000000000..591599f5233 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/rifle/shot.ogg b/voidcrew/modules/gun_feel/sound/rifle/shot.ogg new file mode 100644 index 00000000000..2bd58e34490 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/rifle/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/shotgun/shot.ogg b/voidcrew/modules/gun_feel/sound/shotgun/shot.ogg new file mode 100644 index 00000000000..2f499f08b01 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/shotgun/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/smg/shot.ogg b/voidcrew/modules/gun_feel/sound/smg/shot.ogg new file mode 100644 index 00000000000..98d262b0f9a Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/smg/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/smg/smg_reload.ogg b/voidcrew/modules/gun_feel/sound/smg/smg_reload.ogg new file mode 100644 index 00000000000..4e7b8f7ea91 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/smg/smg_reload.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg b/voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg new file mode 100644 index 00000000000..677b5a8f3d2 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg differ diff --git a/voidcrew/modules/gun_feel/sound/sniper/shot.ogg b/voidcrew/modules/gun_feel/sound/sniper/shot.ogg new file mode 100644 index 00000000000..4c280c224a9 Binary files /dev/null and b/voidcrew/modules/gun_feel/sound/sniper/shot.ogg differ diff --git a/voidcrew/modules/gun_feel/sounds.dm b/voidcrew/modules/gun_feel/sounds.dm new file mode 100644 index 00000000000..dcf8c96b7ee --- /dev/null +++ b/voidcrew/modules/gun_feel/sounds.dm @@ -0,0 +1,181 @@ +// Gun sounds: powder guns play at their recorded pitch, our stock shots and handling sounds are swapped +// for Shiptest's fuller recordings by gun class, and the sound sets for bullet impacts and near misses +// (used in impacts.dm). The recordings come from Shiptest (shiptest-ss13/Shiptest); sources and +// licences are in sound/attribution.txt next to them. + +// ---- Pitch ---- + +// Shiptest's powder guns never shift pitch, and with only a few recordings per class a random 0.73x-1.25x +// made half of our shots sound thin. The low-ammo click keeps its rising pitch (VOIDCREW EDITs in +// ballistic.dm and revolver.dm pass it vary = TRUE itself). +/obj/item/gun/ballistic + vary_fire_sound = FALSE + +/// Playback speed for one shot of a gun that still varies its pitch (energy weapons): a narrow spread +/// around the recording's own rate. Called from a VOIDCREW EDIT in energy.dm. Null plays it as recorded. +/obj/item/gun/proc/gun_feel_fire_frequency() + if(!vary_fire_sound) + return null + return GUN_FEEL_PITCH(GUN_FEEL_ENERGY_PITCH_SPREAD) + +// ---- Swapping in Shiptest's recordings ---- + +/// Our stock ballistic shots, by file path, and the Shiptest recording that replaces each. +/// Our shot files are already named by gun class, so this is a class-to-class swap. +GLOBAL_LIST_INIT(gun_feel_fire_sounds, list( + "sound/items/weapons/gun/pistol/shot.ogg" = 'voidcrew/modules/gun_feel/sound/pistol/shot.ogg', + "sound/items/weapons/gun/pistol/shot_alt.ogg" = 'voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg', + "sound/items/weapons/gun/revolver/shot.ogg" = 'voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg', + "sound/items/weapons/gun/revolver/shot_alt.ogg" = 'voidcrew/modules/gun_feel/sound/revolver/shot.ogg', + "sound/items/weapons/gun/smg/shot.ogg" = 'voidcrew/modules/gun_feel/sound/smg/shot.ogg', + "sound/items/weapons/gun/smg/shot_alt.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/shot.ogg', + "sound/items/weapons/gun/rifle/shot.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/shot.ogg', + "sound/items/weapons/gun/shotgun/shot.ogg" = 'voidcrew/modules/gun_feel/sound/shotgun/shot.ogg', + "sound/items/weapons/gun/sniper/shot.ogg" = 'voidcrew/modules/gun_feel/sound/sniper/shot.ogg', + "sound/items/weapons/gun/l6/shot.ogg" = 'voidcrew/modules/gun_feel/sound/l6/shot.ogg', +)) + +/// Per gun class, our shared magazine and rack sounds and the class's own Shiptest handling sound. +/// Classes not listed (pistols, revolvers, shotguns, bolt rifles) already have their own handling sounds. +GLOBAL_LIST_INIT(gun_feel_handling_sounds, list( + GUN_FEEL_CLASS_SMG = list( + "sound/items/weapons/gun/general/magazine_insert_full.ogg" = 'voidcrew/modules/gun_feel/sound/smg/smg_reload.ogg', + "sound/items/weapons/gun/general/magazine_insert_empty.ogg" = 'voidcrew/modules/gun_feel/sound/smg/smg_reload.ogg', + "sound/items/weapons/gun/general/magazine_remove_full.ogg" = 'voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg', + "sound/items/weapons/gun/general/magazine_remove_empty.ogg" = 'voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg', + ), + GUN_FEEL_CLASS_RIFLE = list( + "sound/items/weapons/gun/general/magazine_insert_full.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg', + "sound/items/weapons/gun/general/magazine_insert_empty.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg', + "sound/items/weapons/gun/general/magazine_remove_full.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg', + "sound/items/weapons/gun/general/magazine_remove_empty.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg', + "sound/items/weapons/gun/smg/smgrack.ogg" = 'voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg', + ), + GUN_FEEL_CLASS_HEAVY = list( + "sound/items/weapons/gun/general/magazine_insert_full.ogg" = 'voidcrew/modules/gun_feel/sound/hmg/cm40_reload.ogg', + "sound/items/weapons/gun/general/magazine_insert_empty.ogg" = 'voidcrew/modules/gun_feel/sound/hmg/cm40_reload.ogg', + "sound/items/weapons/gun/general/magazine_remove_full.ogg" = 'voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg', + "sound/items/weapons/gun/general/magazine_remove_empty.ogg" = 'voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg', + ), +)) + +/// Returns the replacement for `current` from `swaps` (keyed by file path), or `current` if it has none. +/// Anything a gun sets for itself (smartgun, launchers, lasers in a magazine...) is not in the table and stays. +/proc/gun_feel_swap_sound(current, list/swaps) + if(isnull(current) || !length(swaps)) + return current + var/replacement = swaps["[current]"] + return isnull(replacement) ? current : replacement + +/obj/item/gun/ballistic/Initialize(mapload) + . = ..() + gun_feel_swap_sounds() + +/// Swaps this gun's stock shot and handling sounds for its class's Shiptest ones. +/obj/item/gun/ballistic/proc/gun_feel_swap_sounds() + var/list/fire_swaps = GLOB.gun_feel_fire_sounds + fire_sound = gun_feel_swap_sound(fire_sound, fire_swaps) + // Guns that rechamber for another caliber put one of these back when they switch. + initial_fire_sound = gun_feel_swap_sound(initial_fire_sound, fire_swaps) + alternative_fire_sound = gun_feel_swap_sound(alternative_fire_sound, fire_swaps) + // Only reads the class: kick values stay unresolved until the first shot, as before. + var/sound_class = gun_feel_class || gun_feel_pick_class() + var/list/handling_swaps = GLOB.gun_feel_handling_sounds[sound_class] + if(!handling_swaps) + return + load_sound = gun_feel_swap_sound(load_sound, handling_swaps) + load_empty_sound = gun_feel_swap_sound(load_empty_sound, handling_swaps) + eject_sound = gun_feel_swap_sound(eject_sound, handling_swaps) + eject_empty_sound = gun_feel_swap_sound(eject_empty_sound, handling_swaps) + rack_sound = gun_feel_swap_sound(rack_sound, handling_swaps) + +// NPCs that shoot through the ranged attack component (pirates, troopers) use the same recordings as +// the player guns of their class. +/datum/component/ranged_attacks/Initialize( + casing_type, + projectile_type, + projectile_sound = 'sound/items/weapons/gun/pistol/shot.ogg', + burst_shots, + burst_intervals = 0.2 SECONDS, + cooldown_time = 3 SECONDS, +) + . = ..() + if(. == COMPONENT_INCOMPATIBLE) + return + src.projectile_sound = gun_feel_swap_sound(src.projectile_sound, GLOB.gun_feel_fire_sounds) + +// ---- Sound sets for impacts and near misses ---- + +/// A bullet hitting flesh. Shiptest's bullet_hit set. +/datum/sound_effect/gun_feel_bullet_flesh + key = SFX_GUN_FEEL_BULLET_FLESH + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_impact2.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg', + ) + +/// A bullet passing close by. +/datum/sound_effect/gun_feel_bullet_miss + key = SFX_GUN_FEEL_BULLET_MISS + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg', + ) + +/datum/sound_effect/gun_feel_hit_metal + key = SFX_GUN_FEEL_HIT_METAL + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_metal_01.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_metal_02.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_metal_06.ogg', + ) + +/datum/sound_effect/gun_feel_hit_glass + key = SFX_GUN_FEEL_HIT_GLASS + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_03.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_04.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_05.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_06.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg', + ) + +/datum/sound_effect/gun_feel_hit_stone + key = SFX_GUN_FEEL_HIT_STONE + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_masonry_03.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_masonry_06.ogg', + ) + +/datum/sound_effect/gun_feel_hit_wood + key = SFX_GUN_FEEL_HIT_WOOD + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_wood_04.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg', + ) + +/datum/sound_effect/gun_feel_hit_snow + key = SFX_GUN_FEEL_HIT_SNOW + file_paths = list( + 'voidcrew/modules/gun_feel/sound/hit/bullet_snow_01.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_snow_02.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_snow_03.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg', + 'voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg', + ) diff --git a/voidcrew/modules/human_npcs/human_npc.dm b/voidcrew/modules/human_npcs/human_npc.dm new file mode 100644 index 00000000000..aec6ce77173 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc.dm @@ -0,0 +1,665 @@ +/** + * # Human NPCs + * + * A human NPC is a real /mob/living/carbon/human driven by an AI (human_npc_ai.dm). Everything a + * player's body does, it does through the same tg code: wounds, bleeding, dismemberment, soft and hard + * crit, suffocation, stamina damage and stamcrit, stuns, cuffs, grabs, pulling, death, revival, + * organ decay, and the hyperviolence gore and executions. There are no stand-in states, health + * pools or heals. Phases B-E of the human NPC migration build pirates, troopers, hermits and bounty + * criminals on this type; the developer notes are in the kit vault (modules/human_npcs.md). + * + * Making one: spawn_human_npc() (human_npc_spawning.dm) queues it, so a wave is built a body or two + * per tick, never all at once. Maps place /obj/effect/spawner/human_npc. A subtype sets its look, + * outfit, name, faction, rank, toughness and death drops with the vars below. + * + * What differs from a player's body, and why. These are the only deviations: + * - Dormancy (performance). Life() does nothing while no player is on the NPC's z-level, alive or + * dead (npc_dormant()), the same test tg uses to switch the AI off. While dormant nothing in Life() + * runs: bleeding, wound and organ processing, metabolism, breathing, body temperature, pressure, + * crit oxygen loss, natural healing, disease. A dormant NPC is frozen in time; a wounded one doesn't + * bleed out until someone arrives. A corpse that wakes catches up the organ decay it missed while + * dormant (npc_catch_up_decay()), so the defib window and organ rot match a player's corpse; its + * body temperature is not caught up. Stamina recovery is a timer, not Life(), so it is not paused. + * A space suit's thermal regulator is switched off while it is dormant and back on when it wakes + * (npc_set_suit_heating()), so a cell doesn't run flat keeping a frozen-in-time body warm. + * This has to be a guard on this type's own Life(): COMSIG_LIVING_LIFE's cancel flag only stops + * /mob/living/Life(), and carbon and human Life() run organs, wounds, limbs and temperature around it. + * - No mood datum and TRAIT_NOHUNGER (performance, and an NPC never eats over a 6 hour round). + * - Gear lock (owner decision D3, human_npc_gear.dm): its kit can't be stripped, dropped or looted. + * - Aimed crits and gun executions only from elites and bosses (owner decision D4, npc_rank). + */ +/mob/living/carbon/human/npc + name = "stranger" + faction = list(FACTION_HOSTILE) + ai_controller = /datum/ai_controller/basic_controller/human_npc + /// What it wears and holds: an outfit typepath. Weapons go in its r_hand and l_hand, spare + /// magazines in pockets or belt_contents, an air tank with internals_slot set. + var/datum/outfit/npc_outfit + /** + * How it looks. Null: an ordinary random person of the default species. A + * /obj/effect/mob_spawn typepath: that spawner's species, hair and skin, and its outfit if + * npc_outfit is unset (the corpse spawners of today's NPCs). spawn_human_npc(look =) also takes a + * /datum/bounty_look or a /datum/bounty_record. + */ + var/npc_look + /// Its name. Null keeps the random name its look rolled. + var/npc_name + /// HUMAN_NPC_RANK_*. Only elites and bosses land aimed crits and gun executions. + var/npc_rank = HUMAN_NPC_RANK_REGULAR + /** + * Toughness: brute and burn damage taken is multiplied by this, through the body's physiology, + * as species and cyberware do it for players. Never touches stamina or stuns. 1 is a person. + * Change it on a live NPC with set_npc_toughness(). + */ + var/npc_damage_taken = 1 + /// A /datum/armor added to the body's own physiology armour (dermal plating on a player). Null for none. + var/datum/armor/npc_body_armor + /** + * Cyberware organ typepaths installed at spawn (voidcrew/modules/cyberware/). It works as it does + * in a player: a dodge shunt dodges, plating armours, a brownout browns out. It burns out to scrap + * when the NPC dies or the ware is taken out of it (owner ruling), so it is never salvaged working. + */ + var/list/npc_cyberware + /** + * Handles its own air the way a person does (npc_update_internals()): opens a tank it carries when + * the air it would breathe is bad and it has a mask or helmet that takes one, and closes the valve + * again once the air is fine, so its tank doesn't run dry in a breathable room. + */ + var/npc_internals = TRUE + /// Typepaths spawned under it the first time it dies (its designed loot). Never a second time. + var/list/npc_death_loot + /// Credits dropped as a holochip the first time it dies. + var/npc_death_cash = 0 + /// How long its corpse lies before SShuman_npcs may delete it once nobody is near. 0: never; the + /// system that owns the NPC cleans it up. + var/npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + /// Apply the dormancy and life trims. Only the measurement harness turns this off. + var/npc_trim_life = TRUE + + // ===== Per-family behaviour (phase A2). A family sets these on its own subtype. ===== + /** + * Picks its outfit by zone and rank when npc_outfit is unset: a /datum/human_npc_outfit_table typepath + * (human_npc_outfits.dm). spawn_human_npc(outfit =) also takes a table. + */ + var/datum/human_npc_outfit_table/npc_outfit_table + /// HUMAN_NPC_SITE_* flags it always dresses for, on top of the air where it spawns: a boarder that + /// crosses vacuum sets HUMAN_NPC_SITE_VACUUM so it carries a sealed suit even when it spawns indoors. + var/npc_always_site = NONE + /// What it says when things happen to it: a /datum/human_npc_voice typepath (human_npc_barks.dm), or + /// null for nothing. Override npc_bark() to voice it another way. + var/datum/human_npc_voice/npc_voice + /// When it gives up: a /datum/human_npc_surrender typepath (human_npc_surrender.dm), or null if it never does. + var/datum/human_npc_surrender/npc_surrender + /** + * The worst state a target can be in and still get attacked, written into its AI's + * BB_TARGET_MINIMUM_STAT at spawn: HUMAN_NPC_FINISHES_DOWNED (pirates, troopers, hermits) or + * HUMAN_NPC_SPARES_DOWNED (criminals, bosses, prisoners, guards). + */ + var/npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + /// D3: its kit is locked to its body. Prisoners turn this off (owner ruling OC-11): their jumpsuits, + /// contraband and shivs are ordinary items that shakedowns and confiscation work on. + var/npc_gear_locked = TRUE + /** + * How it fights stun weapons (human_npc_combat_sense.dm): its family's /datum/npc_combat_sense typepath, + * or null for none. Only armed hostiles set it, never civilians, townsfolk or prisoners. Its controller + * must list /datum/ai_planning_subtree/npc_combat_sense. + */ + var/datum/npc_combat_sense/npc_combat_sense + + /// The HUMAN_NPC_SITE_* flags it was dressed for + var/npc_site = NONE + /// The bark kind it last gave, for tests and admins + var/npc_last_bark + COOLDOWN_DECLARE(npc_bark_cooldown) + /// world.time it may next plead while down in soft crit + var/npc_next_plead = 0 + /// TRUE while the paralysis of its own *surrender runs + var/npc_surrendering = FALSE + /// Set when a surrender's paralysis ended with nobody cuffing it: it decides again at once + var/npc_surrender_redecide = FALSE + /// world.time it may next roll to surrender + var/npc_next_surrender_roll = 0 + /// How many times it has rolled to give up, and how many times it did + var/npc_surrender_rolls = 0 + var/npc_surrender_count = 0 + /// Weakref to whoever held it when it last resisted, and the world.time it did + var/datum/weakref/npc_resisted_hold + var/npc_resisted_at = 0 + /// The turf it is walking out to (npc_walk_out()), or null + var/turf/npc_exit + /// Called with it just before it is deleted on walking out + var/datum/callback/npc_on_left + + /// TRUE once its death loot has dropped + var/npc_loot_dropped = FALSE + /// world.time it last died, or 0 + var/npc_died_at = 0 + /// world.time its current dormant spell began, or 0 while awake + var/npc_dormant_since = 0 + /// The damage multiplier set_npc_toughness() last applied + var/npc_damage_taken_applied = 1 + /// Weakrefs to limbs and organs that came off it, cleaned up with its corpse + var/list/datum/weakref/npc_remains + +/mob/living/carbon/human/npc/Initialize(mapload, datum/human_npc_order/order) + . = ..() + if(npc_trim_life) + ADD_TRAIT(src, TRAIT_NOHUNGER, INNATE_TRAIT) + npc_dress(order) + for(var/ware_type in npc_cyberware) + npc_install_cyberware(ware_type) + npc_conceal_presence() + if(npc_gear_locked) + npc_lock_all_gear() + for(var/obj/item/held as anything in held_items) + if(held) + human_npc_ready_weapon(src, held) + npc_update_internals() + npc_set_suit_heating(TRUE) + set_npc_toughness(npc_damage_taken) + if(npc_body_armor) + physiology.armor = physiology.armor.add_other_armor(npc_body_armor) + if(istype(ai_controller)) + ai_controller.set_blackboard_key(BB_TARGET_MINIMUM_STAT, npc_target_stat) + AddElement(/datum/element/human_npc_body) + AddElement(/datum/element/human_npc_reactions) + if(ispath(npc_combat_sense, /datum/npc_combat_sense)) + AddComponent(/datum/component/npc_combat_sense, npc_combat_sense) + +/mob/living/carbon/human/npc/Destroy() + SShuman_npcs?.forget_corpse(src) + SShuman_npcs?.leavers -= src + npc_remains = null + npc_resisted_hold = null + npc_exit = null + npc_on_left = null + return ..() + +/mob/living/carbon/human/npc/setup_mood() + if(npc_trim_life) + return + return ..() + +// ===== DORMANCY ===== + +/mob/living/carbon/human/npc/Life(seconds_per_tick = SSMOBS_DT, times_fired) + if(npc_trim_life) + if(npc_dormant()) + if(!npc_dormant_since) + npc_dormant_since = world.time + npc_set_suit_heating(FALSE) + return FALSE + if(npc_dormant_since) + npc_catch_up_decay((world.time - npc_dormant_since) / (1 SECONDS), times_fired) + npc_dormant_since = 0 + npc_set_suit_heating(TRUE) + // Before the breath this Life() takes: a person who steps into bad air reaches for the valve. + if(stat != DEAD) + npc_update_internals() +#ifdef HUMAN_NPC_SPIKE_HARNESS + GLOB.human_npc_spike_life_passes++ +#endif + return ..() + +/// TRUE while this NPC's Life() should not run: no player is on its z-level. +/mob/living/carbon/human/npc/proc/npc_dormant() + return !npc_players_on_level() + +/// Whether any living player shares this NPC's z-level. Same test as get_expected_ai_status(). +/mob/living/carbon/human/npc/proc/npc_players_on_level() + var/turf/our_turf = get_turf(src) + if(isnull(our_turf)) + return FALSE + return length(SSmobs.clients_by_zlevel[our_turf.z]) > 0 + +/** + * A corpse that slept for `slept_seconds` decays its organs for that long at once, through tg's own + * handle_organs() (which skips frozen and robotic organs and bodies kept in formaldehyde or + * cryostylane), so its defib window closes when a player's would. A living NPC catches nothing up. + */ +/mob/living/carbon/human/npc/proc/npc_catch_up_decay(slept_seconds, times_fired) + if(stat != DEAD || slept_seconds <= 0 || HAS_TRAIT(src, TRAIT_STASIS)) + return + handle_organs(slept_seconds, times_fired) + +// ===== DRESSING ===== + +/** + * Puts on its look, outfit, name and faction. `order` may override the type's own. + * + * The outfit is, first found: the order's outfit, npc_outfit, a pick from npc_outfit_table, or the + * outfit of a spawner look. An outfit table (as the order's outfit or npc_outfit_table) picks by the + * zone it spawns in and its rank. Then the site layers go on (human_npc_outfits.dm): where the air + * can't keep it alive it gets a sealed suit and a tank, where it is cold it gets cold-weather wear, so + * no NPC suffocates or freezes on the spot it was made. Species that don't need them (skeletons in + * vacuum) are left as they are. + */ +/mob/living/carbon/human/npc/proc/npc_dress(datum/human_npc_order/order) + var/look = order?.look || npc_look + var/outfit = order?.outfit || npc_outfit + var/datum/human_npc_outfit_table/table + if(ispath(outfit, /datum/human_npc_outfit_table)) + table = outfit + outfit = null + else if(isnull(outfit) && ispath(npc_outfit_table, /datum/human_npc_outfit_table)) + table = npc_outfit_table + var/datum/bounty_record/record + if(istype(look, /datum/bounty_record)) + record = look + look = bounty_ensure_look(record) + var/spawner_outfit + if(ispath(look, /obj/effect/mob_spawn)) + spawner_outfit = npc_apply_spawner_look(look) + else if(istype(look, /datum/bounty_look)) + npc_apply_bounty_look(look) + else + npc_apply_random_look() + // Plasmamen and other species that need their own kit to live get it first, as from a spawner. + dna.species.give_important_for_life(src) + if(table) + table = human_npc_singleton(table) + outfit = table.pick_outfit(src, order ? order.resolve_zone() : npc_spawn_zone()) + outfit ||= spawner_outfit + // Dressed directly, not through /obj/effect/mob_spawn/equip(), whose ladder strip + // (voidcrew/edits/mobs/ladder_outfits.dm) would delete the guns an NPC fights with. + if(outfit) + outfit = npc_fit_outfit_to_site(outfit, table) + equipOutfit(outfit) + if(record) + bounty_wear_features(src, record.features) + var/new_name = order?.name || npc_name + if(new_name) + fully_replace_character_name(real_name, new_name) + if(length(order?.faction)) + faction = order.faction.Copy() + +/// An ordinary random person, as tg's corpse spawners make them. +/mob/living/carbon/human/npc/proc/npc_apply_random_look() + randomize_human_normie(src, update_body = FALSE) + update_body(is_creating = TRUE) + +/// The look of a /obj/effect/mob_spawn typepath, applied the way its special() applies it. Returns its outfit. +/mob/living/carbon/human/npc/proc/npc_apply_spawner_look(obj/effect/mob_spawn/spawner) + var/datum/species/species = initial(spawner.mob_species) + if(ispath(species, /datum/species) && dna.species.type != species) + set_species(species) + underwear = "Nude" + undershirt = "Nude" + socks = "Nude" + randomize_human_normie(src, update_body = FALSE) + var/hairstyle = initial(spawner.hairstyle) + if(hairstyle) + set_hairstyle(hairstyle, update = FALSE) + var/facial_hairstyle = initial(spawner.facial_hairstyle) + if(facial_hairstyle) + set_facial_hairstyle(facial_hairstyle, update = FALSE) + var/haircolor = initial(spawner.haircolor) + if(haircolor) + set_haircolor(haircolor, update = FALSE) + var/facial_haircolor = initial(spawner.facial_haircolor) + if(facial_haircolor) + set_facial_haircolor(facial_haircolor, update = FALSE) + var/spawner_skin = initial(spawner.skin_tone) + if(spawner_skin) + skin_tone = spawner_skin + update_body(is_creating = TRUE) + return initial(spawner.outfit) + +/// A bounty record's face (voidcrew/modules/bounties/), applied in the order bounty_dress_dummy() uses. +/mob/living/carbon/human/npc/proc/npc_apply_bounty_look(datum/bounty_look/look) + // Before the species: its visible organs take their shape from dna.features as they go in. + for(var/feature in look.features) + dna.features[feature] = look.features[feature] + gender = look.physique == FEMALE ? FEMALE : MALE + physique = gender + if(ispath(look.species, /datum/species) && dna.species.type != look.species) + set_species(look.species) + if(look.skin_tone) + skin_tone = look.skin_tone + set_hairstyle(look.hairstyle || "Bald", update = FALSE) + if(look.hair_color) + set_haircolor(look.hair_color, update = FALSE) + set_facial_hairstyle(look.facial_hairstyle || "Shaved", update = FALSE) + if(look.facial_hair_color || look.hair_color) + set_facial_haircolor(look.facial_hair_color || look.hair_color, update = FALSE) + if(look.eye_color) + set_eye_color(look.eye_color) + underwear = "Nude" + undershirt = "Nude" + socks = "Nude" + update_body(is_creating = TRUE) + +/// Keeps its PDA off messenger lists and its suit sensors off crew monitors, as corpse spawners do. +/mob/living/carbon/human/npc/proc/npc_conceal_presence() + var/obj/item/modular_computer/pda/messenger = locate() in src + if(messenger) + var/datum/computer_file/program/messenger/message_app = locate() in messenger.stored_files + if(message_app) + message_app.invisible = TRUE + var/obj/item/clothing/under/sensor_clothes = w_uniform + if(istype(sensor_clothes)) + sensor_clothes.sensor_mode = SENSOR_OFF + update_suit_sensors() + +/** + * Opens or closes its internals for the air where it stands, as a person minds their valve: open while + * the air would hurt it (too thin, short of what its lungs need, poisonous), closed while it is fine. A + * conscious NPC only; one that can't use its hands (cuffs, a stun) leaves the valve as it is, as would a + * person. Species that don't breathe, or that only live on what's in their tank (plasmamen), keep it + * as it is. Called from Life() and when it is dressed. + */ +/mob/living/carbon/human/npc/proc/npc_update_internals() + if(!npc_internals || HAS_TRAIT(src, TRAIT_NOBREATH)) + return + if(stat > SOFT_CRIT || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + return + var/obj/item/organ/lungs/lungs = get_organ_slot(ORGAN_SLOT_LUNGS) + if(isnull(lungs)) + return + // Lungs that need something ordinary air doesn't have: whatever it was dressed with stays on. + if(lungs.safe_plasma_min > 0 || lungs.safe_nitro_min > 0) + return + var/bad_air = human_npc_air_hurts(get_turf(src), lungs, margin = internal ? HUMAN_NPC_AIR_MARGIN : 0) + if(bad_air) + if(!internal) + npc_open_internals() + else if(internal && !external) + close_internals() + +/// Turns on an air tank it wears, if it has something to breathe it through, the way a player does. +/mob/living/carbon/human/npc/proc/npc_open_internals() + if(internal || !can_breathe_internals()) + return + for(var/obj/item/tank/internals/tank in get_equipped_items(INCLUDE_POCKETS | INCLUDE_HELD)) + if(tank.air_contents?.return_pressure() <= 0) + continue + if(open_internals(tank)) + return + +/** + * Whether breathing the air at `spot` would hurt someone with `lungs`: pressure too low or too high, + * too little oxygen, or too much plasma, carbon dioxide or sleeping gas, by those lungs' own limits. + * `margin` (kPa) asks for that much better than the limit before the air counts as fine again, so a + * valve isn't worked every breath at the edge. + */ +/proc/human_npc_air_hurts(turf/spot, obj/item/organ/lungs/lungs, margin = 0) + var/datum/gas_mixture/air = spot?.return_air() + if(isnull(air)) + return TRUE + var/pressure = air.return_pressure() + if(pressure < HAZARD_LOW_PRESSURE + margin * 4 || pressure > HAZARD_HIGH_PRESSURE) + return TRUE + if(lungs.safe_oxygen_min && human_npc_partial_pressure(air, /datum/gas/oxygen, pressure) < lungs.safe_oxygen_min + margin) + return TRUE + if(lungs.safe_plasma_max && human_npc_partial_pressure(air, /datum/gas/plasma, pressure) > max(lungs.safe_plasma_max - margin * 0.01, 0)) + return TRUE + if(lungs.safe_co2_max && human_npc_partial_pressure(air, /datum/gas/carbon_dioxide, pressure) > lungs.safe_co2_max - margin) + return TRUE + if(lungs.n2o_para_min && human_npc_partial_pressure(air, /datum/gas/nitrous_oxide, pressure) >= lungs.n2o_para_min) + return TRUE + return FALSE + +/** + * Sets how much brute and burn damage it takes, as a multiple of a person's. Scales the + * physiology mods it set before, so other changes to them (species, implants) are kept. + */ +/mob/living/carbon/human/npc/proc/set_npc_toughness(damage_taken) + if(damage_taken <= 0) + CRASH("[src] ([type]) was given a damage multiplier of [damage_taken]") + var/change = damage_taken / npc_damage_taken_applied + physiology.brute_mod *= change + physiology.burn_mod *= change + npc_damage_taken_applied = damage_taken + npc_damage_taken = damage_taken + +// ===== RULES OF ENGAGEMENT ===== + +// The strip menu works as on anyone, but locked gear (D3) carries TRAIT_NO_STRIP, so its slots read as +// empty and can't be emptied. What isn't gear (a ship key, bounty proof, a paper) can be taken from a +// cuffed, downed or careless NPC the way it could from a person. An NPC whose gear isn't locked +// (prisoners, OC-11) is searched and stripped like anyone. + +// D4: an NPC's hits become aimed crits and executions only if it is an elite or a boss, or a +// person is driving the body. +/mob/living/carbon/human/npc/hv_can_land_aimed_crits() + return client || npc_rank >= HUMAN_NPC_RANK_ELITE + +// Gun executions are rarer still: bosses only, as a deliberate move (owner ruling OC-9). +/mob/living/carbon/human/npc/hv_can_execute() + return client || npc_rank >= HUMAN_NPC_RANK_BOSS + +/mob/living/carbon/human/npc/Login() + . = ..() + // A player in control of the body (a brain transplant) can't keep its gear (D3). Admins + // driving an NPC for an event keep it. + if(!. || isnull(client) || client.holder) + return + npc_delete_locked_gear() + +// ===== DEATH AND REMAINS ===== + +/mob/living/carbon/human/npc/death(gibbed) + if(stat == DEAD) + return ..() + . = ..() + if(stat != DEAD) + return + npc_died_at = world.time + npc_drop_death_loot() + npc_drop_not_gear() + npc_burn_out_chrome() + if(!gibbed && npc_corpse_lifetime && !QDELING(src)) + SShuman_npcs.track_corpse(src) + +/// Drops its designed loot under it, once in its life however often it dies. +/mob/living/carbon/human/npc/proc/npc_drop_death_loot() + if(npc_loot_dropped) + return + npc_loot_dropped = TRUE + var/atom/drop_spot = drop_location() + if(isnull(drop_spot)) + return + // As death_drops does for basic mobs (voidcrew/edits/mobs/weapon_drops.dm): admin-spawned NPCs drop no guns. + var/no_guns = (flags_1 & ADMIN_SPAWNED_1) || npc_spawn_context?.suppress_gun_loot + for(var/loot_type in npc_roll_death_loot()) + if(no_guns && ispath(loot_type, /obj/item/gun)) + continue + new loot_type(drop_spot) + if(npc_death_cash > 0) + new /obj/item/holochip(drop_spot, npc_death_cash) + +/// The typepaths it drops on its first death. Override to roll them (zone ladders, rare pools). +/mob/living/carbon/human/npc/proc/npc_roll_death_loot() + return npc_death_loot + +/// Back from the dead (defib, strange reagent): no longer a corpse to clean up. +/mob/living/carbon/human/npc/proc/npc_revived() + npc_died_at = 0 + SShuman_npcs.forget_corpse(src) + +/// A limb or organ came off: remember it, so it goes when the corpse does. +/mob/living/carbon/human/npc/proc/npc_lost_part(obj/item/part) + LAZYADD(npc_remains, WEAKREF(part)) + +// ===== CYBERWARE ===== + +/** + * Installs a cyberware organ at spawn, the way the cyberware code's own staging inserts do + * (special = TRUE skips the install-context gate; the brownout monitor still applies). Returns the + * organ, or null if it didn't go in. + */ +/mob/living/carbon/human/npc/proc/npc_install_cyberware(ware_type) + var/obj/item/organ/ware = new ware_type() + if(!ware.GetComponent(/datum/component/cyberware)) + stack_trace("[type] was given [ware_type] as cyberware, which isn't chrome") + qdel(ware) + return null + if(!ware.Insert(src, special = TRUE)) + qdel(ware) + return null + return ware + +/// Owner ruling: an NPC's chrome burns out when it dies. Every piece comes out and turns to scrap. +/mob/living/carbon/human/npc/proc/npc_burn_out_chrome() + var/atom/drop_spot = drop_location() + var/burnt = 0 + for(var/obj/item/organ/ware in organs.Copy()) + if(!ware.GetComponent(/datum/component/cyberware)) + continue + ware.Remove(src, special = TRUE) + if(drop_spot) + ware.forceMove(drop_spot) + human_npc_burn_out_chrome(ware) + burnt++ + if(burnt && drop_spot) + do_sparks(min(burnt, 3), TRUE, src) + visible_message(span_warning("[src]'s chrome sparks and goes dead.")) + +/// Turns a piece of a human NPC's chrome that has left its body into scrap where it lies. +/proc/human_npc_burn_out_chrome(obj/item/organ/ware) + if(QDELETED(ware) || ware.owner) + return + var/turf/floor = get_turf(ware) + if(isnull(floor)) + qdel(ware) + return + var/mob/living/holder = ismob(ware.loc) ? ware.loc : null + var/obj/item/burnt_chrome/scrap = new(floor, ware) + qdel(ware) + holder?.put_in_hands(scrap) + +/// What a human NPC's cyberware leaves once it burns out: scrap, never a working implant. +/obj/item/burnt_chrome + name = "burnt-out chrome" + desc = "Cyberware fused black inside. Only good for scrap." + icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + icon_state = "scrapper" + w_class = WEIGHT_CLASS_SMALL + color = "#4a4a4a" + custom_materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT) + +/obj/item/burnt_chrome/Initialize(mapload, obj/item/organ/ware) + . = ..() + if(isnull(ware)) + return + name = "burnt-out [format_text(ware.name)]" + icon = ware.icon + icon_state = ware.icon_state + +/** + * Listens to a human NPC's body for the base type. An element, not RegisterSignal(src) on the mob, + * so a subtype can register these signals on itself without replacing the base's handlers. + */ +/datum/element/human_npc_body + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_body/Attach(datum/target) + . = ..() + if(!ishumannpc(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_CARBON_REMOVE_LIMB, PROC_REF(on_lost_limb)) + RegisterSignal(target, COMSIG_CARBON_LOSE_ORGAN, PROC_REF(on_lost_organ)) + RegisterSignal(target, COMSIG_LIVING_REVIVE, PROC_REF(on_revived)) + +/datum/element/human_npc_body/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_CARBON_REMOVE_LIMB, COMSIG_CARBON_LOSE_ORGAN, COMSIG_LIVING_REVIVE)) + return ..() + +/datum/element/human_npc_body/proc/on_lost_limb(mob/living/carbon/human/npc/source, obj/item/bodypart/limb, special, dismembered) + SIGNAL_HANDLER + if(!special) + source.npc_lost_part(limb) + +/datum/element/human_npc_body/proc/on_lost_organ(mob/living/carbon/human/npc/source, obj/item/organ/organ, special) + SIGNAL_HANDLER + // Chrome taken out of an NPC, by surgery, a lost limb, gibbing or dusting, burns out like it does at + // death. Next tick, once whatever moved it has put it down. + if(organ.GetComponent(/datum/component/cyberware)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(human_npc_burn_out_chrome), organ), 0) + return + if(!special) + source.npc_lost_part(organ) + +/datum/element/human_npc_body/proc/on_revived(mob/living/carbon/human/npc/source, full_heal_flags) + SIGNAL_HANDLER + source.npc_revived() + +/** + * Whether its corpse may be cleaned up now: dead for npc_corpse_lifetime, with no player within + * HUMAN_NPC_CORPSE_WATCH_RANGE, and not being dragged, carried or held. + */ +/mob/living/carbon/human/npc/proc/npc_corpse_expired() + if(stat != DEAD || !npc_corpse_lifetime || !npc_died_at) + return FALSE + if(world.time < npc_died_at + npc_corpse_lifetime) + return FALSE + if(pulledby || buckled || ismob(loc)) + return FALSE + return !human_npc_watched(src) + +/// Deletes its corpse, and the limbs and organs it lost that lie unwatched on the floor. +/mob/living/carbon/human/npc/proc/npc_fade() + for(var/datum/weakref/remain_ref as anything in npc_remains) + var/obj/item/remain = remain_ref.resolve() + if(QDELETED(remain) || !isturf(remain.loc) || remain.pulledby) + continue + var/obj/item/bodypart/limb = remain + if(istype(limb) && limb.owner) + continue + var/obj/item/organ/organ = remain + if(istype(organ) && organ.owner) + continue + if(human_npc_watched(remain)) + continue + qdel(remain) + npc_remains = null + qdel(src) + +/// Whether a player is within HUMAN_NPC_CORPSE_WATCH_RANGE of `thing`. +/proc/human_npc_watched(atom/thing) + var/turf/spot = get_turf(thing) + if(isnull(spot) || !length(SSmobs.clients_by_zlevel[spot.z])) + return FALSE + for(var/mob/viewer as anything in SSspatial_grid.orthogonal_range_search(spot, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, HUMAN_NPC_CORPSE_WATCH_RANGE)) + if(get_dist(viewer, spot) <= HUMAN_NPC_CORPSE_WATCH_RANGE) + return TRUE + return FALSE + +/// Whether tg weather should act on `candidate`, a mob that has never had a mind: a human NPC that +/// isn't dormant. Read by the mind gates in tg's weather code. +/proc/human_npc_weathered(mob/living/candidate) + if(!ishumannpc(candidate)) + return FALSE + var/mob/living/carbon/human/npc/npc = candidate + return !npc.npc_dormant() + +/// Shared instances of the per-family rule datums (voices, surrender rules, outfit tables, site layers), +/// one per type, made on first use. +GLOBAL_LIST_EMPTY(human_npc_singletons) + +/// The shared instance of `datum_type`. +/proc/human_npc_singleton(datum_type) + var/datum/instance = GLOB.human_npc_singletons[datum_type] + if(isnull(instance)) + instance = new datum_type() + GLOB.human_npc_singletons[datum_type] = instance + return instance + +/** + * The zone it was made in, for outfit tables: its spawn context's zone, else the zone of where it + * stands (a ship's overmap tile when aboard one), else green, the weakest. + */ +/mob/living/carbon/human/npc/proc/npc_spawn_zone() + return npc_spawn_context?.zone_type || get_zone_type_for_player_turf(get_turf(src)) || ZONE_GREEN + +/** + * Switches its space suit's thermal regulator on or off, as a player does with the suit's button. On only + * where it was dressed for vacuum or cold, and never for a corpse. Off while dormant (see the top of this + * file). + */ +/mob/living/carbon/human/npc/proc/npc_set_suit_heating(on) + if(on && (stat == DEAD || !(npc_site & (HUMAN_NPC_SITE_VACUUM | HUMAN_NPC_SITE_COLD)))) + return + var/obj/item/clothing/suit/space/suit = wear_suit + if(istype(suit) && suit.thermal_on != !!on) + suit.toggle_spacesuit(src) diff --git a/voidcrew/modules/human_npcs/human_npc_admin.dm b/voidcrew/modules/human_npcs/human_npc_admin.dm new file mode 100644 index 00000000000..88da89ce822 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_admin.dm @@ -0,0 +1,30 @@ +// Admin debug tool: spawn human NPCs around yourself, through the same throttled queue the game uses. + +ADMIN_VERB(spawn_human_npcs, R_SPAWN|R_DEBUG, "Spawn Human NPCs", "Spawn human NPCs of one type around you, built a few per tick.", ADMIN_CATEGORY_DEBUG) + var/turf/center = get_turf(user.mob) + if(isnull(center)) + to_chat(user, span_warning("You need to be standing somewhere."), confidential = TRUE) + return + var/list/choices = list() + for(var/npc_type in subtypesof(/mob/living/carbon/human/npc)) + var/mob/living/carbon/human/npc/npc_path = npc_type + choices["[initial(npc_path.npc_name) || initial(npc_path.name)] ([npc_type])"] = npc_type + var/choice = tgui_input_list(user, "Which human NPC?", "Spawn Human NPCs", sort_list(choices)) + if(isnull(choice) || isnull(choices[choice])) + return + var/count = tgui_input_number(user, "How many?", "Spawn Human NPCs", 1, 50, 1) + if(!count) + return + var/list/spots = list() + for(var/turf/open/spot in RANGE_TURFS(3, center)) + if(!spot.is_blocked_turf(exclude_mobs = TRUE)) + spots += spot + if(!length(spots)) + spots += center + var/npc_type = choices[choice] + var/datum/callback/mark = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(human_npc_mark_admin_spawned)) + for(var/i in 1 to count) + spawn_human_npc(npc_type, pick(spots), on_spawned = mark) + log_admin("[key_name(user)] spawned [count] [npc_type] at [AREACOORD(center)]") + message_admins("[key_name_admin(user)] spawned [count] [npc_type] at [ADMIN_VERBOSEJMP(center)]") + BLACKBOX_LOG_ADMIN_VERB("Spawn Human NPCs") diff --git a/voidcrew/modules/human_npcs/human_npc_ai.dm b/voidcrew/modules/human_npcs/human_npc_ai.dm new file mode 100644 index 00000000000..4d6675b26b8 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_ai.dm @@ -0,0 +1,540 @@ +/** + * # Human NPC AI + * + * Fights through the body's own hands, the way a player does. Everything goes through + * controller.ai_interact(), which is a player's click (tg's monkey AI and ranged_skirmish use it too): + * - a gun is fired by clicking the target with it, so the gun's real fire path runs: fire delay, + * firing pin, projectile, casing, recoil, wounds and the hyperviolence executions; + * - a melee weapon (or a fist) hits through the real attack chain; + * - a reload is the player's reload: the spare magazine comes out of a pocket, belt or bag into the + * free hand, the gun is clicked with it (tg's insert or tactical reload), the bolt is racked by + * using the gun, and the empty magazine is dropped. Nothing refills from nowhere. When the spares + * run out it draws another weapon it carries, or fights with what it holds. + * + * tg's basic controller already stops the AI at any stat above CONSCIOUS and while stunned, knocked + * out, in stamcrit or in stasis. It also stops while cuffed, which would keep tg's escape_captivity + * subtree from ever working the cuffs; get_able_to_run() below lets a cuffed or grabbed NPC struggle the + * way a player does (resist), and do_after()s pause it so a breakout isn't cut short by walking. It also + * lets the AI run in soft crit, where only the soft-crit subtree plans (human_npc_soft_crit.dm). + * + * The AI goes off with the body's dormancy: tg switches it off while no player is on the z-level, the + * same test human_npc.dm uses for Life(). + * + * Planning order: soft crit (plead, medipen, elites fire wild), escape captivity, walk out (human_npc_states.dm), + * surrender (human_npc_surrender.dm), then find a target, combat sense for the types that have it + * (human_npc_combat_sense.dm), arm, shoot, melee. + * + * Phase B (PR #500's pirate crews) can drop these subtrees into its own planning lists: + * human_npc_arm, human_npc_shoot and human_npc_melee need only BB_BASIC_MOB_CURRENT_TARGET and + * BB_TARGETING_STRATEGY; the shooting keys have defaults. The helper procs at the bottom are for + * any behaviour that has to handle a human NPC's weapons. + */ + +/datum/ai_controller/basic_controller/human_npc + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + BB_AGGRO_RANGE = HUMAN_NPC_SIGHT_RANGE, + BB_HUMAN_NPC_SHOT_DELAY = 3 SECONDS, + BB_HUMAN_NPC_FIRE_RANGE = 7, + BB_HUMAN_NPC_KEEP_AWAY = 2, + ) + ai_movement = /datum/ai_movement/basic_avoidance + idle_behavior = /datum/idle_behavior/idle_random_walk/less_walking + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/human_npc_surrender, + /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/human_npc_arm, + /datum/ai_planning_subtree/human_npc_shoot, + /datum/ai_planning_subtree/human_npc_melee, + ) + +/datum/ai_controller/basic_controller/human_npc/TryPossessPawn(atom/new_pawn) + if(!ishuman(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + // Relays who hit us, so we turn on someone shooting from where we weren't looking + new_pawn.AddElement(/datum/element/relay_attackers) + RegisterSignal(new_pawn, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_pawn_attacked)) + return ..() + +/datum/ai_controller/basic_controller/human_npc/UnpossessPawn(destroy) + if(pawn) + UnregisterSignal(pawn, COMSIG_ATOM_WAS_ATTACKED) + return ..() + +// The basic controller's own check, except that cuffs and soft crit don't stop us (see the top of this +// file, and human_npc_soft_crit.dm). +/datum/ai_controller/basic_controller/human_npc/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/human_npc/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + +/// Someone hit us: with nothing else to fight, fight them, if we can see them. +/datum/ai_controller/basic_controller/human_npc/proc/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(source == attacker || QDELETED(attacker) || blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(strategy?.can_attack(source, attacker)) + set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, attacker) + +// ===== TARGETING ===== + +/** + * tg's basic targeting, but a check made without a range (target finding filters its candidates + * that way) uses the NPC's sight range instead of can_see()'s default of 5 tiles. It also never + * picks someone in another packed encounter on the same z-level, who can't be reached across the + * cordon (the containment the old simple-animal hostiles have in ListTargetsLazy()). + */ +/datum/targeting_strategy/basic/human_npc + +/datum/targeting_strategy/basic/human_npc/can_attack(mob/living/living_mob, atom/the_target, vision_range) + if(isnull(vision_range)) + vision_range = living_mob.ai_controller?.blackboard[BB_AGGRO_RANGE] || HUMAN_NPC_SIGHT_RANGE + if(!..(living_mob, the_target, vision_range)) + return FALSE + return !map_region_excludes_turf(map_region_for_turf(get_turf(living_mob)), get_turf(the_target)) + +// ===== ARMING: RELOAD OR DRAW ===== + +/// Keeps a usable weapon in hand: reloads a spent gun from the magazines it carries, or draws another weapon. +/datum/ai_planning_subtree/human_npc_arm + +/datum/ai_planning_subtree/human_npc_arm/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/npc = controller.pawn + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + return + if(world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(gun?.can_shoot()) + return + if(human_npc_can_reload(npc, gun)) + controller.queue_behavior(/datum/ai_behavior/human_npc_reload) + return SUBTREE_RETURN_FINISH_PLANNING + if(human_npc_held_melee_weapon(npc)) + return + var/obj/item/backup = human_npc_backup_weapon(npc) + if(isnull(backup)) + return + controller.set_blackboard_key(BB_HUMAN_NPC_DRAW_ITEM, backup) + controller.queue_behavior(/datum/ai_behavior/human_npc_draw, BB_HUMAN_NPC_DRAW_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * A player's reload, one step per action: drop the spent magazine, rack a loaded gun, take a spare + * into the free hand, click the gun with it. Fails, and the NPC fights on with something else, if + * it has no spare or no hand free. + */ +/datum/ai_behavior/human_npc_reload + action_cooldown = 0.8 SECONDS + /// Gives up after this long, so a gun that won't take what it carries can't hold the NPC forever + var/give_up_after = 8 SECONDS + +/datum/ai_behavior/human_npc_reload/setup(datum/ai_controller/controller) + . = ..() + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_UNTIL, world.time + give_up_after) + npc.visible_message(span_notice("[npc] reloads [gun].")) + +/datum/ai_behavior/human_npc_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun) || world.time >= controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL]) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/spent = human_npc_spent_ammo_in_hand(npc, gun) + if(spent) + npc.dropItemToGround(spent, force = TRUE) + return AI_BEHAVIOR_DELAY + if(gun.can_shoot()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(human_npc_gun_needs_rack(gun)) + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + var/obj/item/ammo = human_npc_find_ammo(npc, gun) + if(isnull(ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(ammo)) + if(!human_npc_take_in_hand(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/human_npc_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + // A reload that got nowhere isn't tried again straight away; the NPC fights with what it has. + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, succeeded ? 0 : world.time + 10 SECONDS) + +/// Takes another weapon it carries into hand: a loaded gun, or failing that a melee weapon. +/datum/ai_behavior/human_npc_draw + action_cooldown = 0.6 SECONDS + +/datum/ai_behavior/human_npc_draw/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/weapon = controller.blackboard[item_key] + if(QDELETED(weapon) || human_npc_holding(weapon) != npc) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(weapon)) + if(!length(npc.get_empty_held_indexes())) + // Both hands full: holster the spent gun first, if it has somewhere to go. + var/obj/item/gun/spent = human_npc_held_gun(npc) + if(isnull(spent) || spent.can_shoot() || !spent.equip_to_best_slot(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(npc, weapon)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + human_npc_ready_item(npc, weapon) + human_npc_ready_weapon(npc, weapon) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/datum/ai_behavior/human_npc_draw/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + if(!succeeded) + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 10 SECONDS) + +// ===== SHOOTING ===== + +/** + * With a loaded gun in hand: shoots the target from up to BB_HUMAN_NPC_FIRE_RANGE, stepping back + * from anyone closer than BB_HUMAN_NPC_KEEP_AWAY, and closes in when it can't see them or they are + * too far. + */ +/datum/ai_planning_subtree/human_npc_shoot + +/datum/ai_planning_subtree/human_npc_shoot/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(QDELETED(target)) + return + var/mob/living/carbon/npc = controller.pawn + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || isnull(human_npc_loaded_gun(npc))) + return + var/fire_range = controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + var/distance = get_dist(npc, target) + if(distance <= fire_range && can_see(npc, target, fire_range)) + var/keep_away = controller.blackboard[BB_HUMAN_NPC_KEEP_AWAY] + if(keep_away && distance < keep_away && !LAZYLEN(npc.do_afters)) + controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/human_npc_shoot, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + else + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, fire_range) + return SUBTREE_RETURN_FINISH_PLANNING + +/// One pull of the trigger, then a wait of BB_HUMAN_NPC_SHOT_DELAY. Doesn't hold up planning. +/datum/ai_behavior/human_npc_shoot + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_shoot/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(QDELETED(target) || !strategy?.can_attack(npc, target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + // Empty now: the next plan reloads or changes weapon. + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_SHOT]) + return AI_BEHAVIOR_DELAY + var/atom/hiding_target = strategy.find_hidden_mobs(npc, target) + controller.set_blackboard_key(hiding_location_key, hiding_target) + var/atom/final_target = hiding_target || target + if(human_npc_ally_in_line(npc, final_target)) + human_npc_sidestep(npc, final_target) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + npc.zone_selected = human_npc_pick_aim_zone() + controller.ai_interact(target = final_target, combat_mode = TRUE) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + (controller.blackboard[BB_HUMAN_NPC_SHOT_DELAY] || 3 SECONDS)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + +/// Walks at the target until it is within `range` and in sight. +/datum/ai_behavior/human_npc_close_in + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_close_in/setup(datum/ai_controller/controller, target_key, range) + . = ..() + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target)) + return FALSE + set_movement_target(controller, target) + +/datum/ai_behavior/human_npc_close_in/perform(seconds_per_tick, datum/ai_controller/controller, target_key, range) + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(get_dist(controller.pawn, target) <= range && can_see(controller.pawn, target, range)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_INSTANT + +// ===== MELEE ===== + +/// With no loaded gun: walks up and hits with the best melee weapon held, or a fist. +/datum/ai_planning_subtree/human_npc_melee + +/datum/ai_planning_subtree/human_npc_melee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/human_npc, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + +// tg's basic melee attack already clicks the target through ai_interact(); this picks what to hit with and where. +/datum/ai_behavior/basic_melee_attack/human_npc + +/datum/ai_behavior/basic_melee_attack/human_npc/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/weapon = human_npc_held_melee_weapon(npc) + if(weapon) + human_npc_ready_item(npc, weapon) + human_npc_ready_weapon(npc, weapon) // a two-hander let go of with one hand is gripped again + else + human_npc_ready_empty_hand(npc) + npc.zone_selected = human_npc_pick_aim_zone() + return ..() + +// ===== HELPERS ===== + +/// Body zones an NPC aims at, weighted. A player picks their own zone; an NPC's hits spread out. +/proc/human_npc_pick_aim_zone() + var/static/list/aim_zones = list( + BODY_ZONE_CHEST = 40, + BODY_ZONE_HEAD = 15, + BODY_ZONE_L_ARM = 10, + BODY_ZONE_R_ARM = 10, + BODY_ZONE_L_LEG = 10, + BODY_ZONE_R_LEG = 10, + BODY_ZONE_PRECISE_GROIN = 5, + ) + return pick_weight(aim_zones) + +/// Makes `item` the active hand. Returns FALSE if `npc` isn't holding it. +/proc/human_npc_ready_item(mob/living/carbon/npc, obj/item/item) + if(isnull(item)) + return FALSE + if(npc.get_active_held_item() == item) + return TRUE + var/index = npc.get_held_index_of_item(item) + if(!index) + return FALSE + npc.swap_hand(index, silent = TRUE) + return npc.get_active_held_item() == item + +/// Makes an empty hand the active one, for a punch. Returns FALSE if both hands are full. +/proc/human_npc_ready_empty_hand(mob/living/carbon/npc) + if(isnull(npc.get_active_held_item())) + return TRUE + var/list/empty = npc.get_empty_held_indexes() + if(!length(empty)) + return FALSE + npc.swap_hand(empty[1], silent = TRUE) + return isnull(npc.get_active_held_item()) + +/// Switches on or wields a weapon in hand the way its user would: energy blades, batons, two-handed weapons. +/proc/human_npc_ready_weapon(mob/living/carbon/npc, obj/item/weapon) + if(QDELETED(weapon) || !npc.is_holding(weapon)) + return + if(weapon.GetComponent(/datum/component/transforming)) + if(!HAS_TRAIT(weapon, TRAIT_TRANSFORM_ACTIVE)) + weapon.attack_self(npc) + return + if(istype(weapon, /obj/item/melee/baton/security)) + var/obj/item/melee/baton/security/baton = weapon + if(!baton.active) + baton.attack_self(npc) + return + if(weapon.GetComponent(/datum/component/two_handed) && !HAS_TRAIT(weapon, TRAIT_WIELDED) && length(npc.get_empty_held_indexes())) + // Wielding takes the weapon's own hand as the active one and the other hand free, as for a player + if(human_npc_ready_item(npc, weapon)) + weapon.attack_self(npc) + +/// The gun it holds, preferring one that can fire. +/proc/human_npc_held_gun(mob/living/carbon/npc) + var/obj/item/gun/found + for(var/obj/item/gun/gun in npc.held_items) + if(gun.can_shoot()) + return gun + found ||= gun + return found + +/// A gun it holds that can fire right now, or null. +/proc/human_npc_loaded_gun(mob/living/carbon/npc) + for(var/obj/item/gun/gun in npc.held_items) + if(gun.can_shoot()) + return gun + return null + +/// Whether `item` is any use as a melee weapon. +/proc/human_npc_is_melee_weapon(obj/item/item) + if(isnull(item) || isgun(item) || isbodypart(item) || isorgan(item) || (item.item_flags & ABSTRACT)) + return FALSE + // Energy blades and batons are weak until switched on. + return human_npc_melee_force(item) >= 10 || item.GetComponent(/datum/component/transforming) || istype(item, /obj/item/melee/baton) + +/// What `item` hits for once readied: a two-handed weapon (a fireaxe) counts wielded, as human_npc_ready_weapon() swings it. +/proc/human_npc_melee_force(obj/item/item) + var/datum/component/two_handed/two_handed = item.GetComponent(/datum/component/two_handed) + if(two_handed && !HAS_TRAIT(item, TRAIT_WIELDED)) + return max(item.force, two_handed.force_wielded, item.force * two_handed.force_multiplier) + return item.force + +/// The best melee weapon it holds, or null. +/proc/human_npc_held_melee_weapon(mob/living/carbon/npc) + var/obj/item/best + for(var/obj/item/held in npc.held_items) + if(human_npc_is_melee_weapon(held) && (isnull(best) || human_npc_melee_force(held) > human_npc_melee_force(best))) + best = held + return best + +/// Something better to fight with that it carries but doesn't hold: a loaded gun first, then the strongest melee weapon. +/proc/human_npc_backup_weapon(mob/living/carbon/npc) + var/obj/item/best_melee + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(npc.is_holding(thing) || !human_npc_can_draw(npc, thing)) + continue + if(isgun(thing)) + var/obj/item/gun/spare = thing + if(spare.can_shoot()) + return spare + continue + if(human_npc_is_melee_weapon(thing) && (isnull(best_melee) || human_npc_melee_force(thing) > human_npc_melee_force(best_melee))) + best_melee = thing + return best_melee + +/// Whether `item` is somewhere a person can take it from in a moment: a worn slot, or a bag or pouch it wears. +/proc/human_npc_can_draw(mob/living/carbon/npc, obj/item/item) + if(isbodypart(item) || isorgan(item) || istype(item, /obj/item/implant)) + return FALSE + if(item.loc == npc) + return !isnull(npc.get_slot_by_item(item)) + var/obj/item/container = item.loc + return istype(container) && !isnull(container.atom_storage) && human_npc_holding(container) == npc + +/// Takes `item` out of a slot or a bag into a free hand. Returns TRUE if it ends up held. +/proc/human_npc_take_in_hand(mob/living/carbon/npc, obj/item/item) + if(npc.is_holding(item)) + return TRUE + if(!length(npc.get_empty_held_indexes()) || !human_npc_can_draw(npc, item)) + return FALSE + if(item.loc == npc) + if(!npc.temporarilyRemoveItemFromInventory(item)) + return FALSE + else + var/obj/item/container = item.loc + if(!container.atom_storage.attempt_remove(item, npc, silent = TRUE)) + return FALSE + if(npc.put_in_hands(item)) + return TRUE + npc.equip_to_appropriate_slot(item) + return FALSE + +/// Live rounds in a magazine, box or casing. +/proc/human_npc_live_rounds(obj/item/ammo) + if(isammocasing(ammo)) + var/obj/item/ammo_casing/casing = ammo + return casing.loaded_projectile ? 1 : 0 + if(istype(ammo, /obj/item/ammo_box/magazine)) + var/obj/item/ammo_box/magazine/magazine = ammo + return magazine.ammo_count(FALSE) + var/obj/item/ammo_box/box = ammo + if(!istype(box)) + return 0 + . = 0 + for(var/obj/item/ammo_casing/round as anything in box.stored_ammo) + if(ispath(round) || round.loaded_projectile) + .++ + +/// Whether a ballistic gun has live rounds loaded but none chambered, so using it (rack, pump, drop the bolt) readies it. +/proc/human_npc_gun_needs_rack(obj/item/gun/ballistic/gun) + if(gun.bolt_type == BOLT_TYPE_NO_BOLT || gun.chambered?.loaded_projectile) + return FALSE + return gun.magazine && human_npc_live_rounds(gun.magazine) > 0 + +/// Spare ammunition it carries that `gun` takes: a loaded magazine for a detachable-magazine gun, +/// or a box, clip, speedloader or loose round of its calibre for one with an internal magazine. +/proc/human_npc_find_ammo(mob/living/carbon/npc, obj/item/gun/ballistic/gun) + if(!istype(gun)) + return null + // Whatever is already in hand first, then pockets, belt and bags. + for(var/obj/item/held in npc.held_items) + if(held != gun && human_npc_ammo_fits(gun, held)) + return held + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(npc.is_holding(thing) || thing == gun.magazine || thing.loc == gun || !human_npc_can_draw(npc, thing)) + continue + if(human_npc_ammo_fits(gun, thing)) + return thing + return null + +/// Whether `ammo` loads `gun` and has a live round in it. +/proc/human_npc_ammo_fits(obj/item/gun/ballistic/gun, obj/item/ammo) + if(!gun.internal_magazine) + return istype(ammo, gun.accepted_magazine_type) && human_npc_live_rounds(ammo) > 0 + var/caliber = gun.magazine?.caliber + if(isnull(caliber)) + return FALSE + if(istype(ammo, /obj/item/ammo_box)) + var/obj/item/ammo_box/box = ammo + return box.caliber == caliber && human_npc_live_rounds(box) > 0 + if(isammocasing(ammo)) + var/obj/item/ammo_casing/casing = ammo + return casing.caliber == caliber && !isnull(casing.loaded_projectile) + return FALSE + +/// Whether it carries anything that makes `gun` fire again. +/proc/human_npc_can_reload(mob/living/carbon/npc, obj/item/gun/ballistic/gun) + if(!istype(gun)) + return FALSE + return human_npc_gun_needs_rack(gun) || !isnull(human_npc_find_ammo(npc, gun)) + +/// A spent magazine, empty box or spent casing in its hands that's no use to `gun`. +/proc/human_npc_spent_ammo_in_hand(mob/living/carbon/npc, obj/item/gun/ballistic/gun) + for(var/obj/item/held in npc.held_items) + if(held == gun || !(istype(held, /obj/item/ammo_box) || isammocasing(held))) + continue + if(!human_npc_live_rounds(held)) + return held + return null + +/// Whether an ally stands between `shooter` and `target`. Shots pass over anyone lying down. +/proc/human_npc_ally_in_line(mob/living/shooter, atom/target) + var/turf/shooter_turf = get_turf(shooter) + var/turf/target_turf = get_turf(target) + for(var/turf/line_turf as anything in get_line(shooter_turf, target_turf)) + if(line_turf == shooter_turf || line_turf == target_turf) + continue + for(var/mob/living/other in line_turf) + if(other.stat == DEAD || other.body_position == LYING_DOWN) + continue + if(shooter.faction_check_atom(other)) + return TRUE + return FALSE + +/// One step sideways, keeping no further from the target, for a clear shot. +/proc/human_npc_sidestep(mob/living/shooter, atom/target) + var/turf/our_turf = get_turf(shooter) + var/list/possible_turfs = list() + for(var/direction in GLOB.alldirs) + var/turf/step_turf = get_step(our_turf, direction) + if(isnull(step_turf) || step_turf.is_blocked_turf() || get_dist(step_turf, target) > get_dist(shooter, target)) + continue + possible_turfs += step_turf + if(!length(possible_turfs)) + return + var/turf/picked = pick(possible_turfs) + step(shooter, get_dir(shooter, picked)) diff --git a/voidcrew/modules/human_npcs/human_npc_barks.dm b/voidcrew/modules/human_npcs/human_npc_barks.dm new file mode 100644 index 00000000000..4aa4ea541e8 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_barks.dm @@ -0,0 +1,203 @@ +/** + * # Human NPC barks (N3) + * + * What a human NPC says when something real happens to its body. Every hook hangs on a tg state change, + * never on a stand-in state: entering crit (TRAIT_CRITICAL_CONDITION), leaving crit or getting up from + * stamcrit, handcuffs going on and coming off (TRAIT_RESTRAINED), starting to work at its cuffs (tg's + * cuff_resist(), COMSIG_MOB_REMOVING_CUFFS), breaking out of a grab or off someone's shoulders by + * resisting, giving up (human_npc_surrender.dm) and dying. + * + * A family supplies its lines with a /datum/human_npc_voice subtype on the NPC's npc_voice. A voice has + * spoken `lines` and third-person `emotes` ("collapses.") per kind (HUMAN_NPC_BARK_*, or any kind of the + * family's own: npc_bark("alert") works the same). Speech goes through say() without `forced`, so a gag or + * a mute works on an NPC as on a player, and tg whispers it in soft crit. In hard crit and after death it + * can't speak, so only the emote shows. One NPC barks at most once per voice `cooldown`; surrender and + * death always come through. A family that already has its own voice system (bounty criminals' + * bounty_say()) overrides npc_bark() instead. + * + * Death also plays the hyperviolence death throes (HV-21, hv_death_throes(), a shudder as the body + * falls). The deathgasp itself is tg's: carbons always give it. + */ + +/// Says or shows its line for `kind` (HUMAN_NPC_BARK_*). `force` skips the cooldown and the voice's chance. +/// Returns TRUE if it said or did anything. Override to voice a family another way. +/mob/living/carbon/human/npc/proc/npc_bark(kind, force = FALSE) + if(QDELETED(src) || !ispath(npc_voice, /datum/human_npc_voice)) + return FALSE + var/datum/human_npc_voice/voice = human_npc_singleton(npc_voice) + return voice.bark(src, kind, force) + +/** + * A family's lines. Lists are kind => list of strings. Emotes are third person without the name + * ("collapses.", "groans and tries to get up."). Keep them short and in character (kit rules, "Writing + * for players"): no mechanics, no hints. + */ +/datum/human_npc_voice + /// Spoken lines by kind + var/list/lines = list() + /// Emotes by kind, shown before the line, and alone when it can't speak + var/list/emotes = list() + /// Percent chance it barks at all, by kind. A kind not listed always barks (cooldown allowing). + var/list/chances = list() + /// Seconds between two barks from one NPC + var/cooldown = HUMAN_NPC_BARK_COOLDOWN + +/// The spoken lines for `kind`. Pleading falls back to the downed lines. +/datum/human_npc_voice/proc/lines_for(kind) + . = lines[kind] + if(!length(.) && kind == HUMAN_NPC_BARK_PLEAD) + . = lines[HUMAN_NPC_BARK_DOWNED] + +/// The emotes for `kind`. +/datum/human_npc_voice/proc/emotes_for(kind) + return emotes[kind] + +/datum/human_npc_voice/proc/bark(mob/living/carbon/human/npc/speaker, kind, force = FALSE) + if(!force && !COOLDOWN_FINISHED(speaker, npc_bark_cooldown)) + return FALSE + if(!force && (kind in chances) && !prob(chances[kind])) + return FALSE + var/list/acts = emotes_for(kind) + var/list/spoken = lines_for(kind) + var/can_talk = length(spoken) && human_npc_can_talk(speaker) + if(!length(acts) && !can_talk) + return FALSE + if(length(acts)) + speaker.visible_message(span_emote("[speaker] [pick(acts)]")) + if(can_talk) + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), pick(spoken)) + speaker.npc_last_bark = kind + COOLDOWN_START(speaker, npc_bark_cooldown, cooldown) + return TRUE + +/// Whether `speaker` can speak out loud or whisper now. Never in hard crit: tg turns that into last words. +/proc/human_npc_can_talk(mob/living/speaker) + if(speaker.stat != CONSCIOUS && speaker.stat != SOFT_CRIT) + return FALSE + return speaker.can_speak() + +/** + * Relays the tg state changes the barks hang on to the NPC's own procs, so a family can override what + * happens (npc_on_downed() and so on) without touching the signals. An element, so a subtype can listen + * to the same signals on itself. + */ +/datum/element/human_npc_reactions + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_reactions/Attach(datum/target) + . = ..() + if(!ishumannpc(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), PROC_REF(on_crit_started)) + RegisterSignal(target, SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), PROC_REF(on_crit_ended)) + RegisterSignal(target, COMSIG_LIVING_STATUS_REMOVED, PROC_REF(on_status_removed)) + RegisterSignal(target, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), PROC_REF(on_restrained)) + RegisterSignal(target, SIGNAL_REMOVETRAIT(TRAIT_RESTRAINED), PROC_REF(on_unrestrained)) + RegisterSignal(target, COMSIG_MOB_REMOVING_CUFFS, PROC_REF(on_working_cuffs)) + RegisterSignal(target, COMSIG_LIVING_RESIST, PROC_REF(on_resist)) + RegisterSignal(target, COMSIG_ATOM_NO_LONGER_PULLED, PROC_REF(on_let_go)) + RegisterSignal(target, COMSIG_LIVING_SET_BUCKLED, PROC_REF(on_buckled)) + RegisterSignal(target, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/element/human_npc_reactions/Detach(datum/source) + UnregisterSignal(source, list( + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), + COMSIG_LIVING_STATUS_REMOVED, + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), + SIGNAL_REMOVETRAIT(TRAIT_RESTRAINED), + COMSIG_MOB_REMOVING_CUFFS, + COMSIG_LIVING_RESIST, + COMSIG_ATOM_NO_LONGER_PULLED, + COMSIG_LIVING_SET_BUCKLED, + COMSIG_LIVING_DEATH, + )) + return ..() + +/datum/element/human_npc_reactions/proc/on_crit_started(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + if(source.stat != DEAD) + source.npc_on_downed() + +/datum/element/human_npc_reactions/proc/on_crit_ended(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + // tg drops the trait on death too; only a living body that came out of crit has recovered. + if(source.stat != DEAD && !QDELING(source)) + source.npc_on_recovered() + +/datum/element/human_npc_reactions/proc/on_status_removed(mob/living/carbon/human/npc/source, datum/status_effect/effect) + SIGNAL_HANDLER + if(source.stat == DEAD || QDELING(source)) + return + if(istype(effect, /datum/status_effect/incapacitating/stamcrit)) + if(source.stat == CONSCIOUS) + source.npc_on_recovered() + return + if(istype(effect, /datum/status_effect/incapacitating/paralyzed) && source.npc_surrendering) + source.npc_surrender_ended() + +/datum/element/human_npc_reactions/proc/on_restrained(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + if(source.handcuffed && source.stat != DEAD) + source.npc_bark(HUMAN_NPC_BARK_CUFFED) + +/datum/element/human_npc_reactions/proc/on_unrestrained(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + if(source.stat != DEAD && !QDELING(source)) + source.npc_bark(HUMAN_NPC_BARK_UNCUFFED) + +/datum/element/human_npc_reactions/proc/on_working_cuffs(mob/living/carbon/human/npc/source, obj/item/cuffs) + SIGNAL_HANDLER + source.npc_bark(HUMAN_NPC_BARK_SLIPPING) + +/// Notes who held it as it resisted. tg's resist_grab() lets go in the same call when it works. +/datum/element/human_npc_reactions/proc/on_resist(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + var/atom/movable/holder = source.pulledby || (ismob(source.buckled) ? source.buckled : null) + if(isnull(holder) || HAS_TRAIT(source, TRAIT_RESTRAINED)) + return + source.npc_resisted_hold = WEAKREF(holder) + source.npc_resisted_at = world.time + +/datum/element/human_npc_reactions/proc/on_let_go(mob/living/carbon/human/npc/source, atom/movable/puller) + SIGNAL_HANDLER + source.npc_check_pulled_free(puller) + +/datum/element/human_npc_reactions/proc/on_buckled(mob/living/carbon/human/npc/source, atom/movable/new_buckled) + SIGNAL_HANDLER + // Squirming off a carrier's shoulders can take a moment; a grab breaks in the same call. + if(isnull(new_buckled)) + source.npc_check_pulled_free(within = 6 SECONDS) + +/datum/element/human_npc_reactions/proc/on_death(mob/living/carbon/human/npc/source, gibbed) + SIGNAL_HANDLER + source.npc_surrendering = FALSE + if(gibbed) + return + source.hv_death_throes() + source.npc_bark(HUMAN_NPC_BARK_DEATH, force = TRUE) + +// ===== What it does when those things happen. Families override these. ===== + +/// It entered crit. +/mob/living/carbon/human/npc/proc/npc_on_downed() + npc_surrendering = FALSE + npc_bark(HUMAN_NPC_BARK_DOWNED, force = TRUE) + npc_next_plead = world.time + HUMAN_NPC_PLEAD_INTERVAL + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_DOWNED) + +/// It left crit, or got up from stamcrit. +/mob/living/carbon/human/npc/proc/npc_on_recovered() + npc_bark(HUMAN_NPC_BARK_RECOVER) + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_RECOVERED) + +/// It stopped being held. If it resisted its way out (this tick, or `within` that long ago), it pulled free. +/mob/living/carbon/human/npc/proc/npc_check_pulled_free(atom/movable/holder, within = 0) + if(!npc_resisted_at || world.time > npc_resisted_at + within) + return + var/atom/movable/resisted = npc_resisted_hold?.resolve() + npc_resisted_hold = null + npc_resisted_at = 0 + if(holder && resisted != holder) + return + npc_bark(HUMAN_NPC_BARK_PULLED_FREE) diff --git a/voidcrew/modules/human_npcs/human_npc_combat_sense.dm b/voidcrew/modules/human_npcs/human_npc_combat_sense.dm new file mode 100644 index 00000000000..c1cc0f02860 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_combat_sense.dm @@ -0,0 +1,633 @@ +/** + * # Combat sense + * + * Armed hostile human NPCs that opt in fight back against stun weapons the way a player does, through + * the body's own systems. Two parts: + * - A stimulant when it tires. Past HUMAN_NPC_SENSE_STIM_SHARE of the stamina loss that would stamcrit + * it, and still in a fight, it takes a stimulant pen it carries into a free hand and uses it (tg's + * use in hand, so the pen's real reagents go in). Only what it carries, and never while a stimulant + * is already in its blood. What a family carries is its outfit's business: see each family's file. + * - Stun fire. Hit by a shot that does stamina damage (a disabler bolt, a rubber round), it stops + * standing in the open. A gunner runs to the nearest tile out of the shooter's line of fire + * (human_npc_find_cover()) and keeps its head down for a moment, longer while it is winded (which is + * when tg's own stamina recovery catches up with it). Then the family's own fight subtrees walk it + * back out to shoot: it peeks. Three runs for cover in one exchange, then it stands and fights. A + * fighter that hits from close (a blade, fists, a shotgun) charges the shooter instead. + * + * Every move goes through the AI's movement (the controller's own movement type, so a family's leash + * pathing applies, at the body's real move delay: basic controllers track cached_multiplicative_slowdown). + * Every reaction shows in the world first ("ducks for cover!"). A family's rules say which tiles it may + * use (tile_ok(): a criminal's leash, a pirate crew's battlefield, a guard's wing). + * + * Nothing here touches the body: no immunity, no stamina, health or stun change, no stand-in state. + * Cuffed, in crit, stunned, in stamcrit, knocked down, grabbed, giving up or walking out, it does nothing + * (can_act(); tg's controller also stops the AI in stamcrit and stuns). + * + * Opting in: a human NPC type sets npc_combat_sense to a /datum/npc_combat_sense typepath (its family's + * rules), and its controller lists /datum/ai_planning_subtree/npc_combat_sense where it should plan: after + * soft crit, breaking free, walking out, surrender and finding a target, and before arming and attacking. + * Civilians, townsfolk and prisoners never set it. Anything else may AddComponent(/datum/component/ + * npc_combat_sense, rules_type) on a human NPC whose controller lists the subtree, and subclass + * /datum/npc_combat_sense to change the numbers or any step (a bounty boss, say). + */ + +// ===== THE RULES ===== + +/** + * How a family uses its combat sense. One shared instance per type (human_npc_singleton()). The vars are + * the numbers; each proc is a step a family may override. + */ +/datum/npc_combat_sense + /// Whether it uses stimulant pens it carries when it tires + var/use_stims = TRUE + /// Stamina loss, as a share of what stamcrits it, at which it reaches for a stimulant + var/stim_share = HUMAN_NPC_SENSE_STIM_SHARE + /// Whether stun fire makes it move at all + var/react_to_stun_fire = TRUE + /// How long after its last stun hit it keeps reacting + var/react_time = HUMAN_NPC_SENSE_REACT_TIME + /// The furthest it walks to cover, in steps + var/cover_steps = HUMAN_NPC_SENSE_COVER_STEPS + /// How long it keeps its head down before it looks out + var/peek_after = HUMAN_NPC_SENSE_PEEK_AFTER + /// The longest it stays down while winded + var/max_hold = HUMAN_NPC_SENSE_MAX_HOLD + /// Runs for cover per exchange of fire before it stands and fights + var/cover_moves = HUMAN_NPC_SENSE_COVER_MOVES + /// A fighter that hits from this close charges the shooter instead of taking cover. 0: it never charges. + var/close_in_reach = HUMAN_NPC_SENSE_CLOSE_IN_REACH + +/** + * Whether it can do anything about a fight right now: on its feet and free. Never cuffed, grabbed, + * buckled, stunned, knocked down, in crit, busy with a do_after, giving up or walking out. + */ +/datum/npc_combat_sense/proc/can_act(mob/living/carbon/human/npc, datum/ai_controller/controller) + if(npc.stat != CONSCIOUS || !isturf(npc.loc) || npc.buckled || npc.pulledby) + return FALSE + if(HAS_TRAIT(npc, TRAIT_RESTRAINED) || HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(npc, TRAIT_INCAPACITATED)) + return FALSE + if(HAS_TRAIT(npc, TRAIT_IMMOBILIZED) || HAS_TRAIT(npc, TRAIT_FLOORED) || npc.body_position == LYING_DOWN) + return FALSE + if(LAZYLEN(npc.do_afters)) + return FALSE + var/mob/living/carbon/human/npc/human_npc = npc + if(istype(human_npc) && (human_npc.npc_surrendering || human_npc.npc_exit)) + return FALSE + return TRUE + +/// Whether it is in a fight: it has a target, or someone is shooting stun rounds at it. +/datum/npc_combat_sense/proc/in_fight(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + return controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) || sense.under_fire() + +/// Whether it is winded: stamina loss past stim_share of what would stamcrit it. +/datum/npc_combat_sense/proc/winded(mob/living/carbon/human/npc) + return npc.getStaminaLoss() >= stim_share * (npc.maxHealth - npc.crit_threshold) + +/// A stimulant pen it carries and could take out now, or null. +/datum/npc_combat_sense/proc/find_stim(mob/living/carbon/human/npc) + return human_npc_find_stim(npc) + +/// Whether the one who fired `shooter`'s stun shot is on its side, so the shot is friendly fire. +/datum/npc_combat_sense/proc/allied(mob/living/carbon/human/npc, atom/shooter) + return isliving(shooter) && npc.faction_check_atom(shooter) + +/// Whether a stun shot from `shooter` is one to react to: a person or a mech, not itself, not a friend. +/datum/npc_combat_sense/proc/threat_ok(mob/living/carbon/human/npc, atom/shooter) + if(QDELETED(shooter) || shooter == npc || !(isliving(shooter) || ismecha(shooter))) + return FALSE + return !allied(npc, shooter) + +/** + * How close it fights from, in tiles: its gun's range if it holds or carries one that fires (the shooting + * subtrees' BB_HUMAN_NPC_FIRE_RANGE), else 1. At or under close_in_reach it charges; over it, it takes cover. + */ +/datum/npc_combat_sense/proc/reach(mob/living/carbon/human/npc, datum/ai_controller/controller) + if(HAS_TRAIT(npc, TRAIT_NOGUNS)) + return 1 + if(human_npc_held_gun(npc) || isgun(human_npc_backup_weapon(npc))) + return controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + return 1 + +/// Whether it may leave where it stands for this fight at all (a pirate holding the helm's line doesn't). +/datum/npc_combat_sense/proc/may_move(mob/living/carbon/human/npc, datum/ai_controller/controller) + return TRUE + +/** + * Whether it may stand on `tile` (and walk through it on the way to cover): open floor that is safe to + * cross, in the NPC's own map region. Families add their leash. + */ +/datum/npc_combat_sense/proc/tile_ok(mob/living/carbon/human/npc, datum/ai_controller/controller, turf/tile) + if(!isopenturf(tile) || isspaceturf(tile) || isopenspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) + return FALSE + if(!tile.can_cross_safely(npc)) + return FALSE + return !map_region_excludes_turf(map_region_for_turf(get_turf(npc)), tile) + +/// The nearest tile it may reach that is out of a shot's line from `threat_turf`, or null. +/datum/npc_combat_sense/proc/find_cover(mob/living/carbon/human/npc, datum/ai_controller/controller, turf/threat_turf, pass_flags) + return human_npc_find_cover(npc, threat_turf, cover_steps, pass_flags, CALLBACK(src, PROC_REF(tile_ok), npc, controller)) + +/// How long it keeps down in cover it reached at `arrived_at`: peek_after, or up to max_hold while winded. +/datum/npc_combat_sense/proc/hold_until(mob/living/carbon/human/npc, datum/component/npc_combat_sense/sense) + return sense.arrived_at + (winded(npc) ? max_hold : peek_after) + +/** + * What the people around see it do, before it does it (and a line from its voice, if its family has one + * for `kind`). In the world and in character: no rules, no numbers. + */ +/datum/npc_combat_sense/proc/telegraph(mob/living/carbon/human/npc, kind, atom/about, obj/item/used) + switch(kind) + if(HUMAN_NPC_BARK_TAKE_COVER) + npc.visible_message(span_warning("[npc] ducks for cover!")) + if(HUMAN_NPC_BARK_CHARGE) + npc.visible_message(span_warning("[npc] charges at [about]!")) + if(HUMAN_NPC_BARK_STIMULANT) + npc.visible_message(span_warning("[npc] jabs [used] into [npc.p_their()] thigh.")) + return + var/mob/living/carbon/human/npc/human_npc = npc + if(istype(human_npc)) + human_npc.npc_bark(kind) + +// ===== PLANNING ===== + +/** + * One plan. Under stun fire a gunner gets out of the line of fire first and uses its stimulant from + * cover; out of cover's reach (none near, out of runs, looking out) it uses the stimulant where it is. + * A close fighter takes its stimulant first, then charges. Returns SUBTREE_RETURN_FINISH_PLANNING while it + * is doing something about it, so the attacks below wait. + */ +/datum/npc_combat_sense/proc/plan(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + if(!can_act(npc, controller)) + sense.stand_down(controller) + return + var/under_fire = sense.under_fire() + if(!under_fire && sense.hits) + sense.end_exchange(controller) + var/reacting = under_fire && react_to_stun_fire && may_move(npc, controller) + var/close_fighter = reacting && close_in_reach && reach(npc, controller) <= close_in_reach + if(reacting && !close_fighter) + . = plan_cover(npc, controller, sense) + if(.) + return + if(plan_stim(npc, controller, sense)) + return SUBTREE_RETURN_FINISH_PLANNING + if(close_fighter) + return plan_close_in(npc, controller, sense) + +/// Queues the stimulant it wants now, if any. Returns TRUE if it did. +/datum/npc_combat_sense/proc/plan_stim(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/obj/item/reagent_containers/hypospray/medipen/stim = wants_stim(npc, controller, sense) + if(isnull(stim)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_STIM_ITEM, stim) + controller.queue_behavior(/datum/ai_behavior/npc_combat_sense_stim, BB_HUMAN_NPC_STIM_ITEM) + return TRUE + +/// The stimulant pen it should use now, or null: the one in hand from the last plan, or one it carries while winded in a fight. +/datum/npc_combat_sense/proc/wants_stim(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + if(!use_stims || world.time < sense.next_stim_at) + return null + var/obj/item/reagent_containers/hypospray/medipen/using = controller.blackboard[BB_HUMAN_NPC_STIM_ITEM] + if(istype(using) && !QDELETED(using) && human_npc_is_stim(using) && human_npc_holding(using) == npc) + return using + if(!winded(npc) || !in_fight(npc, controller, sense) || human_npc_stimmed(npc)) + return null + . = find_stim(npc) + // Nothing on it: don't turn its pockets out again every plan + if(isnull(.)) + sense.next_stim_at = world.time + HUMAN_NPC_SENSE_STIM_RETRY + +/** + * A gunner under stun fire: on the way to cover, it keeps going; in cover, it keeps down (using its + * stimulant or reloading there if it needs to) until it is time to look out or the shooter can see it + * there anyway; in the open, each new stun hit is weighed once, and it runs for the nearest cover if it + * has runs left. Returns null when it has nothing to do about it here. + */ +/datum/npc_combat_sense/proc/plan_cover(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/turf/here = get_turf(npc) + var/turf/threat_turf = sense.threat_turf(npc) + if(isnull(here) || isnull(threat_turf)) + return + if(sense.cover) + var/blown = human_npc_line_of_fire(threat_turf, sense.cover, sense.shot_pass_flags) + if(here == sense.cover) + sense.arrived_at ||= world.time + // Whoever it is fighting at arm's length gets fought, cover or not + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(target && get_dist(npc, target) <= 1) + blown = TRUE + if(!blown && world.time < hold_until(npc, sense)) + // Hits taken getting here or in here were answered by being here + sense.decided_at = world.time + // Its stimulant or a fresh magazine while it is out of sight, else it keeps its head down + if(!plan_stim(npc, controller, sense) && !human_npc_cover_reload(npc, controller)) + controller.queue_behavior(/datum/ai_behavior/npc_combat_sense_hold) + return SUBTREE_RETURN_FINISH_PLANNING + // Time to look out, or it is seen here anyway: the fight subtrees take it from here + sense.stand_down(controller) + return + if(!blown && world.time < sense.cover_deadline && tile_ok(npc, controller, sense.cover)) + sense.decided_at = world.time + controller.queue_behavior(/datum/ai_behavior/npc_combat_sense_take_cover, BB_HUMAN_NPC_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + sense.stand_down(controller) + return + if(sense.last_hit_at <= sense.decided_at) + return + sense.decided_at = world.time + if(sense.cover_moves >= cover_moves || !human_npc_line_of_fire(threat_turf, here, sense.shot_pass_flags)) + return + var/turf/spot = find_cover(npc, controller, threat_turf, sense.shot_pass_flags) + if(isnull(spot)) + return + sense.take_cover(controller, spot) + telegraph(npc, HUMAN_NPC_BARK_TAKE_COVER, sense.threat()) + controller.queue_behavior(/datum/ai_behavior/npc_combat_sense_take_cover, BB_HUMAN_NPC_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * A close fighter under stun fire: it runs at the shooter, if the shooter is who it is already fighting, + * until it is close enough to hit. Then the family's attacks take over. + */ +/datum/npc_combat_sense/proc/plan_close_in(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/atom/shooter = sense.threat() + if(isnull(shooter) || shooter != controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET]) + return + var/reach = max(reach(npc, controller), 1) + if(get_dist(npc, shooter) <= reach && can_see(npc, shooter, reach)) + return + if(world.time - sense.charged_at > react_time) + telegraph(npc, HUMAN_NPC_BARK_CHARGE, shooter) + sense.charged_at = world.time + sense.decided_at = world.time + controller.set_blackboard_key(BB_HUMAN_NPC_STUN_SHOOTER, shooter) + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_HUMAN_NPC_STUN_SHOOTER, reach) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Plans the combat sense of a pawn that has one. Controllers that support it list this where it should run. +/datum/ai_planning_subtree/npc_combat_sense + +/datum/ai_planning_subtree/npc_combat_sense/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc = controller.pawn + if(!istype(npc)) + return + var/datum/component/npc_combat_sense/sense = npc.GetComponent(/datum/component/npc_combat_sense) + if(isnull(sense)) + return + return sense.rules.plan(npc, controller, sense) + +// ===== THE COMPONENT: what it noticed ===== + +/** + * On a human NPC with combat sense: notes stun hits (who, from where, what the shot could pass) and keeps + * the state of the current exchange. The decisions are its rules' (plan()). + */ +/datum/component/npc_combat_sense + dupe_mode = COMPONENT_DUPE_UNIQUE_PASSARGS + /// The family's rules, a shared instance + var/datum/npc_combat_sense/rules + /// Whoever last shot it with stun fire + var/datum/weakref/shooter_ref + /// Where that shot was fired from + var/turf/shot_from + /// What that shot could fly through (glass, tables, grilles), to judge cover against the next one + var/shot_pass_flags = PASSTABLE + /// world.time of its last stun hit + var/last_hit_at = 0 + /// Stun hits taken in this exchange + var/hits = 0 + /// world.time it last answered a stun hit (ran for cover, charged, or chose to stand) + var/decided_at = 0 + /// The cover it is going to or keeping down in + var/turf/cover + /// world.time it gives up on reaching that cover + var/cover_deadline = 0 + /// world.time it got there, or 0 + var/arrived_at = 0 + /// Runs for cover in this exchange + var/cover_moves = 0 + /// world.time it last charged a shooter + var/charged_at = 0 + /// Stimulants it has used + var/stims_used = 0 + /// world.time it may next reach for a stimulant, after a try that got nowhere + var/next_stim_at = 0 + +/datum/component/npc_combat_sense/Initialize(rules_type = /datum/npc_combat_sense) + if(!ishumannpc(parent) || !ispath(rules_type, /datum/npc_combat_sense)) + return COMPONENT_INCOMPATIBLE + rules = human_npc_singleton(rules_type) + +/datum/component/npc_combat_sense/InheritComponent(datum/component/new_comp, i_am_original, rules_type = /datum/npc_combat_sense) + if(ispath(rules_type, /datum/npc_combat_sense)) + rules = human_npc_singleton(rules_type) + +/datum/component/npc_combat_sense/RegisterWithParent() + RegisterSignal(parent, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_shot)) + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/npc_combat_sense/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_ATOM_BULLET_ACT, COMSIG_LIVING_DEATH)) + +/datum/component/npc_combat_sense/Destroy(force) + rules = null + shooter_ref = null + shot_from = null + cover = null + return ..() + +/// A shot landed. Stun fire is noted; the next plan decides what to do about it. +/datum/component/npc_combat_sense/proc/on_shot(mob/living/carbon/human/source, obj/projectile/shot, def_zone, piercing_hit, blocked) + SIGNAL_HANDLER + if(source.stat == DEAD || !human_npc_stun_projectile(shot)) + return + var/atom/shooter = shot.firer + if(!rules.threat_ok(source, shooter)) + return + if(!under_fire()) + hits = 0 + cover_moves = 0 + shooter_ref = WEAKREF(shooter) + shot_from = get_turf(shot.starting) || get_turf(shooter) + shot_pass_flags = shot.pass_flags + last_hit_at = world.time + hits++ + +/datum/component/npc_combat_sense/proc/on_death(mob/living/source, gibbed) + SIGNAL_HANDLER + end_exchange(source.ai_controller) + +/// Whether stun fire hit it recently enough to still react. +/datum/component/npc_combat_sense/proc/under_fire() + return last_hit_at && world.time - last_hit_at <= rules.react_time + +/// The one shooting stun rounds at it, if they are still about on its level. +/datum/component/npc_combat_sense/proc/threat() + var/atom/shooter = shooter_ref?.resolve() + if(QDELETED(shooter)) + return null + var/turf/shooter_turf = get_turf(shooter) + var/turf/our_turf = get_turf(parent) + if(isnull(shooter_turf) || shooter_turf.z != our_turf?.z) + return null + return shooter + +/// Where the stun fire comes from: the shooter's tile while it can tell, else where the last shot was fired from. +/datum/component/npc_combat_sense/proc/threat_turf(mob/living/npc) + var/atom/shooter = threat() + if(shooter && can_see(npc, shooter, HUMAN_NPC_SIGHT_RANGE)) + return get_turf(shooter) + return shot_from + +/// Sets off for `spot`. +/datum/component/npc_combat_sense/proc/take_cover(datum/ai_controller/controller, turf/spot) + cover = spot + arrived_at = 0 + cover_moves++ + cover_deadline = world.time + 1 SECONDS + get_dist(parent, spot) * HUMAN_NPC_SENSE_COVER_STEP_TIME + controller.set_blackboard_key(BB_HUMAN_NPC_COVER, spot) + +/// Leaves its cover plan (it is going back to the fight, or can't do anything). +/datum/component/npc_combat_sense/proc/stand_down(datum/ai_controller/controller) + cover = null + arrived_at = 0 + cover_deadline = 0 + controller?.clear_blackboard_key(BB_HUMAN_NPC_COVER) + +/// The exchange of fire is over: next time starts afresh. +/datum/component/npc_combat_sense/proc/end_exchange(datum/ai_controller/controller) + stand_down(controller) + hits = 0 + cover_moves = 0 + last_hit_at = 0 + decided_at = 0 + shooter_ref = null + shot_from = null + +// ===== BEHAVIOURS ===== + +/// Walks to the cover turf in `cover_key`. The plan holds it there once it arrives. +/datum/ai_behavior/npc_combat_sense_take_cover + required_distance = 0 + action_cooldown = 0.2 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/npc_combat_sense_take_cover/setup(datum/ai_controller/controller, cover_key) + . = ..() + var/turf/spot = controller.blackboard[cover_key] + if(!isturf(spot)) + return FALSE + set_movement_target(controller, spot) + +/datum/ai_behavior/npc_combat_sense_take_cover/perform(seconds_per_tick, datum/ai_controller/controller, cover_key) + var/turf/spot = controller.blackboard[cover_key] + if(!isturf(spot)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(get_turf(controller.pawn) == spot) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/// Keeps its head down where it is. Ends when the plan stops asking for it. +/datum/ai_behavior/npc_combat_sense_hold + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/npc_combat_sense_hold/perform(seconds_per_tick, datum/ai_controller/controller) + return AI_BEHAVIOR_DELAY + +/** + * Uses the stimulant pen in `item_key` on itself the way a player does: takes it in a free hand (letting + * go of a two-handed grip, or putting away a spent gun, if both are full), then use in hand. The spent pen + * is dropped and its weapons taken up again. + */ +/datum/ai_behavior/npc_combat_sense_stim + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/npc_combat_sense_stim/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/human/npc = controller.pawn + var/obj/item/reagent_containers/hypospray/medipen/pen = controller.blackboard[item_key] + if(!istype(pen) || QDELETED(pen) || human_npc_holding(pen) != npc || npc.stat != CONSCIOUS || HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!human_npc_is_stim(pen)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(pen)) + if(!human_npc_free_a_hand(npc) || !human_npc_take_in_hand(npc, pen)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, pen) || npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + var/datum/component/npc_combat_sense/sense = npc.GetComponent(/datum/component/npc_combat_sense) + sense?.rules.telegraph(npc, HUMAN_NPC_BARK_STIMULANT, null, pen) + npc.execute_mode() + if(!pen.used_up) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(sense) + sense.stims_used++ + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/datum/ai_behavior/npc_combat_sense_stim/finish_action(datum/ai_controller/controller, succeeded, item_key) + var/mob/living/carbon/human/npc = controller.pawn + var/obj/item/reagent_containers/hypospray/medipen/pen = controller.blackboard[item_key] + . = ..() + controller.clear_blackboard_key(item_key) + if(!istype(npc)) + return + if(istype(pen) && !QDELETED(pen) && npc.is_holding(pen)) + if(pen.used_up) + npc.dropItemToGround(pen) + else + // Cut short (the plan moved on): back where it came from + pen.equip_to_best_slot(npc) + for(var/obj/item/held in npc.held_items) + human_npc_ready_weapon(npc, held) + if(!succeeded) + var/datum/component/npc_combat_sense/sense = npc.GetComponent(/datum/component/npc_combat_sense) + if(sense) + sense.next_stim_at = world.time + HUMAN_NPC_SENSE_STIM_RETRY + +// ===== HELPERS (API) ===== + +/// Reagents that count as a stimulant: what a person takes to keep going through a fight. +GLOBAL_LIST_INIT(human_npc_stim_reagents, typecacheof(list( + /datum/reagent/medicine/stimulants, + /datum/reagent/medicine/ephedrine, + /datum/reagent/drug/methamphetamine, +))) + +/// Whether `shot` does stamina damage: a disabler bolt, a rubber round. +/proc/human_npc_stun_projectile(obj/projectile/shot) + if(!istype(shot)) + return FALSE + return (shot.damage_type == STAMINA && shot.damage > 0) || shot.stamina > 0 + +/// Whether `pen` is an unspent medipen with a stimulant in it. +/proc/human_npc_is_stim(obj/item/reagent_containers/hypospray/medipen/pen) + if(!istype(pen) || pen.used_up || !pen.reagents?.total_volume) + return FALSE + for(var/datum/reagent/reagent as anything in pen.reagents.reagent_list) + if(is_type_in_typecache(reagent, GLOB.human_npc_stim_reagents)) + return TRUE + return FALSE + +/// Whether a stimulant is already in `body`'s blood. +/proc/human_npc_stimmed(mob/living/carbon/body) + for(var/datum/reagent/reagent as anything in body.reagents?.reagent_list) + if(is_type_in_typecache(reagent, GLOB.human_npc_stim_reagents)) + return TRUE + return FALSE + +/// A stimulant pen `npc` holds, or carries somewhere it can take it from in a moment, or null. +/proc/human_npc_find_stim(mob/living/carbon/npc) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in npc.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(human_npc_is_stim(pen) && (npc.is_holding(pen) || human_npc_can_draw(npc, pen))) + return pen + return null + +/// Frees a hand: lets go of a two-handed grip, else puts away a gun that can't fire. Returns TRUE if a hand is free. +/proc/human_npc_free_a_hand(mob/living/carbon/npc) + if(length(npc.get_empty_held_indexes())) + return TRUE + for(var/obj/item/held in npc.held_items) + if(HAS_TRAIT(held, TRAIT_WIELDED)) + held.attack_self(npc) + break + if(length(npc.get_empty_held_indexes())) + return TRUE + var/obj/item/gun/spent = human_npc_held_gun(npc) + if(spent && !spent.can_shoot()) + spent.equip_to_best_slot(npc) + return length(npc.get_empty_held_indexes()) > 0 + +/** + * Whether a shot from `from_turf` could fly straight to `to_turf`: nothing on the way between the two tile + * centres that blocks it. Walls, closed doors, machines and anything opaque block; glass, tables and grilles + * only stop a shot that can't pass them (`pass_flags`, the projectile's). Directional things (windoors, + * railings) are ignored. A rough line, like a person judging cover by eye. + */ +/proc/human_npc_line_of_fire(turf/from_turf, turf/to_turf, pass_flags = PASSTABLE) + if(isnull(from_turf) || isnull(to_turf) || from_turf.z != to_turf.z) + return FALSE + var/dx = to_turf.x - from_turf.x + var/dy = to_turf.y - from_turf.y + // Four looks per tile along the segment, each at the tile under that point + var/samples = max(abs(dx), abs(dy)) * 4 + var/turf/last = from_turf + for(var/sample in 1 to samples - 1) + var/turf/step = locate(round(from_turf.x + dx * sample / samples + 0.5), round(from_turf.y + dy * sample / samples + 0.5), from_turf.z) + if(step == last || step == to_turf) + continue + last = step + if(isnull(step) || step.density || step.opacity) + return FALSE + for(var/obj/thing in step) + if(thing.opacity) + return FALSE + if(!thing.density || (thing.flags_1 & ON_BORDER_1) || (thing.pass_flags_self & pass_flags)) + continue + return FALSE + return TRUE + +/// Whether someone else is standing on `tile`, so `npc` can't take cover there. +/proc/human_npc_cover_taken(turf/tile, mob/living/npc) + for(var/mob/living/other in tile) + if(other != npc && other.density) + return TRUE + return FALSE + +/** + * The nearest tile `npc` can walk to within `max_steps` that is out of a shot's line from `threat_turf`, + * or null. One flood from where it stands, with the pathfinder's own step check (walls, windows, doors it + * has no access for), never onto or through a tile `tile_ok` (a callback taking the turf) refuses. Of the + * nearest, the one closest to the threat, so it can look out again. Cheap enough to run once per stun + * hit: it never sleeps. + */ +/proc/human_npc_find_cover(mob/living/npc, turf/threat_turf, max_steps = HUMAN_NPC_SENSE_COVER_STEPS, pass_flags = PASSTABLE, datum/callback/tile_ok) + var/turf/start = get_turf(npc) + if(isnull(start) || isnull(threat_turf) || start.z != threat_turf.z) + return null + var/datum/can_pass_info/pass_info = new(npc, npc.ai_controller?.get_access()) + var/list/steps_to = list() + steps_to[start] = 0 + var/list/queue = list(start) + var/index = 1 + var/turf/best + var/best_steps = INFINITY + var/best_distance = INFINITY + while(index <= length(queue)) + var/turf/current = queue[index++] + var/steps = steps_to[current] + // Breadth first: everything from here on is further than the cover already found + if(steps >= best_steps || steps >= max_steps) + break + for(var/direction in GLOB.alldirs) + var/turf/next = get_step(current, direction) + if(isnull(next) || !isnull(steps_to[next]) || next.density) + continue + if(current.LinkBlockedWithAccess(next, pass_info)) + continue + steps_to[next] = steps + 1 + if(tile_ok && !tile_ok.Invoke(next)) + continue + queue += next + if(human_npc_cover_taken(next, npc) || human_npc_line_of_fire(threat_turf, next, pass_flags)) + continue + var/distance = get_dist(next, threat_turf) + if(steps + 1 < best_steps || distance < best_distance) + best = next + best_steps = steps + 1 + best_distance = distance + return best + +/** + * In cover with a gun that ran dry and a magazine to put in it: queues the human NPC base's reload (the + * player's reload) and returns TRUE. Guns loaded a round at a time wait for the family's own arming. + */ +/proc/human_npc_cover_reload(mob/living/carbon/npc, datum/ai_controller/controller) + if(world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return FALSE + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun) || gun.internal_magazine || gun.can_shoot() || !human_npc_can_reload(npc, gun)) + return FALSE + controller.queue_behavior(/datum/ai_behavior/human_npc_reload) + return TRUE diff --git a/voidcrew/modules/human_npcs/human_npc_gear.dm b/voidcrew/modules/human_npcs/human_npc_gear.dm new file mode 100644 index 00000000000..557828ce6ab --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_gear.dm @@ -0,0 +1,247 @@ +// Human NPC gear lock (owner decision D3): everything a human NPC wears or holds stays on its body +// and never becomes loot. Its designed death drops (human_npc.dm, npc_drop_death_loot()) are the +// only things a player gets from killing one. +// +// How it holds, using tg's own inventory paths rather than special cases per route: +// * A drop that would put the item anywhere but on a human NPC is refused (COMSIG_ITEM_PRE_UNEQUIP): +// disarm shoves, a held item dropped on a knockdown, gib item drops, a stray transferItemToLoc(). +// The NPC's own moves inside its inventory (magazine into its gun, gun into its holster, a tank +// into its suit storage) still work, because they end on the NPC. +// * Locked items carry TRAIT_NO_STRIP: tg's strip menu shows their slots as empty and won't take them. +// The menu still opens, so what isn't gear (a captain's ship key in his pocket) can be taken off a +// cuffed or downed NPC as off a person. +// * Anything that forces the item out of the body anyway (an arm cut off still holding the gun, a +// head knocked off with the helmet on, species changes, admin undress) lands outside the NPC, and +// the item is deleted on the next tick (COMSIG_MOVABLE_MOVED). The wait lets tg code that moves an +// item twice in one call finish: a tactical reload drops the old magazine on the floor and then +// puts it back in the NPC's hand. +// * Locked items can't be sold (COMSIG_ITEM_PRE_EXPORT) and carry no materials, so an NPC corpse put +// through the cargo shuttle or an emagged recycler yields nothing for its kit. +// * When the body is deleted (corpse cleanup, gibbing, a site unloading) the gear goes with it, +// since /atom/movable/Destroy() deletes contents. +// * A player who ends up in control of an NPC body (a brain transplant) loses its gear at once +// (human_npc.dm, Login()). +// +// Organs and body parts are the body, not gear: they come off and can be harvested the way a +// player's do (cyberware is the exception: it burns out to scrap, human_npc.dm). Ammunition casings +// are not locked either, so spent brass stays on the floor. +// +// The exceptions, explicit and tested: +// * GLOB.human_npc_not_gear: items whose loss would break something outside the NPC (a ship key, a +// contract's recovery item, bounty proof, a paper someone has to read). Never locked; they drop at +// the NPC's feet when it dies. +// * GLOB.human_npc_consumable_gear: things an NPC uses up (grenades, barrier grenades, bottles, syringe +// darts, medipens, pills, bandages and other medical stacks, thrown spears, stars and bolas). They +// may leave its body the way a person's would: thrown, fired, dropped or knocked out of its hand. +// They still never become loot: in anyone's hands or bags but a human NPC's they are deleted, and +// loose ones go when the NPC that carried them does. +// * NPCs with npc_gear_locked = FALSE lock nothing at all (owner ruling OC-11: prisoners' jumpsuits, +// contraband and shivs are ordinary items, so shakedowns, confiscation and uniform swaps work). Their +// guards keep the lock. + +/// Items an NPC carries that aren't its gear: never locked, dropped when it dies. Their loss would +/// break something outside the NPC (the cryo pod refuses to delete the first two for the same reason). +GLOBAL_LIST_INIT(human_npc_not_gear, typecacheof(list( + /obj/item/ship_key, + /obj/item/mission_recovery, + /obj/item/bounty_proof, + /obj/item/paper, +))) + +/// Items an NPC uses up: they leave its body like a person's would, but never become loot. +GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( + /obj/item/grenade, + /obj/item/reagent_containers/cup/glass/bottle, + /obj/item/reagent_containers/syringe, + /obj/item/reagent_containers/hypospray/medipen, + /obj/item/reagent_containers/applicator/pill, + /obj/item/stack/medical, + /obj/item/spear, + /obj/item/throwing_star, + /obj/item/restraints/legcuffs/bola, +))) + +/// Trait source for the gear lock's traits, shared by the element and the consumable component. +#define HUMAN_NPC_GEAR_TRAIT_SOURCE "human_npc_gear_lock" + +/// Locks an item to the human NPC body that carries it. Safe to call again on a locked item. +/datum/element/human_npc_gear + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_gear/Attach(datum/target) + . = ..() + if(!isitem(target)) + return ELEMENT_INCOMPATIBLE + var/obj/item/gear = target + gear.add_traits(list(TRAIT_HUMAN_NPC_GEAR, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) + RegisterSignal(gear, COMSIG_ITEM_PRE_UNEQUIP, PROC_REF(on_pre_unequip)) + RegisterSignal(gear, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(gear, COMSIG_ITEM_PRE_EXPORT, PROC_REF(on_pre_export)) + human_npc_gear_strip_materials(gear) + +/datum/element/human_npc_gear/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_ITEM_PRE_UNEQUIP, COMSIG_MOVABLE_MOVED, COMSIG_ITEM_PRE_EXPORT)) + source.remove_traits(list(TRAIT_HUMAN_NPC_GEAR, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) + return ..() + +/// Refuses any drop or hand-over that would leave the NPC's body. Forced moves are let through and +/// caught by on_moved(). +/datum/element/human_npc_gear/proc/on_pre_unequip(obj/item/source, force, atom/newloc, no_move, invdrop, silent) + SIGNAL_HANDLER + if(force || isnull(newloc) || human_npc_holding(newloc)) + return NONE + return COMPONENT_ITEM_BLOCK_UNEQUIP + +/datum/element/human_npc_gear/proc/on_moved(obj/item/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + human_npc_gear_check(source) + +/datum/element/human_npc_gear/proc/on_pre_export(obj/item/source) + SIGNAL_HANDLER + return COMPONENT_STOP_EXPORT + +/** + * Something an NPC uses up (GLOB.human_npc_consumable_gear). It leaves the body like anyone's would: + * thrown, fired from a dart gun, dropped, knocked from a hand. Lying loose or in flight it stays; in + * anyone's hands or bags but a human NPC's it is deleted; loose, it goes when its NPC does. + */ +/datum/component/human_npc_consumable + /// The NPC it was issued to + var/mob/living/carbon/human/npc/owner + +/datum/component/human_npc_consumable/Initialize(mob/living/carbon/human/npc/owner) + if(!isitem(parent) || !ishumannpc(owner)) + return COMPONENT_INCOMPATIBLE + src.owner = owner + RegisterSignal(owner, COMSIG_QDELETING, PROC_REF(on_owner_deleted)) + human_npc_gear_strip_materials(parent) + +/datum/component/human_npc_consumable/RegisterWithParent() + var/obj/item/item = parent + item.add_traits(list(TRAIT_HUMAN_NPC_CONSUMABLE, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_ITEM_PRE_EXPORT, PROC_REF(on_pre_export)) + +/datum/component/human_npc_consumable/UnregisterFromParent() + var/obj/item/item = parent + item.remove_traits(list(TRAIT_HUMAN_NPC_CONSUMABLE, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_ITEM_PRE_EXPORT)) + +/datum/component/human_npc_consumable/Destroy(force) + if(owner) + UnregisterSignal(owner, COMSIG_QDELETING) + owner = null + return ..() + +/datum/component/human_npc_consumable/proc/on_moved(obj/item/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + human_npc_gear_check(source) + +/datum/component/human_npc_consumable/proc/on_pre_export(obj/item/source) + SIGNAL_HANDLER + return COMPONENT_STOP_EXPORT + +/// Its NPC is gone: a loose one goes too. One still on the NPC is deleted with it anyway. +/datum/component/human_npc_consumable/proc/on_owner_deleted(datum/source) + SIGNAL_HANDLER + var/obj/item/item = parent + UnregisterSignal(owner, COMSIG_QDELETING) + owner = null + if(!QDELETED(item) && !human_npc_holding(item)) + qdel(item) + +/// The human NPC that `place` is, or is carried by at any depth (a gun in its hand, a magazine in a +/// pouch on its belt, its body in a bag). Null when the chain ends on a turf or in nullspace first. +/proc/human_npc_holding(atom/place) + while(place && !isturf(place)) + if(ishumannpc(place)) + return place + place = place.loc + return null + +/// Whether `item` may be where it is: on a human NPC, or for a consumable, also loose or in flight. +/proc/human_npc_gear_allowed_at(obj/item/item) + if(human_npc_holding(item.loc)) + return TRUE + if(!HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE)) + return FALSE + return isturf(item.loc) || istype(item.loc, /obj/projectile) + +/// After a move: locked gear that ended up somewhere it may not be is deleted on the next tick, unless +/// it is back where it may be by then. +/proc/human_npc_gear_check(obj/item/item) + if(QDELING(item) || human_npc_gear_allowed_at(item) || HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR_LOST)) + return + ADD_TRAIT(item, TRAIT_HUMAN_NPC_GEAR_LOST, HUMAN_NPC_GEAR_TRAIT_SOURCE) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(human_npc_gear_settle), item), 0) + +/// A locked item that went somewhere it may not be last tick: deleted, unless it is back by now. +/proc/human_npc_gear_settle(obj/item/gear) + if(QDELETED(gear)) + return + REMOVE_TRAIT(gear, TRAIT_HUMAN_NPC_GEAR_LOST, HUMAN_NPC_GEAR_TRAIT_SOURCE) + if(human_npc_gear_allowed_at(gear)) + return + qdel(gear) + +/// Locked gear keeps no salvage value. Material effects (a plasteel helmet's colour and armour) live in +/// custom_materials, so only plain items lose theirs. +/proc/human_npc_gear_strip_materials(obj/item/gear) + if(length(gear.custom_materials) && !(gear.material_flags & MATERIAL_EFFECTS)) + gear.set_custom_materials(null) + +/// Whether `item` is locked to a human NPC's body. For tests and admin tools. +/proc/human_npc_gear_locked(obj/item/item) + return !QDELETED(item) && HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) + +/// Whether `item` is a consumable issued to a human NPC. For tests and admin tools. +/proc/human_npc_gear_consumable(obj/item/item) + return !QDELETED(item) && HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE) + +/** + * Locks `item` to this NPC's body, or issues it as a consumable, by the lists above. Items that aren't + * gear, body parts, organs, ammunition casings and abstract items are left alone, and so is everything + * on an NPC whose gear isn't locked (npc_gear_locked). Use it for anything handed to an NPC after it + * spawned. Returns TRUE if the item is now locked or issued. + */ +/mob/living/carbon/human/npc/proc/npc_lock_item(obj/item/item) + if(!npc_gear_locked || QDELETED(item) || isbodypart(item) || isorgan(item) || isammocasing(item) || (item.item_flags & ABSTRACT)) + return FALSE + if(is_type_in_typecache(item, GLOB.human_npc_not_gear)) + return FALSE + if(HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) || HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE)) + return TRUE + if(is_type_in_typecache(item, GLOB.human_npc_consumable_gear)) + item.AddComponent(/datum/component/human_npc_consumable, src) + else + item.AddElement(/datum/element/human_npc_gear) + return TRUE + +/// Locks everything the NPC carries: worn, held, in pockets and bags, implants and skillchips. +/mob/living/carbon/human/npc/proc/npc_lock_all_gear() + for(var/obj/item/item as anything in get_all_contents_type(/obj/item)) + npc_lock_item(item) + +/// Deletes every locked or issued item the NPC carries. Used when a player takes over the body. +/mob/living/carbon/human/npc/proc/npc_delete_locked_gear() + for(var/obj/item/item as anything in get_all_contents_type(/obj/item)) + if(QDELETED(item)) + continue + if(HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) || HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE)) + qdel(item) + +/// Drops what it carries that isn't gear (GLOB.human_npc_not_gear) at its feet, as anyone's would be +/// found on them. Called when it dies, since nobody can search an NPC's pockets. +/mob/living/carbon/human/npc/proc/npc_drop_not_gear() + var/atom/drop_spot = drop_location() + if(isnull(drop_spot)) + return + for(var/obj/item/item as anything in get_all_contents_type(/obj/item)) + if(QDELETED(item) || !is_type_in_typecache(item, GLOB.human_npc_not_gear)) + continue + if(item.loc == src) + dropItemToGround(item, force = TRUE) + else + item.forceMove(drop_spot) + +#undef HUMAN_NPC_GEAR_TRAIT_SOURCE diff --git a/voidcrew/modules/human_npcs/human_npc_outfits.dm b/voidcrew/modules/human_npcs/human_npc_outfits.dm new file mode 100644 index 00000000000..c228e62a787 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_outfits.dm @@ -0,0 +1,251 @@ +/** + * # Human NPC outfits by zone, rank and site (N7) + * + * Toughness for human NPCs comes from what they wear (defaults 1.5), so harder zones get better kit, + * not more health: rags in green, a vest in yellow, a vest and helmet in red, better guns as you go. + * + * An outfit table (/datum/human_npc_outfit_table) picks a /datum/outfit for one NPC from weighted lists + * per zone and rank. Use one by setting npc_outfit_table on the NPC type, or by passing the table + * typepath as spawn_human_npc(outfit =). The zone is the spawn context's (spawn_npc_in_zone()), else + * the zone of the spawn turf (a ship's overmap tile when aboard one), else green. A queued order whose + * zone isn't known yet waits for it in the queue, up to HUMAN_NPC_ZONE_WAIT (human_npc_spawning.dm). + * + * Whatever the outfit, the site layers then make sure the NPC can live where it is made (decision D5 + * and defaults 1.12: real suits and tanks, never immunity traits). human_npc_site_at() reads the air on + * the spawn turf: + * - HUMAN_NPC_SITE_VACUUM (too little pressure): if the outfit's suit and helmet aren't both sealed, + * the table's vacuum_layer replaces them with a sealed suit and helmet; it also gets a tank. A MODsuit + * on its back counts as sealed: tg's outfit path deploys and seals it (quick_activation()). + * - HUMAN_NPC_SITE_BAD_AIR (pressure fine, air not): a breath mask if nothing it wears takes internals, + * and a tank. + * - HUMAN_NPC_SITE_COLD: the table's cold_layer fills the slots the outfit leaves empty with cold-weather + * wear and swaps the shoes for winter boots. An outfit meant for ice planets should bring its own. + * A table's always_site, and the NPC's npc_always_site, add sites regardless of the air (boarders that + * cross vacuum). Species that need none of it (skeletons) are left alone. Resisting low pressure isn't + * enough on its own: vacuum is as cold as space, and only a space suit keeps that out (tg insulation), + * so a pressure-proof species that feels the cold (silverscale, lustrous) is still sealed; one that + * doesn't breathe gets no tank. The NPC then opens its tank and switches on its suit's thermal + * regulator, as a player would. + */ + +/** + * Weighted outfits for one family. Lists are outfit typepath => weight. A zone left null uses the zone + * below it; elites and bosses fall back to the zone's regulars. Override outfits_for() to pick by + * anything else the NPC carries (a bounty's tier, a faction's role). + */ +/datum/human_npc_outfit_table + /// Regulars in the green zone + var/list/green + /// Regulars in the yellow zone. Null: green's. + var/list/yellow + /// Regulars in the red zone. Null: yellow's. + var/list/red + /// Elites in any zone. Null: the zone's regulars. + var/list/elite + /// Bosses in any zone. Null: elite's. + var/list/boss + /// HUMAN_NPC_SITE_* flags every NPC it dresses gets, whatever the air where it spawns + var/always_site = NONE + /// What goes over an outfit that isn't sealed, in vacuum (a /datum/human_npc_layer typepath) + var/datum/human_npc_layer/vacuum_layer = /datum/human_npc_layer/eva + /// What goes over an outfit in the cold + var/datum/human_npc_layer/cold_layer = /datum/human_npc_layer/cold + +/// The weighted outfits `npc` may get in `zone` (ZONE_*). +/datum/human_npc_outfit_table/proc/outfits_for(mob/living/carbon/human/npc/npc, zone) + var/rank = npc?.npc_rank || HUMAN_NPC_RANK_REGULAR + if(rank >= HUMAN_NPC_RANK_BOSS && length(boss)) + return boss + if(rank >= HUMAN_NPC_RANK_ELITE && length(elite)) + return elite + if(zone == ZONE_RED && length(red)) + return red + if(zone >= ZONE_YELLOW && length(yellow)) + return yellow + return green + +/// One outfit typepath for `npc` in `zone`, or null if the table has nothing for it. +/datum/human_npc_outfit_table/proc/pick_outfit(mob/living/carbon/human/npc/npc, zone) + var/list/choices = outfits_for(npc, zone) + if(!length(choices)) + return null + return pick_weight(choices) + +/** + * What `spot` asks of a person standing there: HUMAN_NPC_SITE_* flags read from its air. Space and + * anything under tg's hazard pressure is vacuum; too little oxygen or a lungful of plasma or carbon + * dioxide is bad air; air below HUMAN_NPC_SITE_COLD_TEMP is cold. Only reads the air, so a planet tile + * keeps sharing its planet's mix (return_air_readonly(), planetary_shared_air.dm). + */ +/proc/human_npc_site_at(turf/spot) + if(isnull(spot)) + return NONE + var/datum/gas_mixture/air = spot.return_air_readonly() + if(isnull(air)) + return HUMAN_NPC_SITE_VACUUM | HUMAN_NPC_SITE_COLD + . = NONE + var/pressure = air.return_pressure() + if(pressure < HAZARD_LOW_PRESSURE) + . |= HUMAN_NPC_SITE_VACUUM + else + var/oxygen = human_npc_partial_pressure(air, /datum/gas/oxygen, pressure) + var/plasma = human_npc_partial_pressure(air, /datum/gas/plasma, pressure) + var/carbon_dioxide = human_npc_partial_pressure(air, /datum/gas/carbon_dioxide, pressure) + if(oxygen < HUMAN_NPC_SITE_MIN_OXYGEN || plasma > 0.5 || carbon_dioxide > 10) + . |= HUMAN_NPC_SITE_BAD_AIR + if(air.temperature < HUMAN_NPC_SITE_COLD_TEMP) + . |= HUMAN_NPC_SITE_COLD + +/// The partial pressure of `gas_type` in `air`, in kPa, given the mix's total `pressure`. +/proc/human_npc_partial_pressure(datum/gas_mixture/air, gas_type, pressure) + var/list/entry = air.gases[gas_type] + var/total = air.total_moles() + if(!entry || !total) + return 0 + return pressure * entry[MOLES] / total + +/** + * Its outfit, with whatever the site needs layered on (see the top of this file). Returns `outfit` + * unchanged when nothing is needed, else an outfit instance (an instance passed in is changed in place). + */ +/mob/living/carbon/human/npc/proc/npc_fit_outfit_to_site(outfit, datum/human_npc_outfit_table/table) + npc_site = human_npc_site_at(get_turf(src)) | npc_always_site | table?.always_site + // The cold of vacuum gets through anything but a space suit (SPACE_SUIT_MIN_TEMP_PROTECT) + var/needs_seal = (npc_site & HUMAN_NPC_SITE_VACUUM) && !(HAS_TRAIT(src, TRAIT_RESISTLOWPRESSURE) && HAS_TRAIT(src, TRAIT_RESISTCOLD)) + var/needs_air = (npc_site & (HUMAN_NPC_SITE_VACUUM | HUMAN_NPC_SITE_BAD_AIR)) && !HAS_TRAIT(src, TRAIT_NOBREATH) + var/needs_warmth = (npc_site & HUMAN_NPC_SITE_COLD) && !HAS_TRAIT(src, TRAIT_RESISTCOLD) + if(!needs_seal && !needs_air && !needs_warmth) + return outfit + var/datum/outfit/dressed = ispath(outfit) ? new outfit() : outfit + if(!istype(dressed)) + return outfit + if(needs_seal && !human_npc_outfit_sealed(dressed)) + var/datum/human_npc_layer/seal = human_npc_singleton(table?.vacuum_layer || /datum/human_npc_layer/eva) + seal.apply(dressed) + if(needs_air && !human_npc_outfit_has_air(dressed)) + if(!human_npc_outfit_can_breathe(dressed)) + dressed.mask = /obj/item/clothing/mask/breath + human_npc_outfit_add_tank(dressed) + // A sealed suit with its heating on keeps out the cold of space; warm clothes go on where it isn't sealed. + if(needs_warmth && !(needs_seal && human_npc_outfit_sealed(dressed))) + var/datum/human_npc_layer/warmth = human_npc_singleton(table?.cold_layer || /datum/human_npc_layer/cold) + warmth.apply(dressed) + return dressed + +/// Whether the outfit's suit and helmet both hold pressure and keep out the cold of space. A fire suit or a +/// tactical vest holds pressure too, but a person freezes in one out there (tg's cold protection). A space +/// suit keeps it out with its thermal regulator, which comes on as an outfit puts it on if it has a cell +/// (on_outfit_equip()). +/proc/human_npc_outfit_sealed(datum/outfit/outfit) + // A MODsuit on its back deploys and seals head to toe as the outfit puts it on (quick_activation()) + if(ispath(outfit.back, /obj/item/mod/control)) + return TRUE + // Typed as a space suit only to read its cell; any suit that holds pressure is checked + var/obj/item/clothing/suit/space/suit = outfit.suit + var/obj/item/clothing/head = outfit.head + if(!ispath(suit, /obj/item/clothing) || !ispath(head, /obj/item/clothing)) + return FALSE + if(!(initial(suit.clothing_flags) & STOPSPRESSUREDAMAGE) || !(initial(head.clothing_flags) & STOPSPRESSUREDAMAGE)) + return FALSE + var/suit_cold = (ispath(suit, /obj/item/clothing/suit/space) && initial(suit.cell)) ? SPACE_SUIT_MIN_TEMP_PROTECT : initial(suit.min_cold_protection_temperature) + var/head_cold = initial(head.min_cold_protection_temperature) + return suit_cold && suit_cold <= SPACE_SUIT_MIN_TEMP_PROTECT && head_cold && head_cold <= SPACE_HELM_MIN_TEMP_PROTECT + +/// Whether it wears something it can breathe a tank through: a mask or a helmet that takes internals. +/proc/human_npc_outfit_can_breathe(datum/outfit/outfit) + // A MODsuit's helmet takes internals once sealed + if(ispath(outfit.back, /obj/item/mod/control)) + return TRUE + var/obj/item/clothing/mask = outfit.mask + var/obj/item/clothing/head = outfit.head + if(ispath(mask, /obj/item/clothing) && (initial(mask.clothing_flags) & MASKINTERNALS)) + return TRUE + return ispath(head, /obj/item/clothing) && (initial(head.clothing_flags) & HEADINTERNALS) + +/// Whether it carries a tank where it can open it and can breathe through something. +/proc/human_npc_outfit_has_air(datum/outfit/outfit) + if(!human_npc_outfit_can_breathe(outfit)) + return FALSE + for(var/carried in list(outfit.suit_store, outfit.back, outfit.belt, outfit.l_pocket, outfit.r_pocket)) + if(ispath(carried, /obj/item/tank/internals)) + return TRUE + return FALSE + +/// Gives the outfit an air tank and opens it: in a space suit's or a MODsuit chestplate's storage, else a +/// pocket, else the back or the belt. Returns TRUE if it found a place. +/proc/human_npc_outfit_add_tank(datum/outfit/outfit) + if(isnull(outfit.suit_store) && (ispath(outfit.suit, /obj/item/clothing/suit/space) || ispath(outfit.back, /obj/item/mod/control))) + outfit.suit_store = /obj/item/tank/internals/oxygen + outfit.internals_slot = ITEM_SLOT_SUITSTORE + return TRUE + if(isnull(outfit.l_pocket)) + outfit.l_pocket = /obj/item/tank/internals/emergency_oxygen/double + outfit.internals_slot = ITEM_SLOT_LPOCKET + return TRUE + if(isnull(outfit.r_pocket)) + outfit.r_pocket = /obj/item/tank/internals/emergency_oxygen/double + outfit.internals_slot = ITEM_SLOT_RPOCKET + return TRUE + if(isnull(outfit.back)) + outfit.back = /obj/item/tank/internals/oxygen + outfit.internals_slot = ITEM_SLOT_BACK + return TRUE + if(isnull(outfit.belt)) + outfit.belt = /obj/item/tank/internals/emergency_oxygen/double + outfit.internals_slot = ITEM_SLOT_BELT + return TRUE + stack_trace("[outfit.type] has nowhere to carry an air tank") + return FALSE + +/** + * Clothing layered over an outfit for a site. Slots it sets are filled where the outfit leaves them + * empty, or always for the slots in `replace_slots` (ITEM_SLOT_* flags). Anything in suit storage that + * a replaced suit can't hold moves to the backpack, if there is one. + */ +/datum/human_npc_layer + var/suit + var/head + var/mask + var/gloves + var/shoes + var/neck + /// ITEM_SLOT_* flags of slots it takes over even when the outfit fills them + var/replace_slots = NONE + +/datum/human_npc_layer/proc/apply(datum/outfit/outfit) + if(suit && (isnull(outfit.suit) || (replace_slots & ITEM_SLOT_OCLOTHING))) + if(outfit.suit_store && outfit.suit != suit) + if(outfit.back) + LAZYADDASSOC(outfit.backpack_contents, outfit.suit_store, 1) + outfit.suit_store = null + outfit.suit = suit + if(head && (isnull(outfit.head) || (replace_slots & ITEM_SLOT_HEAD))) + outfit.head = head + if(mask && (isnull(outfit.mask) || (replace_slots & ITEM_SLOT_MASK))) + outfit.mask = mask + if(gloves && (isnull(outfit.gloves) || (replace_slots & ITEM_SLOT_GLOVES))) + outfit.gloves = gloves + if(shoes && (isnull(outfit.shoes) || (replace_slots & ITEM_SLOT_FEET))) + outfit.shoes = shoes + if(neck && (isnull(outfit.neck) || (replace_slots & ITEM_SLOT_NECK))) + outfit.neck = neck + +/// A plain sealed EVA suit and helmet, the fallback for any family in vacuum. +/datum/human_npc_layer/eva + suit = /obj/item/clothing/suit/space/eva + head = /obj/item/clothing/head/helmet/space/eva + replace_slots = ITEM_SLOT_OCLOTHING | ITEM_SLOT_HEAD + +/// Pirates' own space suit: lighter, armoured, and it holds a gun. +/datum/human_npc_layer/eva/pirate + suit = /obj/item/clothing/suit/space/pirate + head = /obj/item/clothing/head/helmet/space/pirate + +/// Cold-weather wear for whatever an outfit leaves bare. +/datum/human_npc_layer/cold + suit = /obj/item/clothing/suit/hooded/wintercoat + head = /obj/item/clothing/head/costume/ushanka + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/winterboots + neck = /obj/item/clothing/neck/scarf + replace_slots = ITEM_SLOT_FEET diff --git a/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm b/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm new file mode 100644 index 00000000000..199f63e5af5 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm @@ -0,0 +1,398 @@ +/** + * # Pirate faction outfits for human NPCs (N7, for phase B) + * + * The faction looks are today's faction pirate corpse outfits (voidcrew/modules/npc_ships/code/ + * faction_pirates/faction_pirate_corpses.dm), so a human pirate looks like the pirate players already + * know. These subtypes add what the basic mob only drew: the real weapon in hand, and the real spare + * magazines, shells or backup knife it fights on with (human_npc_ai.dm reloads from them the way a player + * does). Energy guns can't be reloaded by hand, so energy gunners carry a knife. + * + * Guns per faction follow PR #500's crew gun weights; melee weapons, captains and bosses hold what the + * basic mobs hold today, the bosses' with their own attack verbs (faction_pirate_humans.dm). The melee + * crew carry a crowbar for unpowered doors. The ship crews that wear these are in + * voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm. + * + * Tables (one per faction): /datum/human_npc_outfit_table/pirate/ dresses a mixed crew (melee + * and gunners, the faction's captain for elites and its boss for bosses); + * /datum/human_npc_outfit_table/pirate//gunners only gunners. The ship crews add a boarding + * table per faction (sealed for vacuum) and pick by their crew_role (faction_pirate_humans.dm). In vacuum + * each faction gets its own space suit (vacuum_layer). + */ + +/datum/human_npc_outfit_table/pirate + vacuum_layer = /datum/human_npc_layer/eva/pirate + /// Melee crew outfits: outfit typepath => weight + var/list/melee + /// Gunner outfits: outfit typepath => weight, as the faction's crew guns + var/list/gunners + /// Dress only gunners + var/gunners_only = FALSE + +/datum/human_npc_outfit_table/pirate/New() + . = ..() + if(!isnull(green)) + return + green = gunners?.Copy() || list() + if(gunners_only) + return + for(var/outfit_type in melee) + green[outfit_type] = melee[outfit_type] + +// ===== SILVERSCALE ===== + +/datum/human_npc_layer/eva/silverscale + suit = /obj/item/clothing/suit/space/pirate/silverscale + head = /obj/item/clothing/head/helmet/space/pirate/tophat + +/datum/human_npc_outfit_table/pirate/silverscale + vacuum_layer = /datum/human_npc_layer/eva/silverscale + melee = list(/datum/outfit/piratecorpse/faction/silverscale/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/silverscale/ranged/energy_gun = 3, + /datum/outfit/piratecorpse/faction/silverscale/ranged/laser_rifle = 2, + /datum/outfit/piratecorpse/faction/silverscale/ranged/pistol = 3, + /datum/outfit/piratecorpse/faction/silverscale/ranged/shotgun = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/silverscale/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/silverscale/highlord/armed = 1) + +/datum/human_npc_outfit_table/pirate/silverscale/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/silverscale/melee/armed + name = "Human NPC: Silverscale Duelist" + r_hand = /obj/item/melee/energy/sword/pirate + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/silverscale/ranged/energy_gun + name = "Human NPC: Silverscale Marksman (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/silverscale/ranged/laser_rifle + name = "Human NPC: Silverscale Marksman (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/silverscale/ranged/pistol + name = "Human NPC: Silverscale Marksman (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/silverscale/ranged/shotgun + name = "Human NPC: Silverscale Marksman (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/silverscale/captain/armed + name = "Human NPC: Silverscale Noble" + r_hand = /obj/item/melee/sabre + +/datum/outfit/piratecorpse/faction/silverscale/highlord/armed + name = "Human NPC: Silverscale Highlord" + r_hand = /obj/item/melee/energy/sword/pirate/boss + +// ===== SKELETON ===== + +/datum/human_npc_layer/eva/pirate/bandana + head = /obj/item/clothing/head/helmet/space/pirate/bandana + +// Skeletons don't breathe and shrug off vacuum and cold (their species), so they rarely need the suit. +/datum/human_npc_outfit_table/pirate/skeleton + vacuum_layer = /datum/human_npc_layer/eva/pirate/bandana + melee = list(/datum/outfit/piratecorpse/faction/skeleton/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/skeleton/ranged/laser_rifle = 1, + /datum/outfit/piratecorpse/faction/skeleton/ranged/pistol = 2, + /datum/outfit/piratecorpse/faction/skeleton/ranged/smg = 2, + /datum/outfit/piratecorpse/faction/skeleton/ranged/shotgun = 3, + ) + elite = list(/datum/outfit/piratecorpse/faction/skeleton/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/skeleton/davyjones/armed = 1) + +/datum/human_npc_outfit_table/pirate/skeleton/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/skeleton/melee/armed + name = "Human NPC: Skeleton Swashbuckler" + r_hand = /obj/item/melee/energy/sword/pirate + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/skeleton/ranged/laser_rifle + name = "Human NPC: Skeleton Gunner (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/skeleton/ranged/pistol + name = "Human NPC: Skeleton Gunner (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/skeleton/ranged/smg + name = "Human NPC: Skeleton Gunner (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/skeleton/ranged/shotgun + name = "Human NPC: Skeleton Gunner (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/skeleton/captain/armed + name = "Human NPC: Skeleton Captain" + r_hand = /obj/item/claymore/cutlass + +// His flask of cursed grog (voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm) where +// the corpse kept a doubloon. +/datum/outfit/piratecorpse/faction/skeleton/davyjones/armed + name = "Human NPC: Davy Jones" + r_hand = /obj/item/claymore/cutlass/davy_jones + l_pocket = /obj/item/reagent_containers/cup/glass/flask/davy_jones + +// ===== GREY TIDE ===== + +// Assistants in the station's grey EVA suits: slow, but sealed. +/datum/human_npc_outfit_table/pirate/grey + vacuum_layer = /datum/human_npc_layer/eva + melee = list(/datum/outfit/piratecorpse/faction/grey/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/grey/ranged/energy_gun = 3, + /datum/outfit/piratecorpse/faction/grey/ranged/laser_rifle = 3, + /datum/outfit/piratecorpse/faction/grey/ranged/smg = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/grey/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/grey/robust/armed = 1) + +/datum/human_npc_outfit_table/pirate/grey/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/grey/melee/armed + name = "Human NPC: Grey Tider" + r_hand = /obj/item/storage/toolbox/mechanical + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/grey/ranged/energy_gun + name = "Human NPC: Grey Tider Gunner (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/grey/ranged/laser_rifle + name = "Human NPC: Grey Tider Gunner (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/grey/ranged/smg + name = "Human NPC: Grey Tider Gunner (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/grey/captain/armed + name = "Human NPC: Tidemaster" + r_hand = /obj/item/storage/toolbox/syndicate + +/datum/outfit/piratecorpse/faction/grey/robust/armed + name = "Human NPC: The Robust One" + r_hand = /obj/item/storage/toolbox/mechanical/old/clean/robust_one + +// ===== LUSTROUS ===== + +/datum/human_npc_outfit_table/pirate/lustrous + melee = list(/datum/outfit/piratecorpse/faction/lustrous/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/lustrous/ranged/energy_gun = 3, + /datum/outfit/piratecorpse/faction/lustrous/ranged/laser_rifle = 3, + /datum/outfit/piratecorpse/faction/lustrous/ranged/pistol = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/lustrous/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/lustrous/radiant/armed = 1) + +/datum/human_npc_outfit_table/pirate/lustrous/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/lustrous/melee/armed + name = "Human NPC: Lustrous Scintillant" + r_hand = /obj/item/switchblade + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/lustrous/ranged/energy_gun + name = "Human NPC: Lustrous Coruscant (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/lustrous/ranged/laser_rifle + name = "Human NPC: Lustrous Coruscant (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/lustrous/ranged/pistol + name = "Human NPC: Lustrous Coruscant (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/lustrous/captain/armed + name = "Human NPC: Lustrous Radiant" + r_hand = /obj/item/melee/energy/sword/pirate + +/datum/outfit/piratecorpse/faction/lustrous/radiant/armed + name = "Human NPC: The Radiant One" + r_hand = /obj/item/melee/energy/sword/pirate/boss/radiant + +// ===== INTERDYNE ===== + +/datum/human_npc_layer/eva/interdyne + suit = /obj/item/clothing/suit/space/syndicate/black/med + head = /obj/item/clothing/head/helmet/space/syndicate/black/med + +/datum/human_npc_outfit_table/pirate/interdyne + vacuum_layer = /datum/human_npc_layer/eva/interdyne + melee = list(/datum/outfit/piratecorpse/faction/interdyne/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/interdyne/ranged/energy_gun = 2, + /datum/outfit/piratecorpse/faction/interdyne/ranged/pistol = 2, + /datum/outfit/piratecorpse/faction/interdyne/ranged/smg = 3, + /datum/outfit/piratecorpse/faction/interdyne/ranged/shotgun = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/interdyne/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/interdyne/prime/armed = 1) + +/datum/human_npc_outfit_table/pirate/interdyne/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/interdyne/melee/armed + name = "Human NPC: Interdyne Enforcer" + r_hand = /obj/item/scalpel + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/interdyne/ranged/energy_gun + name = "Human NPC: Interdyne Pharmacist (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/interdyne/ranged/pistol + name = "Human NPC: Interdyne Pharmacist (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/interdyne/ranged/smg + name = "Human NPC: Interdyne Pharmacist (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/interdyne/ranged/shotgun + name = "Human NPC: Interdyne Pharmacist (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/interdyne/captain/armed + name = "Human NPC: Interdyne Director" + r_hand = /obj/item/melee/energy/sword/pirate + +// Today's Director Prime shoots lasers from its body; the human one holds the gun, with its combat +// hypospray in a pocket for phase B's self-treatment. +/datum/outfit/piratecorpse/faction/interdyne/prime/armed + name = "Human NPC: Interdyne Director Prime" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + r_pocket = /obj/item/reagent_containers/hypospray/combat + +// ===== IRS ===== + +/datum/human_npc_layer/eva/irs + suit = /obj/item/clothing/suit/space/syndicate/black + head = /obj/item/clothing/head/helmet/space/syndicate/black + +/datum/human_npc_outfit_table/pirate/irs + vacuum_layer = /datum/human_npc_layer/eva/irs + melee = list(/datum/outfit/piratecorpse/faction/irs/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/irs/ranged/energy_gun = 1, + /datum/outfit/piratecorpse/faction/irs/ranged/pistol = 3, + /datum/outfit/piratecorpse/faction/irs/ranged/smg = 2, + /datum/outfit/piratecorpse/faction/irs/ranged/shotgun = 2, + ) + elite = list(/datum/outfit/piratecorpse/faction/irs/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/irs/chief/armed = 1) + +/datum/human_npc_outfit_table/pirate/irs/gunners + gunners_only = TRUE + +// A real telescopic baton: it knocks people down, where the basic mob's only did brute. +/datum/outfit/piratecorpse/faction/irs/melee/armed + name = "Human NPC: IRS Enforcer" + r_hand = /obj/item/melee/baton/telescopic + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/irs/ranged/energy_gun + name = "Human NPC: IRS Agent (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/irs/ranged/pistol + name = "Human NPC: IRS Agent (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/irs/ranged/smg + name = "Human NPC: IRS Agent (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/irs/ranged/shotgun + name = "Human NPC: IRS Agent (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/irs/captain/armed + name = "Human NPC: IRS Head Auditor" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/irs/chief/armed + name = "Human NPC: IRS Chief Auditor" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + r_pocket = /obj/item/reagent_containers/hypospray/medipen/stimulants + +// ===== MEDIEVAL ===== + +// Footsoldiers and the warlord hate guns (TRAIT_NOGUNS, set by phase B on their types); only the +// crossbowmen shoot, and their crossbows recharge by themselves. +/datum/human_npc_outfit_table/pirate/medieval + melee = list(/datum/outfit/piratecorpse/faction/medieval/melee/armed = 2) + gunners = list(/datum/outfit/piratecorpse/faction/medieval/ranged/crossbow = 1) + elite = list(/datum/outfit/piratecorpse/faction/medieval/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/medieval/blackknight/armed = 1) + +/datum/human_npc_outfit_table/pirate/medieval/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/medieval/melee/armed + name = "Human NPC: Medieval Footsoldier" + r_hand = /obj/item/claymore/shortsword + l_pocket = /obj/item/crowbar + +/datum/outfit/piratecorpse/faction/medieval/ranged/crossbow + name = "Human NPC: Medieval Crossbowman" + r_hand = /obj/item/gun/energy/recharge/ebow/large + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/medieval/captain/armed + name = "Human NPC: Medieval Warlord" + r_hand = /obj/item/fireaxe/boardingaxe/warlord + +/datum/outfit/piratecorpse/faction/medieval/blackknight/armed + name = "Human NPC: The Black Knight" + r_hand = /obj/item/claymore/black_knight diff --git a/voidcrew/modules/human_npcs/human_npc_samples.dm b/voidcrew/modules/human_npcs/human_npc_samples.dm new file mode 100644 index 00000000000..216b76347d8 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_samples.dm @@ -0,0 +1,162 @@ +// Sample human NPCs: the reference kit for the base (admin spawns, tests, the measurement harness). +// Phases B-E make their own types (pirate factions, troopers, hermits, criminals) and replace these. +// The ranged samples carry their spare ammunition, and the spacer its own air: no NPC refills from +// nothing or breathes vacuum. + +/datum/outfit/human_npc + name = "Human NPC" + +/datum/outfit/human_npc/pirate_gunner + name = "Human NPC: pirate gunner" + uniform = /obj/item/clothing/under/costume/pirate + suit = /obj/item/clothing/suit/costume/pirate/armored + head = /obj/item/clothing/head/costume/pirate/armored + glasses = /obj/item/clothing/glasses/eyepatch + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/jackboots + belt = /obj/item/storage/belt/military + belt_contents = list( + /obj/item/ammo_box/magazine/m9mm = 2, + /obj/item/knife/combat/survival = 1, + ) + r_hand = /obj/item/gun/ballistic/automatic/pistol + +/datum/outfit/human_npc/pirate_laser + name = "Human NPC: pirate laser gunner" + uniform = /obj/item/clothing/under/costume/pirate + suit = /obj/item/clothing/suit/costume/pirate/armored + head = /obj/item/clothing/head/costume/pirate/armored + glasses = /obj/item/clothing/glasses/eyepatch + shoes = /obj/item/clothing/shoes/jackboots + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/knife/combat/survival = 1) + r_hand = /obj/item/gun/energy/laser + +/datum/outfit/human_npc/pirate_swashbuckler + name = "Human NPC: pirate swashbuckler" + uniform = /obj/item/clothing/under/costume/pirate + head = /obj/item/clothing/head/costume/pirate/bandana/armored + glasses = /obj/item/clothing/glasses/eyepatch + shoes = /obj/item/clothing/shoes/jackboots + r_hand = /obj/item/melee/energy/sword/pirate + +/// For hulls and ruins open to space: a sealed suit and its own air. +/datum/outfit/human_npc/pirate_spacer + name = "Human NPC: pirate spacer" + uniform = /obj/item/clothing/under/costume/pirate + suit = /obj/item/clothing/suit/space/pirate + head = /obj/item/clothing/head/helmet/space/pirate + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/jackboots + suit_store = /obj/item/tank/internals/oxygen + internals_slot = ITEM_SLOT_SUITSTORE + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/knife/combat/survival = 1) + r_hand = /obj/item/gun/energy/laser + +/mob/living/carbon/human/npc/sample + faction = list(FACTION_PIRATE) + npc_name = "Pirate" + +/mob/living/carbon/human/npc/sample/gunner + npc_name = "Pirate Gunner" + npc_outfit = /datum/outfit/human_npc/pirate_gunner + npc_death_cash = 100 + +/mob/living/carbon/human/npc/sample/laser + npc_name = "Pirate Gunner" + npc_outfit = /datum/outfit/human_npc/pirate_laser + npc_death_cash = 100 + +/mob/living/carbon/human/npc/sample/swashbuckler + npc_name = "Pirate Swashbuckler" + npc_outfit = /datum/outfit/human_npc/pirate_swashbuckler + npc_death_cash = 100 + +/mob/living/carbon/human/npc/sample/spacer + npc_name = "Pirate Spacer" + npc_outfit = /datum/outfit/human_npc/pirate_spacer + npc_death_cash = 100 + +/// An elite: lands aimed crits and executions, and takes a quarter less brute and burn. +/mob/living/carbon/human/npc/sample/elite + npc_name = "Pirate Bosun" + npc_outfit = /datum/outfit/human_npc/pirate_gunner + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = 0.75 + npc_death_cash = 300 + +// ===== Phase A2 samples: a voice, surrender rules and a zone table, for tests and admin spawns ===== + +/datum/human_npc_voice/sample + lines = list( + HUMAN_NPC_BARK_DOWNED = list("I'm hit! I'm hit!", "Not like this..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathing.", "That all you've got?"), + HUMAN_NPC_BARK_PLEAD = list("Please... don't.", "I give, I give..."), + HUMAN_NPC_BARK_CUFFED = list("These are too tight."), + HUMAN_NPC_BARK_SLIPPING = list("Almost got it..."), + HUMAN_NPC_BARK_UNCUFFED = list("Hands free."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("Alright! I give up!", "Don't shoot!"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + +/datum/human_npc_surrender/sample + +/mob/living/carbon/human/npc/sample + npc_voice = /datum/human_npc_voice/sample + +/// Gives up when it is losing, and stops shooting anyone already down: the shape of a criminal. +/mob/living/carbon/human/npc/sample/thug + npc_name = "Thug" + npc_outfit = /datum/outfit/human_npc/thug + npc_surrender = /datum/human_npc_surrender/sample + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + npc_death_cash = 100 + +/// Dressed by zone: rags in green, a vest in yellow, a vest, helmet and SMG in red. +/mob/living/carbon/human/npc/sample/raider + npc_name = "Raider" + npc_outfit_table = /datum/human_npc_outfit_table/sample_raiders + npc_death_cash = 100 + +/datum/human_npc_outfit_table/sample_raiders + green = list(/datum/outfit/human_npc/raider = 1) + yellow = list(/datum/outfit/human_npc/raider/vest = 1) + red = list(/datum/outfit/human_npc/raider/armoured = 1) + elite = list(/datum/outfit/human_npc/pirate_gunner = 1) + vacuum_layer = /datum/human_npc_layer/eva/pirate + +/datum/outfit/human_npc/raider + name = "Human NPC: raider" + uniform = /obj/item/clothing/under/costume/pirate + head = /obj/item/clothing/head/costume/pirate/bandana/armored + shoes = /obj/item/clothing/shoes/jackboots + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/human_npc/raider/vest + name = "Human NPC: raider in a vest" + suit = /obj/item/clothing/suit/armor/vest + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/human_npc/raider/armoured + name = "Human NPC: armoured raider" + suit = /obj/item/clothing/suit/armor/vest + head = /obj/item/clothing/head/helmet + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/// A pistol and somewhere to holster it, so it has somewhere to put the gun when it gives up. +/datum/outfit/human_npc/thug + name = "Human NPC: thug" + uniform = /obj/item/clothing/under/costume/pirate + shoes = /obj/item/clothing/shoes/jackboots + gloves = /obj/item/clothing/gloves/color/black + belt = /obj/item/storage/belt/holster + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/reagent_containers/hypospray/medipen diff --git a/voidcrew/modules/human_npcs/human_npc_soft_crit.dm b/voidcrew/modules/human_npcs/human_npc_soft_crit.dm new file mode 100644 index 00000000000..e60c6715fef --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_soft_crit.dm @@ -0,0 +1,162 @@ +/** + * # Human NPCs in soft crit (N5) + * + * A person in soft crit is on the floor and (Voidcrew's soft-crit item use, PR #284, + * voidcrew/edits/modules/mob/mob.dm) can use what is on them at a quarter of the speed. An NPC stays + * where it fell (the owner didn't want NPCs crawling about), pleads now and then, and injects a + * medipen it carries. Elites and bosses also fire wild from the floor, at the ground around their + * attacker, the way a player shooting from crit does. Everything goes through tg: the medipen is the player's "use in hand" (execute_mode()), the shot is a click with the gun. + * Brute packs and other medical stacks need a do_after, which tg refuses to anyone incapacitated, so a + * person in soft crit can't use them, and neither can an NPC. + * + * tg's basic controller turns the AI off at any stat above CONSCIOUS. human_npc_ai_able_to_run() lets a + * human NPC's AI run in soft crit; this subtree must then come first in its planning list, so nothing + * else plans while it is down. Hard crit, unconscious and dead still stop the AI. + * + * Who a family keeps attacking is npc_target_stat on the NPC (HUMAN_NPC_FINISHES_DOWNED or + * HUMAN_NPC_SPARES_DOWNED), written into BB_TARGET_MINIMUM_STAT at spawn. + */ + +/** + * The run check for any controller whose pawn is a human NPC: the basic controller's, except that cuffs + * don't stop it (so escape_captivity can work at them, phase A) and soft crit doesn't either (so the + * soft-crit subtree runs). A controller of another type (PR #500's pirate crew) calls this from its own + * get_able_to_run(). + */ +/proc/human_npc_ai_able_to_run(datum/ai_controller/controller) + var/mob/living/living_pawn = controller.pawn + if(HAS_TRAIT(living_pawn, TRAIT_AI_PAUSED) || world.time < controller.paused_until) + return AI_UNABLE_TO_RUN + if(living_pawn.stat && living_pawn.stat != SOFT_CRIT && !(controller.ai_traits & CAN_ACT_WHILE_DEAD)) + return AI_UNABLE_TO_RUN + var/ignore_incap_flags = INCAPABLE_RESTRAINTS + if(controller.ai_traits & CAN_ACT_IN_STASIS) + ignore_incap_flags |= INCAPABLE_STASIS + if(controller.ai_traits & CAN_ACT_WHILE_GRABBED) + ignore_incap_flags |= INCAPABLE_GRAB + // The same test the click code uses in soft crit: crit's own incapacitation doesn't count, a stun does. + if(living_pawn.incapacitated_except_softcrit(ignore_incap_flags)) + return AI_UNABLE_TO_RUN + if((controller.ai_traits & PAUSE_DURING_DO_AFTER) && LAZYLEN(living_pawn.do_afters)) + return AI_UNABLE_TO_RUN | AI_PREVENT_CANCEL_ACTIONS + return NONE + +/// For a human NPC controller's on_stat_changed(): falling into soft crit drops whatever it was doing +/// on its feet (a reload, a shot, a chase), so only the soft-crit subtree's behaviours run. +/proc/human_npc_ai_stat_changed(datum/ai_controller/controller, new_stat) + if(new_stat == SOFT_CRIT) + controller.CancelActions() + +/// In soft crit: treat itself, fire wild (elites and bosses), plead. It stays where it fell. Must come first. +/datum/ai_planning_subtree/human_npc_soft_crit + +/datum/ai_planning_subtree/human_npc_soft_crit/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/npc = controller.pawn + if(npc.stat != SOFT_CRIT) + return + var/mob/living/carbon/human/npc/human_npc = ishumannpc(npc) ? npc : null + if(human_npc && world.time >= human_npc.npc_next_plead) + human_npc.npc_next_plead = world.time + HUMAN_NPC_PLEAD_INTERVAL + human_npc.npc_bark(HUMAN_NPC_BARK_PLEAD) + if(world.time >= controller.blackboard[BB_HUMAN_NPC_NEXT_TREAT]) + var/obj/item/treatment = human_npc_soft_crit_treatment(npc) + if(treatment) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, treatment) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + var/mob/living/threat = human_npc_nearest_threat(controller) + if(isnull(threat)) + return SUBTREE_RETURN_FINISH_PLANNING + controller.set_blackboard_key(BB_HUMAN_NPC_FLEE_FROM, threat) + if(human_npc?.npc_rank >= HUMAN_NPC_RANK_ELITE && !isnull(human_npc_loaded_gun(npc))) + controller.queue_behavior(/datum/ai_behavior/human_npc_fire_wild, BB_HUMAN_NPC_FLEE_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Something it carries that it can use on itself in soft crit: a medipen with something in it. +/proc/human_npc_soft_crit_treatment(mob/living/carbon/npc) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in npc.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(pen.used_up || !pen.reagents?.total_volume) + continue + if(npc.is_holding(pen) || human_npc_can_draw(npc, pen)) + return pen + return null + +/// The closest mob it can see that it would fight, whatever state that mob is in. Its target first. +/proc/human_npc_nearest_threat(datum/ai_controller/controller) + var/mob/living/npc = controller.pawn + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(isliving(target) && !QDELETED(target) && target.z == npc.z && can_see(npc, target, HUMAN_NPC_SIGHT_RANGE)) + return target + var/mob/living/closest + var/closest_distance = INFINITY + for(var/mob/living/other in oview(HUMAN_NPC_SIGHT_RANGE, npc)) + if(other.stat == DEAD || npc.faction_check_atom(other)) + continue + var/distance = get_dist(npc, other) + if(distance < closest_distance) + closest = other + closest_distance = distance + return closest + +/** + * Uses a medipen on itself the way a player in soft crit does: takes it in hand (holstering its gun if + * both hands are full), then "use in hand", then lets the empty pen fall. + */ +/datum/ai_behavior/human_npc_treat_self + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_treat_self/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/reagent_containers/hypospray/medipen/pen = controller.blackboard[item_key] + if(!istype(pen) || QDELETED(pen) || human_npc_holding(pen) != npc) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(pen.used_up) + if(npc.is_holding(pen)) + npc.dropItemToGround(pen) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!npc.is_holding(pen)) + if(!length(npc.get_empty_held_indexes())) + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(isnull(gun) || !gun.equip_to_best_slot(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(npc, pen)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, pen)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + npc.execute_mode() + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/human_npc_treat_self/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_TREAT, world.time + (succeeded ? 30 SECONDS : 10 SECONDS)) + +/** + * Fires its gun from the floor at the ground around `target`, not at the person: it can barely aim. + * Half the rate of a standing shooter. Only for elites and bosses (the soft-crit subtree decides). + */ +/datum/ai_behavior/human_npc_fire_wild + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_fire_wild/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(npc.stat != SOFT_CRIT || QDELETED(target) || !can_see(npc, target, controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_SHOT] || npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/turf/aim = pick(RANGE_TURFS(1, get_turf(target))) + controller.ai_interact(target = aim, combat_mode = TRUE) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + 2 * (controller.blackboard[BB_HUMAN_NPC_SHOT_DELAY] || 3 SECONDS)) + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/human_npcs/human_npc_spawning.dm b/voidcrew/modules/human_npcs/human_npc_spawning.dm new file mode 100644 index 00000000000..c5f583c9d6b --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_spawning.dm @@ -0,0 +1,303 @@ +/** + * # Making human NPCs, and clearing their bodies away + * + * A human costs about 11 ms to build (spike measurement), so a boarding wave of eight built in one + * tick stalls the server for two. Every human NPC goes through spawn_human_npc(), which queues an + * order; SShuman_npcs builds HUMAN_NPC_SPAWNS_PER_FIRE of them per fire. + * + * The same subsystem cleans up corpses so they don't pile up over a 6 hour round: a body dead for + * its npc_corpse_lifetime is deleted the first time a sweep finds no player near it, with the limbs + * and organs it lost that lie unwatched on the floor. Bodies being dragged, carried or watched stay. + * An NPC whose own system cleans it up sets npc_corpse_lifetime = 0. It also deletes living NPCs that + * are walking out (npc_walk_out(), human_npc_states.dm) once they may go. + * + * An order dressed by an outfit table (human_npc_outfits.dm) needs its zone. While its spawn context + * is still resolving the zone (a planet level registering late), the order waits in the queue, up to + * HUMAN_NPC_ZONE_WAIT, and is then dressed for green. + */ + +/** + * Queues a human NPC to be built at `where`. Returns the order; qdel() it to cancel. + * + * * npc_type - a /mob/living/carbon/human/npc typepath + * * where - where it appears (its turf is used) + * * on_spawned - called with the new mob straight after it is built, the same tick + * * look - overrides the type's npc_look: a /obj/effect/mob_spawn typepath, a /datum/bounty_look or a + * /datum/bounty_record + * * outfit - overrides the type's npc_outfit: an outfit typepath, or a /datum/human_npc_outfit_table + * typepath to pick one by zone and rank + * * name - overrides the type's npc_name + * * faction - overrides the type's faction list + * * context - a /datum/npc_spawn_context (voidcrew/modules/npc_scaling/); the body is built through + * spawn_npc_in_zone() and the context finalised after on_spawned + * * immediate - build it now instead of queueing it (admin tools, tests) + * * dir - the way it faces when it appears (a mapped spawner's dir), or NONE for its own + */ +/proc/spawn_human_npc(npc_type, atom/where, datum/callback/on_spawned, look, outfit, name, list/faction, datum/npc_spawn_context/context, immediate = FALSE, dir = NONE) + var/datum/human_npc_order/order = new(npc_type, where, on_spawned, look, outfit, name, faction, context) + order.dir = dir + if(immediate) + order.fulfil() + else + SShuman_npcs.queue += order + return order + +/// One queued human NPC. Holds what spawn_human_npc() was given until the body is built. +/datum/human_npc_order + var/npc_type + var/turf/where + var/datum/callback/on_spawned + var/look + var/datum/outfit/outfit + var/name + var/list/faction + var/datum/npc_spawn_context/context + /// The way it faces when built, or NONE + var/dir = NONE + /// The area its spot was in when queued: the order is dropped if the spot has been reused since + var/area/where_area + /// Weakref to the map region its spot was in when queued (a packed encounter's footprint or a + /// reservation), for the same check; null on a level that isn't packed + var/datum/weakref/where_region + /// The ZONE_* it is dressed for, once known (resolve_zone()) + var/zone + /// world.time it was queued + var/queued_at = 0 + /// world.time it may next look up a zone it is waiting for + var/next_zone_check = 0 + /// Weakref to the mob once it is built + var/datum/weakref/spawned_ref + /// Set once fulfil() has run, whether or not it made a mob + var/done = FALSE + +/datum/human_npc_order/New(npc_type, atom/where, datum/callback/on_spawned, look, outfit, name, list/faction, datum/npc_spawn_context/context) + if(!ispath(npc_type, /mob/living/carbon/human/npc)) + CRASH("spawn_human_npc() was given [npc_type], which is not a human NPC type") + src.npc_type = npc_type + src.where = get_turf(where) + src.where_area = src.where?.loc + var/datum/region = map_region_for_turf(src.where) + src.where_region = region ? WEAKREF(region) : null + src.on_spawned = on_spawned + src.look = look + src.outfit = outfit + src.name = name + src.faction = faction?.Copy() + src.context = context + queued_at = world.time + +/datum/human_npc_order/Destroy(force) + SShuman_npcs.queue -= src + release() + return ..() + +/// Drops what the order held, so a fulfilled order keeps nothing alive. +/datum/human_npc_order/proc/release() + where = null + where_area = null + where_region = null + on_spawned = null + look = null + outfit = null + faction = null + context = null + +/// Whether its body is dressed from an outfit table, and so needs to know its zone first. +/datum/human_npc_order/proc/needs_zone() + if(ispath(outfit, /datum/human_npc_outfit_table)) + return TRUE + if(outfit) + return FALSE + var/mob/living/carbon/human/npc/npc_path = npc_type + return !initial(npc_path.npc_outfit) && ispath(initial(npc_path.npc_outfit_table), /datum/human_npc_outfit_table) + +/** + * The zone to dress it for, or null while it is still unknown and worth waiting for: its context's + * zone, else the zone of its spawn turf (a ship's overmap tile when aboard one). `now` settles for + * green instead of waiting, as does an order that has waited HUMAN_NPC_ZONE_WAIT. + */ +/datum/human_npc_order/proc/resolve_zone(now = TRUE) + if(!isnull(zone)) + return zone + zone = context?.zone_type || get_zone_type_for_player_turf(where) + if(isnull(zone) && (now || isnull(context) || world.time >= queued_at + HUMAN_NPC_ZONE_WAIT)) + zone = ZONE_GREEN + return zone + +/// Whether it can be built now: it doesn't need a zone, or its zone is known. A waiting order looks +/// its zone up at most once a second. +/datum/human_npc_order/proc/ready() + if(!isnull(zone) || !needs_zone()) + return TRUE + if(world.time < next_zone_check) + return FALSE + next_zone_check = world.time + 1 SECONDS + return !isnull(resolve_zone(now = FALSE)) + +/** + * Whether the spot it was queued for is still the same place. An order can wait in the queue (up to + * HUMAN_NPC_ZONE_WAIT for its zone); by then the ruin or planet it was for may have unloaded and its + * turfs been reused by something else. The spot must still exist, be in the same area and the same + * packed map region, and not have become a wall. + */ +/datum/human_npc_order/proc/spot_still_valid() + if(isnull(where) || QDELETED(where)) + return FALSE + if(where.loc != where_area) + return FALSE + // A region that registers after the spot was queued (a planet level coming up late) is fine; one + // that has gone, or been replaced by another, means the ground was recycled. + if(where_region && map_region_for_turf(where) != where_region.resolve()) + return FALSE + return !isclosedturf(where) + +/// Builds the NPC now. Returns it, or null if it couldn't be built. +/datum/human_npc_order/proc/fulfil() + if(done) + return spawned_ref?.resolve() + done = TRUE + SShuman_npcs.queue -= src + var/mob/living/carbon/human/npc/npc + if(spot_still_valid()) + if(context) + npc = spawn_npc_in_zone(npc_type, where, context, finalize = FALSE, constructor_args = list(src)) + else + npc = new npc_type(where, src) + if(QDELETED(npc)) + release() + return null + spawned_ref = WEAKREF(npc) + if(dir) + npc.setDir(dir) + SShuman_npcs.spawned_total++ + on_spawned?.Invoke(npc) + context?.finalize() + release() + return npc + +/// Marks a human NPC as admin-spawned (spawn_human_npcs verb). +/proc/human_npc_mark_admin_spawned(mob/living/carbon/human/npc/npc) + npc.flags_1 |= ADMIN_SPAWNED_1 + +SUBSYSTEM_DEF(human_npcs) + name = "Human NPCs" + flags = SS_NO_INIT + wait = 1 + runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT + /// Orders waiting to be built, oldest first + var/list/datum/human_npc_order/queue = list() + /// Dead human NPCs whose corpses may be cleaned up + var/list/mob/living/carbon/human/npc/corpses = list() + /// The corpses this sweep still has to look at + var/list/mob/living/carbon/human/npc/corpse_run = list() + /// world.time of the next corpse sweep + var/next_sweep = 0 + /// Human NPCs built this round + var/spawned_total = 0 + /// Corpses cleaned up this round + var/faded_total = 0 + /// Living NPCs walking out (npc_walk_out()) + var/list/mob/living/carbon/human/npc/leavers = list() + /// world.time of the next check of the leavers + var/next_leaver_sweep = 0 + /// NPCs that walked out this round + var/left_total = 0 + +/datum/controller/subsystem/human_npcs/stat_entry(msg) + msg = "Q:[length(queue)]|Made:[spawned_total]|Bodies:[length(corpses)]|Faded:[faded_total]|Leaving:[length(leavers)]|Left:[left_total]" + return ..() + +/datum/controller/subsystem/human_npcs/fire(resumed = FALSE) + if(length(queue)) + process_spawns(HUMAN_NPC_SPAWNS_PER_FIRE) + if(MC_TICK_CHECK) + return + if(length(leavers) && world.time >= next_leaver_sweep) + next_leaver_sweep = world.time + HUMAN_NPC_LEAVER_SWEEP_INTERVAL + sweep_leavers() + if(MC_TICK_CHECK) + return + if(!length(corpse_run)) + if(world.time < next_sweep || !length(corpses)) + return + next_sweep = world.time + HUMAN_NPC_CORPSE_SWEEP_INTERVAL + corpse_run = corpses.Copy() + var/deleted = 0 + while(length(corpse_run)) + var/mob/living/carbon/human/npc/body = corpse_run[length(corpse_run)] + corpse_run.len-- + if(QDELETED(body)) + corpses -= body + continue + if(body.npc_corpse_expired()) + body.npc_fade() + faded_total++ + deleted++ + if(deleted >= HUMAN_NPC_CORPSE_DELETES_PER_FIRE || MC_TICK_CHECK) + return + +/// Builds up to `budget` queued NPCs, oldest first, passing over orders still waiting for their zone. +/// Returns how many it built. +/datum/controller/subsystem/human_npcs/proc/process_spawns(budget = HUMAN_NPC_SPAWNS_PER_FIRE) + . = 0 + var/index = 1 + while(index <= length(queue) && . < budget) + var/datum/human_npc_order/order = queue[index] + if(QDELETED(order) || order.done) + queue.Cut(index, index + 1) + continue + if(!order.ready()) + index++ + continue + queue.Cut(index, index + 1) + if(order.fulfil()) + .++ + +/// Deletes the NPCs walking out that may go now (npc_may_vanish()). +/datum/controller/subsystem/human_npcs/proc/sweep_leavers() + for(var/mob/living/carbon/human/npc/leaver as anything in leavers.Copy()) + if(QDELETED(leaver) || leaver.stat == DEAD) + leavers -= leaver + continue + if(leaver.npc_may_vanish()) + leaver.npc_vanish() + left_total++ + +/datum/controller/subsystem/human_npcs/proc/track_corpse(mob/living/carbon/human/npc/body) + corpses |= body + +/datum/controller/subsystem/human_npcs/proc/forget_corpse(mob/living/carbon/human/npc/body) + corpses -= body + corpse_run -= body + +/** + * Map placement for a human NPC: queues `npc_type` here when the map loads, so a ruin full of them + * builds over several ticks. + */ +/obj/effect/spawner/human_npc + name = "human NPC spawner" + icon = 'icons/effects/mapping_helpers.dmi' + icon_state = "mobspawner" + /// The /mob/living/carbon/human/npc typepath to spawn + var/npc_type + /// A name for this one NPC (a mapped character), or null for its type's + var/npc_name + /// Factions for this one NPC (a mapped camp's own side), or null for its type's + var/list/npc_faction + /// The family root this type is: never mapped and places nothing, so it may have no npc_type. A + /// family's own root subtype sets this to itself (tests make one of every type). + var/abstract_spawner_type = /obj/effect/spawner/human_npc + +/obj/effect/spawner/human_npc/Initialize(mapload) + . = ..() + if(!ispath(npc_type, /mob/living/carbon/human/npc)) + if(type != abstract_spawner_type) + stack_trace("[type] at [AREACOORD(src)] has no human NPC type to spawn") + return +#ifdef UNIT_TESTS + // The create-and-destroy test makes one of every type on one tile; it isn't a map, so place nothing. + if(GLOB.running_create_and_destroy) + return +#endif + // The mapped spawner's dir is the way the NPC faces. + spawn_human_npc(npc_type, get_turf(src), name = npc_name, faction = npc_faction, context = get_npc_spawn_context(), dir = dir) diff --git a/voidcrew/modules/human_npcs/human_npc_spike_harness.dm b/voidcrew/modules/human_npcs/human_npc_spike_harness.dm new file mode 100644 index 00000000000..894b95afc05 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_spike_harness.dm @@ -0,0 +1,568 @@ +// Measurement harness for the human NPC spike. Compiled only with -DHUMAN_NPC_SPIKE_HARNESS; +// nothing here exists in a normal build. +// +// Boots a clientless world, starts the round, builds a walled, lit, breathable test room in a +// turf reservation, then spawns NPCs of one kind (world.params "spike_kind": human, +// human_untrimmed or trooper) in steps of 20, 50 and 100. At each step it measures them dormant +// (no player on their z-level), awake with nothing to fight, and in combat against damage-proof +// human dummies; then their corpses; then a 25 vs 25 fight to the death. Results go to +// data/human_npc_spike__*: a CSV of per-phase numbers, BYOND profiler dumps, and instance +// censuses. The current phase name is written to data/human_npc_spike__phase.txt so an +// outside sampler can tag process memory readings with it. +#ifdef HUMAN_NPC_SPIKE_HARNESS + +/// Mobs the harness spawned and controls, assoc mob -> TRUE. +GLOBAL_LIST_EMPTY(human_npc_spike_mobs) +/// Whether the harness pretends a player is on the test room's z-level. +GLOBAL_VAR_INIT(human_npc_spike_awake, FALSE) +/// Human NPC Life() calls that got past the dormancy guard (counted in human_npc.dm under this define). +GLOBAL_VAR_INIT(human_npc_spike_life_passes, 0) + +/// Same NPC with every Life() trim switched off, to measure what the trims save. +/mob/living/carbon/human/npc/sample/laser/spike_untrimmed + npc_trim_life = FALSE + +/mob/living/carbon/human/npc/npc_players_on_level() + if(GLOB.human_npc_spike_mobs[src]) + return GLOB.human_npc_spike_awake + return ..() + +// A clientless world has no clients on any z-level, so every AI would be off. While "awake", the +// harness's own mobs get the status a mob near a player would get. +/datum/ai_controller/get_expected_ai_status() + if(GLOB.human_npc_spike_awake && GLOB.human_npc_spike_mobs[pawn]) + var/mob/living/living_pawn = pawn + if(living_pawn.stat != DEAD && able_to_run && !on_failed_planning_timeout && isturf(living_pawn.loc) && !living_pawn.client) + return AI_STATUS_ON + return ..() + +#define SPIKE_ROOM_W 30 +#define SPIKE_ROOM_H 36 + +SUBSYSTEM_DEF(human_npc_spike) + name = "Human NPC Spike Harness" + flags = SS_NO_FIRE + /// human, human_untrimmed or trooper + var/kind = "human" + var/mob_type + var/out_prefix + var/csv_path + var/phase_path + var/datum/turf_reservation/reservation + /// Bottom-left floor turf of the test room. + var/turf/origin + var/list/turf/room_turfs = list() + var/list/mob/living/npcs = list() + var/list/mob/living/dummies = list() + +/datum/controller/subsystem/human_npc_spike/Initialize() + Master.sleep_offline_after_initializations = FALSE + var/list/params = world.params + kind = params?["spike_kind"] || "human" + switch(kind) + if("trooper") + mob_type = /mob/living/basic/trooper/pirate/ranged + if("human_untrimmed") + mob_type = /mob/living/carbon/human/npc/sample/laser/spike_untrimmed + else + kind = "human" + mob_type = /mob/living/carbon/human/npc/sample/laser + if(params?["spike_mode"] == "dormancy") + kind = "[kind]_dormancy" + out_prefix = "data/human_npc_spike_[kind]" + csv_path = "[out_prefix]_results.csv" + phase_path = "[out_prefix]_phase.txt" + fdel(csv_path) + rustg_file_write("kind,phase,n,metric,value\n", csv_path) + set_phase("init") + SSticker.start_immediately = TRUE + if(params?["spike_mode"] == "dormancy") + INVOKE_ASYNC(src, PROC_REF(run_dormancy_proof)) + else + INVOKE_ASYNC(src, PROC_REF(run_spike)) + return SS_INIT_SUCCESS + +/datum/controller/subsystem/human_npc_spike/proc/set_phase(label) + rustg_file_write(label, phase_path) + log_world("human_npc_spike: phase [label]") + +/datum/controller/subsystem/human_npc_spike/proc/record(phase, n, metric, value) + rustg_file_append("[kind],[phase],[n],[metric],[value]\n", csv_path) + +/datum/controller/subsystem/human_npc_spike/proc/run_spike() + UNTIL(SSticker.current_state == GAME_STATE_PLAYING) + SSticker.delay_end = TRUE + SSprofiler.can_fire = FALSE + world.Profile(PROFILE_STOP) + set_phase("settle_roundstart") + sleep(60 SECONDS) + if(!build_room()) + set_phase("failed_room") + return + sleep(10 SECONDS) + census("baseline") + measure("baseline", 0, 30 SECONDS) + profile("baseline", 0, 15 SECONDS) + + for(var/n in list(20, 50, 100)) + spawn_npcs_to(n) + set_awake(FALSE) + settle("spawned", n, 20 SECONDS) + if(n == 100) + census("spawned_100") + measure("dormant", n, 30 SECONDS) + set_awake(TRUE) + settle("awake_settle", n, 10 SECONDS) + measure("awake", n, 30 SECONDS) + profile("awake", n, 15 SECONDS) + spawn_dummies() + settle("combat_settle", n, 10 SECONDS) + measure("combat", n, 45 SECONDS) + profile("combat", n, 15 SECONDS) + clear_dummies() + set_awake(FALSE) + settle("post_combat", n, 10 SECONDS) + clear_debris() + + bulk_memory(400) + + // Corpses: kill every NPC the way a fight would, then measure the bodies left behind. + set_awake(TRUE) + kill_npcs() + settle("corpse_settle", 100, 20 SECONDS) + census("corpses_100") + measure("corpses_awake", 100, 30 SECONDS) + profile("corpses_awake", 100, 15 SECONDS) + set_awake(FALSE) + measure("corpses_dormant", 100, 30 SECONDS) + clear_room() + settle("cleared", 0, 20 SECONDS) + census("cleared") + + team_fight(25) + settle("after_fight", 0, 10 SECONDS) + clear_room() + settle("cleared_fight", 0, 15 SECONDS) + + bench_spawn_and_kill(20) + clear_room() + settle("cleared_bench", 0, 10 SECONDS) + bench_hits(/obj/projectile/bullet/c9mm, 150) + bench_hits(/obj/projectile/beam/laser, 150) + clear_room() + settle("cleared_hits", 0, 15 SECONDS) + + // The same fight again under the profiler, to see where its time goes. + world.Profile(PROFILE_CLEAR) + world.Profile(PROFILE_START) + team_fight(25, "fight_profiled") + var/fight_profile = world.Profile(PROFILE_REFRESH, format = "json") + world.Profile(PROFILE_STOP) + rustg_file_write(fight_profile, "[out_prefix]_profile_fight_25.json") + settle("after_fight_profiled", 0, 10 SECONDS) + set_phase("done") + +/** + * spike_mode=dormancy: a short run for the production base. Queues 100 NPCs through + * spawn_human_npc() and records how many were built per tick, then measures their Life() and AI + * cost dormant, awake, and as corpses without and with a player on the z-level, counting the Life() + * calls that got past the dormancy guard in each phase, and checks that a waking corpse catches up + * the organ decay it slept through. + */ +/datum/controller/subsystem/human_npc_spike/proc/run_dormancy_proof() + UNTIL(SSticker.current_state == GAME_STATE_PLAYING) + SSticker.delay_end = TRUE + SSprofiler.can_fire = FALSE + world.Profile(PROFILE_STOP) + set_phase("settle_roundstart") + sleep(45 SECONDS) + if(!build_room()) + set_phase("failed_room") + return + sleep(10 SECONDS) + census("baseline") + measure("baseline", 0, 20 SECONDS) + queue_npcs(100) + set_awake(FALSE) + settle("spawned", 100, 15 SECONDS) + life_phase("dormant", 100, 30 SECONDS) + set_awake(TRUE) + settle("awake_settle", 100, 5 SECONDS) + life_phase("awake", 100, 30 SECONDS) + kill_npcs() + set_awake(FALSE) + settle("corpse_settle", 100, 10 SECONDS) + life_phase("corpses_dormant", 100, 30 SECONDS) + var/mob/living/carbon/human/sample_corpse + for(var/mob/living/carbon/human/npc/body as anything in npcs) + if(!QDELETED(body) && body.stat == DEAD) + sample_corpse = body + break + var/obj/item/organ/brain/brain = sample_corpse?.get_organ_slot(ORGAN_SLOT_BRAIN) + record("decay", 100, "brain_damage_asleep", brain?.damage) + set_awake(TRUE) + life_phase("corpses_awake", 100, 30 SECONDS) + record("decay", 100, "brain_damage_after_waking", brain?.damage) + set_phase("done") + +/// Queues `count` NPCs through the production spawn queue and records how fast it builds them. +/datum/controller/subsystem/human_npc_spike/proc/queue_npcs(count) + set_phase("queueing_[count]") + var/start_built = SShuman_npcs.spawned_total + var/start_time = world.time + var/first_slot = length(npcs) + for(var/i in 1 to count) + spawn_human_npc(mob_type, npc_slot(first_slot + i), on_spawned = CALLBACK(src, PROC_REF(adopt_npc))) + var/most_in_one_tick = 0 + var/last_built = start_built + var/ticks = 0 + while(SShuman_npcs.spawned_total - start_built < count && ticks < 4000) + sleep(world.tick_lag) + ticks++ + most_in_one_tick = max(most_in_one_tick, SShuman_npcs.spawned_total - last_built) + last_built = SShuman_npcs.spawned_total + record("spawn_queue", count, "built", SShuman_npcs.spawned_total - start_built) + record("spawn_queue", count, "ticks", ticks) + record("spawn_queue", count, "seconds", (world.time - start_time) / 10) + record("spawn_queue", count, "most_built_in_one_tick", most_in_one_tick) + +/datum/controller/subsystem/human_npc_spike/proc/adopt_npc(mob/living/npc) + npcs += npc + GLOB.human_npc_spike_mobs[npc] = TRUE + RegisterSignal(npc, COMSIG_QDELETING, PROC_REF(forget_mob)) + +/// measure(), plus how many Life() calls got past the dormancy guard. +/datum/controller/subsystem/human_npc_spike/proc/life_phase(label, n, duration) + GLOB.human_npc_spike_life_passes = 0 + var/start = world.time + measure(label, n, duration) + var/seconds = max((world.time - start) / 10, 1) + record(label, n, "life_passes", GLOB.human_npc_spike_life_passes) + record(label, n, "life_passes_per_npc_per_s", GLOB.human_npc_spike_life_passes / max(n, 1) / seconds) + +/datum/controller/subsystem/human_npc_spike/proc/settle(label, n, duration) + set_phase("[label]_[n]") + sleep(duration) + +/datum/controller/subsystem/human_npc_spike/proc/build_room() + reservation = SSmapping.request_turf_block_reservation(SPIKE_ROOM_W + 8, SPIKE_ROOM_H + 8, requester = "human NPC spike harness") + if(!reservation) + log_world("human_npc_spike: no reservation") + return FALSE + var/turf/bottom_left = reservation.bottom_left_turfs[1] + origin = locate(bottom_left.x + 4, bottom_left.y + 4, bottom_left.z) + var/area/misc/testroom/room_area = GLOB.areas_by_type[/area/misc/testroom] || new + for(var/dx in -1 to SPIKE_ROOM_W) + for(var/dy in -1 to SPIKE_ROOM_H) + var/turf/spot = locate(origin.x + dx, origin.y + dy, origin.z) + var/edge = dx == -1 || dy == -1 || dx == SPIKE_ROOM_W || dy == SPIKE_ROOM_H + if(edge) + spot = spot.ChangeTurf(/turf/closed/indestructible) + else + spot = spot.ChangeTurf(/turf/open/floor/iron) + room_turfs += spot + spot.change_area(spot.loc, room_area) + CHECK_TICK + record("room", 0, "z", origin.z) + return TRUE + +/// Spawn slot `index` (1-100): four columns, 25 rows, front column first. +/datum/controller/subsystem/human_npc_spike/proc/npc_slot(index, x_front = 9, x_step = -1) + var/col = round((index - 1) / 25) + var/row = (index - 1) % 25 + return locate(origin.x + x_front + col * x_step, origin.y + 5 + row, origin.z) + +/datum/controller/subsystem/human_npc_spike/proc/add_npc(turf/where, list/faction) + var/mob/living/npc = new mob_type(where) + if(faction) + npc.faction = faction.Copy() + npcs += npc + GLOB.human_npc_spike_mobs[npc] = TRUE + RegisterSignal(npc, COMSIG_QDELETING, PROC_REF(forget_mob)) + return npc + +/datum/controller/subsystem/human_npc_spike/proc/forget_mob(mob/living/gone) + SIGNAL_HANDLER + npcs -= gone + dummies -= gone + GLOB.human_npc_spike_mobs -= gone + +/datum/controller/subsystem/human_npc_spike/proc/spawn_npcs_to(n) + set_phase("spawning_[n]") + while(length(npcs) < n) + add_npc(npc_slot(length(npcs) + 1)) + CHECK_TICK + record("spawn", n, "npcs", length(npcs)) + if(length(npcs)) + var/mob/living/sample = npcs[1] + record("spawn", n, "atoms_in_one_npc", length(sample.get_all_contents())) + +/datum/controller/subsystem/human_npc_spike/proc/set_awake(awake) + GLOB.human_npc_spike_awake = awake + for(var/mob/living/npc as anything in GLOB.human_npc_spike_mobs) + var/datum/ai_controller/controller = npc.ai_controller + if(!controller) + continue + controller.can_idle = !awake + controller.reset_ai_status() + +/// BYOND reuses freed heap, so a few dozen mobs barely move the process's private bytes. Spawns +/// `count` more dormant NPCs in the free half of the room so the outside sampler sees a real step, +/// counts the world, then deletes them again. +/datum/controller/subsystem/human_npc_spike/proc/bulk_memory(count) + set_phase("bulk_spawning") + set_awake(FALSE) + var/list/bulk = list() + for(var/i in 1 to count) + var/turf/where = locate(origin.x + 12 + (i - 1) % 18, origin.y + round((i - 1) / 18), origin.z) + bulk += add_npc(where) + CHECK_TICK + settle("bulk", length(npcs), 25 SECONDS) + census("bulk_[length(npcs)]") + settle("bulk_after_census", length(npcs), 15 SECONDS) + set_phase("bulk_deleting") + for(var/mob/living/npc as anything in bulk) + qdel(npc) + CHECK_TICK + settle("bulk_deleted", length(npcs), 30 SECONDS) + +/// Damage-proof clientless humans the NPCs see as hostile. Every hit lands and is processed, but +/// deals nothing, so they never fall and the fight never ends. +/datum/controller/subsystem/human_npc_spike/proc/spawn_dummies() + for(var/k in 0 to 4) + var/turf/where = locate(origin.x + 11, origin.y + 6 + k * 6, origin.z) + var/mob/living/carbon/human/dummy_target = new(where) + dummy_target.faction = list("spike_target") + var/datum/physiology/body = dummy_target.physiology + body.brute_mod = 0 + body.burn_mod = 0 + body.tox_mod = 0 + body.oxy_mod = 0 + body.stamina_mod = 0 + body.bleed_mod = 0 + dummies += dummy_target + RegisterSignal(dummy_target, COMSIG_QDELETING, PROC_REF(forget_mob)) + +/datum/controller/subsystem/human_npc_spike/proc/clear_dummies() + for(var/mob/living/dummy_target as anything in dummies.Copy()) + qdel(dummy_target) + dummies.Cut() + +/// Deletes blood, casings, dropped items and scorch marks left on the floor. +/datum/controller/subsystem/human_npc_spike/proc/clear_debris() + var/removed = 0 + for(var/turf/spot as anything in room_turfs) + for(var/atom/movable/thing as anything in spot) + if(istype(thing, /obj/effect/decal) || isitem(thing)) + qdel(thing) + removed++ + CHECK_TICK + record("debris", length(npcs), "removed", removed) + +/// Deletes everything in the room that isn't a client's mob. +/datum/controller/subsystem/human_npc_spike/proc/clear_room() + for(var/turf/spot as anything in room_turfs) + for(var/atom/movable/thing as anything in spot) + if(ismob(thing)) + var/mob/thing_mob = thing + if(thing_mob.client) + continue + qdel(thing) + CHECK_TICK + npcs.Cut() + GLOB.human_npc_spike_mobs.Cut() + +/// Kills every NPC with a big chest hit, as a fight would. Troopers leave their human corpse. +/datum/controller/subsystem/human_npc_spike/proc/kill_npcs() + set_phase("killing") + for(var/mob/living/npc as anything in npcs.Copy()) + if(QDELETED(npc)) + continue + npc.apply_damage(300, BRUTE, BODY_ZONE_CHEST) + if(!QDELETED(npc) && npc.stat != DEAD) + npc.death() + CHECK_TICK + var/corpses = 0 + for(var/turf/spot as anything in room_turfs) + for(var/mob/living/body in spot) + if(body.stat == DEAD) + corpses++ + record("corpses", 100, "corpses_in_room", corpses) + +/// Times making `count` NPCs one by one, then killing each with one big hit (a trooper's kill +/// includes building its human corpse). A synchronous call can't span ticks, so the tick usage +/// delta of each call is its whole cost. +/datum/controller/subsystem/human_npc_spike/proc/bench_spawn_and_kill(count) + set_phase("bench_spawn_kill") + set_awake(FALSE) + var/list/made = list() + var/spawn_usage = 0 + for(var/i in 1 to count) + var/turf/where = npc_slot(i) + var/start = TICK_USAGE_REAL + made += add_npc(where) + spawn_usage += TICK_USAGE_REAL - start + stoplag() + var/kill_usage = 0 + for(var/mob/living/npc as anything in made) + if(QDELETED(npc)) + continue + var/start = TICK_USAGE_REAL + npc.apply_damage(300, BRUTE, BODY_ZONE_CHEST) + if(!QDELETED(npc) && npc.stat != DEAD) + npc.death() + kill_usage += TICK_USAGE_REAL - start + stoplag() + record("bench", count, "ms_per_spawn", TICK_DELTA_TO_MS(spawn_usage) / count) + record("bench", count, "ms_per_kill", TICK_DELTA_TO_MS(kill_usage) / count) + +/// Times `hits` hits of `projectile_type` from a human shooter on NPCs of the kind under test, +/// aimed at random zones. Each hit is processed the way a fired projectile's impact is (armour +/// check, bullet_act, wounds, gore). Damage carries between hits; an NPC that drops to hard crit +/// or dies is replaced, untimed. +/datum/controller/subsystem/human_npc_spike/proc/bench_hits(projectile_type, hits) + set_phase("bench_hits") + set_awake(FALSE) + var/turf/shooter_turf = locate(origin.x + 20, origin.y + 18, origin.z) + var/turf/target_turf = locate(origin.x + 20, origin.y + 20, origin.z) + var/mob/living/carbon/human/shooter = new(shooter_turf) + shooter.faction = list("spike_shooter") + var/mob/living/target + var/usage = 0 + var/replaced = 0 + for(var/i in 1 to hits) + if(QDELETED(target) || target.stat >= HARD_CRIT) + if(!QDELETED(target)) + qdel(target) + target = add_npc(target_turf) + replaced++ + var/obj/projectile/shot = new projectile_type(target_turf) + shot.firer = shooter + shot.fired_from = shooter + shot.starting = shooter_turf + shot.def_zone = human_npc_pick_aim_zone() + var/start = TICK_USAGE_REAL + target.projectile_hit(shot, shot.def_zone) + usage += TICK_USAGE_REAL - start + qdel(shot) + stoplag() + qdel(shooter) + var/projectile_name = replacetext("[projectile_type]", "/obj/projectile/", "") + record("bench", hits, "ms_per_hit:[projectile_name]", TICK_DELTA_TO_MS(usage) / hits) + record("bench", hits, "targets_used:[projectile_name]", replaced) + +/// Two teams of `per_side`, the kind under test on both, fight until one side is down. +/datum/controller/subsystem/human_npc_spike/proc/team_fight(per_side, label = "fight") + var/list/team_a = list() + var/list/team_b = list() + for(var/i in 1 to per_side) + team_a += add_npc(npc_slot(i, x_front = 9, x_step = -1), list("spike_a")) + team_b += add_npc(npc_slot(i, x_front = 13, x_step = 1), list("spike_b")) + settle("[label]_spawned", per_side, 10 SECONDS) + set_awake(TRUE) + var/datum/callback/fight_over = CALLBACK(src, PROC_REF(fight_over), team_a, team_b) + measure(label, per_side, 180 SECONDS, fight_over) + record(label, per_side, "a_standing", count_standing(team_a)) + record(label, per_side, "b_standing", count_standing(team_b)) + var/dead = 0 + for(var/turf/spot as anything in room_turfs) + for(var/mob/living/body in spot) + if(body.stat == DEAD) + dead++ + record(label, per_side, "dead_bodies", dead) + set_awake(FALSE) + +/datum/controller/subsystem/human_npc_spike/proc/count_standing(list/team) + . = 0 + for(var/mob/living/member as anything in team) + if(!QDELETED(member) && member.stat == CONSCIOUS) + .++ + +/datum/controller/subsystem/human_npc_spike/proc/fight_over(list/team_a, list/team_b) + return !count_standing(team_a) || !count_standing(team_b) + +/// Measures `duration` of play: world.cpu per tick, time dilation, and each subsystem's CPU time +/// from the MC's rolling usage, which records the real tick usage of every fire. +/datum/controller/subsystem/human_npc_spike/proc/measure(label, n, duration, datum/callback/stop_when) + set_phase("[label]_[n]") + for(var/datum/controller/subsystem/subsystem as anything in Master.subsystems) + subsystem.rolling_usage = list() + Master.rolling_usage_length = 30 MINUTES + Master.use_rolling_usage = TRUE + var/start_time = world.time + var/start_real = REALTIMEOFDAY + var/end_time = world.time + duration + var/list/cpu_samples = list() + var/map_cpu_sum = 0 + while(world.time < end_time) + sleep(world.tick_lag) + cpu_samples += world.cpu + map_cpu_sum += world.map_cpu + if(stop_when?.Invoke()) + break + Master.use_rolling_usage = FALSE + var/game_seconds = max((world.time - start_time) / 10, 0.1) + var/real_seconds = max((REALTIMEOFDAY - start_real) / 10, 0.1) + var/samples = max(length(cpu_samples), 1) + var/cpu_sum = 0 + for(var/sample in cpu_samples) + cpu_sum += sample + sortTim(cpu_samples, cmp = GLOBAL_PROC_REF(cmp_numeric_asc)) + record(label, n, "game_seconds", game_seconds) + record(label, n, "real_seconds", real_seconds) + record(label, n, "cpu_avg", cpu_sum / samples) + record(label, n, "cpu_p95", length(cpu_samples) ? cpu_samples[max(1, round(length(cpu_samples) * 0.95))] : 0) + record(label, n, "cpu_max", length(cpu_samples) ? cpu_samples[length(cpu_samples)] : 0) + record(label, n, "map_cpu_avg", map_cpu_sum / samples) + record(label, n, "mob_living_list", length(GLOB.mob_living_list)) + record(label, n, "ai_on", length(GLOB.ai_controllers_by_status[AI_STATUS_ON])) + record(label, n, "npcs_alive", count_alive()) + var/total_ms = 0 + for(var/datum/controller/subsystem/subsystem as anything in Master.subsystems) + var/list/usage = subsystem.rolling_usage + var/sum = 0 + for(var/i in 2 to length(usage) step 2) + sum += usage[i] + subsystem.rolling_usage = list() + if(!sum) + continue + var/ms_per_second = TICK_DELTA_TO_MS(sum) / game_seconds + total_ms += ms_per_second + record(label, n, "ss_ms_per_s:[subsystem.name]", ms_per_second) + record(label, n, "ss_total_ms_per_s", total_ms) + +/datum/controller/subsystem/human_npc_spike/proc/count_alive() + . = 0 + for(var/mob/living/npc as anything in npcs) + if(!QDELETED(npc) && npc.stat != DEAD) + .++ + +/// Runs the BYOND profiler for `duration` and saves its JSON. +/datum/controller/subsystem/human_npc_spike/proc/profile(label, n, duration) + set_phase("profile_[label]_[n]") + world.Profile(PROFILE_CLEAR) + world.Profile(PROFILE_START) + sleep(duration) + var/data = world.Profile(PROFILE_REFRESH, format = "json") + world.Profile(PROFILE_STOP) + rustg_file_write(data, "[out_prefix]_profile_[label]_[n].json") + +/// Counts every atom and datum in the world by type and saves the totals. +/datum/controller/subsystem/human_npc_spike/proc/census(label) + set_phase("census_[label]") + var/list/atoms = count_atoms() + var/list/datums = count_datums() + var/atom_total = 0 + for(var/key in atoms) + atom_total += atoms[key] + var/datum_total = 0 + for(var/key in datums) + datum_total += datums[key] + record("census_[label]", 0, "atoms_total", atom_total) + record("census_[label]", 0, "datums_total", datum_total) + rustg_file_write(json_encode(list("atoms" = atoms, "datums" = datums)), "[out_prefix]_census_[label].json") + +#undef SPIKE_ROOM_W +#undef SPIKE_ROOM_H + +#endif diff --git a/voidcrew/modules/human_npcs/human_npc_states.dm b/voidcrew/modules/human_npcs/human_npc_states.dm new file mode 100644 index 00000000000..279cc850d6e --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_states.dm @@ -0,0 +1,108 @@ +/** + * # Shared state rules for human NPCs + * + * N4, out of the fight: one test for "this body isn't fighting any more", read from tg state, used by + * boarding waves and the pirate pool slot (owner ruling OC-7), hull turrets, bounty pay, and AI morale. + * + * N8, walking out: a living NPC that leaves (a goon running off, a decoy heading for the lift, a crewman + * abandoning a fight) walks to its exit for real. Shots land, wounds slow it, cuffs and grabs stop it. + * It is deleted only when it stands on its exit tile, or when no player is near enough to see it + * (human_npc_watched()), and never while it is down, cuffed, dragged, carried or buckled. There is no + * godmode beam. SShuman_npcs checks the NPCs that are leaving once a second. + */ + +/** + * Whether `body` is out of the fight: dead, in crit (tg's TRAIT_CRITICAL_CONDITION, soft or hard, + * asleep or not), or restrained (handcuffs, a straitjacket). Stamcrit and stuns pass, so they don't + * count. Works on any mob; a basic mob is out only once dead. + */ +/proc/human_npc_out_of_fight(mob/living/body) + if(QDELETED(body) || body.stat == DEAD) + return TRUE + return HAS_TRAIT(body, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(body, TRAIT_RESTRAINED) + +// ===== Walking out ===== + +/** + * Sends it walking to `exit` (an atom; its turf is used). It is deleted on that tile, or wherever nobody + * can see it (npc_may_vanish()). `on_left`, if given, is called with it just before it goes, after + * COMSIG_HUMAN_NPC_LEFT. Call npc_stop_leaving() to call it back. + */ +/mob/living/carbon/human/npc/proc/npc_walk_out(atom/exit, datum/callback/on_left) + var/turf/exit_turf = get_turf(exit) + if(isnull(exit_turf) || stat == DEAD) + return FALSE + npc_exit = exit_turf + npc_on_left = on_left + SShuman_npcs.leavers |= src + if(istype(ai_controller)) + ai_controller.set_blackboard_key(BB_HUMAN_NPC_EXIT, exit_turf) + // The way out may be round corners: walk it with a pathfinder, as a person would. + human_npc_set_ai_movement(ai_controller, /datum/ai_movement/jps) + return TRUE + +/// Calls off its walk out. +/mob/living/carbon/human/npc/proc/npc_stop_leaving() + npc_exit = null + npc_on_left = null + SShuman_npcs.leavers -= src + if(istype(ai_controller)) + ai_controller.clear_blackboard_key(BB_HUMAN_NPC_EXIT) + human_npc_set_ai_movement(ai_controller, initial(ai_controller.ai_movement)) + +/** + * Whether a living NPC that is leaving may be deleted now: up and free (not in crit, not cuffed, not + * stunned, not dragged, carried or buckled, standing on a turf), and either on its exit tile or out of + * sight of every player. + */ +/mob/living/carbon/human/npc/proc/npc_may_vanish() + if(isnull(npc_exit) || stat != CONSCIOUS || human_npc_out_of_fight(src)) + return FALSE + if(INCAPACITATED_IGNORING(src, NONE) || pulledby || buckled || !isturf(loc)) + return FALSE + return get_turf(src) == npc_exit || !human_npc_watched(src) + +/// Switches a controller's movement type, stopping any walk the old one was doing. +/proc/human_npc_set_ai_movement(datum/ai_controller/controller, datum/ai_movement/movement_type) + if(!ispath(movement_type, /datum/ai_movement)) + return + if(controller.ai_movement?.moving_controllers[controller]) + controller.ai_movement.stop_moving_towards(controller) + controller.change_ai_movement_type(movement_type) + +/// It has left: what it carries that isn't gear drops where it stood, and it is deleted with its kit. +/mob/living/carbon/human/npc/proc/npc_vanish() + SShuman_npcs.leavers -= src + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_LEFT) + npc_on_left?.Invoke(src) + npc_on_left = null + npc_drop_not_gear() + qdel(src) + +/// While it has somewhere to walk out to, it walks there and does nothing else. +/datum/ai_planning_subtree/human_npc_walk_out + +/datum/ai_planning_subtree/human_npc_walk_out/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/npc = controller.pawn + if(npc.stat != CONSCIOUS || !controller.blackboard_key_exists(BB_HUMAN_NPC_EXIT)) + return + controller.queue_behavior(/datum/ai_behavior/human_npc_walk_out, BB_HUMAN_NPC_EXIT) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Walks to the exit. SShuman_npcs deletes it when it may go (npc_may_vanish()). +/datum/ai_behavior/human_npc_walk_out + required_distance = 0 + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_walk_out/setup(datum/ai_controller/controller, exit_key) + . = ..() + var/turf/exit = controller.blackboard[exit_key] + if(isnull(exit)) + return FALSE + set_movement_target(controller, exit) + +/datum/ai_behavior/human_npc_walk_out/perform(seconds_per_tick, datum/ai_controller/controller, exit_key) + if(!controller.blackboard_key_exists(exit_key)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/human_npcs/human_npc_surrender.dm b/voidcrew/modules/human_npcs/human_npc_surrender.dm new file mode 100644 index 00000000000..93c8f3b7f9b --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_surrender.dm @@ -0,0 +1,161 @@ +/** + * # Human NPC surrender (N2) + * + * Giving up is an AI choice followed by the same action a player takes (defaults 1.3, decision D3): + * 1. The NPC weighs its odds (a /datum/human_npc_surrender on its npc_surrender: how hurt it is, how many + * hostiles are on it, how many friends are still standing). + * 2. On a yes it stows the weapons in its hands in its own belt, bag, holster or suit storage. Its gear + * is locked (D3), so it never drops them; one with nowhere to put a gun keeps holding it, hands blocked. + * 3. It says its surrender line and runs tg's *surrender emote, a real 20 second Paralyze. That is the + * cuff window: tg cuffs (4 s) work on it like on anyone. + * 4. When the Paralyze ends and nobody cuffed it, it decides again at once: give up again, or (if its + * rules say so) run from the nearest hostile for a while, or fight on. + * There is no surrender state, trait or mark: what players see and act on is tg's Paralyze and cuffs. + * + * A family picks the numbers with a subtype, and overrides hurt_enough(), should_consider() or odds() + * for its own rules (the kingpin only once his crew is down; convicts the moment they are sighted, so + * hurt_enough() is always TRUE for them). Code that wants an NPC to give up at once calls + * npc_surrender() itself. + */ + +/// A family's surrender rules. One shared instance per type (human_npc_singleton()). +/datum/human_npc_surrender + /// Percent chance on the first roll of a fight + var/chance = 60 + /// Percent chance on later rolls + var/retry_chance = 25 + /// Percent taken off the chance for each friend still standing near it + var/ally_penalty = 20 + /// It considers giving up only at or below this share of its health + var/health_share = 0.4 + /// ...and only with at least this many hostiles near it + var/hostiles_needed = 2 + /// How far it looks for hostiles and friends, in tiles + var/range = 7 + /// Time between two rolls + var/retry_delay = 5 SECONDS + /// When a surrender's paralysis ends uncuffed and it decides not to give up again: run (TRUE), or fight on + var/flees = TRUE + /// How long it runs for + var/flee_time = 20 SECONDS + +/// Whether `npc` is in a bad enough way to think about giving up. Cheap: checked before it looks around. +/datum/human_npc_surrender/proc/hurt_enough(mob/living/carbon/human/npc/npc) + return npc.health <= npc.maxHealth * health_share + +/// Whether, having looked around, it thinks about giving up. +/datum/human_npc_surrender/proc/should_consider(mob/living/carbon/human/npc/npc, list/hostiles, list/allies) + return length(hostiles) >= hostiles_needed + +/// Percent chance it gives up on this roll. `first` is TRUE for its first roll of the fight. +/datum/human_npc_surrender/proc/odds(mob/living/carbon/human/npc/npc, list/hostiles, list/allies, first) + if(!length(hostiles)) + return 0 + return clamp((first ? chance : retry_chance) - ally_penalty * length(allies), 0, 100) + +/// The living hostiles near `npc` that are still in the fight and that it can see. +/datum/human_npc_surrender/proc/hostiles_near(mob/living/carbon/human/npc/npc) + . = list() + for(var/mob/living/other in oview(range, npc)) + if(other.faction_check_atom(npc) || human_npc_out_of_fight(other)) + continue + if(other.client || other.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == npc || npc.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == other) + . += other + +/// Its friends near it that are still standing. +/datum/human_npc_surrender/proc/allies_near(mob/living/carbon/human/npc/npc) + . = list() + for(var/mob/living/other in oview(range, npc)) + if(other.client || !npc.faction_check_atom(other) || human_npc_out_of_fight(other)) + continue + if(other.body_position == LYING_DOWN) + continue + . += other + +/// The rules it surrenders by, or null if it never does. +/mob/living/carbon/human/npc/proc/npc_surrender_rules() + if(!ispath(npc_surrender, /datum/human_npc_surrender)) + return null + return human_npc_singleton(npc_surrender) + +/** + * Gives up now, the way a player does: weapons stowed, its surrender line, tg's *surrender (a 20 second + * Paralyze). Returns TRUE if it is lying there with its hands on its head. + */ +/mob/living/carbon/human/npc/proc/npc_surrender() + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_RESTRAINED) || npc_surrendering) + return FALSE + npc_stow_weapons() + npc_bark(HUMAN_NPC_BARK_SURRENDER, force = TRUE) + npc_surrendering = TRUE + emote("surrender", intentional = TRUE) + if(!IsParalyzed()) + npc_surrendering = FALSE + return FALSE + npc_surrender_count++ + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_SURRENDERED) + return TRUE + +/// Puts the weapons in its hands away on its body. D3: never on the floor. +/mob/living/carbon/human/npc/proc/npc_stow_weapons() + for(var/obj/item/held in held_items) + if(isgun(held) || human_npc_is_melee_weapon(held)) + held.equip_to_best_slot(src) + +/// Its surrender's paralysis is over. Cuffed: taken, nothing to decide. Otherwise it decides again. +/mob/living/carbon/human/npc/proc/npc_surrender_ended() + if(IsParalyzed()) + return + npc_surrendering = FALSE + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_RESTRAINED)) + return + npc_surrender_redecide = TRUE + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_SURRENDER_ENDED) + +/** + * Rolls to give up when its rules say it is losing, and decides again straight after a surrender + * nobody took. Put it after escape_captivity and before targeting in any controller whose pawn is a + * human NPC. + */ +/datum/ai_planning_subtree/human_npc_surrender + +/datum/ai_planning_subtree/human_npc_surrender/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/npc = controller.pawn + if(!ishumannpc(npc) || npc.stat != CONSCIOUS || HAS_TRAIT(npc, TRAIT_RESTRAINED)) + return + var/datum/human_npc_surrender/rules = npc.npc_surrender_rules() + if(isnull(rules)) + return + // Running after walking away from a surrender. + if(world.time < controller.blackboard[BB_HUMAN_NPC_FLEE_UNTIL] && controller.blackboard_key_exists(BB_HUMAN_NPC_FLEE_FROM)) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_HUMAN_NPC_FLEE_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + var/redecide = npc.npc_surrender_redecide + if(!redecide && (world.time < npc.npc_next_surrender_roll || !rules.hurt_enough(npc))) + return + // Looking around costs, so it looks at most once per retry_delay. + npc.npc_next_surrender_roll = world.time + rules.retry_delay + npc.npc_surrender_redecide = FALSE + var/list/hostiles = rules.hostiles_near(npc) + var/list/allies = rules.allies_near(npc) + if(!redecide && !rules.should_consider(npc, hostiles, allies)) + return + var/first = !npc.npc_surrender_rolls + npc.npc_surrender_rolls++ + if(prob(rules.odds(npc, hostiles, allies, first))) + controller.queue_behavior(/datum/ai_behavior/human_npc_surrender) + return SUBTREE_RETURN_FINISH_PLANNING + if(redecide && rules.flees && length(hostiles)) + controller.set_blackboard_key(BB_HUMAN_NPC_FLEE_FROM, get_closest_atom(/mob/living, hostiles, npc)) + controller.set_blackboard_key(BB_HUMAN_NPC_FLEE_UNTIL, world.time + rules.flee_time) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_HUMAN_NPC_FLEE_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Gives up (npc_surrender()). +/datum/ai_behavior/human_npc_surrender + +/datum/ai_behavior/human_npc_surrender/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/npc = controller.pawn + if(!ishumannpc(npc) || !npc.npc_surrender()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm new file mode 100644 index 00000000000..23e2866be79 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm @@ -0,0 +1,145 @@ +/** + * # Ruin, planet and mission human NPCs (phase C of the human NPC migration) + * + * The people found in ruins, on planets and at mission sites: tg's troopers (space pirates, Syndicate + * operatives, Nanotrasen security, Russian mobsters), the beach and boat pirates, and the wasteland hermits + * and poacher squads. The faction pirate crews squatting in ruins or named in kill contracts are the same + * faction pirates that crew NPC ships (/mob/living/carbon/human/npc/faction_pirate, + * voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm), which do the same things for + * themselves (planet alliance, spot line, contract cash). They were + * basic and simple mobs that swapped to a corpse on death; now they are real + * /mob/living/carbon/human/npc bodies (voidcrew/modules/human_npcs/), so wounds, crit, bleeding, + * medicine, cuffs and revival work on them as on players. Developer notes: kit vault modules/human_npcs.md. + * + * What the families share (this root): + * - They belong where they spawn. On a planet they join its fauna's alliance (map_footprint.dm), as the + * mobs they replace did, so hermits and beach pirates don't fight the local wildlife. Anywhere else they + * keep their own factions. + * - A "spot" line when they pick someone new to fight (RUIN_NPC_BARK_SPOT). + * - Zone outfit tables (/datum/human_npc_outfit_table/ruin): more armour, better guns and a medipen in + * the yellow and red zones, never more health, and outfits of their own on cold sites. + * - spawn_npc_in_zone_or_queue(): planet, biome and marker spawners queue human types through + * spawn_human_npc() instead of building a dozen bodies in one tick. + * - A kill contract's cash entitlement (npc_payout_mission): a named target pays its cash once per + * contract, as the faction pirates did. + */ + +/// Bark kind: it picked someone new to fight. +#define RUIN_NPC_BARK_SPOT "spot" + +/mob/living/carbon/human/npc/ruin + npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + /// Weakref to the /datum/mission whose named target this is; its death cash pays only if that + /// contract can still claim it (/datum/mission/proc/claim_boss_cash()). + var/datum/weakref/npc_payout_mission + /// Weakref to the last mob it called out, so one fight gives one "spot" line + var/datum/weakref/npc_spotted + /// The ZONE_* its spawn order dressed it for, or null if it was made without one + var/npc_dressed_zone + +/mob/living/carbon/human/npc/ruin/Initialize(mapload, datum/human_npc_order/order) + . = ..() + npc_dressed_zone = order?.resolve_zone() + // tg's Initialize gave it the planet's alliance before it was dressed; a mapped or ordered faction + // list has replaced its factions since, so it rejoins here. + inherit_planetary_faction(src) + AddElement(/datum/element/human_npc_spot_bark) + +/mob/living/carbon/human/npc/ruin/Destroy() + npc_payout_mission = null + npc_spotted = null + return ..() + +/** + * The zone it belongs to, for zone-laddered loot: its spawn context's, which may have resolved after it + * was made (a ruin loading before its planet registered), else the zone it was dressed for, else where + * it stands. + */ +/mob/living/carbon/human/npc/ruin/proc/npc_home_zone() + return npc_spawn_context?.zone_type || npc_dressed_zone || npc_spawn_zone() + +// Natives of the planet they spawn on, like the fauna they share it with. +/mob/living/carbon/human/npc/ruin/should_inherit_planetary_faction() + return TRUE + +// A named contract target's cash goes to the contract once (the faction pirates' rule): if the +// contract is gone or has already paid, the body carries none. +/mob/living/carbon/human/npc/ruin/npc_drop_death_loot() + if(!npc_loot_dropped && npc_death_cash > 0 && npc_payout_mission) + var/datum/mission/mission = npc_payout_mission.resolve() + if(!mission?.claim_boss_cash()) + npc_death_cash = 0 + return ..() + +/** + * Says the family's spot line when the NPC picks someone new to fight. An element, so the NPC's own + * types can listen to the same blackboard signal. + */ +/datum/element/human_npc_spot_bark + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_spot_bark/Attach(datum/target) + . = ..() + if(!istype(target, /mob/living/carbon/human/npc/ruin)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target_set)) + +/datum/element/human_npc_spot_bark/Detach(datum/source) + UnregisterSignal(source, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET)) + return ..() + +/datum/element/human_npc_spot_bark/proc/on_target_set(mob/living/carbon/human/npc/ruin/source) + SIGNAL_HANDLER + var/mob/living/target = source.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!isliving(target) || source.stat != CONSCIOUS) + return + if(source.npc_spotted?.resolve() == target) + return + source.npc_spotted = WEAKREF(target) + source.npc_bark(RUIN_NPC_BARK_SPOT) + +/** + * Outfit tables for the ruin families. Green regulars wear what they always did; yellow adds armour; red + * adds a helmet or better gun and a medipen (used in soft crit, human_npc_soft_crit.dm). Families that + * are found on cold sites set a cold_layer that swaps their suit for a cold-weather one (the base cold + * layer only fills empty slots, which leaves an armour vest's wearer to freeze). + */ +/datum/human_npc_outfit_table/ruin + +/// Cold-weather wear that also replaces the suit: for outfits whose suit is armour with no warmth in it. +/datum/human_npc_layer/cold/coat + replace_slots = ITEM_SLOT_FEET | ITEM_SLOT_OCLOTHING + +/** + * spawn_npc_in_zone() for spawners that roll many mobs at once (planet population, biome terrain, zone + * markers): a human NPC type is queued through spawn_human_npc() with the same context, so it is built + * over the next ticks instead of now. Returns the mob, or the human NPC order. + */ +/proc/spawn_npc_in_zone_or_queue(spawn_type, atom/location, datum/npc_spawn_context/context) + if(ispath(spawn_type, /mob/living/carbon/human/npc)) + return spawn_human_npc(spawn_type, location, context = context) + return spawn_npc_in_zone(spawn_type, location, context) + +/// Map spawners for the ruin families (ruin_npc_spawners.dm). Each subtype names the NPC it places; +/// npc_name and npc_faction set one mapped NPC's own name and side. +/obj/effect/spawner/human_npc/ruin + name = "ruin NPC spawner" + // The family's own type places nothing (it is never mapped; tests create every type once). + abstract_spawner_type = /obj/effect/spawner/human_npc/ruin + +// ===== Derelict shuttles inside space ruins ===== +// The caravan ambush ruin docks four upstream derelict shuttles (code/datums/shuttles/ruin.dm) that +// carried trooper mobs. Following the space ruins' copy-and-redirect scheme (voidcrew/datums/ruins/space.dm), +// voidcrew copies of those maps with human NPC spawners live in _maps/voidcrew/shuttles/, and these +// prefixes point the upstream templates at them. The upstream maps stay as merge reference. +/datum/map_template/shuttle/ruin/caravan_victim + prefix = "_maps/voidcrew/shuttles/" + +/datum/map_template/shuttle/ruin/pirate_cutter + prefix = "_maps/voidcrew/shuttles/" + +/datum/map_template/shuttle/ruin/syndicate_dropship + prefix = "_maps/voidcrew/shuttles/" + +/datum/map_template/shuttle/ruin/syndicate_fighter_shiv + prefix = "_maps/voidcrew/shuttles/" diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm new file mode 100644 index 00000000000..fcd6c135127 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm @@ -0,0 +1,89 @@ +/** + * # Wasteland hermits and poacher squads (AM-9, AM-3) + * + * The wild-eyed people of the sandworlds, in their tattered survival gear (the wasteland corpse outfits, + * voidcrew/modules/mob_spawn/corpses/wasteland_corpses.dm): wanderers who fight with their fists or a + * knife, hunters with a .38 revolver and speedloaders, and soldiers with a WT-550 and spare magazines. + * They spawn from the wasteland biomes, wasteland ruins and the poacher bust contract, which renames them. + * + * Their survival suits keep the cold off their bodies; on a cold site the cold layer adds a hat, scarf and + * winter boots (a poacher camp on an ice planet), and a bad-air site a mask and tank. Nothing makes them + * immune to the weather: a storm that hurts a player hurts them. + */ + +/datum/human_npc_voice/ruin_hermit + lines = list( + RUIN_NPC_BARK_SPOT = list( + "The sun... The sun...", + "You'll do...", + "All mine...", + "Gg..free..", + "Drink.. need drink...", + "I can smell... you..", + "G-g...", + "Taste..the salt.", + ), + HUMAN_NPC_BARK_DOWNED = list("G-g...", "The sun..."), + HUMAN_NPC_BARK_PLEAD = list("Drink... need drink..."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +/datum/outfit/hermit/survivor/knife + name = "Wasteland Survivor (knife)" + r_hand = /obj/item/knife/combat/survival + +/datum/outfit/hermit/survivor/knife/red + name = "Wasteland Survivor (knife, red zone)" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A .38 revolver and two speedloaders. +/datum/outfit/hermit/survivor/hunter/armed + name = "Wasteland Hunter (armed)" + r_hand = /obj/item/gun/ballistic/revolver/c38 + backpack_contents = list(/obj/item/ammo_box/c38 = 2) + +/datum/outfit/hermit/survivor/hunter/armed/red + name = "Wasteland Hunter (armed, red zone)" + backpack_contents = list(/obj/item/ammo_box/c38 = 2, /obj/item/reagent_containers/hypospray/medipen = 1) + +/// A WT-550 and two spare magazines. +/datum/outfit/hermit/survivor/gunslinger/armed + name = "Wasteland Gunslinger (armed)" + r_hand = /obj/item/gun/ballistic/automatic/wt550 + backpack_contents = list(/obj/item/ammo_box/magazine/wt550m9 = 2) + +/datum/outfit/hermit/survivor/gunslinger/armed/red + name = "Wasteland Gunslinger (armed, red zone)" + backpack_contents = list(/obj/item/ammo_box/magazine/wt550m9 = 2, /obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/human_npc_outfit_table/ruin/hermit + green = list(/datum/outfit/hermit/survivor = 1) + yellow = list(/datum/outfit/hermit/survivor/knife = 1) + red = list(/datum/outfit/hermit/survivor/knife/red = 1) + +/datum/human_npc_outfit_table/ruin/hermit/hunter + green = list(/datum/outfit/hermit/survivor/hunter/armed = 1) + yellow = list(/datum/outfit/hermit/survivor/hunter/armed = 1) + red = list(/datum/outfit/hermit/survivor/hunter/armed/red = 1) + +/datum/human_npc_outfit_table/ruin/hermit/gunslinger + green = list(/datum/outfit/hermit/survivor/gunslinger/armed = 1) + yellow = list(/datum/outfit/hermit/survivor/gunslinger/armed = 1) + red = list(/datum/outfit/hermit/survivor/gunslinger/armed/red = 1) + +/mob/living/carbon/human/npc/ruin/hermit + npc_name = "Hermit Wanderer" + faction = list("hermit", FACTION_WASTELAND) + npc_voice = /datum/human_npc_voice/ruin_hermit + npc_outfit_table = /datum/human_npc_outfit_table/ruin/hermit + +/mob/living/carbon/human/npc/ruin/hermit/hunter + npc_name = "Hermit Hunter" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/hermit/hunter + +/mob/living/carbon/human/npc/ruin/hermit/gunslinger + npc_name = "Hermit Soldier" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/hermit/gunslinger diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm new file mode 100644 index 00000000000..1185c09b221 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm @@ -0,0 +1,111 @@ +// Map spawners for the ruin, planet and mission human NPCs. Each one queues its NPC when the map loads +// (spawn_human_npc(), a body per tick), dressed for the zone the ruin landed in. Set npc_name or +// npc_faction on a placed spawner for one NPC's own name or side (a camp's private faction). + +// ===== Pirates ===== + +/obj/effect/spawner/human_npc/ruin/pirate + name = "pirate swashbuckler spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate + +/obj/effect/spawner/human_npc/ruin/pirate/gunner + name = "pirate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/gunner + +/obj/effect/spawner/human_npc/ruin/pirate/space + name = "space pirate swashbuckler spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/space + +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner + name = "space pirate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/space/gunner + +/obj/effect/spawner/human_npc/ruin/pirate/beach + name = "beach pirate swashbuckler spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/beach + +/obj/effect/spawner/human_npc/ruin/pirate/beach/gunner + name = "beach pirate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner + +/obj/effect/spawner/human_npc/ruin/pirate/cosplayer + name = "pirate cosplayer spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/cosplayer + +// ===== Syndicate ===== + +/obj/effect/spawner/human_npc/ruin/syndicate + name = "Syndicate operative spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate + +/obj/effect/spawner/human_npc/ruin/syndicate/sword + name = "Syndicate sword guard spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/sword + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner + name = "Syndicate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/smg + name = "Syndicate SMG gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/shotgun + name = "Syndicate shotgunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/pilot + name = "Syndicate salvage pilot spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/pilot + +/obj/effect/spawner/human_npc/ruin/syndicate/commando + name = "Syndicate commando spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando + +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner + name = "Syndicate commando gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/gunner + +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg + name = "Syndicate commando SMG gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/smg + +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper + name = "Syndicate stormtrooper spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper + +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper/shotgun + name = "Syndicate stormtrooper shotgunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/shotgun + +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper/sword + name = "Syndicate stormtrooper sword guard spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/sword + +// ===== Nanotrasen and Russians ===== + +/obj/effect/spawner/human_npc/ruin/nanotrasen + name = "Nanotrasen security officer spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/nanotrasen + +/obj/effect/spawner/human_npc/ruin/russian + name = "Russian mobster spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/russian + +/obj/effect/spawner/human_npc/ruin/russian/gunner + name = "Russian gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/russian/gunner + +// ===== Hermits ===== + +/obj/effect/spawner/human_npc/ruin/hermit + name = "hermit wanderer spawner" + npc_type = /mob/living/carbon/human/npc/ruin/hermit + +/obj/effect/spawner/human_npc/ruin/hermit/hunter + name = "hermit hunter spawner" + npc_type = /mob/living/carbon/human/npc/ruin/hermit/hunter + +/obj/effect/spawner/human_npc/ruin/hermit/gunslinger + name = "hermit soldier spawner" + npc_type = /mob/living/carbon/human/npc/ruin/hermit/gunslinger diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm new file mode 100644 index 00000000000..565a8c5b3a7 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm @@ -0,0 +1,596 @@ +/** + * # Troopers as human NPCs: pirates, Syndicate operatives, Nanotrasen security, Russian mobsters + * + * The human versions of tg's /mob/living/basic/trooper families placed in Voidcrew ruins, ruin shuttles, + * beaches and zone markers. Each keeps its trooper's name, faction and look (today's corpse outfits), + * holds the weapon the trooper only drew, and carries real spare ammunition. + * + * Zones (/datum/human_npc_outfit_table/ruin): green wears what the trooper wore; yellow adds armour; red + * adds a helmet and a medipen. The trooper weapon ladder (voidcrew/edits/mobs/weapon_drops.dm) lives in + * the tables: Syndicate SMGs and shotguns are pistols outside the red zone (stormtroopers' SMGs only in + * the green), and the Syndicate sword guard has only a knife in the green. Nanotrasen officers only ever + * carried the M1911 in Voidcrew maps. + * + * Designed drops stay as the troopers had them: only the Russians carried any (a kitchen knife, a Nagant + * revolver). Their clothes and guns are gear (decision D3) and never loot. The pirates' coin-snatching + * hits are gone: an NPC hits like a person. + */ + +// ===== VOICES ===== + +/datum/human_npc_voice/ruin_pirate + lines = list( + RUIN_NPC_BARK_SPOT = list("Yarr! Fresh meat!", "Ahoy, landlubber!", "Cut 'em down!", "Avast!"), + HUMAN_NPC_BARK_DOWNED = list("Arr... me guts...", "Not like this..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathin'!", "That all ye got?"), + HUMAN_NPC_BARK_PLEAD = list("Mercy, matey..."), + HUMAN_NPC_BARK_CUFFED = list("Ye'll hang for this."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 60) + +/datum/human_npc_voice/ruin_syndicate + lines = list( + RUIN_NPC_BARK_SPOT = list("Contact!", "Hostile sighted.", "Engaging.", "Death to Nanotrasen!"), + HUMAN_NPC_BARK_DOWNED = list("Man down...", "I'm hit..."), + HUMAN_NPC_BARK_RECOVER = list("Back in it.", "Still in the fight."), + HUMAN_NPC_BARK_CUFFED = list("I'm not telling you anything."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +/datum/human_npc_voice/ruin_nanotrasen + lines = list( + RUIN_NPC_BARK_SPOT = list("Halt! Private security!", "Hostile in sight.", "Stop right there!"), + HUMAN_NPC_BARK_DOWNED = list("Officer down...", "Need a medic..."), + HUMAN_NPC_BARK_RECOVER = list("Still standing."), + HUMAN_NPC_BARK_CUFFED = list("You'll answer to Central for this."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +/datum/human_npc_voice/ruin_russian + lines = list( + RUIN_NPC_BARK_SPOT = list("Hey! You!", "Who let you in?", "For the Motherland!"), + HUMAN_NPC_BARK_DOWNED = list("Ugh... not good...", "Someone... help..."), + HUMAN_NPC_BARK_RECOVER = list("Is that all?"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +// ===== FAMILY ROOT ===== + +/mob/living/carbon/human/npc/ruin/trooper + name = "trooper" + // Armed hostiles: cover from stun fire, and a stimulant if one carries it (human_npc_combat_sense.dm) + npc_combat_sense = /datum/npc_combat_sense + +// ===== SPACE PIRATES (tg's pirate troopers) ===== + +/datum/outfit/ruin_npc + name = "Ruin NPC" + +/// The swashbuckler: a pirate's rags, an eyepatch and an energy cutlass. +/datum/outfit/ruin_npc/pirate + name = "Ruin NPC: pirate swashbuckler" + uniform = /obj/item/clothing/under/costume/pirate + shoes = /obj/item/clothing/shoes/jackboots + glasses = /obj/item/clothing/glasses/eyepatch + head = /obj/item/clothing/head/costume/pirate/bandana/armored + r_hand = /obj/item/melee/energy/sword/pirate + +/datum/outfit/ruin_npc/pirate/coat + name = "Ruin NPC: pirate swashbuckler in a coat" + suit = /obj/item/clothing/suit/costume/pirate/armored + +/datum/outfit/ruin_npc/pirate/coat/red + name = "Ruin NPC: pirate swashbuckler, red zone" + gloves = /obj/item/clothing/gloves/color/black + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// The gunner: a laser gun and the armoured captain's hat. +/datum/outfit/ruin_npc/pirate/gunner + name = "Ruin NPC: pirate gunner" + head = /obj/item/clothing/head/costume/pirate/armored + r_hand = /obj/item/gun/energy/laser + +/datum/outfit/ruin_npc/pirate/gunner/coat + name = "Ruin NPC: pirate gunner in a coat" + suit = /obj/item/clothing/suit/costume/pirate/armored + +/datum/outfit/ruin_npc/pirate/gunner/coat/red + name = "Ruin NPC: pirate gunner, red zone" + gloves = /obj/item/clothing/gloves/color/black + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// Space pirates fight in breached hulls: the pirate space suit and its own air. +/datum/outfit/ruin_npc/pirate/space + name = "Ruin NPC: space pirate swashbuckler" + suit = /obj/item/clothing/suit/space/pirate + head = /obj/item/clothing/head/helmet/space/pirate/bandana + gloves = /obj/item/clothing/gloves/color/black + suit_store = /obj/item/tank/internals/oxygen + internals_slot = ITEM_SLOT_SUITSTORE + +/datum/outfit/ruin_npc/pirate/space/red + name = "Ruin NPC: space pirate swashbuckler, red zone" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/datum/outfit/ruin_npc/pirate/space/gunner + name = "Ruin NPC: space pirate gunner" + head = /obj/item/clothing/head/helmet/space/pirate + r_hand = /obj/item/gun/energy/e_gun/lethal + +/datum/outfit/ruin_npc/pirate/space/gunner/red + name = "Ruin NPC: space pirate gunner, red zone" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// Timothy, the pirate cosplayer locked in the Hightide's kitchen: the costume, not the armour. +/datum/outfit/ruin_npc/pirate/cosplayer + name = "Ruin NPC: pirate cosplayer" + head = /obj/item/clothing/head/costume/pirate + suit = /obj/item/clothing/suit/costume/pirate + shoes = /obj/item/clothing/shoes/sneakers/black + r_hand = /obj/item/gun/energy/laser + +/datum/human_npc_outfit_table/ruin/pirate + vacuum_layer = /datum/human_npc_layer/eva/pirate + green = list(/datum/outfit/ruin_npc/pirate = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/coat = 1) + red = list(/datum/outfit/ruin_npc/pirate/coat/red = 1) + +/datum/human_npc_outfit_table/ruin/pirate/gunner + green = list(/datum/outfit/ruin_npc/pirate/gunner = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/gunner/coat = 1) + red = list(/datum/outfit/ruin_npc/pirate/gunner/coat/red = 1) + +/datum/human_npc_outfit_table/ruin/pirate/space + green = list(/datum/outfit/ruin_npc/pirate/space = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/space = 1) + red = list(/datum/outfit/ruin_npc/pirate/space/red = 1) + +/datum/human_npc_outfit_table/ruin/pirate/space/gunner + green = list(/datum/outfit/ruin_npc/pirate/space/gunner = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/space/gunner = 1) + red = list(/datum/outfit/ruin_npc/pirate/space/gunner/red = 1) + +/mob/living/carbon/human/npc/ruin/trooper/pirate + npc_name = "Pirate Swashbuckler" + faction = list(FACTION_PIRATE) + npc_voice = /datum/human_npc_voice/ruin_pirate + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate + +/mob/living/carbon/human/npc/ruin/trooper/pirate/gunner + npc_name = "Pirate Gunner" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/gunner + +/mob/living/carbon/human/npc/ruin/trooper/pirate/space + npc_name = "Space Pirate Swashbuckler" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/space + +/mob/living/carbon/human/npc/ruin/trooper/pirate/space/gunner + npc_name = "Space Pirate Gunner" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/space/gunner + +/// The beach's own pirates, sided with the shore's wildlife: the barrel spawners' crews, and the boat +/// pirates of the deep water, who now wade (the old boat sprite was this same costume in a boat). +/mob/living/carbon/human/npc/ruin/trooper/pirate/beach + faction = list(FACTION_BEACH) + +/mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner + npc_name = "Pirate Gunner" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/gunner + +/// Timothy. He survived on the rats, which shows: he goes down twice as fast as anyone else. +/mob/living/carbon/human/npc/ruin/trooper/pirate/cosplayer + npc_name = "Timothy" + npc_outfit_table = null + npc_outfit = /datum/outfit/ruin_npc/pirate/cosplayer + npc_damage_taken = 2 + +// ===== SYNDICATE OPERATIVES ===== + +/// A Syndicate operative with a knife. +/datum/outfit/ruin_npc/syndicate + name = "Ruin NPC: Syndicate operative" + uniform = /obj/item/clothing/under/syndicate + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + r_hand = /obj/item/knife/combat/survival + +/datum/outfit/ruin_npc/syndicate/vest + name = "Ruin NPC: Syndicate operative in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/syndicate/vest/helmet + name = "Ruin NPC: Syndicate operative, red zone" + head = /obj/item/clothing/head/helmet/swat + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// The sword guard: an energy sword and an energy shield. +/datum/outfit/ruin_npc/syndicate/vest/sword + name = "Ruin NPC: Syndicate sword guard" + r_hand = /obj/item/melee/energy/sword/saber/red + l_hand = /obj/item/shield/energy + +/datum/outfit/ruin_npc/syndicate/vest/sword/helmet + name = "Ruin NPC: Syndicate sword guard, red zone" + head = /obj/item/clothing/head/helmet/swat + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A Makarov and two spare magazines. +/datum/outfit/ruin_npc/syndicate/pistol + name = "Ruin NPC: Syndicate gunner" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/syndicate/pistol/vest + name = "Ruin NPC: Syndicate gunner in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet + name = "Ruin NPC: Syndicate gunner, red zone" + head = /obj/item/clothing/head/helmet/swat + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/// The C-20r, in the red zone only. +/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/smg + name = "Ruin NPC: Syndicate SMG gunner" + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + +/// The Bulldog, in the red zone only. +/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/shotgun + name = "Ruin NPC: Syndicate shotgunner" + r_hand = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted + l_pocket = /obj/item/ammo_box/magazine/m12g + r_pocket = /obj/item/ammo_box/magazine/m12g + +/// The hijack team a cargo shuttle loan brings aboard (voidcrew/modules/cargo/shipping/_shuttle_loan.dm): +/// a suppressed Makarov and two spare magazines. +/datum/outfit/ruin_npc/syndicate/pistol/infiltrator + name = "Ruin NPC: Syndicate infiltrator" + r_hand = /obj/item/gun/ballistic/automatic/pistol/suppressed + +/datum/outfit/ruin_npc/syndicate/pistol/infiltrator/vest + name = "Ruin NPC: Syndicate infiltrator in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/syndicate/pistol/infiltrator/vest/helmet + name = "Ruin NPC: Syndicate infiltrator, red zone" + head = /obj/item/clothing/head/helmet/swat + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/// The salvage pilots of the caravan ambush: flight scarf and goggles over the armour. +/datum/outfit/ruin_npc/syndicate/pistol/pilot + name = "Ruin NPC: Syndicate salvage pilot" + suit = /obj/item/clothing/suit/armor/vest/alt + neck = /obj/item/clothing/neck/large_scarf/syndie + glasses = /obj/item/clothing/glasses/cold + +/datum/outfit/ruin_npc/syndicate/pistol/pilot/smg + name = "Ruin NPC: Syndicate salvage pilot, red zone" + head = /obj/item/clothing/head/helmet/swat + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + +/// Commandos: the black Syndicate space suit and its own air. +/datum/outfit/ruin_npc/syndicate/commando + name = "Ruin NPC: Syndicate commando" + suit = /obj/item/clothing/suit/space/syndicate/black + head = /obj/item/clothing/head/helmet/space/syndicate/black + suit_store = /obj/item/tank/internals/oxygen + internals_slot = ITEM_SLOT_SUITSTORE + +/datum/outfit/ruin_npc/syndicate/commando/red + name = "Ruin NPC: Syndicate commando, red zone" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/datum/outfit/ruin_npc/syndicate/commando/pistol + name = "Ruin NPC: Syndicate commando gunner" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/syndicate/commando/pistol/red + name = "Ruin NPC: Syndicate commando gunner, red zone" + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/outfit/ruin_npc/syndicate/commando/pistol/red/smg + name = "Ruin NPC: Syndicate commando SMG gunner" + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + +/datum/outfit/ruin_npc/syndicate/commando/pistol/red/shotgun + name = "Ruin NPC: Syndicate commando shotgunner" + r_hand = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted + l_pocket = /obj/item/ammo_box/magazine/m12g + r_pocket = /obj/item/ammo_box/magazine/m12g + +/datum/outfit/ruin_npc/syndicate/commando/sword + name = "Ruin NPC: Syndicate commando sword guard" + r_hand = /obj/item/melee/energy/sword/saber/red + l_hand = /obj/item/shield/energy + +/// Stormtroopers: the blood-red Syndicate space suit. +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper + name = "Ruin NPC: Syndicate stormtrooper" + suit = /obj/item/clothing/suit/space/syndicate/black/red + head = /obj/item/clothing/head/helmet/space/syndicate/black/red + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/smg + name = "Ruin NPC: Syndicate stormtrooper SMG gunner" + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/shotgun + name = "Ruin NPC: Syndicate stormtrooper shotgunner" + r_hand = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted + l_pocket = /obj/item/ammo_box/magazine/m12g + r_pocket = /obj/item/ammo_box/magazine/m12g + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword + name = "Ruin NPC: Syndicate stormtrooper sword guard" + r_hand = /obj/item/melee/energy/sword/saber/red + l_hand = /obj/item/shield/energy + l_pocket = /obj/item/reagent_containers/hypospray/medipen + r_pocket = null + +/// Operatives on the ice wear the Syndicate's armoured explorer suit over their kit. +/datum/human_npc_layer/cold/coat/syndicate + suit = /obj/item/clothing/suit/hooded/explorer/syndicate + +/datum/human_npc_outfit_table/ruin/syndicate + vacuum_layer = /datum/human_npc_layer/eva/syndicate + cold_layer = /datum/human_npc_layer/cold/coat/syndicate + green = list(/datum/outfit/ruin_npc/syndicate = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/vest = 1) + red = list(/datum/outfit/ruin_npc/syndicate/vest/helmet = 1) + +/// The sword guard has only a knife in the green zone (the trooper weapon ladder). +/datum/human_npc_outfit_table/ruin/syndicate/sword + green = list(/datum/outfit/ruin_npc/syndicate = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/vest/sword = 1) + red = list(/datum/outfit/ruin_npc/syndicate/vest/sword/helmet = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/pistol + green = list(/datum/outfit/ruin_npc/syndicate/pistol = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/pistol/vest = 1) + red = list(/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet = 1) + +/// SMGs and shotguns are pistols outside the red zone (the trooper weapon ladder). +/datum/human_npc_outfit_table/ruin/syndicate/pistol/smg + red = list(/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/smg = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/pistol/shotgun + red = list(/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/shotgun = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/infiltrator + green = list(/datum/outfit/ruin_npc/syndicate/pistol/infiltrator = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/pistol/infiltrator/vest = 1) + red = list(/datum/outfit/ruin_npc/syndicate/pistol/infiltrator/vest/helmet = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/pilot + green = list(/datum/outfit/ruin_npc/syndicate/pistol/pilot = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/pistol/pilot = 1) + red = list(/datum/outfit/ruin_npc/syndicate/pistol/pilot/smg = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/commando + green = list(/datum/outfit/ruin_npc/syndicate/commando = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/red = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/commando/pistol + green = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/pistol/red = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/commando/smg + green = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/pistol/red/smg = 1) + +/// Stormtroopers are elites but follow the zone ladder like the regulars, so their tables pick by zone +/// alone: SMG stormtroopers carry pistols in the green zone, shotgunners outside the red. +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/outfits_for(mob/living/carbon/human/npc/npc, zone) + if(zone == ZONE_RED && length(red)) + return red + if(zone >= ZONE_YELLOW && length(yellow)) + return yellow + return green + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/smg + green = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/smg = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/smg = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/shotgun + green = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/shotgun = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/sword + green = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword = 1) + +/// Space Syndicate kit: the black suit. +/datum/human_npc_layer/eva/syndicate + suit = /obj/item/clothing/suit/space/syndicate/black + head = /obj/item/clothing/head/helmet/space/syndicate/black + +/mob/living/carbon/human/npc/ruin/trooper/syndicate + npc_name = "Syndicate Operative" + faction = list(ROLE_SYNDICATE) + npc_voice = /datum/human_npc_voice/ruin_syndicate + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/sword + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/sword + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol/smg + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol/shotgun + +/// The hijack team a cargo shuttle loan brings aboard. +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/infiltrator + npc_name = "Syndicate Infiltrator" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/infiltrator + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/pilot + npc_name = "Syndicate Salvage Pilot" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pilot + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando + npc_name = "Syndicate Commando" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/commando + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/commando/pistol + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/smg + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/commando/smg + +/// Stormtroopers: elites in the blood-red suit. They take a fifth less brute and burn, and land aimed hits. +/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper + npc_name = "Syndicate Stormtrooper" + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = 0.8 + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/smg + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/shotgun + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/shotgun + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/sword + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/sword + +// ===== NANOTRASEN PRIVATE SECURITY ===== + +/// An M1911 and two spare magazines, in security blues. +/datum/outfit/ruin_npc/nanotrasen + name = "Ruin NPC: Nanotrasen security officer" + uniform = /obj/item/clothing/under/rank/security/officer + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + back = /obj/item/storage/backpack/security + r_hand = /obj/item/gun/ballistic/automatic/pistol/m1911 + l_pocket = /obj/item/ammo_box/magazine/m45 + r_pocket = /obj/item/ammo_box/magazine/m45 + +/datum/outfit/ruin_npc/nanotrasen/vest + name = "Ruin NPC: Nanotrasen security officer in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/nanotrasen/vest/helmet + name = "Ruin NPC: Nanotrasen security officer, red zone" + head = /obj/item/clothing/head/helmet/swat/nanotrasen + backpack_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/human_npc_outfit_table/ruin/nanotrasen + green = list(/datum/outfit/ruin_npc/nanotrasen = 1) + yellow = list(/datum/outfit/ruin_npc/nanotrasen/vest = 1) + red = list(/datum/outfit/ruin_npc/nanotrasen/vest/helmet = 1) + +/mob/living/carbon/human/npc/ruin/trooper/nanotrasen + npc_name = "Nanotrasen Private Security Officer" + faction = list(ROLE_DEATHSQUAD) + npc_voice = /datum/human_npc_voice/ruin_nanotrasen + npc_outfit_table = /datum/human_npc_outfit_table/ruin/nanotrasen + +// ===== RUSSIAN MOBSTERS ===== + +/// A soviet uniform, a bear pelt and a kitchen knife. +/datum/outfit/ruin_npc/russian + name = "Ruin NPC: Russian mobster" + uniform = /obj/item/clothing/under/costume/soviet + shoes = /obj/item/clothing/shoes/jackboots + head = /obj/item/clothing/head/costume/bearpelt + gloves = /obj/item/clothing/gloves/color/black + r_hand = /obj/item/knife/kitchen + +/datum/outfit/ruin_npc/russian/vest + name = "Ruin NPC: Russian mobster in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/// The red zone's Russians are troopers: camo, a bulletproof vest and a helmet. +/datum/outfit/ruin_npc/russian/trooper + name = "Ruin NPC: Russian trooper" + uniform = /obj/item/clothing/under/syndicate/camo + suit = /obj/item/clothing/suit/armor/bulletproof + head = /obj/item/clothing/head/helmet/alt + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A Makarov and two spare magazines, under an ushanka. +/datum/outfit/ruin_npc/russian/gunner + name = "Ruin NPC: Russian gunner" + head = /obj/item/clothing/head/costume/ushanka + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/russian/gunner/vest + name = "Ruin NPC: Russian gunner in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/russian/gunner/trooper + name = "Ruin NPC: Russian trooper gunner" + uniform = /obj/item/clothing/under/syndicate/camo + suit = /obj/item/clothing/suit/armor/bulletproof + head = /obj/item/clothing/head/helmet/alt + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/human_npc_outfit_table/ruin/russian + green = list(/datum/outfit/ruin_npc/russian = 1) + yellow = list(/datum/outfit/ruin_npc/russian/vest = 1) + red = list(/datum/outfit/ruin_npc/russian/trooper = 1) + +/datum/human_npc_outfit_table/ruin/russian/gunner + green = list(/datum/outfit/ruin_npc/russian/gunner = 1) + yellow = list(/datum/outfit/ruin_npc/russian/gunner/vest = 1) + red = list(/datum/outfit/ruin_npc/russian/gunner/trooper = 1) + +/mob/living/carbon/human/npc/ruin/trooper/russian + npc_name = "Russian Mobster" + faction = list(FACTION_RUSSIAN) + npc_voice = /datum/human_npc_voice/ruin_russian + npc_outfit_table = /datum/human_npc_outfit_table/ruin/russian + npc_death_loot = list(/obj/item/knife/kitchen) + +/mob/living/carbon/human/npc/ruin/trooper/russian/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/russian/gunner + npc_death_loot = list(/obj/item/gun/ballistic/revolver/nagant) diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm new file mode 100644 index 00000000000..2aafc284d62 --- /dev/null +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -0,0 +1,53 @@ +// Hyperviolence core: the shared hooks every gore part uses. +// hv_add_hit_line() (a line added to the combat message of the hit being resolved on a mob) lives +// in hitlines/hit_lines.dm, which batches the lines into the attack message. + +/// Sprays blood from src toward spray_dir: streaks and a splash on the floor, a smear on the first +/// wall in the way, and anyone standing in the line is drenched. The body lives in the blood part +/// (blood/blood_spray.dm). +/mob/living/proc/hv_blood_spray(spray_dir, strength = 3) + return hv_blood_throw(spray_dir, strength) + +/// Bursts src's head: it comes apart in gibs and the brain is thrown out intact. burst_dir is the way +/// the shot or blow travelled. The body is in braingut/brain_burst.dm. +/// Returns TRUE if the head was burst. +/mob/living/carbon/proc/hv_burst_head(burst_dir) + return hv_burst_head_apart(burst_dir) + +/// Sends a loose part (a severed limb or head, a brain, an eye) up to `distance` tiles along `fling_dir`, +/// tumbling, and calls `on_landed` when it comes to rest. Never tg's throw: a thrown part hurts and shoves +/// whoever it hits, and can be caught. `from` is where on its tile it comes from, in pixels from the +/// middle (list(x, y)): the head an eye is torn out of, say. Without it the part starts from the middle. +/// The body is in visuals/flying_gore.dm. Returns TRUE if it took off. +/obj/item/proc/hv_fling(fling_dir, distance = 2, datum/callback/on_landed, list/from) + if(from && !hv_vis_fling_from) + hv_vis_fling_from = from + return hv_vis_fly(fling_dir, distance, on_landed) + +/** + * A body shudders as it dies. With `rise` (a body that has just fallen) it is first drawn upright over + * its lying pose, seizing, then falls the way tg lays a body down. Human NPCs give it from their death + * signal (human_npcs/human_npc_barks.dm), after tg's own deathgasp. Every step is relative and ends where + * it began, so the body's real transform and offsets are untouched. + */ +/mob/living/proc/hv_death_throes(rise = FALSE) + // How far it is turned now: tg lays a body down by turning it. + var/angle = round(arctan(transform.a, transform.b)) + if(rise && angle) + var/matrix/fallen = matrix(transform) + var/matrix/upright = matrix(transform) + upright.Turn(-angle) + animate(src, transform = upright, time = 0) + animate(transform = upright, time = HV_DEATH_THROES_TIME) + animate(transform = fallen, time = UPDATE_TRANSFORM_ANIMATION_TIME, easing = EASE_IN|EASE_OUT) + // The shudder: sideways offsets that die down, each step relative to the last, ending where it began. + var/static/list/shudder = list(3, -3, 2, -2, 2, -1, 1, 0) + var/step_time = HV_DEATH_THROES_TIME / length(shudder) + var/offset = 0 + for(var/i in 1 to length(shudder)) + var/shift = shudder[i] - offset + offset = shudder[i] + if(i == 1) + animate(src, pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + else + animate(pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE) diff --git a/voidcrew/modules/hyperviolence/blood/_blood_defines.dm b/voidcrew/modules/hyperviolence/blood/_blood_defines.dm new file mode 100644 index 00000000000..9b7077e1705 --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/_blood_defines.dm @@ -0,0 +1,45 @@ +// Hyperviolence blood part: defines private to this part. Defines shared between the +// hyperviolence parts live in voidcrew/_DEFINES/hyperviolence.dm. + +/// Smallest brute hit, after armour, that can throw blood across the floor. +#define HV_BLOOD_MIN_HIT 12 +/// Brute damage per tile a sprayed hit reaches. +#define HV_BLOOD_DAMAGE_PER_TILE 12 +/// Furthest a single spray line can reach. +#define HV_BLOOD_MAX_RANGE 5 +/// A mob throws one spray per window, so a burst of shots into one body paints the same few tiles once. +#define HV_BLOOD_SPRAY_WINDOW (0.4 SECONDS) +/// A limb that comes off within this long of a hit is blown off in the direction of that hit. +#define HV_BLOOD_HIT_MEMORY (0.5 SECONDS) +/// Sprays at or above this many tiles also throw a pair of flanking lines either side of the main one. +#define HV_BLOOD_FAN_STRENGTH 3 +/// Tiles a spray reaches when a limb comes off. +#define HV_BLOOD_DISMEMBER_STRENGTH 3 +/// Most a run of cast-off drops sits off the middle of its line, in pixels, straight and diagonal. +#define HV_BLOOD_DROPS_DRIFT 4 +#define HV_BLOOD_DROPS_DRIFT_DIAGONAL 2 +/// Most a tile of cast-off drops is turned off the line, in degrees either way. +#define HV_BLOOD_DROPS_TURN 7 + +/// Each unit of blood a standing bleeder loses adds this much to the pool under them. +#define HV_BLOOD_POOL_PER_UNIT 1 +/// Pool pictures are 64x64 and drawn this many pixels left of and below their tile, which centres them on it. +#define HV_BLOOD_POOL_OFFSET -16 +/// Pool sizes up to this one fit inside their own tile. Bigger ones reach over its edges. +#define HV_BLOOD_POOL_CONTAINED_TIER 2 +/// The pool size from which a pool runs onto the tiles beside it. +#define HV_BLOOD_POOL_LOBE_TIER 3 +/// The biggest pool size. +#define HV_BLOOD_POOL_BIGGEST_TIER 4 +/// How many neighbouring tiles a pool of the first big size has run onto, and a pool of the biggest size. +#define HV_BLOOD_POOL_LOBES_BIG 1 +#define HV_BLOOD_POOL_LOBES_BIGGEST 3 + +/// How often, at most, floating blood is checked against gravity when nothing announced a change. +#define HV_BLOOD_FLOAT_SWEEP (15 SECONDS) +/// Floating blood states in blood_weightless.dmi. +#define HV_BLOOD_FLOAT_VARIANTS 12 + +#define HV_BLOOD_SPRAY_ICON 'voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi' +#define HV_BLOOD_POOL_ICON 'voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi' +#define HV_BLOOD_FLOAT_ICON 'voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi' diff --git a/voidcrew/modules/hyperviolence/blood/blood_pools.dm b/voidcrew/modules/hyperviolence/blood/blood_pools.dm new file mode 100644 index 00000000000..51446683949 --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/blood_pools.dm @@ -0,0 +1,160 @@ +// Hyperviolence blood part: blood pools that grow as a bleeder stands in their own blood. +// +// Art: icons/blood_pool.dmi is original, drawn for this part, greyscale so the decal takes the +// bleeder's blood colour. Every picture is 64x64 and drawn centred on its tile (pixel_x and pixel_y +// of -16), so a big pool's rounded edge wanders over the tiles around it instead of stopping dead at +// the tile line. All of them are shaded from one tiling texture, so neighbouring pools join without +// a seam. +// +// A pool is the ordinary blood decal tg already leaves under a bleeding mob: one per tile, picked +// up by cleaning, merging with new blood, drying out like any other. It only gets bigger. The +// extra blood a standing bleeder loses is added to it, and its picture steps up as it fills. A big +// pool also runs onto the open tiles beside it, a few times at most: each one gets an ordinary +// decal of its own (blood DNA, mopable) drawn as the end of the pool running onto it. + +/// How much blood the plain splatter on a tile holds before it grows into each pool size. +GLOBAL_LIST_INIT(hv_blood_pool_thresholds, list(90, 140, 200, 260)) + +/obj/effect/decal/cleanable/blood + /// How far this splatter has grown into a pool, 0 being the stock splatter. + var/hv_pool_tier = 0 + /// The way the blood flowed when this is only the end of a bigger pool running onto the tile, else 0. + var/hv_pool_lobe_dir = 0 + /// Neighbouring tiles this pool has already tried to run onto, as directions. Each is tried once. + var/hv_pool_lobe_tried = 0 + /// How many tiles this pool has run onto. + var/hv_pool_lobes = 0 + +/obj/effect/decal/cleanable/blood/adjust_bloodiness(by_amount, ignore_timer = FALSE) + . = ..() + if(. && by_amount > 0) + hv_update_pool_tier() + +/// Steps this splatter up to the next pool size once it holds enough blood. Never steps back down. +/obj/effect/decal/cleanable/blood/proc/hv_update_pool_tier() + // Only the plain splatter grows; trails, gibs, footprints, sprays and the rest keep their own look. + if(type != /obj/effect/decal/cleanable/blood || hv_floating || dried || QDELETED(src)) + return + var/new_tier = 0 + for(var/tier in 1 to length(GLOB.hv_blood_pool_thresholds)) + if(bloodiness >= GLOB.hv_blood_pool_thresholds[tier]) + new_tier = tier + if(new_tier <= hv_pool_tier) + return + hv_pool_tier = new_tier + hv_pool_lobe_dir = 0 + // A pool beside open space keeps to a size that stays on its own tile. + var/art_tier = new_tier + if(new_tier > HV_BLOOD_POOL_CONTAINED_TIER && !hv_pool_can_overflow()) + art_tier = HV_BLOOD_POOL_CONTAINED_TIER + hv_pool_show("pool[art_tier]_[rand(1, 3)]") + if(new_tier >= HV_BLOOD_POOL_LOBE_TIER) + hv_pool_run_onto_neighbours(new_tier) + +/// Puts this decal's picture on a state of the pool art, drawn centred on the tile. +/obj/effect/decal/cleanable/blood/proc/hv_pool_show(state) + icon = HV_BLOOD_POOL_ICON + icon_state = state + pixel_x = base_pixel_x + HV_BLOOD_POOL_OFFSET + pixel_y = base_pixel_y + HV_BLOOD_POOL_OFFSET + update_appearance() + +/// Forgets everything about being a pool, for blood that starts over as a plain splatter. +/obj/effect/decal/cleanable/blood/proc/hv_pool_reset() + hv_pool_tier = 0 + hv_pool_lobe_dir = 0 + hv_pool_lobe_tried = 0 + hv_pool_lobes = 0 + +/// Whether a pool's big picture, which reaches over the edges of its tile, would lie over open space. +/obj/effect/decal/cleanable/blood/proc/hv_pool_can_overflow() + var/turf/here = loc + if(!isturf(here)) + return FALSE + for(var/direction in GLOB.alldirs) + var/turf/around = get_step(here, direction) + if(!around || isgroundlessturf(around)) + return FALSE + return TRUE + +/// Lets a big pool run onto the open tiles beside it: one at the first big size, more at the biggest. +/// Each side is tried once, so a pool does this a few times in its life and no more. +/obj/effect/decal/cleanable/blood/proc/hv_pool_run_onto_neighbours(tier) + var/turf/here = loc + if(!isturf(here)) + return + var/wanted = tier >= HV_BLOOD_POOL_BIGGEST_TIER ? HV_BLOOD_POOL_LOBES_BIGGEST : HV_BLOOD_POOL_LOBES_BIG + for(var/direction in shuffle(GLOB.cardinals)) + if(hv_pool_lobes >= wanted) + return + if(hv_pool_lobe_tried & direction) + continue + hv_pool_lobe_tried |= direction + if(hv_pool_run_onto(get_step(here, direction), direction, tier)) + hv_pool_lobes++ + +/// Whether blood could run from this pool onto the tile beside it: open floor with gravity, no wall, +/// window or closed door in the way. +/obj/effect/decal/cleanable/blood/proc/hv_pool_can_run_onto(turf/target, direction) + var/turf/here = loc + if(!isturf(here) || !target || target.z != here.z) + return FALSE + if(!isopenturf(target) || isgroundlessturf(target) || islava(target) || ischasm(target) || iswaterturf(target) || target.has_gravity() <= 0) + return FALSE + if(hv_blood_spray_blocked(target) || !here.Adjacent(target)) + return FALSE + return TRUE + +/// Runs this pool onto the tile beside it. Returns TRUE if it did. +/obj/effect/decal/cleanable/blood/proc/hv_pool_run_onto(turf/target, direction, tier) + if(!hv_pool_can_run_onto(target, direction)) + return FALSE + var/obj/effect/decal/cleanable/blood/lobe + for(var/obj/effect/decal/cleanable/blood/found in target) + if(found.type != /obj/effect/decal/cleanable/blood || found.dried || found.hv_floating || QDELETED(found)) + continue + // A pool or an earlier lobe is blood enough already; a plain splatter is stretched into this one. + if(found.hv_pool_tier || found.hv_pool_lobe_dir) + return FALSE + lobe = found + break + var/list/blood_dna = GET_ATOM_BLOOD_DNA(src) + if(lobe) + if(length(blood_dna)) + lobe.add_blood_DNA(blood_dna) + else + if(length(blood_dna)) + lobe = new /obj/effect/decal/cleanable/blood(target, null, blood_dna) + else + lobe = new /obj/effect/decal/cleanable/blood(target) + if(QDELETED(lobe)) + return FALSE + lobe.hv_pool_lobe_dir = direction + lobe.hv_pool_show("lobe[tier >= HV_BLOOD_POOL_BIGGEST_TIER ? HV_BLOOD_POOL_BIGGEST_TIER : HV_BLOOD_POOL_LOBE_TIER]_[dir2text(direction)]_[rand(1, 2)]") + return TRUE + +/mob/living + /// Which tile this mob was on the last time it bled, packed into one number. + var/hv_blood_pool_key = 0 + +/mob/living/bleed(amt) + . = ..() + if(amt > 0 && isturf(loc)) + hv_blood_feed_pool(amt) + +/// Blood lost while standing still goes into the splatter on the tile, which grows into a pool. +/mob/living/proc/hv_blood_feed_pool(amt) + var/turf/here = loc + var/key = (here.z * 1000 + here.y) * 1000 + here.x + var/stationary = (key == hv_blood_pool_key) + hv_blood_pool_key = key + // Moving leaves tg's drips and trails; only a bleeder who stays put builds a pool. + if(!stationary || HAS_TRAIT(src, TRAIT_GODMODE) || can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + if(isgroundlessturf(here) || !here.has_gravity()) + return + for(var/obj/effect/decal/cleanable/blood/pool in here) + if(pool.type != /obj/effect/decal/cleanable/blood || pool.dried || pool.hv_floating || QDELETED(pool)) + continue + pool.adjust_bloodiness(amt * HV_BLOOD_POOL_PER_UNIT) + return diff --git a/voidcrew/modules/hyperviolence/blood/blood_spray.dm b/voidcrew/modules/hyperviolence/blood/blood_spray.dm new file mode 100644 index 00000000000..f08361b10c7 --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/blood_spray.dm @@ -0,0 +1,343 @@ +// Hyperviolence blood part: directional blood spray. +// +// Art: icons/blood_spray.dmi holds the squirt_greyscale, splatter_greyscale and +// splatterbloodalt_greyscale states copied from Azure Peak (Azure-Peak/Azure-Peak at commit +// 023a346, icons/effects/blood.dmi), licensed CC BY-SA 3.0. The streak and drops states are drawn +// for this part. +// Code: the spray line, wall splatter and drench are adapted from the same repository's +// code/modules/mob/living/blood.dm (blood_spray, blood_squirt, drench, add_splatter_wall), +// licensed AGPL-3.0. tg's flying hitsplatter is left alone; a hit that already threw one this +// tick does not throw a second spray. + +/mob/living + /// World time before which this mob will not throw another spray, unless it is a stronger one. + var/hv_blood_spray_until = 0 + /// How far the last spray reached, so a stronger one inside the window can still go. + var/hv_blood_spray_strength = 0 + /// The tick tg's own wound spray last fired from this mob, so one hit does not spray twice. + var/hv_blood_tg_spray_tick = -1 + /// Direction of the last brute hit this mob took, and when. + var/hv_blood_hit_dir = 0 + var/hv_blood_hit_time = -100000 + /// Whether this mob was alive when the hit landing now began, so the hit that kills it is known as one. + var/hv_blood_alive_at_hit = FALSE + /// World time before which this mob is not told again that blood spattered across it. + var/hv_blood_drench_until = 0 + +/mob/living/Initialize(mapload) + . = ..() + AddElement(/datum/element/hv_blood_hooks) + +/// Listens for hits and lost limbs on a mob and sprays its blood. An element rather than the mob +/// registering on itself: a mob holds one handler per signal per listener, so a self-registration +/// would replace (or be replaced by) the mob's own handlers for the same signals. +/datum/element/hv_blood_hooks + +/datum/element/hv_blood_hooks/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_hit)) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) + if(iscarbon(target)) + RegisterSignal(target, COMSIG_CARBON_REMOVE_LIMB, PROC_REF(on_limb_removed)) + +/datum/element/hv_blood_hooks/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_MOB_APPLY_DAMAGE, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_CARBON_REMOVE_LIMB)) + return ..() + +/// A brute hit is about to land: its direction is noted now, before its damage. tg takes a limb off in +/// the middle of the hit that severs it, so the limb and its spray would otherwise go the way of the hit +/// before, or any way at all. +/datum/element/hv_blood_hooks/proc/on_hit(mob/living/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + source.hv_blood_alive_at_hit = source.stat != DEAD + if(damagetype != BRUTE || !attack_direction) + return + source.hv_blood_hit_dir = attack_direction + source.hv_blood_hit_time = world.time + +/datum/element/hv_blood_hooks/proc/on_damaged(mob/living/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + source.hv_blood_on_damaged(source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + +/datum/element/hv_blood_hooks/proc/on_limb_removed(mob/living/carbon/source, obj/item/bodypart/removed_limb, special, dismembered) + SIGNAL_HANDLER + source.hv_blood_on_limb_removed(source, removed_limb, special, dismembered) + +/// A fan of blood flung across a floor tile, facing the way it flew. +/obj/effect/decal/cleanable/blood/hv_spray + name = "blood splatter" + icon = HV_BLOOD_SPRAY_ICON + icon_state = "splatter_greyscale" + random_icon_states = list("splatter_greyscale", "splatterbloodalt_greyscale") + // One per tile and direction: the spray helper refreshes an existing one instead of stacking. + mergeable_decal = FALSE + // Thin enough that walking through it does not leave footprints. + bloodiness = 15 + drying_time = 3 MINUTES + beauty = -30 + base_name = null + base_suffix = "splatter" + +/// Turns the decal to the way the blood flew. +/obj/effect/decal/cleanable/blood/hv_spray/proc/hv_aim(spray_dir) + setDir(spray_dir) + +// Too thin to dive into. +/obj/effect/decal/cleanable/blood/hv_spray/can_bloodcrawl_in() + return FALSE + +/// A thin streak of blood along the floor: one unbroken bar down the tile. Right for a single tile +/// (a blade drawn through a body, a drip off a housing); a spray line is laid in drops (below), since +/// bars end to end read as a ruled line. +/// squirt_greyscale is the old single look, kept in the DMI but no longer laid. +/obj/effect/decal/cleanable/blood/hv_spray/streak + name = "blood streak" + icon_state = "streak1_greyscale" + random_icon_states = list("streak1_greyscale", "streak2_greyscale", "streak3_greyscale", "streak4_greyscale") + base_suffix = "streak" + /// The thinner, broken-up looks for the far end of a line. + var/list/tail_icon_states = list("streaktail1_greyscale", "streaktail2_greyscale", "streaktail3_greyscale") + +/// The part of a spray line before its final splash: cast-off blood, separate drops long along the way +/// they flew, a gout and its followers near the wound and small scattered ones further out. Each tile +/// is turned a few degrees and sits a little off the line, so a long spray never reads as ruled. +/obj/effect/decal/cleanable/blood/hv_spray/streak/drops + icon_state = "drops1_greyscale" + random_icon_states = list("drops1_greyscale", "drops2_greyscale", "drops3_greyscale", "drops4_greyscale") + tail_icon_states = list("dropstail1_greyscale", "dropstail2_greyscale", "dropstail3_greyscale") + +/obj/effect/decal/cleanable/blood/hv_spray/streak/drops/Initialize(mapload, list/datum/disease/diseases, list/blood_or_dna = get_default_blood_type()) + . = ..() + if(mapload || . == INITIALIZE_HINT_QDEL) + return + var/matrix/turned = matrix(transform) + turned.Turn(rand(-HV_BLOOD_DROPS_TURN, HV_BLOOD_DROPS_TURN)) + transform = turned + +/// Picks a look for this tile of the line: the thinner, broken-up tail set near the end of it, and +/// a small shift across the line so neighbouring tiles do not look stamped. drift is in pixels. +/obj/effect/decal/cleanable/blood/hv_spray/streak/proc/hv_set_look(tail, drift) + icon_state = pick(tail ? tail_icon_states : random_icon_states) + pixel_x = 0 + pixel_y = 0 + if(dir == EAST || dir == WEST) + pixel_y = drift + else + // North and south shift sideways; a diagonal shifted sideways moves across itself too. + pixel_x = drift + +/// Pulls the end splash back along the line, and across it by drift pixels, so the streak runs into it. +/obj/effect/decal/cleanable/blood/hv_spray/proc/hv_meet_line(spray_dir, drift) + var/pull = ISDIAGONALDIR(spray_dir) ? 7 : 9 + pixel_x = 0 + pixel_y = 0 + if(spray_dir & EAST) + pixel_x = -pull + else if(spray_dir & WEST) + pixel_x = pull + if(spray_dir & NORTH) + pixel_y = -pull + else if(spray_dir & SOUTH) + pixel_y = pull + if(spray_dir == EAST || spray_dir == WEST) + pixel_y += drift + else + pixel_x += drift + +/// Blood on the face of a wall or window. Sits on the floor tile in front of it, drawn onto the obstruction. +/obj/effect/decal/cleanable/blood/hv_spray/wall + plane = GAME_PLANE + layer = ABOVE_WINDOW_LAYER + alpha = 200 + +/obj/effect/decal/cleanable/blood/hv_spray/wall/hv_aim(spray_dir) + . = ..() + pixel_x = 0 + pixel_y = 0 + if(spray_dir & NORTH) + pixel_y = 32 + else if(spray_dir & SOUTH) + pixel_y = -32 + if(spray_dir & EAST) + pixel_x = 32 + else if(spray_dir & WEST) + pixel_x = -32 + +/// tg's wound spray: note the tick, so the heavy-hit spray does not add a second one to the same hit. +/// tg rolls a limb off without saying which way the blow went, and its spray with no direction flies +/// south whatever happened. That one is left out: a lost limb already sprays along the blow +/// (hv_blood_on_limb_removed()), and a second spray down the same line made one thick straight band. +/mob/living/carbon/spray_blood(splatter_direction, splatter_strength = 3) + if(!splatter_direction) + return + hv_blood_tg_spray_tick = world.time + return ..() + +/// Whether a spray stops at this tile: walls, windows and closed doors. +/proc/hv_blood_spray_blocked(turf/next) + if(isclosedturf(next)) + return TRUE + for(var/obj/blocker in next) + if(!blocker.density) + continue + if(istype(blocker, /obj/structure/window) || istype(blocker, /obj/machinery/door) || istype(blocker, /obj/structure/falsewall)) + return TRUE + return FALSE + +/// Lays one spray decal on a floor tile, or turns the one already there. Returns it. +/proc/hv_blood_lay_spray(turf/target, decal_type, spray_dir, list/blood_dna, list/viruses) + for(var/obj/effect/decal/cleanable/blood/hv_spray/existing in target) + // A tile holds one floor spray, whatever shape it came in; wall smears are kept apart. + if(istype(existing, /obj/effect/decal/cleanable/blood/hv_spray/wall) || existing.dried || QDELETED(existing)) + continue + existing.add_blood_DNA(blood_dna, viruses) + existing.hv_aim(spray_dir) + return existing + var/obj/effect/decal/cleanable/blood/hv_spray/fresh = new decal_type(target, viruses, blood_dna) + if(QDELETED(fresh)) + return null + fresh.hv_aim(spray_dir) + return fresh + +/** + * Throws blood from src towards spray_dir: streaks along the floor, a splash at the end, a smear + * on the first wall in the way, and anyone standing in the line is drenched. The hook the other + * hyperviolence parts call for a spray; hit and dismember triggers are at the bottom of this file. + * + * spray_dir is the way the blow or shot travelled, away from the attacker. strength is how many + * tiles the line reaches; big sprays also throw two flanking lines. killing: the blow that killed src, which + * sprays even right after another spray. + * Returns TRUE if anything was sprayed. + */ +/mob/living/proc/hv_blood_throw(spray_dir, strength = 3, killing = FALSE) + if(QDELETED(src) || !isturf(loc)) + return FALSE + if(can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return FALSE + strength = clamp(round(strength), 1, HV_BLOOD_MAX_RANGE) + if(!killing && world.time < hv_blood_spray_until && strength <= hv_blood_spray_strength) + return FALSE + hv_blood_spray_until = world.time + HV_BLOOD_SPRAY_WINDOW + hv_blood_spray_strength = strength + if(!spray_dir) + spray_dir = pick(GLOB.alldirs) + + var/list/blood_dna = get_blood_dna_list() + var/list/viruses = get_static_viruses() + var/list/drenched = list() + hv_blood_spray_line(spray_dir, strength, blood_dna, viruses, drenched) + if(strength >= HV_BLOOD_FAN_STRENGTH) + // The flanking lines are thin all along: scatter either side of the main line, not two more of it. + hv_blood_spray_line(turn(spray_dir, 45), strength - 1, blood_dna, viruses, drenched, TRUE) + hv_blood_spray_line(turn(spray_dir, -45), strength - 1, blood_dna, viruses, drenched, TRUE) + for(var/mob/living/splashed as anything in drenched) + splashed.hv_blood_drench(blood_dna, viruses) + return TRUE + +/// One line of spray out from src. Mobs it reaches are added to drenched. `light` lays the thin tail +/// looks all along it. Returns TRUE if it hit a wall. +/mob/living/proc/hv_blood_spray_line(spray_dir, tiles, list/blood_dna, list/viruses, list/drenched, light = FALSE) + var/turf/current = get_turf(src) + // How far the drops sit off the centre of the line, walked a little each tile so the line wanders. + var/max_drift = ISDIAGONALDIR(spray_dir) ? HV_BLOOD_DROPS_DRIFT_DIAGONAL : HV_BLOOD_DROPS_DRIFT + var/drift = rand(-2, 2) + for(var/step in 1 to tiles) + var/turf/next = get_step(current, spray_dir) + if(!next) + return FALSE + if(hv_blood_spray_blocked(next)) + hv_blood_lay_wall_spray(current, spray_dir, blood_dna, viruses) + return TRUE + current = next + var/mob/living/in_the_way = locate() in current + if(in_the_way) + if(!(in_the_way in drenched)) + drenched += in_the_way + return FALSE + if(isgroundlessturf(current)) + continue + if(!current.has_gravity()) + // With nothing to pull it down the blood hangs in the air; the floating blood part takes over. + // At once: left to its tick's grace it is drawn as a whole splatter on the floor for two frames + // before it turns into beads. + var/obj/effect/decal/cleanable/blood/hanging = new(current, viruses, blood_dna) + if(!QDELETED(hanging)) + hanging.hv_blood_float_begin() + continue + var/obj/effect/decal/cleanable/blood/hv_spray/laid = hv_blood_lay_spray(current, step < tiles ? /obj/effect/decal/cleanable/blood/hv_spray/streak/drops : /obj/effect/decal/cleanable/blood/hv_spray, spray_dir, blood_dna, viruses) + if(istype(laid, /obj/effect/decal/cleanable/blood/hv_spray/streak)) + var/obj/effect/decal/cleanable/blood/hv_spray/streak/streak = laid + drift = clamp(drift + rand(-2, 2), -max_drift, max_drift) + // The far half of the line thins out before the splash. + streak.hv_set_look(light || step * 2 > tiles, drift) + else if(laid && step == tiles && laid.type == /obj/effect/decal/cleanable/blood/hv_spray) + laid.hv_meet_line(spray_dir, drift) + return FALSE + +/// Blood on whatever blocks the spray, drawn from the floor tile in front of it. +/mob/living/proc/hv_blood_lay_wall_spray(turf/floor, spray_dir, list/blood_dna, list/viruses) + if(isclosedturf(floor) || isgroundlessturf(floor)) + return + for(var/obj/effect/decal/cleanable/blood/hv_spray/wall/existing in floor) + if(existing.dir != spray_dir || existing.dried || QDELETED(existing)) + continue + existing.add_blood_DNA(blood_dna, viruses) + return existing + var/obj/effect/decal/cleanable/blood/hv_spray/wall/fresh = new(floor, viruses, blood_dna) + if(QDELETED(fresh)) + return null + fresh.hv_aim(spray_dir) + return fresh + +/// Blood across this mob and what it wears, through the usual forensics path. +/mob/living/proc/hv_blood_drench(list/blood_dna, list/viruses) + if(QDELETED(src) || !length(blood_dna)) + return + add_blood_DNA(blood_dna, viruses) + if(world.time < hv_blood_drench_until) + return + hv_blood_drench_until = world.time + 3 SECONDS + to_chat(src, span_warning("Blood spatters across you.")) + +/// Big brute hits, and the one that kills, throw blood the way they travelled. +/mob/living/proc/hv_blood_on_damaged(datum/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + // The blow that kills always sprays. By now, after the damage, the body already counts as dead. + var/killing = hv_blood_alive_at_hit && stat == DEAD + hv_blood_alive_at_hit = FALSE + if(damagetype != BRUTE || !attack_direction) + return + // Remembered for any limb this hit takes off a moment later. + hv_blood_hit_dir = attack_direction + hv_blood_hit_time = world.time + if(damage_dealt < HV_BLOOD_MIN_HIT && !killing) + return + if(hv_blood_tg_spray_tick == world.time || (world.time < hv_blood_spray_until && !killing)) + return + if(blood_volume <= BLOOD_VOLUME_BAD || !isturf(loc)) + return + if(isbodypart(def_zone)) + var/obj/item/bodypart/hit_part = def_zone + if(!hit_part.can_bleed()) + return + // A hard hit always sprays; a glancing one sometimes does. A body already down is hit less wetly. + var/chance = clamp((damage_dealt - 8) * 4, 0, 100) + if(stat == DEAD) + chance *= 0.5 + if(!killing && !prob(chance)) + return + hv_blood_throw(attack_direction, round(damage_dealt / HV_BLOOD_DAMAGE_PER_TILE), killing) + +/// The way the last brute hit travelled if it landed a moment ago, else null. +/mob/living/proc/hv_blood_recent_hit_dir() + if(world.time - hv_blood_hit_time <= HV_BLOOD_HIT_MEMORY) + return hv_blood_hit_dir + +/// A limb torn off sprays the way the blow that took it travelled. +/mob/living/carbon/proc/hv_blood_on_limb_removed(datum/source, obj/item/bodypart/removed_limb, special, dismembered) + SIGNAL_HANDLER + if(special || !dismembered || !removed_limb.can_bleed()) + return + hv_blood_throw(hv_blood_recent_hit_dir() || pick(GLOB.alldirs), HV_BLOOD_DISMEMBER_STRENGTH) diff --git a/voidcrew/modules/hyperviolence/blood/blood_weightless.dm b/voidcrew/modules/hyperviolence/blood/blood_weightless.dm new file mode 100644 index 00000000000..0cec368db28 --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/blood_weightless.dm @@ -0,0 +1,169 @@ +// Hyperviolence blood part: blood in zero gravity. +// +// Art: icons/blood_weightless.dmi is the mfloor1-7 and mgibbl1-5 globules from Paradise +// (ParadiseSS13/Paradise at commit d2a2bb03d588, icons/effects/blood_weightless.dmi), licensed +// CC BY-SA 3.0, recoloured to greyscale so they take the blood's own colour. +// Idea: Paradise's weightless_icon / check_gravity / splat() in +// code/game/objects/effects/decals/Cleanable/humans.dm (AGPL-3.0). +// +// Blood that lands where nothing pulls it down hangs in the air as bobbing beads, does not dry, +// and falls into an ordinary splatter once gravity comes back. Gravity changes are announced by +// the mobs standing in them (refresh_gravity), which is how tg and Voidcrew's gravity failure +// already tell everyone aboard. A slow sweep covers changes nobody announces, such as a gravity +// field around a turf. Blood already on the floor when gravity goes stays where it is. + +/// Blood that is hanging in the air right now. +GLOBAL_LIST_EMPTY(hv_floating_blood) +/// Timer for the sweep that catches gravity changes nothing announced. +GLOBAL_VAR(hv_blood_sweep_timer) + +/obj/effect/decal/cleanable/blood + /// Hanging in the air with no gravity to pull it down. + var/hv_floating = FALSE + +/// Whether blood here would hang in the air rather than lie on the floor. +/obj/effect/decal/cleanable/blood/proc/hv_blood_would_float() + // Only the plain splatters float; trails, gibs, footprints and flying splatters keep their own handling. + if(type != /obj/effect/decal/cleanable/blood && type != /obj/effect/decal/cleanable/blood/splatter) + return FALSE + if(dried || !isturf(loc)) + return FALSE + return has_gravity() <= 0 + +/obj/effect/decal/cleanable/blood/Initialize(mapload, list/datum/disease/diseases, list/blood_or_dna = get_default_blood_type()) + . = ..() + if(. == INITIALIZE_HINT_QDEL || mapload) + return + // A tick's grace: whatever made this decal may still be choosing its picture. + if(hv_blood_would_float()) + addtimer(CALLBACK(src, PROC_REF(hv_blood_float_begin)), 1) + +/obj/effect/decal/cleanable/blood/Destroy() + GLOB.hv_floating_blood -= src + return ..() + +/// Floating blood only runs together with floating blood, and lying blood with lying blood. +/obj/effect/decal/cleanable/blood/replace_decal(obj/effect/decal/cleanable/blood/merger) + if(istype(merger) && hv_blood_would_float() != merger.hv_floating) + return FALSE + return ..() + +/// Lifts this blood into the air. +/obj/effect/decal/cleanable/blood/proc/hv_blood_float_begin() + if(hv_floating || QDELETED(src) || !hv_blood_would_float()) + return + hv_floating = TRUE + // Weightless blood never dries. + can_dry = FALSE + STOP_PROCESSING(SSblood_drying, src) + GLOB.hv_floating_blood += src + hv_blood_arm_sweep() + icon = HV_BLOOD_FLOAT_ICON + icon_state = "float[rand(1, HV_BLOOD_FLOAT_VARIANTS)]" + layer = ABOVE_MOB_LAYER + SET_PLANE_IMPLICIT(src, GAME_PLANE) + base_name = "globules of" + // Drops the slow fade to dried that was set up when this was a splatter on the floor. + update_atom_colour() + update_appearance() + hv_blood_float_bob() + +/// Lets this blood fall: it joins the splatter already on the tile, or becomes one. +/obj/effect/decal/cleanable/blood/proc/hv_blood_land() + if(!hv_floating || QDELETED(src)) + return + hv_floating = FALSE + GLOB.hv_floating_blood -= src + animate(src) + pixel_x = initial(pixel_x) + pixel_y = initial(pixel_y) + transform = null + for(var/obj/effect/decal/cleanable/blood/lying in loc) + if(lying == src || lying.type != type || lying.hv_floating || lying.dried || QDELETED(lying)) + continue + handle_merge_decal(lying) + qdel(src) + return + icon = initial(icon) + icon_state = base_icon_state + layer = initial(layer) + SET_PLANE_IMPLICIT(src, initial(plane)) + base_name = initial(base_name) + can_dry = initial(can_dry) + if(!dried) + START_PROCESSING(SSblood_drying, src) + // Starts the fade to dried again, from the full drying time. + update_atom_colour() + update_appearance() + hv_pool_reset() + hv_update_pool_tier() + +/// A slow rise and fall, a little different for every bead so they do not move together. +/obj/effect/decal/cleanable/blood/proc/hv_blood_float_bob() + var/lift = rand(2, 5) + var/drift = rand(-3, 3) + var/tilt = rand(6, 14) * pick(-1, 1) + var/period = rand(14, 26) + animate(src, pixel_y = lift, pixel_x = drift, transform = matrix().Turn(tilt), time = period, loop = -1, easing = SINE_EASING) + animate(pixel_y = 0, pixel_x = -drift, transform = matrix().Turn(-tilt), time = period, easing = SINE_EASING) + +// tg stops every animation on a decal whenever its colour is recalculated; put the bobbing back. +/obj/effect/decal/cleanable/blood/update_blood_color() + . = ..() + if(hv_floating) + hv_blood_float_bob() + +/obj/effect/decal/cleanable/blood/update_desc(updates) + . = ..() + if(hv_floating) + desc = "Beads of blood hang in the air." + +/obj/effect/decal/cleanable/blood/process(seconds_per_tick) + if(hv_floating) + return PROCESS_KILL + return ..() + +// Nothing to step in, and nothing to crawl into. +/obj/effect/decal/cleanable/blood/on_entered(datum/source, atom/movable/AM) + SIGNAL_HANDLER + if(hv_floating) + return + return ..() + +/obj/effect/decal/cleanable/blood/can_bloodcrawl_in() + if(hv_floating) + return FALSE + return ..() + +/// Without gravity a drip has nothing to fall onto: the blood hangs in the air as beads instead. +/datum/blood_type/make_blood_splatter(mob/living/bleeding, turf/blood_turf, drip = FALSE) + if(drip && isturf(blood_turf) && !blood_turf.has_gravity()) + drip = FALSE + return ..() + +/// Lets every piece of floating blood that now has gravity under it fall. +/proc/hv_blood_recheck_floating() + for(var/obj/effect/decal/cleanable/blood/floater as anything in GLOB.hv_floating_blood.Copy()) + if(QDELETED(floater)) + GLOB.hv_floating_blood -= floater + continue + if(floater.has_gravity() > 0) + floater.hv_blood_land() + +/// Starts the slow sweep if there is floating blood and no sweep already waiting. +/proc/hv_blood_arm_sweep() + if(GLOB.hv_blood_sweep_timer || !length(GLOB.hv_floating_blood)) + return + GLOB.hv_blood_sweep_timer = addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(hv_blood_gravity_sweep)), HV_BLOOD_FLOAT_SWEEP, TIMER_STOPPABLE) + +/proc/hv_blood_gravity_sweep() + GLOB.hv_blood_sweep_timer = null + hv_blood_recheck_floating() + hv_blood_arm_sweep() + +/// Anyone whose gravity comes back tells the floating blood to look again, once however many of them there are. +/mob/living/refresh_gravity() + var/old_gravity = gravity_state + . = ..() + if(gravity_state > 0 && !(old_gravity > 0) && length(GLOB.hv_floating_blood)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(hv_blood_recheck_floating)), 1, TIMER_UNIQUE) diff --git a/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi b/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi new file mode 100644 index 00000000000..b278093f81b Binary files /dev/null and b/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi differ diff --git a/voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi b/voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi new file mode 100644 index 00000000000..e3d853c23a7 Binary files /dev/null and b/voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi differ diff --git a/voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi b/voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi new file mode 100644 index 00000000000..e054b7a1387 Binary files /dev/null and b/voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi differ diff --git a/voidcrew/modules/hyperviolence/braingut/blast_gore.dm b/voidcrew/modules/hyperviolence/braingut/blast_gore.dm new file mode 100644 index 00000000000..9224df5d5d1 --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/blast_gore.dm @@ -0,0 +1,11 @@ +// Blast gore (hyperviolence, braingut part): a heavy explosion can burst the head of a victim who is +// already down. It rolls against the victim's bomb armour. A blast that splits a chest open does so +// through tg's own chest spill, which brings the intestines out with the other organs (gut_spill.dm). + +/mob/living/carbon/human/ex_act(severity, target, origin) + . = ..() + if(!. || severity != EXPLODE_HEAVY || QDELETED(src)) + return + if(stat < HARD_CRIT || !prob(HV_HEAD_BURST_EXPLOSION_CHANCE) || prob(getarmor(get_bodypart(BODY_ZONE_HEAD), BOMB))) + return + hv_burst_head() diff --git a/voidcrew/modules/hyperviolence/braingut/brain_burst.dm b/voidcrew/modules/hyperviolence/braingut/brain_burst.dm new file mode 100644 index 00000000000..b515fbb4580 --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/brain_burst.dm @@ -0,0 +1,155 @@ +// Brain burst (hyperviolence, braingut part): a head that bursts rather than coming off whole. It +// comes apart in gibs and the brain is thrown out intact, still holding its owner's mind, so the +// victim can be revived later like any other loose brain. +// The idea is Septic Shock's brain spill (modular_septic wounds/organic/spilling.dm). Nothing of its +// code, art or sound is used: the burst is built from tg's gibs and blood splatter. + +/obj/item/bodypart/head + /// Set while a blow that is allowed to burst this head instead of knocking it off is being resolved. + var/hv_burst_pending = FALSE + /// Direction of the blow being resolved, from the attacker toward this head. + var/hv_blow_dir + /// Set while this head bursts: it comes apart where it is, so taking it off must not send it anywhere. + var/hv_bursting = FALSE + +/// Bursts the head: gibs, a spray of blood, a burst of splatter, and the brain thrown intact along burst_dir. +/// announce is off when the caller has already said what happened. Called by hv_burst_head() in _core.dm. +/// Returns TRUE if the head burst. +/mob/living/carbon/proc/hv_burst_head_apart(burst_dir, announce = TRUE) + if(HAS_TRAIT(src, TRAIT_GODMODE)) + return FALSE + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(!head || !head.can_dismember()) + return FALSE + if(!burst_dir) + burst_dir = pick(GLOB.alldirs) + var/turf/here = get_turf(src) + var/organic = IS_ORGANIC_LIMB(head) + var/bleeds = head.can_bleed() + var/list/blood_dna = bleeds ? get_blood_dna_list() : null + var/blood_color = bleeds ? get_bloodtype()?.get_color() : null + // Where the head is drawn right now, standing or lying: the burst comes out of it, not out of the + // middle of the body. Read before the head comes off, which drops a standing body. + var/list/head_at = hv_vis_part_offset(BODY_ZONE_HEAD) + // everything inside the head that can come out: brain, eyes, ears, tongue, and whatever was implanted + var/list/obj/item/organ/loose = list() + for(var/obj/item/organ/organ in head) + if(!(organ.organ_flags & ORGAN_UNREMOVABLE)) + loose += organ + + hv_blood_spray(burst_dir, 4) + // A burst head comes apart where it was. Taking it off must not fling it (hv_bursting): tg's + // dismember() throws the part, and a thrown head shoves whoever it hits, the attacker included. + head.hv_bursting = TRUE + var/came_off = head.dismember(BRUTE, silent = TRUE) + if(!QDELETED(head)) + head.hv_bursting = FALSE + if(!came_off) + return FALSE + // Its insides fall out where the body is, whatever else moved the head in the meantime. + if(isturf(here) && head.loc != here) + head.forceMove(here) + // no violent_removal: that would damage the brain, and it has to stay good for revival + head.drop_organs() + qdel(head) + + if(isturf(here)) + if(!organic) + new /obj/effect/gibspawner/robot(here, src) + else if(bleeds) + hv_spawn_head_gibs(here, burst_dir, blood_dna) + hv_head_burst_splatter(here, burst_dir, blood_color, head_at) + playsound(here, 'sound/effects/wounds/splatter.ogg', 80, TRUE) + + for(var/obj/item/organ/organ as anything in loose) + if(QDELETED(organ) || !isturf(organ.loc)) + continue + if(istype(organ, /obj/item/organ/brain)) + var/datum/callback/landed = blood_dna ? CALLBACK(organ, TYPE_PROC_REF(/obj/item/organ, hv_landed), blood_dna) : null + hv_fling_organ(organ, burst_dir, HV_BRAIN_FLING_MIN, HV_BRAIN_FLING_MAX, landed) + else + hv_fling_organ(organ, turn(burst_dir, pick(-45, 0, 45)), 1, 2) + + if(announce) + hv_add_hit_line(pick("[src]'s head bursts apart!", "[src]'s skull bursts open!"), HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + return TRUE + +/// Flings a loose organ min_dist to max_dist tiles along fling_dir. It lands as an ordinary item. +/// Never tg's throw (hv_fling(), _core.dm): nobody is hit by it and nobody catches it. It never goes over +/// a hole or lava either: a brain lost in one is a player lost for good, so it stops short. +/proc/hv_fling_organ(obj/item/organ/organ, fling_dir, min_dist, max_dist, datum/callback/on_landed) + var/turf/start = get_turf(organ) + if(!start || isgroundlessturf(start)) + return + organ.hv_fling(fling_dir, rand(min_dist, max_dist), on_landed) + +/// A thrown organ has come down: it hits the floor with a wet sound and leaves a smear of the victim's blood. +/obj/item/organ/proc/hv_landed(list/blood_dna) + if(QDELETED(src) || !isturf(loc)) + return + playsound(src, 'sound/misc/moist_impact.ogg', 50, TRUE) + new /obj/effect/decal/cleanable/blood/splatter(loc, null, blood_dna) + +/// Chunks of head thrown across the burst's cone, and a core, using tg's own gib decals so they merge and clean up like any others. +/proc/hv_spawn_head_gibs(turf/where, burst_dir, list/blood_dna) +#ifndef UNIT_TESTS + var/list/cone = list(burst_dir, turn(burst_dir, 45), turn(burst_dir, -45)) +#endif + for(var/i in 1 to HV_HEAD_BURST_GIBS) +// These might streak off into space and cause annoying flaky failures with mapping nearstation tests +#ifndef UNIT_TESTS + var/obj/effect/decal/cleanable/blood/gibs/gib = new(where, null, blood_dna) + gib.streak(cone) +#else + new /obj/effect/decal/cleanable/blood/gibs(where, null, blood_dna) +#endif + new /obj/effect/decal/cleanable/blood/gibs/core(where, null, blood_dna) + +/// The short burst itself: blood and a pale smear of brain thrown out of the head along the blow. +/// head_at is where the head is drawn on the tile (list(x, y) in pixels from its middle), so the burst +/// starts at the head of a body on its feet or on the floor; without it, it starts at the middle. +/proc/hv_head_burst_splatter(turf/where, burst_dir, blood_color, list/head_at) + var/list/obj/effect/splatters = list() + for(var/angle in list(0, 45, -45)) + splatters += new /obj/effect/temp_visual/dir_setting/bloodsplatter(where, turn(burst_dir, angle), blood_color) + splatters += new /obj/effect/temp_visual/dir_setting/bloodsplatter(where, burst_dir, "#c9a0ac") + if(!head_at) + return + // pixel_w and pixel_z: tg's splatter animates its own pixel_x and pixel_y outward. + for(var/obj/effect/splatter as anything in splatters) + splatter.pixel_w += head_at[1] + splatter.pixel_z += head_at[2] + +/// Whether a blow that is about to knock this head off should burst it instead. Rare on purpose: the +/// victim must already be dead or in hard crit, the head already half destroyed, and the blow a heavy +/// crushing or piercing one. Cuts and burns never burst a head. +/obj/item/bodypart/head/proc/hv_blow_bursts(wounding_type, wounding_dmg) + if(!owner || wounding_type == WOUND_SLASH || wounding_type == WOUND_BURN) + return FALSE + if(wounding_dmg < HV_HEAD_BURST_MIN_DAMAGE || owner.stat < HARD_CRIT) + return FALSE + return get_damage() >= max_damage * HV_HEAD_BURST_MIN_HEAD_DAMAGE + +/obj/item/bodypart/head/receive_damage(brute = 0, burn = 0, blocked = 0, updating_health = TRUE, forced = FALSE, required_bodytype = null, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction = null, damage_source, wound_clothing = TRUE) + hv_blow_dir = attack_direction + . = ..() + hv_blow_dir = null + +/obj/item/bodypart/head/try_dismember(wounding_type, wounding_dmg, wound_bonus, exposed_wound_bonus) + hv_burst_pending = hv_blow_bursts(wounding_type, wounding_dmg) + . = ..() + hv_burst_pending = FALSE + +/// The loss wound announces what happened to the head, so a burst gets its own wording there. +/obj/item/bodypart/head/get_dismember_message(wounding_type, outright) + if(hv_burst_pending) + return "bursts apart!" + return ..() + +/obj/item/bodypart/head/dismember(dam_type = BRUTE, silent = TRUE, wounding_type) + if(hv_burst_pending && owner) + hv_burst_pending = FALSE + // the wound that got us here has already said the head burst, so no second line + if(owner.hv_burst_head_apart(hv_blow_dir, FALSE)) + return TRUE + return ..() diff --git a/voidcrew/modules/hyperviolence/braingut/gut_spill.dm b/voidcrew/modules/hyperviolence/braingut/gut_spill.dm new file mode 100644 index 00000000000..f6a6e12f266 --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/gut_spill.dm @@ -0,0 +1,221 @@ +// Gut spill (hyperviolence, braingut part): tg already spills a victim's chest organs onto the floor +// when the chest is split open (/obj/item/bodypart/chest/dismember, only ever on someone in hard crit +// or dead, or cursed). The intestines come out in that same spill, every time, and nothing else +// triggers them. On a living victim they stay joined to the body by a tether (gut_tether.dm) through +// a critical slash wound, so gauze, sutures and cautery all work on the bleeding, but the cut will not +// close while the guts are out. Pushing the guts back in turns it into an ordinary severe cut. On a +// corpse there is nothing to drag: the guts just lie with the other organs. +// The design idea is Septic Shock's gut spill and Vanderlin's disembowel (AGPL code, nothing copied). + +/// Slows a victim holding their guts in. +/datum/movespeed_modifier/hv_gut_spill + multiplicative_slowdown = 0.7 + +/datum/wound_pregen_data/flesh_slash/disembowel + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/critical/disembowel + // only ever applied on purpose, by the chest spill hook below, never by the wound roll + can_be_randomly_generated = FALSE + viable_zones = list(BODY_ZONE_CHEST) + threshold_minimum = WOUND_DISMEMBER_OUTRIGHT_THRESH + +/// A spill replaces the cut it comes from, so an existing cut in the same series must not stop it. +/datum/wound_pregen_data/flesh_slash/disembowel/can_be_applied_to(obj/item/bodypart/limb, list/suggested_wounding_types = required_wounding_types, datum/wound/old_wound, random_roll = FALSE, duplicates_allowed = src.duplicates_allowed, care_about_existing_wounds = TRUE) + return ..(limb, suggested_wounding_types, old_wound, random_roll, duplicates_allowed, FALSE) + +/datum/wound/slash/flesh/critical/disembowel + name = "Disembowelment" + desc = "Abdominal wall torn open. Intestines are outside the body." + treat_text = "Return the intestines. Dress the wound." + treat_text_short = "Return the intestines." + simple_treat_text = "Return the intestines. Dress the wound." + homemade_treat_text = "Return the intestines. Dress the wound." + examine_desc = "is torn open, guts hanging out" + occur_text = "is torn open, guts spilling out" + initial_flow = 4 + // the cut cannot close while the guts are out, so this is also the floor the bleeding stops at + minimum_flow = 2.5 + clot_rate = 0 + treatable_by = list(/obj/item/stack/medical/suture, /obj/item/hv_intestines) + scar_keyword = "slashcritical" + + /// The spilled guts, while they exist. + var/obj/item/hv_intestines/guts + /// Whether the guts are still outside the body. Cleared when they are pushed back in or destroyed. + var/guts_out = FALSE + /// The strand between the victim and the guts. + var/datum/hv_gut_tether/tether + +/// Spills quietly: tg's chest spill line and the guts line already say it, so the generic "chest is torn open" line would only repeat them. +/datum/wound/slash/flesh/critical/disembowel/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) + return ..(L, TRUE, old_wound, smited, attack_direction, wound_source, replacing) + +/datum/wound/slash/flesh/critical/disembowel/wound_injury(datum/wound/slash/flesh/old_wound = null, attack_direction = null) + . = ..() + spill_out(attack_direction) + +/datum/wound/slash/flesh/critical/disembowel/Destroy() + release_guts() + return ..() + +/datum/wound/slash/flesh/critical/disembowel/remove_wound(ignore_limb, replaced = FALSE) + release_guts() + return ..() + +/datum/wound/slash/flesh/critical/disembowel/get_self_check_description(self_aware) + if(guts_out) + return span_bolddanger("Your insides are hanging out of it!") + return ..() + +/// The cut cannot close while the guts are out. +/datum/wound/slash/flesh/critical/disembowel/adjust_blood_flow(adjust_by, minimum = 0) + if(guts_out) + minimum = max(minimum, minimum_flow) + return ..(adjust_by, minimum) + +/// Open guts never clot, so a cut with them out is never reported as healing. +/datum/wound/slash/flesh/critical/disembowel/get_bleed_rate_of_change() + return ..() || BLOOD_FLOW_STEADY + +/// Drops the guts on the floor and ties them to the victim. The other organs are already out: the chest spill +/// that brought this wound took them. +/datum/wound/slash/flesh/critical/disembowel/proc/spill_out(attack_direction) + var/obj/item/bodypart/chest/chest = limb + guts = chest.hv_drop_intestines(attack_direction) + if(!guts) + return + guts_out = TRUE + RegisterSignal(guts, COMSIG_QDELETING, PROC_REF(on_guts_deleted)) + victim.add_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill) + INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") + tether = new(victim, guts) + RegisterSignal(tether, COMSIG_QDELETING, PROC_REF(on_tether_deleted)) + +/datum/wound/slash/flesh/critical/disembowel/proc/on_tether_deleted(datum/source) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_QDELETING) + tether = null + +/// The guts were destroyed, so there is nothing left to push back in. +/datum/wound/slash/flesh/critical/disembowel/proc/on_guts_deleted(datum/source) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_QDELETING) + guts = null + end_spill() + +/// Lets go of the guts: no tether, no slowdown. Leaves the guts item where it lies. +/datum/wound/slash/flesh/critical/disembowel/proc/release_guts() + if(guts) + UnregisterSignal(guts, COMSIG_QDELETING) + guts = null + end_spill() + +/// Nothing is outside the body any more. +/datum/wound/slash/flesh/critical/disembowel/proc/end_spill() + guts_out = FALSE + if(tether) + UnregisterSignal(tether, COMSIG_QDELETING) + QDEL_NULL(tether) + victim?.remove_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill) + +/datum/wound/slash/flesh/critical/disembowel/treat(obj/item/I, mob/user) + if(istype(I, /obj/item/hv_intestines)) + return push_guts_back(I, user) + return ..() + +/// Someone pushes the guts back in. Whichever guts they hold go in, and the wound becomes the plain severe cut it was. +/datum/wound/slash/flesh/critical/disembowel/proc/push_guts_back(obj/item/hv_intestines/returned, mob/user) + if(!guts_out) + return FALSE + var/mob/living/carbon/patient = victim + var/self_treat = (user == patient) + user.visible_message( + span_notice("[user] starts pushing [self_treat ? user.p_their() : "[patient]'s"] guts back in."), + span_notice("You start pushing [self_treat ? "your" : "[patient]'s"] guts back in..."), + ) + if(!do_after(user, (self_treat ? 6 SECONDS : 4 SECONDS), target = patient, extra_checks = CALLBACK(src, PROC_REF(can_push_back), returned))) + return TRUE + user.visible_message( + span_notice("[user] pushes [self_treat ? user.p_their() : "[patient]'s"] guts back in."), + span_notice("You push [self_treat ? "your" : "[patient]'s"] guts back in."), + ) + finish_push_back(returned) + return TRUE + +/datum/wound/slash/flesh/critical/disembowel/proc/can_push_back(obj/item/hv_intestines/returned) + return guts_out && still_exists() && !QDELETED(returned) + +/// The guts go back in for good: the held guts are used up and the wound drops to the cut it came from. +/datum/wound/slash/flesh/critical/disembowel/proc/finish_push_back(obj/item/hv_intestines/returned) + end_spill() + qdel(returned) + if(!QDELETED(src) && limb && demotes_to) + replace_wound(new demotes_to) + +/// Whether this chest could spill intestines at all: a human body that can be wounded. +/obj/item/bodypart/chest/proc/hv_chest_can_spill() + if(!owner || !ishuman(owner)) + return FALSE + if(HAS_TRAIT(owner, TRAIT_NODISMEMBER) || HAS_TRAIT(owner, TRAIT_NEVER_WOUNDED) || HAS_TRAIT(owner, TRAIT_GODMODE)) + return FALSE + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[/datum/wound/slash/flesh/critical/disembowel] + return pregen.can_be_applied_to(src) + +/// Puts a coil of intestines on the floor where the organs fell, ahead of the victim when the blow's direction +/// is known, with a smear of gibs and a line in the hit message. Returns the guts, or null if there was nowhere to put them. +/obj/item/bodypart/chest/proc/hv_drop_intestines(attack_direction) + var/mob/living/carbon/victim = owner + var/turf/victim_turf = get_turf(victim) + if(!victim_turf) + return null + var/atom/drop_spot = victim.drop_location() || victim_turf + if(isturf(victim.loc) && attack_direction) + var/turf/ahead = get_step(victim, attack_direction) + if(ahead && !ahead.is_blocked_turf(exclude_mobs = TRUE)) + drop_spot = ahead + var/obj/item/hv_intestines/guts = new(drop_spot) + guts.add_mob_blood(victim) + if(victim.can_bleed() && isturf(drop_spot)) + new /obj/effect/decal/cleanable/blood/gibs(drop_spot, null, victim.get_blood_dna_list()) + // Reads after tg's "chest is split open, causing their internal organs to spill out!" + victim.hv_add_hit_line("[victim]'s guts unspool across the floor!", HV_LINE_BOLD, "Your guts unspool across the floor!", DEFAULT_MESSAGE_RANGE) + return guts + +/// Puts a disembowelment on this chest, replacing the cut it comes from, and drops the guts it holds on a tether. +/// Returns the wound, or null if it could not be applied. +/obj/item/bodypart/chest/proc/hv_spill_guts(attack_direction, damage_source) + if(!hv_chest_can_spill()) + return null + var/datum/wound/slash/flesh/critical/disembowel/spill = new + var/datum/wound/slash/flesh/existing = get_wound_type(/datum/wound/slash/flesh) + if(existing) + existing.replace_wound(spill, attack_direction = attack_direction) + else + spill.apply_wound(src, attack_direction = attack_direction, wound_source = damage_source) + if(QDELETED(spill) || spill.limb != src) + return null + return spill + +/// The intestines that come out in the same moment as the other chest organs. A living victim (in hard crit, +/// or cursed) is disemboweled, so the guts stay tied to them; a corpse has nothing to drag, so its guts only drop. +/// Does nothing if the guts are already out. +/obj/item/bodypart/chest/proc/hv_spill_intestines(attack_direction) + if(!hv_chest_can_spill()) + return + var/datum/wound/slash/flesh/critical/disembowel/already = get_wound_type(/datum/wound/slash/flesh/critical/disembowel) + if(already?.guts_out) + return + if(owner.stat == DEAD) + hv_drop_intestines(attack_direction) + return + hv_spill_guts(attack_direction, "chest spill") + +/// tg splits the chest open by spilling its organs onto the floor; the intestines are part of that spill. +/// The way the last blow travelled, if one just landed, is where the guts are thrown. +/obj/item/bodypart/chest/dismember(dam_type = BRUTE, silent = TRUE, wounding_type) + var/mob/living/carbon/chest_owner = owner + . = ..() + // tg gives back the organs it spilled, or FALSE when it did nothing + if(!islist(.) || !length(.) || !chest_owner || chest_owner != owner) + return + hv_spill_intestines(chest_owner.hv_blood_recent_hit_dir()) diff --git a/voidcrew/modules/hyperviolence/braingut/gut_tether.dm b/voidcrew/modules/hyperviolence/braingut/gut_tether.dm new file mode 100644 index 00000000000..df6a319d0a0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/gut_tether.dm @@ -0,0 +1,223 @@ +// Gut tether (hyperviolence, braingut part): while spilled guts lie on the floor near their owner, a +// strand of gut joins them. Walking too far drags the guts along and hurts. A sharp item on the guts +// cuts the strand. Idea from Vanderlin's gut rope (Monkestation/Vanderlin 1fc0bf8, AGPL code); this is +// a rewrite on tg's /datum/beam, with a strand sprite drawn for this module. + +/// One piece of the strand. Lies on the floor, so mobs walk over it rather than under it. +/obj/effect/ebeam/hv_gut + name = "intestines" + layer = BELOW_MOB_LAYER + emissive = FALSE + +/// The strand of gut between a disemboweled victim and their spilled guts. Owned by the disembowel wound. +/// Destroys itself when the victim or the guts are deleted. +/datum/hv_gut_tether + /// Whose guts these are. + var/mob/living/carbon/victim + /// The spilled guts. + var/obj/item/hv_intestines/guts + /// The drawn strand. Purely visual; the tether still holds when it is missing. + var/datum/beam/strand + /// Set once the strand is cut or torn. A cut tether never reconnects. + var/cut = FALSE + /// World time the next tug is allowed to hurt. + var/next_yank = 0 + /// The tiles the victim has left since the guts last lay beside them, oldest first: the way the guts + /// are dragged in when the strand pulls tight. + var/list/turf/trail = list() + /// The timer of the guts' next step along the trail while they are being dragged in. + var/drag_timer + /// Set while the tether itself moves the guts, to tell that from anyone else moving them. + var/dragging = FALSE + +/datum/hv_gut_tether/New(mob/living/carbon/victim, obj/item/hv_intestines/guts) + src.victim = victim + src.guts = guts + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_victim_moved)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_end_deleted)) + RegisterSignal(guts, COMSIG_MOVABLE_MOVED, PROC_REF(on_guts_moved)) + RegisterSignal(guts, COMSIG_QDELETING, PROC_REF(on_end_deleted)) + RegisterSignal(guts, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_guts_item_interaction)) + refresh() + +/datum/hv_gut_tether/Destroy() + stop_drag() + trail = null + drop_strand() + if(victim) + UnregisterSignal(victim, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + if(guts) + UnregisterSignal(guts, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING, COMSIG_ATOM_ITEM_INTERACTION)) + victim = null + guts = null + return ..() + +/// Whether the guts are lying on the floor on the victim's level, so the strand is taut between them. +/datum/hv_gut_tether/proc/connected() + return !cut && isturf(victim.loc) && isturf(guts.loc) && victim.z == guts.z + +/// Brings the drawn strand in line with the state of the tether. +/datum/hv_gut_tether/proc/refresh() + if(!connected() || get_dist(victim, guts) > HV_GUT_TETHER_SNAP_RANGE) + drop_strand() + return + if(strand) + return + strand = victim.Beam(guts, "gut_beam", 'voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi', INFINITY, HV_GUT_TETHER_SNAP_RANGE + 2, /obj/effect/ebeam/hv_gut, emissive = FALSE) + RegisterSignal(strand, COMSIG_QDELETING, PROC_REF(on_strand_deleted)) + +/// Removes the drawn strand, if there is one. +/datum/hv_gut_tether/proc/drop_strand() + if(!strand) + return + UnregisterSignal(strand, COMSIG_QDELETING) + QDEL_NULL(strand) + +/// The beam removes itself when either end is deleted or they drift too far apart. +/datum/hv_gut_tether/proc/on_strand_deleted(datum/beam/source) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_QDELETING) + strand = null + +/// The victim or the guts are being deleted: nothing left to tether. +/datum/hv_gut_tether/proc/on_end_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/hv_gut_tether/proc/on_guts_moved(atom/movable/source, atom/old_loc, dir, forced) + SIGNAL_HANDLER + if(cut) + return + // Someone else has moved them: the way the victim went no longer leads to where they lie. + if(!dragging) + stop_drag() + if(connected() && get_dist(victim, guts) > HV_GUT_TETHER_SNAP_RANGE) + INVOKE_ASYNC(src, PROC_REF(tear), FALSE) + return + refresh() + +/datum/hv_gut_tether/proc/on_victim_moved(atom/movable/source, atom/old_loc, dir, forced) + SIGNAL_HANDLER + if(cut) + return + if(!connected()) + // guts left behind on another level are gone for good; guts in someone's hands or a container only wait + if(isturf(victim.loc) && isturf(guts.loc)) + cut = TRUE + drop_strand() + return + var/distance = get_dist(victim, guts) + if(distance > HV_GUT_TETHER_SNAP_RANGE) + INVOKE_ASYNC(src, PROC_REF(tear), TRUE) + return + note_step(old_loc) + if(distance > HV_GUT_TETHER_RANGE) + // the guts follow where the victim was, so they trail a few steps behind + var/turf/behind = isturf(old_loc) ? old_loc : victim.loc + if(!length(trail)) + trail += behind + drag_guts() + INVOKE_ASYNC(src, PROC_REF(yank), behind) + refresh() + +/// Remembers the tile the victim has just left. Beside the guts again the strand is slack and there is +/// nothing to remember; a victim who doubles back takes the loop out of the way. +/datum/hv_gut_tether/proc/note_step(atom/old_loc) + if(get_dist(victim, guts) <= 1) + trail.Cut() + return + var/doubled_back = trail.Find(victim.loc) + if(doubled_back) + trail.Cut(doubled_back) + return + if(!isturf(old_loc) || old_loc == guts.loc) + return + trail += old_loc + if(length(trail) > HV_GUT_TETHER_SNAP_RANGE) + trail.Cut(1, 2) + +/// The strand has pulled tight: the guts are dragged in along the way the victim went, to the tile behind +/// them. Their first step is taken at once and the rest follow a tile at a time, so they are seen sliding +/// across the floor (a move of more than one tile has no glide: the guts used to vanish from where they +/// lay and appear behind the victim). +/datum/hv_gut_tether/proc/drag_guts() + if(drag_timer) + return + drag_step() + +/// One tile further along the trail. +/datum/hv_gut_tether/proc/drag_step() + drag_timer = null + if(QDELETED(src) || cut || !connected()) + return + // Tiles they already lie on are behind them. + while(length(trail) && trail[1] == guts.loc) + trail.Cut(1, 2) + if(!length(trail)) + return + var/turf/next = trail[1] + if(get_dist(guts, next) > 1) + // Not beside the start of the trail (someone moved them): straight toward it. + next = get_step_towards(guts, next) + else + trail.Cut(1, 2) + dragging = TRUE + guts.set_glide_size(DELAY_TO_GLIDE_SIZE(HV_GUT_DRAG_STEP)) + guts.forceMove(next) + dragging = FALSE + if(QDELETED(src) || cut || !length(trail)) + return + drag_timer = addtimer(CALLBACK(src, PROC_REF(drag_step)), HV_GUT_DRAG_STEP, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Stops dragging the guts in and forgets the way the victim went. +/datum/hv_gut_tether/proc/stop_drag() + if(drag_timer) + deltimer(drag_timer) + drag_timer = null + trail?.Cut() + +/// A tug on the strand: it hurts, at most once per cooldown, and a standing victim is pulled off their feet. +/datum/hv_gut_tether/proc/yank(turf/behind) + if(QDELETED(src) || cut || world.time < next_yank) + return + next_yank = world.time + HV_GUT_YANK_COOLDOWN + victim.add_splatter_floor(behind, small_drip = TRUE) + if(victim.stat == DEAD) + return + victim.apply_damage(HV_GUT_YANK_DAMAGE, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, wound_clothing = FALSE) + victim.visible_message(span_danger("[victim] drags [victim.p_their()] guts across the floor!"), span_userdanger("Your guts drag across the floor!")) + victim.emote("scream") + if(victim.body_position == STANDING_UP) + victim.Knockdown(1 SECONDS) + +/// The strand parts for good, from a cut or because it was stretched too far. The victim feels it. +/datum/hv_gut_tether/proc/tear(stretched) + if(QDELETED(src) || cut) + return + cut = TRUE + drop_strand() + if(victim.stat == DEAD) + return + if(stretched) + victim.visible_message(span_danger("[victim]'s guts tear loose!"), span_userdanger("Your guts tear loose!")) + victim.apply_damage(HV_GUT_YANK_DAMAGE * 2, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, wound_clothing = FALSE) + victim.emote("scream") + victim.Knockdown(3 SECONDS) + +/// Anyone can cut the strand with something sharp. +/datum/hv_gut_tether/proc/on_guts_item_interaction(obj/item/source, mob/living/user, obj/item/tool, list/modifiers) + SIGNAL_HANDLER + if(!connected() || !tool.get_sharpness()) + return NONE + INVOKE_ASYNC(src, PROC_REF(cut_with), user, tool) + return ITEM_INTERACT_SUCCESS + +/datum/hv_gut_tether/proc/can_cut() + return !QDELETED(src) && connected() + +/datum/hv_gut_tether/proc/cut_with(mob/living/user, obj/item/tool) + user.visible_message(span_danger("[user] cuts at [victim]'s guts with [tool]!"), span_danger("You cut at [victim == user ? "your" : "[victim]'s"] guts with [tool].")) + playsound(guts, 'sound/items/weapons/slice.ogg', 60, TRUE) + if(!do_after(user, 1 SECONDS, target = guts, extra_checks = CALLBACK(src, PROC_REF(can_cut)))) + return + tear(FALSE) diff --git a/voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi b/voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi new file mode 100644 index 00000000000..7d98b940587 Binary files /dev/null and b/voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi differ diff --git a/voidcrew/modules/hyperviolence/braingut/intestines.dm b/voidcrew/modules/hyperviolence/braingut/intestines.dm new file mode 100644 index 00000000000..8675411296a --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/intestines.dm @@ -0,0 +1,25 @@ +// Gut spill (hyperviolence, braingut part): the gore item that lands on the floor when a victim is +// disemboweled. Sprite drawn for this module (icons/gore_guts.dmi); the idea of guts on a tether +// comes from Vanderlin (Monkestation/Vanderlin 1fc0bf8, AGPL code, CC BY-SA 3.0 art) but neither +// its code nor its art is copied here. + +/// A coil of someone's intestines, spilled by a disemboweling wound. Plain item: it is not an organ, +/// so nothing counts it as a body part and surgery never holds it. +/obj/item/hv_intestines + name = "intestines" + desc = "Someone's intestines. There is a lot more of it than there seems to be room for." + icon = 'voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi' + icon_state = "intestines" + w_class = WEIGHT_CLASS_BULKY + item_flags = NOBLUDGEON + force = 0 + throwforce = 0 + throw_range = 3 + drop_sound = 'sound/misc/moist_impact.ogg' + pickup_sound = 'sound/misc/moist_impact.ogg' + sound_vary = TRUE + +/obj/item/hv_intestines/Initialize(mapload) + . = ..() + pixel_x = base_pixel_x + rand(-6, 6) + pixel_y = base_pixel_y + rand(-5, 5) diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm new file mode 100644 index 00000000000..568a95244fd --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -0,0 +1,350 @@ +// Hyperviolence: aimed critical wounds. +// +// A big enough hit, aimed at the right spot with the right kind of weapon, can do one specific, +// lasting injury: tear out an eye, cut off the tongue, slit the throat, tear the aorta, cave in +// the skull or shatter the jaw. The design follows Roguetown's try_crit (BLACKSTONE, +// code/modules/surgery/bodyparts/bodypart_wounds.dm) and Vanderlin's per-wound crit data +// (code/datums/wounds/_wound.dm). Only the ideas were taken; no code, art or sound was copied. +// +// How a crit is rolled. These numbers are balance notes for developers and never go in player +// text. +// * Only aimed hits count. The attacker must be a carbon (something with a target doll) holding +// the weapon or having fired the shot, must not be the victim, and the hit has to land on the +// limb they aimed at. NPC basic mobs never land crits, so NPC fights keep tg's normal wounds. +// Human NPCs (voidcrew/modules/human_npcs/) are carbons, but only their elites and bosses land +// crits (owner decision D4, hv_can_land_aimed_crits()). Players always can. +// * The hit must be brute damage with a wound roll (wound_bonus not CANT_WOUND) and a weapon or +// projectile behind it. Only the aorta can come from a projectile. +// * The roll is tg's own wound roll: rand(1, min(damage, 25) ^ 1.4), so 1 to 90. On top of the +// weapon's wound_bonus we add up to +30 for how battered the limb already is (its damage over +// its max damage) and +25 if the victim is helpless (lying down, in crit, stunned, cuffed or +// held in an aggressive grab), +15 more if the attacker is the one holding them. Then tg's +// check_woundings_mods() adds the limb's existing wound penalties and scales everything by +// wound armour (worn WOUND ratings plus the limb's own resistance: head 5%, chest 10%). +// * Each crit has a threshold and a minimum damage after armour (crit_types.dm). A fresh, +// standing, unarmoured target hit by a 25+ damage weapon tops out at about 90, so the face +// crits (eye, tongue and jaw at 80) land on about one such hit in eight; the throat (90) on +// about one in a hundred; the aorta (90, but the chest's resistance caps a fresh roll at 85) +// and the skull (95) not at all until the victim is worn down or helpless. A helpless victim +// held in the attacker's grab loses the throat to about one combat knife slash in three, one +// in two for a 25+ weapon. A 10-force kitchen knife tops out near 30, and even on a held +// victim under 70, so it never crits by roll. +// * An attacker can carry a keyed roll bonus of its own (hv_crit_set_attacker_bonus()): a Cascade-line +// reflex window (voidcrew/modules/cyberware/ware_legend.dm) adds 9, 14 or 18. On the 1 to 90 roll +// of a 25+ damage hit that is about +10, +15 or +20 points of chance to clear a threshold. It +// never applies against a helpless or held victim, so finishers and executions are unchanged. +// * Gear that covers the spot blocks a crit outright (see each crit's blocked_on_carbon()). Worn +// armour and the body's own plating (cyberware) count together, as tg's check_armor() stacks them. +// * A crit never stacks on itself: the wound can only be on a limb once. + +/// Most a battered limb adds to the roll, scaled by how much of its max damage it has taken. +#define HV_CRIT_BATTERED_BONUS 30 +/// Added to the roll when the victim is helpless. +#define HV_CRIT_HELPLESS_BONUS 25 +/// Added on top when the attacker holds the victim in an aggressive grab or tighter. +#define HV_CRIT_HELD_BONUS 15 + +/// Every aimed crit, one singleton per kind. See crit_types.dm. +GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) + +/proc/hv_crit_init_list() + var/list/crits = list() + for(var/crit_type in subtypesof(/datum/hv_crit)) + crits += new crit_type + return crits + +/// One kind of aimed critical wound: where it must be aimed, what can cause it, how hard it is to +/// land, what stops it and what it does. +/datum/hv_crit + /// Plain name, for logs. + var/name = "aimed crit" + /// The limb the hit has to land on. + var/limb_zone = BODY_ZONE_HEAD + /// What the attacker must have selected on their target doll. + var/list/aim_zones = list() + /// Wounding types (WOUND_SLASH, WOUND_PIERCE, WOUND_BLUNT) that can cause it. + var/list/wounding_types = list() + /// Least damage the hit must deal after armour. + var/min_damage = 15 + /// The roll must reach this. See the notes at the top of this file. + var/threshold = 90 + /// Can a projectile cause it? + var/allow_projectiles = FALSE + /// Can it happen to a corpse? + var/allow_dead = FALSE + /// The wound a carbon gets. + var/datum/wound/wound_type + /// Size of the hit line: HV_LINE_BOLD or HV_LINE_HUGE. + var/line_severity = HV_LINE_BOLD + /// How far the hit line carries. As tg does for wounds: critical ones carry the full + /// DEFAULT_MESSAGE_RANGE, lesser ones only the close combat range. + var/line_range = COMBAT_MESSAGE_RANGE + /// Played on the victim when it lands. Must be a sound already in the repo. + var/hit_sound + /// How long the victim's screen shakes, in deciseconds. 0 for none. + var/shake = 0 + +/// TRUE if a hit on hit_zone, aimed at aimed_zone, of this wounding type could cause this crit. +/datum/hv_crit/proc/matches(hit_zone, aimed_zone, wounding_type, from_projectile) + if(hit_zone != limb_zone || !(aimed_zone in aim_zones) || !(wounding_type in wounding_types)) + return FALSE + return !from_projectile || allow_projectiles + +/// TRUE if something the victim wears stops this crit outright. +/datum/hv_crit/proc/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return FALSE + +/// TRUE if this crit's wound can go on this limb right now. +/datum/hv_crit/proc/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[wound_type] + return !!pregen?.can_be_applied_to(limb, list(wounding_type)) + +/// TRUE if this crit happens on this hit without a roll (a ruined eye, say). Rolled each hit. +/datum/hv_crit/proc/happens_anyway(mob/living/carbon/victim, obj/item/bodypart/limb) + return FALSE + +/// Applies the crit to a carbon. Returns TRUE if it landed. +/datum/hv_crit/proc/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + var/datum/wound/crit_wound = hv_crit_apply_wound(limb, wound_type, attack_direction, source) + if(!crit_wound) + return FALSE + announce(victim, attack_direction) + return TRUE + +/// The line added to the hit message. +/datum/hv_crit/proc/hit_line(mob/living/victim) + return "[victim] is badly hurt!" + +/// What the victim reads in place of hit_line(). +/datum/hv_crit/proc/self_line(mob/living/victim) + return "You are badly hurt!" + +/// Hit line, sound and shake. line and own_line override hit_line() and self_line(). +/datum/hv_crit/proc/announce(mob/living/victim, attack_direction, line, own_line) + victim.hv_add_hit_line(line || hit_line(victim), line_severity, own_line || self_line(victim), line_range) + if(hit_sound) + playsound(victim, hit_sound, 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) + if(shake) + shake_camera(victim, shake, 1) + +// --------------------------------------------------------------------------------------------- +// Helpers + +/// Applies wound_type to limb the way tg's own roll does: it replaces a lesser wound of the same +/// series, and does nothing if one as bad is already there. Silent, since the crit adds its own +/// hit line. Returns the wound, or null. +/proc/hv_crit_apply_wound(obj/item/bodypart/limb, datum/wound/wound_type, attack_direction, atom/source) + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[wound_type] + if(!pregen) + return null + var/datum/wound/replaced + for(var/datum/wound/existing as anything in limb.wounds) + if(existing.get_pregen_data().wound_series != pregen.wound_series) + continue + if(existing.severity >= initial(wound_type.severity)) + return null + replaced = existing + var/datum/wound/new_wound = new wound_type + if(replaced) + // tg's replace_wound(), but silent: tg would print its own wound message on a promotion. + replaced.already_scarred = TRUE + replaced.remove_wound(replaced = TRUE) + var/applied = new_wound.apply_wound(limb, silent = TRUE, old_wound = replaced, attack_direction = attack_direction, wound_source = source, replacing = TRUE) + qdel(replaced) + return applied ? new_wound : null + if(!new_wound.apply_wound(limb, silent = TRUE, attack_direction = attack_direction, wound_source = source)) + return null + return new_wound + +/// The carbon who aimed this hit: whoever holds the weapon, or fired the projectile. Null if +/// nobody aimed it, or if whoever did may not land aimed crits. +/proc/hv_crit_get_aimer(atom/source) + var/atom/aimer + if(isprojectile(source)) + var/obj/projectile/shot = source + aimer = shot.firer + else if(isitem(source)) + aimer = source.loc + if(!iscarbon(aimer)) + return null + var/mob/living/carbon/carbon_aimer = aimer + return carbon_aimer.hv_can_land_aimed_crits() ? carbon_aimer : null + +/// Whether this mob's aimed hits can be aimed crits, and its point-blank head shots executions. +/// Everyone can, bar human NPCs that aren't elites or bosses (voidcrew/modules/human_npcs/human_npc.dm). +/mob/living/proc/hv_can_land_aimed_crits() + return TRUE + +/// tg's wounding type for a brute hit. Eye stabs and a few other callers pass no sharpness to +/// apply_damage, so fall back to the weapon's own. +/proc/hv_crit_wounding_type(sharpness, atom/source) + if(!sharpness && isitem(source)) + var/obj/item/weapon = source + sharpness = weapon.get_sharpness() + if(sharpness & SHARP_EDGED) + return WOUND_SLASH + if(sharpness & SHARP_POINTY) + return WOUND_PIERCE + return WOUND_BLUNT + +/// The armour flag the hit is checked against. +/proc/hv_crit_attack_flag(atom/source) + if(isprojectile(source)) + var/obj/projectile/shot = source + return shot.armor_flag + return MELEE + +/// The hit's armour penetration. +/proc/hv_crit_armour_pen(atom/source) + if(isprojectile(source)) + var/obj/projectile/shot = source + return shot.armour_penetration + if(isitem(source)) + var/obj/item/weapon = source + return weapon.hv_crit_armour_pen() + return 0 + +/// How much armour this weapon ignores at the crit gates. Its armour_penetration unless it says otherwise: +/// the Widowline's full penetration is for its cuts, not for getting a crit past a collar (hyperviolence/wire/). +/obj/item/proc/hv_crit_armour_pen() + return armour_penetration + +/// How well the victim is armoured against armour_flag on limb, less armour_penetration: the best rating among +/// the clothes covering it, stacked on the human's own physiology armour (cyberware dermal plating lives there) +/// the way tg's check_armor() stacks them, each layer stopping its share of what the one before let through. +/proc/hv_crit_cover_rating(mob/living/carbon/victim, obj/item/bodypart/limb, armour_flag, armour_penetration = 0) + if(!ishuman(victim)) + return 0 + var/mob/living/carbon/human/human_victim = victim + var/best = 0 + for(var/obj/item/clothing/worn as anything in human_victim.get_clothing_on_part(limb)) + best = max(best, worn.get_armor_rating(armour_flag)) + var/plating = human_victim.physiology?.armor.get_rating(armour_flag) || 0 + var/combined = 100 - (100 - min(best, 100)) * (100 - min(plating, 100)) * 0.01 + return combined - armour_penetration + +/// Roll bonus for a victim who cannot defend themselves. +/mob/living/proc/hv_crit_helpless_bonus(mob/living/attacker) + . = 0 + if(incapacitated || body_position == LYING_DOWN || stat >= SOFT_CRIT) + . += HV_CRIT_HELPLESS_BONUS + if(attacker && attacker.pulling == src && attacker.grab_state >= GRAB_AGGRESSIVE) + . += HV_CRIT_HELD_BONUS + +/mob/living + /// Roll bonuses this mob's own aimed hits get, by source (a reflex window, say). Lazy; the highest counts. + var/list/hv_crit_attacker_bonuses + +/// Sets (or with a null amount clears) this mob's keyed roll bonus for its own aimed hits. +/mob/living/proc/hv_crit_set_attacker_bonus(source, amount) + if(isnull(amount) || amount <= 0) + LAZYREMOVE(hv_crit_attacker_bonuses, source) + return + LAZYSET(hv_crit_attacker_bonuses, source, amount) + +/// Roll bonus for this mob's own hit on `victim`: the highest keyed bonus. None against a helpless or +/// held victim, so finishers, executions and the garrote roll as they always did. +/mob/living/proc/hv_crit_attacker_bonus(mob/living/victim) + if(!LAZYLEN(hv_crit_attacker_bonuses)) + return 0 + if(victim && (victim.hv_is_helpless() || victim.has_status_effect(/datum/status_effect/hv_finisher_hold))) + return 0 + . = 0 + for(var/source in hv_crit_attacker_bonuses) + . = max(., hv_crit_attacker_bonuses[source]) + +/// The crit roll for this limb. tg's wound roll plus the battered and helpless bonuses, run +/// through check_woundings_mods() so wound armour and existing wounds count as they do for tg. +/obj/item/bodypart/proc/hv_crit_roll(wounding_type, damage, wound_bonus, exposed_wound_bonus, mob/living/attacker) + var/easily_wounded = HAS_TRAIT(owner, TRAIT_EASILY_WOUNDED) + var/hardly_wounded = HAS_TRAIT(owner, TRAIT_HARDLY_WOUNDED) + if(easily_wounded && !hardly_wounded) + damage *= 1.5 + else + damage = min(damage, WOUND_MAX_CONSIDERED_DAMAGE) + if(hardly_wounded && !easily_wounded) + damage *= 0.85 + var/base_roll = rand(1, max(1, round(damage ** WOUND_DAMAGE_EXPONENT))) + var/bonus = wound_bonus + round(HV_CRIT_BATTERED_BONUS * get_damage() / max(max_damage, 1)) + owner.hv_crit_helpless_bonus(attacker) + bonus += attacker?.hv_crit_attacker_bonus(owner) || 0 + return check_woundings_mods(wounding_type, base_roll, damage, bonus, exposed_wound_bonus, FALSE) + +// --------------------------------------------------------------------------------------------- +// Entry point. Every damaging brute hit with a weapon or projectile behind it passes through +// here once, after tg has applied the damage and rolled its own wound for it. + +/mob/living/apply_damage( + damage = 0, + damagetype = BRUTE, + def_zone = null, + blocked = 0, + forced = FALSE, + spread_damage = FALSE, + wound_bonus = 0, + exposed_wound_bonus = 0, + sharpness = NONE, + attack_direction = null, + attacking_item, + wound_clothing = TRUE, +) + . = ..() + if(. <= 0 || damagetype != BRUTE || forced || wound_bonus == CANT_WOUND || isnull(attacking_item) || QDELETED(src)) + return . + hv_crit_check_hit(., def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + return . + +/// Decides whether this hit can be an aimed crit and rolls it. Only carbons take crits: every human-looking +/// NPC is a real body now (human NPC migration), so there is no stand-in for basic mobs. +/mob/living/proc/hv_crit_check_hit(damage, def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/source) + return + +/mob/living/carbon/hv_crit_check_hit(damage, def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/source) + if(!isbodypart(def_zone)) + return + var/obj/item/bodypart/limb = def_zone + if(limb.owner != src) + return + var/mob/living/carbon/attacker = hv_crit_get_aimer(source) + if(isnull(attacker) || attacker == src) + return + var/wounding_type = hv_crit_wounding_type(sharpness, source) + hv_try_aimed_crit(limb, attacker.zone_selected, wounding_type, damage, wound_bonus, exposed_wound_bonus, attack_direction || get_dir(attacker, src), source, attacker) + +/** + * Rolls every crit that fits this hit and applies the first one that lands. Returns that crit's + * datum, or null. + * + * * limb - the limb that was hit + * * aimed_zone - what the attacker had selected + * * forced_roll - for tests: use this number instead of rolling + */ +/mob/living/carbon/proc/hv_try_aimed_crit(obj/item/bodypart/limb, aimed_zone, wounding_type, damage, wound_bonus, exposed_wound_bonus, attack_direction, atom/source, mob/living/attacker, forced_roll) + if(check_zone(aimed_zone) != limb.body_zone) + return null + var/from_projectile = isprojectile(source) + var/roll + for(var/datum/hv_crit/crit as anything in GLOB.hv_crits) + if(!crit.matches(limb.body_zone, aimed_zone, wounding_type, from_projectile)) + continue + if(stat == DEAD && !crit.allow_dead) + continue + if(crit.blocked_on_carbon(src, limb, source)) + continue + if(!crit.can_apply_carbon(src, limb, wounding_type)) + continue + if(!crit.happens_anyway(src, limb)) + if(damage < crit.min_damage) + continue + if(isnull(roll)) + roll = isnull(forced_roll) ? limb.hv_crit_roll(wounding_type, damage, wound_bonus, exposed_wound_bonus, attacker) : forced_roll + if(roll < crit.threshold) + continue + if(!crit.apply_carbon(src, limb, damage, attack_direction, source, attacker)) + continue + log_combat(attacker, src, "landed an aimed crit on", source, "([crit.name], roll [isnull(roll) ? "none" : roll])") + return crit + return null + +#undef HV_CRIT_BATTERED_BONUS +#undef HV_CRIT_HELPLESS_BONUS +#undef HV_CRIT_HELD_BONUS diff --git a/voidcrew/modules/hyperviolence/crits/crit_types.dm b/voidcrew/modules/hyperviolence/crits/crit_types.dm new file mode 100644 index 00000000000..fba193fc3f8 --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_types.dm @@ -0,0 +1,293 @@ +// Hyperviolence aimed crits: the six kinds. Thresholds and minimum damage are explained at the top +// of crit_roll.dm. Hit lines are short and say what happened, nothing about why. + +/// Chance per sharp hit that an eye already ruined past tg's high damage threshold is torn out, +/// with no roll and no minimum damage. This is how repeated eye stabs with a knife end. +#define HV_EYE_RUINED_CHANCE 25 +/// The skull bursts instead of caving in when the victim is already in crit and the hit is this big. +#define HV_SKULL_BURST_DAMAGE 20 +/// Melee armour on the head (after armour penetration) that stops the skull caving in. +#define HV_SKULL_HELMET_ARMOUR 30 +/// Melee armour on an armoured collar (after armour penetration) that stops a throat slash. +#define HV_THROAT_COLLAR_ARMOUR 10 +/// Chest armour against the hit (after armour penetration) that stops a torn aorta. +#define HV_AORTA_CHEST_ARMOUR 40 + +// --------------------------------------------------------------------------------------------- +// Eye torn out: a slash or stab aimed at the eyes. Any eye cover (glasses, visor, sealed helmet) +// stops it. Knives and screwdrivers stab eyes through tg's eyestab element for small damage, so +// they never pass the roll; they get the eye through HV_EYE_RUINED_CHANCE once it is ruined. + +/datum/hv_crit/eye + name = "torn-out eye" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_PRECISE_EYES) + wounding_types = list(WOUND_SLASH, WOUND_PIERCE) + min_damage = 12 + threshold = 80 + wound_type = /datum/wound/pierce/bleed/severe/eye/hv_gouged + hit_sound = 'sound/effects/wounds/pierce2.ogg' + shake = 4 + +/datum/hv_crit/eye/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return !!victim.is_eyes_covered() + +/datum/hv_crit/eye/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + if(!eyes || eyes.bodypart_owner != limb || (eyes.organ_flags & ORGAN_UNREMOVABLE)) + return FALSE + if(limb.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged)) + return TRUE // the socket wound is already there; this one takes the other eye + return ..() + +/datum/hv_crit/eye/happens_anyway(mob/living/carbon/victim, obj/item/bodypart/limb) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + return eyes && eyes.damage >= eyes.high_threshold && prob(HV_EYE_RUINED_CHANCE) + +/datum/hv_crit/eye/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + if(!eyes) + return FALSE + var/list/open_sides = list() + if(!(eyes.scarring & RIGHT_EYE_SCAR)) + open_sides += RIGHT_EYE_SCAR + if(!(eyes.scarring & LEFT_EYE_SCAR)) + open_sides += LEFT_EYE_SCAR + var/side = length(open_sides) ? pick(open_sides) : RIGHT_EYE_SCAR + var/last_eye = length(open_sides) <= 1 + + var/datum/wound/pierce/bleed/severe/eye/hv_gouged/socket = limb.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged) + if(socket) + if(last_eye) + socket.lose_other_eye() + else // a fresh pair of eyes went in since the last one + socket.set_blood_flow(max(socket.blood_flow, socket.initial_flow)) + else + socket = new + if(!socket.apply_wound(limb, silent = TRUE, attack_direction = attack_direction, wound_source = source, right_side = (side == RIGHT_EYE_SCAR))) + return FALSE + if(last_eye) + socket.lose_other_eye() + + // tg's eye scarring does the seeing: one scarred eye is nearsighted, two are blind. + if(length(open_sides)) + eyes.apply_scar(side) + victim.adjust_eye_blur(20 SECONDS) + victim.add_mood_event("eye_stab", /datum/mood_event/eye_stab) + + var/side_name = side == RIGHT_EYE_SCAR ? "right" : "left" + var/line = "[victim]'s [side_name] eye is torn out!" + var/own_line = "Your [side_name] eye is torn out!" + if(last_eye) + // Nothing left to see with: the whole organ comes out. + eyes.Remove(victim) + var/turf/drop_turf = victim.drop_location() + eyes.forceMove(drop_turf) + eyes.hv_fling(attack_direction, 1, null, victim.hv_vis_head_offset()) + if(!length(open_sides)) + line = "[victim]'s eyes are torn out!" + own_line = "Your eyes are torn out!" + victim.hv_blood_spray(attack_direction, 2) + announce(victim, attack_direction, line, own_line) + return TRUE + +/datum/hv_crit/eye/hit_line(mob/living/victim) + return "[victim]'s eye is torn out!" + +/datum/hv_crit/eye/self_line(mob/living/victim) + return "Your eye is torn out!" + +// --------------------------------------------------------------------------------------------- +// Tongue cut off: a slash aimed at the mouth. A mask or helmet over the mouth stops it. + +/datum/hv_crit/tongue + name = "severed tongue" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_PRECISE_MOUTH) + wounding_types = list(WOUND_SLASH) + min_damage = 12 + threshold = 80 + wound_type = /datum/wound/slash/flesh/moderate/hv_tongue + hit_sound = 'sound/effects/wounds/blood2.ogg' + +/datum/hv_crit/tongue/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return !!victim.is_mouth_covered() + +/datum/hv_crit/tongue/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + var/obj/item/organ/tongue/tongue = victim.get_organ_slot(ORGAN_SLOT_TONGUE) + if(!tongue || tongue.bodypart_owner != limb || (tongue.organ_flags & ORGAN_UNREMOVABLE)) + return FALSE + return ..() + +/datum/hv_crit/tongue/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + var/obj/item/organ/tongue/tongue = victim.get_organ_slot(ORGAN_SLOT_TONGUE) + if(!tongue || !hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + tongue.Remove(victim) + var/turf/drop_turf = victim.drop_location() + tongue.forceMove(drop_turf) + tongue.hv_fling(attack_direction, 1, null, victim.hv_vis_head_offset()) + victim.hv_blood_spray(attack_direction, 1) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/tongue/hit_line(mob/living/victim) + return "[victim]'s tongue is cut off!" + +/datum/hv_crit/tongue/self_line(mob/living/victim) + return "Your tongue is cut off!" + +// --------------------------------------------------------------------------------------------- +// Throat slit: a slash aimed at the head. A sealed helmet or an armoured collar stops it. + +/datum/hv_crit/throat + name = "slit throat" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_HEAD) + wounding_types = list(WOUND_SLASH) + min_damage = 18 + threshold = 90 + wound_type = /datum/wound/slash/flesh/critical/hv_carotid + line_severity = HV_LINE_HUGE + line_range = DEFAULT_MESSAGE_RANGE + hit_sound = 'sound/effects/wounds/blood3.ogg' + shake = 5 + +/datum/hv_crit/throat/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + if(victim.is_mouth_covered(ITEM_SLOT_HEAD)) + return TRUE + if(!ishuman(victim)) + return FALSE + var/mob/living/carbon/human/human_victim = victim + var/obj/item/clothing/collar = human_victim.wear_neck + if(!istype(collar)) + return FALSE + return collar.get_armor_rating(MELEE) - hv_crit_armour_pen(source) >= HV_THROAT_COLLAR_ARMOUR + +/datum/hv_crit/throat/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + if(!hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + victim.hv_blood_spray(attack_direction, 3) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/throat/hit_line(mob/living/victim) + return "[victim]'s throat is slit open!" + +/datum/hv_crit/throat/self_line(mob/living/victim) + return "Your throat is slit open!" + +// --------------------------------------------------------------------------------------------- +// Aorta torn: a huge stab, slash or shot aimed at the chest. Heavy chest armour against that kind +// of hit stops it; ordinary vests only make it rarer through their wound armour. + +/datum/hv_crit/aorta + name = "torn aorta" + limb_zone = BODY_ZONE_CHEST + aim_zones = list(BODY_ZONE_CHEST) + wounding_types = list(WOUND_SLASH, WOUND_PIERCE) + min_damage = 25 + threshold = 90 + allow_projectiles = TRUE + wound_type = /datum/wound/pierce/bleed/critical/hv_aorta + line_range = DEFAULT_MESSAGE_RANGE + hit_sound = 'sound/effects/wounds/pierce3.ogg' + shake = 4 + +/datum/hv_crit/aorta/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return hv_crit_cover_rating(victim, limb, hv_crit_attack_flag(source), hv_crit_armour_pen(source)) >= HV_AORTA_CHEST_ARMOUR + +/datum/hv_crit/aorta/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + if(!hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + victim.hv_blood_spray(attack_direction, 2) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/aorta/hit_line(mob/living/victim) + return "[victim]'s aorta is torn open!" + +/datum/hv_crit/aorta/self_line(mob/living/victim) + return "Something tears deep in your chest!" + +// --------------------------------------------------------------------------------------------- +// Skull caved in: a heavy blunt blow aimed at the head. A real helmet, or anything that blocks +// head injuries (hard hats, riot helmets), stops it. If the victim is already in hard crit the head +// bursts instead (hv_burst_head(), owned by the brain-and-guts part). + +/datum/hv_crit/skull + name = "caved-in skull" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_HEAD) + wounding_types = list(WOUND_BLUNT) + min_damage = 20 + threshold = 95 + allow_dead = TRUE + wound_type = /datum/wound/blunt/bone/critical/hv_skull + line_severity = HV_LINE_HUGE + line_range = DEFAULT_MESSAGE_RANGE + hit_sound = 'sound/effects/wounds/crack2.ogg' + shake = 8 + +/datum/hv_crit/skull/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + if(HAS_TRAIT(victim, TRAIT_HEAD_INJURY_BLOCKED)) + return TRUE + return hv_crit_cover_rating(victim, limb, MELEE, hv_crit_armour_pen(source)) >= HV_SKULL_HELMET_ARMOUR + +/datum/hv_crit/skull/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + if(victim.stat >= HARD_CRIT && limb.can_dismember()) + return TRUE // the head can still burst even if the skull is already broken + if(victim.stat == DEAD) + return FALSE + return ..() + +/datum/hv_crit/skull/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + if(victim.stat >= HARD_CRIT && damage >= HV_SKULL_BURST_DAMAGE && victim.hv_burst_head(attack_direction)) + playsound(get_turf(victim), hit_sound, 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) + return TRUE + if(victim.stat == DEAD || !hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/skull/hit_line(mob/living/victim) + return "[victim]'s skull caves in!" + +/datum/hv_crit/skull/self_line(mob/living/victim) + return "Your skull caves in!" + +// --------------------------------------------------------------------------------------------- +// Jaw shattered: a blunt blow aimed at the mouth. A helmet that covers the mouth stops it. + +/datum/hv_crit/jaw + name = "shattered jaw" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_PRECISE_MOUTH) + wounding_types = list(WOUND_BLUNT) + min_damage = 12 + threshold = 80 + wound_type = /datum/wound/blunt/bone/severe/hv_jaw + hit_sound = 'sound/effects/wounds/crack1.ogg' + shake = 3 + +/datum/hv_crit/jaw/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return !!victim.is_mouth_covered(ITEM_SLOT_HEAD) + +/datum/hv_crit/jaw/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + if(!hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + victim.add_splatter_floor(get_turf(victim), small_drip = TRUE) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/jaw/hit_line(mob/living/victim) + return "[victim]'s jaw shatters!" + +/datum/hv_crit/jaw/self_line(mob/living/victim) + return "Your jaw shatters!" + +#undef HV_EYE_RUINED_CHANCE +#undef HV_SKULL_BURST_DAMAGE +#undef HV_SKULL_HELMET_ARMOUR +#undef HV_THROAT_COLLAR_ARMOUR +#undef HV_AORTA_CHEST_ARMOUR diff --git a/voidcrew/modules/hyperviolence/crits/crit_wounds.dm b/voidcrew/modules/hyperviolence/crits/crit_wounds.dm new file mode 100644 index 00000000000..9c0eff762f0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_wounds.dm @@ -0,0 +1,343 @@ +// Hyperviolence aimed crits: the wounds each crit leaves behind. +// +// These are ordinary tg wound subtypes, so health analyzers, examine text, treatment items, +// surgery and scars all handle them like any other wound. None of them can come from tg's random +// wound roll (can_be_randomly_generated = FALSE): only the aimed crit roll in crit_roll.dm applies +// them, and it applies them silently and adds its own hit line instead of tg's wound message. + +// --------------------------------------------------------------------------------------------- +// Torn-out eye. tg keeps both eyes in one organ, so the first crit takes one eye (it scars that +// side, which leaves the victim nearsighted, the same as tg's own eye scarring) and the second +// takes the other and tears the whole organ out onto the floor. + +/datum/wound/pierce/bleed/severe/eye/hv_gouged + name = "Torn-Out Eye" + undiagnosed_name = "Torn-Out Eye" + desc = "Patient's eye has been torn from its socket. The socket is bleeding freely." + treat_text = "Suture or cauterize the socket. Only a replacement pair of eyes will restore sight." + treat_text_short = "Suture or cauterize the socket." + simple_desc = "Patient's eye has been torn out." + simple_treat_text = "Suture or cauterize the socket. Sight only returns with new eyes." + homemade_treat_text = "Press gauze over the socket." + occur_text = "loses an eye in a spray of blood" + internal_bleeding_chance = 20 + /// Set once the second eye has gone too. + var/both_gone = FALSE + +/datum/wound/pierce/bleed/severe/eye/hv_gouged/apply_wound(obj/item/bodypart/limb, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE, right_side = FALSE) + . = ..() + if(.) + update_socket_desc() + +/// The second eye has been torn out as well. +/datum/wound/pierce/bleed/severe/eye/hv_gouged/proc/lose_other_eye() + both_gone = TRUE + set_blood_flow(max(blood_flow, initial_flow)) + update_socket_desc() + +/datum/wound/pierce/bleed/severe/eye/hv_gouged/proc/update_socket_desc() + if(both_gone) + examine_desc = "has two empty eye sockets, weeping blood" + else + examine_desc = "has an empty [right_side ? "right" : "left"] eye socket, weeping blood" + +/datum/wound_pregen_data/flesh_pierce/open_puncture/eye/hv_gouged + wound_path_to_generate = /datum/wound/pierce/bleed/severe/eye/hv_gouged + required_wounding_types = list(WOUND_PIERCE, WOUND_SLASH) + wound_series = WOUND_SERIES_HV_EYE + can_be_randomly_generated = FALSE + +// --------------------------------------------------------------------------------------------- +// Severed tongue. The crit drops the tongue organ; tg's tongueless speech does the rest. The +// wound is the bleeding stump, which clots on its own like a light cut. + +/datum/wound/slash/flesh/moderate/hv_tongue + name = "Severed Tongue" + undiagnosed_name = "Severed Tongue" + desc = "Patient's tongue has been cut off. The stump is bleeding into the mouth." + treat_text = "Suture or cauterize the stump. A new tongue can be implanted." + treat_text_short = "Suture or cauterize the stump." + simple_desc = "Patient's tongue has been cut off." + simple_treat_text = "The bleeding stops on its own. A new tongue has to be implanted." + homemade_treat_text = "Keep the head still and let it clot." + examine_desc = "is bleeding from the mouth" + occur_text = "loses its tongue" + initial_flow = 2 + minimum_flow = 0.5 + clot_rate = 0.04 + +/datum/wound_pregen_data/flesh_slash/hv_tongue + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/moderate/hv_tongue + wound_series = WOUND_SERIES_HV_TONGUE + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 20 + +// --------------------------------------------------------------------------------------------- +// Torn carotid (slit throat). Bleeds about as hard as tg's worst cut and never clots, spurts on a +// pulse, and gargles speech while it is open. A hemostat clamps it (a big cut to the bleeding), +// then sutures or cautery close it as with any cut. Untreated it kills in roughly two minutes. + +/// Blood flow at or above which the throat spurts and the victim gargles. +#define HV_CAROTID_OPEN_FLOW 1.5 + +/datum/wound/slash/flesh/critical/hv_carotid + name = "Torn Carotid Artery" + undiagnosed_name = "Slit Throat" + desc = "Patient's throat is cut open through the carotid artery. Blood loss will kill within minutes." + treat_text = "Clamp the artery with a hemostat, then suture or cauterize the wound. Transfuse blood." + treat_text_short = "Clamp with a hemostat, then suture or cauterize." + simple_desc = "Patient's throat is cut open and pumping blood." + simple_treat_text = "Clamp the artery with a hemostat, then suture or cauterize it." + homemade_treat_text = "Press hard on the wound and lie down." + examine_desc = "has its throat slit open, pumping out blood" + occur_text = "is slit open at the throat" + sound_effect = 'sound/effects/wounds/blood3.ogg' + initial_flow = 4.25 + minimum_flow = 0.5 + clot_rate = 0 + demotes_to = null + treatable_tools = list(TOOL_CAUTERY, TOOL_HEMOSTAT) + /// Set once someone has clamped the artery with a hemostat. + var/clamped = FALSE + /// world.time of the next spurt. + var/next_spurt = 0 + +/datum/wound/slash/flesh/critical/hv_carotid/set_victim(new_victim) + if(victim) + UnregisterSignal(victim, COMSIG_LIVING_TREAT_MESSAGE) + if(new_victim) + RegisterSignal(new_victim, COMSIG_LIVING_TREAT_MESSAGE, PROC_REF(gargle_speech)) + return ..() + +/datum/wound/slash/flesh/critical/hv_carotid/handle_process(seconds_per_tick, times_fired) + . = ..() + if(QDELETED(src) || !victim || !limb || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_STASIS)) + return + if(clamped || blood_flow < HV_CAROTID_OPEN_FLOW || world.time < next_spurt || !limb.can_bleed()) + return + next_spurt = world.time + rand(4 SECONDS, 7 SECONDS) + victim.hv_blood_spray(pick(GLOB.alldirs), 2) + +/datum/wound/slash/flesh/critical/hv_carotid/treat(obj/item/I, mob/user) + if(I.tool_behaviour == TOOL_HEMOSTAT) + return clamp_artery(I, user) + return ..() + +/// Someone clamps the torn artery shut with a hemostat. +/datum/wound/slash/flesh/critical/hv_carotid/proc/clamp_artery(obj/item/tool, mob/user) + if(clamped) + to_chat(user, span_warning("The artery is already clamped.")) + return TRUE + var/whose = (user == victim) ? "your" : "[victim]'s" + user.visible_message(span_danger("[user] reaches into [victim]'s throat with [tool]..."), span_warning("You reach into [whose] throat with [tool]...")) + if(!do_after(user, base_treat_time * (user == victim ? 2 : 1.5), target = victim, extra_checks = CALLBACK(src, PROC_REF(still_exists)))) + return TRUE + clamped = TRUE + user.visible_message(span_green("[user] clamps the artery in [victim]'s throat."), span_green("You clamp the artery in [whose] throat.")) + adjust_blood_flow(-2.5) + return TRUE + +/datum/wound/slash/flesh/critical/hv_carotid/modify_desc_before_span(desc, mob/user) + . = ..() + if(clamped) + . += ", held shut by a clamp" + +/datum/wound/slash/flesh/critical/hv_carotid/proc/gargle_speech(datum/source, list/message_args) + SIGNAL_HANDLER + if(clamped || blood_flow < HV_CAROTID_OPEN_FLOW) + return + message_args[TREAT_MESSAGE_ARG] = hv_crit_gargle(message_args[TREAT_MESSAGE_ARG]) + +#undef HV_CAROTID_OPEN_FLOW + +/datum/wound_pregen_data/flesh_slash/hv_carotid + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/critical/hv_carotid + wound_series = WOUND_SERIES_HV_CAROTID + required_limb_biostate = (BIO_FLESH|BIO_BLOODED) + ignore_cannot_bleed = FALSE + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 80 + +// --------------------------------------------------------------------------------------------- +// Torn aorta. A deep chest bleed that never clots, shrugs off gauze, cannot be cauterized from +// outside and makes the victim cough blood. tg's "Repair puncture" surgery fixes it; blood and +// clotting drugs buy time. + +/datum/wound/pierce/bleed/critical/hv_aorta + name = "Torn Aorta" + undiagnosed_name = "Chest Bleed" + desc = "Patient's aorta is torn. The chest cavity is filling with blood." + treat_text = "Repair the vessel surgically at once. Blood transfusions and clotting agents buy time." + treat_text_short = "Surgical repair required." + simple_desc = "A great artery in the chest is torn." + simple_treat_text = "Needs surgery at once. Blood and clotting drugs buy time." + homemade_treat_text = "Keep them lying still." + examine_desc = "has a deep wound that pumps blood with every heartbeat" + occur_text = "is torn open to the heart" + initial_flow = 3.5 + gauzed_clot_rate = 0.1 + internal_bleeding_chance = 100 + internal_bleeding_coefficient = 2 + treatable_by = list() + treatable_tools = list() + wound_flags = (ACCEPTS_GAUZE | MANGLES_EXTERIOR) + /// world.time of the next mouthful of blood. + var/next_cough = 0 + +/datum/wound/pierce/bleed/critical/hv_aorta/wound_injury(datum/wound/old_wound = null, attack_direction = null) + next_cough = world.time + rand(8 SECONDS, 15 SECONDS) + return ..() + +/datum/wound/pierce/bleed/critical/hv_aorta/check_grab_treatments(obj/item/I, mob/user) + return FALSE // a hot knife on the skin does nothing for a torn aorta + +/datum/wound/pierce/bleed/critical/hv_aorta/handle_process(seconds_per_tick, times_fired) + . = ..() + if(QDELETED(src) || !victim || !limb || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_STASIS)) + return + if(world.time < next_cough || !limb.can_bleed()) + return + next_cough = world.time + rand(15 SECONDS, 25 SECONDS) + victim.visible_message( + span_danger("[victim] coughs up a mouthful of blood!"), + span_userdanger("You cough up a mouthful of blood!"), + vision_distance = COMBAT_MESSAGE_RANGE, + ) + victim.bleed(4) + victim.add_splatter_floor(get_step(victim, victim.dir) || get_turf(victim)) + +/datum/wound_pregen_data/flesh_pierce/hv_aorta + abstract = FALSE + wound_path_to_generate = /datum/wound/pierce/bleed/critical/hv_aorta + required_wounding_types = list(WOUND_PIERCE, WOUND_SLASH) + wound_series = WOUND_SERIES_HV_AORTA + required_limb_biostate = (BIO_FLESH|BIO_BLOODED) + ignore_cannot_bleed = FALSE + viable_zones = list(BODY_ZONE_CHEST) + can_be_randomly_generated = FALSE + threshold_minimum = 100 + +// --------------------------------------------------------------------------------------------- +// Caved-in skull. tg's compound fracture with heavy brain damage and a knockdown on top. It sits +// in tg's blunt bone series as a critical, so it replaces a lesser head fracture and gives tg's +// severe brain trauma cycle until the bone is fixed (compound fracture surgery or gel and tape). + +/// Brain damage dealt when the skull caves in (brains die at 200). +#define HV_SKULL_BRAIN_DAMAGE 35 + +/datum/wound/blunt/bone/critical/hv_skull + name = "Depressed Skull Fracture" + undiagnosed_name = "Caved-In Skull" + desc = "Patient's skull has been driven in against the brain." + treat_text = "Repair surgically and treat the brain damage. Bone gel and surgical tape will hold it in an emergency." + treat_text_short = "Repair surgically, or apply bone gel and surgical tape." + simple_desc = "Patient's skull is caved in." + simple_treat_text = "Surgery, or bone gel and surgical tape. Treat the brain as well." + homemade_treat_text = "Keep them lying down." + occur_text = "caves in" + +/datum/wound/blunt/bone/critical/hv_skull/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) + . = ..() + examine_desc = "is caved in, the skull driven into a deep dent" + +/datum/wound/blunt/bone/critical/hv_skull/wound_injury(datum/wound/old_wound = null, attack_direction = null) + . = ..() + if(!victim) + return + victim.adjustOrganLoss(ORGAN_SLOT_BRAIN, HV_SKULL_BRAIN_DAMAGE) + if(victim.stat == DEAD) + return + victim.Knockdown(3 SECONDS) + victim.set_confusion_if_lower(15 SECONDS) + victim.adjust_eye_blur(10 SECONDS) + +#undef HV_SKULL_BRAIN_DAMAGE + +/datum/wound_pregen_data/bone/hv_skull + abstract = FALSE + wound_path_to_generate = /datum/wound/blunt/bone/critical/hv_skull + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 115 + +// --------------------------------------------------------------------------------------------- +// Shattered jaw. tg's hairline fracture on the head without its brain traumas, plus mumbled +// speech while it lasts. Heals like any hairline fracture (surgery, or bone gel and tape). +// tg has no teeth item, so no teeth fly. + +/datum/wound/blunt/bone/severe/hv_jaw + name = "Shattered Jaw" + undiagnosed_name = "Broken Jaw" + desc = "Patient's jaw is broken in several places." + treat_text = "Repair surgically, or set it with bone gel and surgical tape." + treat_text_short = "Repair surgically, or apply bone gel and surgical tape." + simple_desc = "Patient's jaw is broken." + simple_treat_text = "Surgery, or bone gel and surgical tape." + homemade_treat_text = "Keep them off solid food." + examine_desc = "hangs open on a shattered jaw" + occur_text = "has its jaw shattered" + sound_effect = 'sound/effects/wounds/crack1.ogg' + brain_trauma_group = null + +/datum/wound/blunt/bone/severe/hv_jaw/set_victim(new_victim) + if(victim) + UnregisterSignal(victim, COMSIG_LIVING_TREAT_MESSAGE) + if(new_victim) + RegisterSignal(new_victim, COMSIG_LIVING_TREAT_MESSAGE, PROC_REF(mumble_speech)) + return ..() + +/datum/wound/blunt/bone/severe/hv_jaw/get_scanner_description(mob/user) + . = ..() + // tg's bone wounds report brain traumas for any head fracture; a broken jaw gives none. + . = replacetext(., "Cranial Trauma Detected: Patient will suffer random bouts of mild brain traumas until bone is repaired.", "") + +/datum/wound/blunt/bone/severe/hv_jaw/proc/mumble_speech(datum/source, list/message_args) + SIGNAL_HANDLER + message_args[TREAT_MESSAGE_ARG] = hv_crit_mumble(message_args[TREAT_MESSAGE_ARG]) + +/datum/wound_pregen_data/bone/hv_jaw + abstract = FALSE + wound_path_to_generate = /datum/wound/blunt/bone/severe/hv_jaw + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 60 + +// --------------------------------------------------------------------------------------------- +// Speech mangling for the throat and jaw. Mirrors tg's speech status effects: decode, walk the +// characters (multi-byte safe), re-sanitize. + +/// Speech through a cut throat: words drown in blood. +/proc/hv_crit_gargle(message) + var/phrase = html_decode(message) + if(!length(phrase)) + return message + var/output = "" + var/char = "" + for(var/i = 1, i <= length(phrase), i += length(char)) + char = phrase[i] + if(findtext("aeiou", LOWER_TEXT(char)) && prob(40)) + output += pick("gh", "rgl", "hl") + else + output += char + return sanitize(output) + +/// Speech through a broken jaw: the mouth barely moves. +/proc/hv_crit_mumble(message) + var/static/list/hard_sounds = list("t", "d", "s", "z", "k", "g", "c", "j", "x", "q") + var/phrase = html_decode(message) + if(!length(phrase)) + return message + var/output = "" + var/char = "" + for(var/i = 1, i <= length(phrase), i += length(char)) + char = phrase[i] + var/lower = LOWER_TEXT(char) + if((lower in hard_sounds) && prob(60)) + output += (char == lower) ? pick("m", "h", "f") : pick("M", "H", "F") + else + output += char + return sanitize(output) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher.dm b/voidcrew/modules/hyperviolence/finishers/finisher.dm new file mode 100644 index 00000000000..bacc17c2aca --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher.dm @@ -0,0 +1,483 @@ +/** + * # Hyperviolence finishers + * + * A boss or elite gets hold of someone who is down and kills them in stages: a wind-up everyone + * can see, the pin, a beat or two, then the gore. Each stage is a /datum/hv_finisher_stage with a + * duration, a line and an effect (finisher_stages.dm). + * + * The rules that keep it fair: + * - It only starts on someone alive and down (crit, knocked over or stunned), and never on an + * ally or on someone another finisher already has (hv_finisher_valid_victim()). + * - The wind-up holds nobody: pull the victim clear and it never starts. + * - Every tick a safety check can call it off: the attacker died, was stunned or was knocked + * away, the victim was dragged off, died first or was deleted. + * - Damage on the attacker breaks its hold ([var/interrupt_damage]), and it reels for a moment. + * While it works it is rooted and takes [var/vulnerability] times the damage, so the victim's + * crew can save them. + * - A person doing it (a human NPC boss, [var/real_hits]) does it with their body, as a player would + * (human NPC migration, defaults HV-4 and HV-5): the pin is a real aggressive grab, which the victim + * can resist out of; every beat is a real blow with the weapon in hand (tg damage, armour, wounds and + * dismemberment decide what it does); the killing blow is the same with a higher wound bonus. It isn't + * rooted, takes no extra damage, and doesn't reel: a hit that makes it let go is just that. + * - Both mobs carry a status effect that expires on its own (finisher_effects.dm), and the + * finisher stops itself after its longest possible length, so nothing holds a victim forever. + * - The kill never destroys the brain: a torn-off head lands at the victim's feet with the brain + * inside, and hv_burst_head() drops it intact. + * + * Design adapted from Dead Space 13's execution framework (DS-13-Dev-Team/DS13 @ 1751066f68b8, + * AGPL-3.0: code/modules/mob/living/abilities/execution/execution.dm and execution_stage.dm): + * stage datums run in order, a safety check, a damage interrupt and extra incoming damage while + * busy. Rewritten for tg's basic mobs; no DS13 art or sound is used. + * + * Who finishes whom, and walking up to them: finisher_ai.dm. The bosses that use it: + * finisher_users.dm. + */ + +/// How often a running finisher checks itself +#define HV_FINISHER_TICK (0.2 SECONDS) +/// Slack on top of the stage durations before the timeout safety net ends it +#define HV_FINISHER_SLACK (5 SECONDS) + +/datum/hv_finisher + /// For logs only + var/name = "finisher" + /// The mob doing it + var/mob/living/user + /// The person being finished + var/mob/living/carbon/victim + /// Stage types in order. Each instance gets its own stage datums in New(). + var/list/stages = list() + /// Index of the stage running now, 0 before the first + var/stage_index = 0 + /// The stage running now + var/datum/hv_finisher_stage/current_stage + /// world.time the current stage began + var/stage_started_at = 0 + /// world.time it began + var/started_at = 0 + /// TRUE from start() until stop() + var/running = FALSE + /// TRUE once the killing blow has landed. After that nothing calls it off. + var/completed = FALSE + /// TRUE while the victim is held down + var/gripped = FALSE + /// TRUE once the victim has been held down at all + var/ever_gripped = FALSE + /// Health the attacker can lose before it lets go + var/interrupt_damage = 40 + /// Multiplier on damage the attacker takes while it works + var/vulnerability = 1.5 + /// How long the attacker reels after being made to let go + var/stagger_time = 2 SECONDS + /// The attacker's health when it began + var/start_health = 0 + /// Body zones the kill goes for, in order of preference. pick_zone() takes the first the victim can lose. + var/list/zone_preference = list(BODY_ZONE_HEAD) + /// The zone picked at the start, or null if the victim has none of them to lose + var/target_zone + /// Timer id of the next tick + var/tick_timer + /// How it ended (HV_FINISHER_*), once it has + var/result + /// Called once when it ends: (datum/hv_finisher/finisher, result) + var/datum/callback/on_end + /// Set when the attacker is a carbon: real grab, real blows (see the top of this file) + var/real_hits = FALSE + /// Wound bonus added to the killing blow when it is a real one + var/kill_wound_bonus = 30 + /// The bar over the attacker that everyone sees fill until the kill (finisher_bar.dm) + var/obj/effect/abstract/hv_exec_bar/exec_bar + +/datum/hv_finisher/New(mob/living/user, mob/living/carbon/victim, datum/callback/on_end) + . = ..() + src.user = user + src.victim = victim + src.on_end = on_end + real_hits = iscarbon(user) + var/list/stage_types = stages + stages = list() + for(var/stage_type in stage_types) + stages += new stage_type(src) + +/datum/hv_finisher/Destroy() + if(running) + stop(HV_FINISHER_TIMED_OUT) + on_end = null + current_stage = null + QDEL_LIST(stages) + user = null + victim = null + return ..() + +/// Sum of every stage's duration +/datum/hv_finisher/proc/total_duration() + . = 0 + for(var/datum/hv_finisher_stage/stage as anything in stages) + . += stage.duration + +/// How long from the start to the killing blow: every stage before it, and its own beat's delay +/datum/hv_finisher/proc/time_to_kill() + . = 0 + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.killing_blow) + return . + stage.effect_delay + . += stage.duration + +/// Ends the bar over the attacker: full on a kill, failed otherwise +/datum/hv_finisher/proc/end_exec_bar(success) + if(!QDELETED(exec_bar)) + exec_bar.finish(success) + exec_bar = null + +/// The longest it can possibly run before the timeout safety net ends it +/datum/hv_finisher/proc/longest_duration() + return total_duration() + HV_FINISHER_SLACK + +/// Whether it can begin right now: a valid victim, right next to a conscious attacker +/datum/hv_finisher/proc/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) + return FALSE + if(real_hits && (HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || isnull(hv_finisher_weapon(user)))) + return FALSE + if(!hv_finisher_valid_victim(user, victim)) + return FALSE + return get_dist(user, victim) <= 1 && user.Adjacent(victim) + +/** + * Begins: marks both mobs, enters the first stage and starts ticking. + * Returns FALSE, having changed nothing, if it cannot begin. + */ +/datum/hv_finisher/proc/start() + if(running || completed || !length(stages) || !can_start()) + return FALSE + running = TRUE + started_at = world.time + start_health = user.health + pick_zone() + var/longest = longest_duration() + user.apply_status_effect(/datum/status_effect/hv_finishing, longest, real_hits ? 1 : vulnerability, !real_hits) + victim.apply_status_effect(/datum/status_effect/hv_finisher_hold, longest, real_hits) + RegisterSignals(user, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING), PROC_REF(on_user_down)) + RegisterSignal(user, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_user_damaged)) + RegisterSignal(victim, COMSIG_LIVING_DEATH, PROC_REF(on_victim_died)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + RegisterSignal(user, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + user.face_atom(victim) + exec_bar = hv_exec_bar_start(user, time_to_kill()) + log_combat(user, victim, "began a finisher on", addition = "([name])") + advance() + if(running) + schedule_tick() + return TRUE + +/// Picks the body zone the kill goes for from [var/zone_preference] +/datum/hv_finisher/proc/pick_zone() + target_zone = null + for(var/zone in zone_preference) + var/obj/item/bodypart/part = victim.get_bodypart(zone) + if(part?.can_dismember()) + target_zone = zone + return zone + +/datum/hv_finisher/proc/schedule_tick() + tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), HV_FINISHER_TICK, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// The safety check, the damage check, the stage's beat and the move to the next stage +/datum/hv_finisher/proc/tick() + tick_timer = null + if(!running) + return + var/reason = safety_check() + if(!reason && !completed && took_too_much_damage()) + reason = HV_FINISHER_DAMAGED + if(reason) + end_early(reason) + return + if(world.time - started_at > longest_duration()) + stop(HV_FINISHER_TIMED_OUT) + return + var/elapsed = world.time - stage_started_at + if(!current_stage.effect_done && elapsed >= current_stage.effect_delay) + fire_effect() + if(!running) + return + if(elapsed >= current_stage.duration) + advance() + if(!running) + return + schedule_tick() + +/** + * Leaves the current stage (firing its beat first if it never fired) and enters the next. + * Past the last stage, it is done. + */ +/datum/hv_finisher/proc/advance() + if(!running) + return + if(current_stage) + if(!current_stage.effect_done) + fire_effect() + if(!running) + return + current_stage.exit() + stage_index++ + if(stage_index > length(stages)) + current_stage = null + completed = TRUE + stop(HV_FINISHER_DONE) + return + current_stage = stages[stage_index] + stage_started_at = world.time + if(current_stage.pins) + if(!take_grip()) + end_early(HV_FINISHER_LOST_GRIP) + return + else + release_grip() + current_stage.enter() + if(!running) + return + if(current_stage.effect_delay <= 0) + fire_effect() + +/// Fires the current stage's beat. A killing blow marks it complete before it lands, so the death it causes doesn't call it off. +/datum/hv_finisher/proc/fire_effect() + current_stage.effect_done = TRUE + if(current_stage.killing_blow && !completed) + completed = TRUE + end_exec_bar(TRUE) + log_combat(user, victim, "finished", addition = "([name])") + current_stage.effect() + +/// Holds the victim down from here on. A person grabs them for real. Returns FALSE if it couldn't. +/datum/hv_finisher/proc/take_grip() + if(gripped) + return TRUE + if(real_hits && !hv_finisher_grab(user, victim)) + return FALSE + gripped = TRUE + ever_gripped = TRUE + var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) + hold?.set_gripped(TRUE) + return TRUE + +/// Lets the victim go, for any stages after the kill +/datum/hv_finisher/proc/release_grip() + if(!gripped) + return + gripped = FALSE + hv_finisher_let_go(user, victim) + var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) + hold?.set_gripped(FALSE) + +/// Returns the HV_FINISHER_* reason it can't go on, or 0 if all is well +/datum/hv_finisher/proc/safety_check() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) + return HV_FINISHER_ATTACKER_DOWN + if(QDELETED(victim)) + return HV_FINISHER_VICTIM_GONE + if(!completed && victim.stat == DEAD) + return HV_FINISHER_VICTIM_GONE + if(!isturf(user.loc) || !isturf(victim.loc) || user.z != victim.z || get_dist(user, victim) > 1) + return HV_FINISHER_LOST_GRIP + // An admin heal, or anything else that took the hold off, frees them + if(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return HV_FINISHER_LOST_GRIP + if(real_hits && !completed) + // Cuffed, or the weapon gone: it can't go on. The victim broke the grab: it lost its grip. + if(HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || isnull(hv_finisher_weapon(user))) + return HV_FINISHER_ATTACKER_DOWN + if(gripped && (user.pulling != victim || user.grab_state < GRAB_AGGRESSIVE)) + return HV_FINISHER_LOST_GRIP + return 0 + +/// Whether the attacker has lost [var/interrupt_damage] health since it began +/datum/hv_finisher/proc/took_too_much_damage() + return (start_health - user.health) >= interrupt_damage + +/// Ends it before it is done. After the killing blow there is nothing left to save, so it simply stops. +/datum/hv_finisher/proc/end_early(reason) + if(!running) + return + if(completed) + stop(HV_FINISHER_DONE) + return + interrupt(reason) + +/// Calls it off before the killing blow, with what the room sees +/datum/hv_finisher/proc/interrupt(reason) + if(!running || completed) + return + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_interrupt() + switch(reason) + if(HV_FINISHER_DAMAGED) + user.visible_message(span_danger("[user] staggers back and lets go of [victim]!")) + // Before stop(), so the AI stays held through the hand-over. A person doesn't reel: the + // hit that made it let go stuns it or doesn't, by tg's rules (HV-5). + if(!real_hits) + user.apply_status_effect(/datum/status_effect/hv_finisher_reeling, stagger_time) + if(HV_FINISHER_LOST_GRIP) + if(gripped && !QDELETED(user) && !QDELETED(victim)) + user.visible_message(span_danger("[user] loses [user.p_their()] grip on [victim].")) + stop(reason) + +/// Ends it for any reason: frees both mobs, puts their poses back and reports the result +/datum/hv_finisher/proc/stop(reason) + if(!running) + return + running = FALSE + result = reason + end_exec_bar(completed) + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + if(gripped && real_hits) + hv_finisher_let_go(user, victim) + if(user) + UnregisterSignal(user, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_MOVABLE_MOVED)) + if(!QDELETED(user)) + user.remove_status_effect(/datum/status_effect/hv_finishing) + user.remove_offsets(HV_FINISHER_OFFSET) + if(victim) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + if(!QDELETED(victim)) + victim.remove_status_effect(/datum/status_effect/hv_finisher_hold) + victim.remove_offsets(HV_FINISHER_OFFSET) + gripped = FALSE + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_stop() + if(on_end) + var/datum/callback/callback = on_end + on_end = null + callback.Invoke(src, reason) + +/// Leans the attacker `lunge` pixels toward the victim and lifts it `rise` pixels. Each call replaces the last pose. +/datum/hv_finisher/proc/pose_user(lunge = 0, rise = 0) + if(QDELETED(user) || QDELETED(victim)) + return + var/toward = get_dir(user, victim) + var/dx = (toward & EAST) ? 1 : ((toward & WEST) ? -1 : 0) + var/dy = (toward & NORTH) ? 1 : ((toward & SOUTH) ? -1 : 0) + user.add_offsets(HV_FINISHER_OFFSET, null, dx * lunge, dy * lunge + rise) + +/** + * A real blow from the attacker at `zone` with the melee weapon in its hand, through the attack chain a + * player's click runs (a person attacker only). `extra_wound_bonus` is added to the weapon's for this + * one blow, the way an execution boosts its one round. Returns TRUE if it swung. + */ +/datum/hv_finisher/proc/real_blow(zone, extra_wound_bonus = 0) + if(QDELETED(user) || QDELETED(victim) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + var/obj/item/weapon = hv_finisher_weapon(user) + if(isnull(weapon)) + return FALSE + var/mob/living/carbon/attacker = user + if(attacker.get_active_held_item() != weapon) + var/index = attacker.get_held_index_of_item(weapon) + if(!index) + return FALSE + attacker.swap_hand(index, silent = TRUE) + user.zone_selected = zone + user.face_atom(victim) + var/old_combat_mode = user.combat_mode + user.set_combat_mode(TRUE) + var/old_wound_bonus = weapon.wound_bonus + weapon.wound_bonus += extra_wound_bonus + weapon.melee_attack_chain(user, victim) + if(!QDELETED(weapon)) + weapon.wound_bonus = old_wound_bonus + if(!QDELETED(user)) + user.set_combat_mode(old_combat_mode) + user.changeNext_move(CLICK_CD_MELEE) + return TRUE + +/// Called as the wind-up begins, for a finisher with something of its own to start then. Nothing marks the victim: +/// the wind-up reads from the bodies alone. +/datum/hv_finisher/proc/on_windup(duration) + return + +// ===== Signal handlers ===== + +/datum/hv_finisher/proc/on_user_down(datum/source) + SIGNAL_HANDLER + end_early(HV_FINISHER_ATTACKER_DOWN) + +/datum/hv_finisher/proc/on_user_damaged(datum/source) + SIGNAL_HANDLER + if(running && !completed && took_too_much_damage()) + end_early(HV_FINISHER_DAMAGED) + +/datum/hv_finisher/proc/on_victim_died(datum/source) + SIGNAL_HANDLER + if(!completed) + end_early(HV_FINISHER_VICTIM_GONE) + +/datum/hv_finisher/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + end_early(HV_FINISHER_VICTIM_GONE) + +/// Either of them moved: dragged off, thrown or pushed apart +/datum/hv_finisher/proc/on_moved(datum/source) + SIGNAL_HANDLER + var/reason = safety_check() + if(reason) + end_early(reason) + +/** + * Whether `user` may start a finisher on `victim`: a carbon who is alive, down and on the floor + * where `user` stands, not an ally, not strapped to anything and not already being finished. + */ +/proc/hv_finisher_valid_victim(mob/living/user, mob/living/carbon/victim) + if(!iscarbon(victim) || !isliving(user) || QDELETED(victim) || QDELETED(user) || victim == user) + return FALSE + if(victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!hv_finisher_is_down(victim)) + return FALSE + if(victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + if(user.faction_check_atom(victim)) + return FALSE + return TRUE + +/// The melee weapon a person attacker swings in a finisher: the heaviest one in its hands, or null +/proc/hv_finisher_weapon(mob/living/user) + var/obj/item/best + for(var/obj/item/held in user.held_items) + if(isgun(held) || (held.item_flags & ABSTRACT) || held.force <= 0) + continue + if(isnull(best) || held.force > best.force) + best = held + return best + +/** + * A person attacker's pin: a real aggressive grab on `victim`, the way a player gets one (a pull, then + * the grip tightened). Fails if the victim blocks it or can't be grabbed hard (a hulk). Returns TRUE if + * it holds them. + */ +/proc/hv_finisher_grab(mob/living/user, mob/living/carbon/victim) + if(user.pulling != victim) + victim.grabbedby(user, supress_message = TRUE) + if(user.pulling != victim) + return FALSE + if(user.grab_state < GRAB_AGGRESSIVE) + victim.grabbedby(user) + return user.pulling == victim && user.grab_state >= GRAB_AGGRESSIVE + +/// A person attacker lets go of the victim it grabbed. +/proc/hv_finisher_let_go(mob/living/user, mob/living/victim) + if(!QDELETED(user) && user.pulling == victim) + user.stop_pulling() + +/// Whether `victim` is down: in crit, on the floor, or stunned +/proc/hv_finisher_is_down(mob/living/victim) + return victim.stat >= SOFT_CRIT || victim.body_position == LYING_DOWN || HAS_TRAIT(victim, TRAIT_INCAPACITATED) + +#undef HV_FINISHER_TICK +#undef HV_FINISHER_SLACK diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm b/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm new file mode 100644 index 00000000000..a02648f4ab3 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm @@ -0,0 +1,239 @@ +/** + * # Finishers: who does them, and the AI that picks a victim + * + * A mob gets a finisher with AddComponent(/datum/component/hv_finisher_user, finisher_type). The + * component grants the mob an ability with no button (only its AI uses it), puts the ability on + * its AI's blackboard and slots /datum/ai_planning_subtree/hv_finisher into + * its planning, right after the subtrees that pick a target. Some Voidcrew code swaps a mob's AI + * controller after it spawns (boarding pods hand bosses a patrolling controller); the component + * follows the mob onto the new controller. + * + * The subtree only acts when the ability is off cooldown and someone valid (finisher.dm, + * hv_finisher_valid_victim()) is down: the mob's own target within + * [var/datum/action/cooldown/mob_cooldown/hv_finisher/seek_range] tiles, or anyone right next to + * it. It walks over and starts the finisher, and gives up on a victim it can't reach in + * [var/datum/action/cooldown/mob_cooldown/hv_finisher/seek_timeout]. + */ + +/datum/component/hv_finisher_user + /// The finisher ability it granted + var/datum/action/cooldown/mob_cooldown/hv_finisher/finisher_action + /// The AI controller it set up, watched so it can follow the mob onto a replacement + var/datum/ai_controller/watched_controller + +/datum/component/hv_finisher_user/Initialize(finisher_type, cooldown) + if(!isliving(parent) || !ispath(finisher_type, /datum/hv_finisher)) + return COMPONENT_INCOMPATIBLE + finisher_action = new(parent) + finisher_action.finisher_type = finisher_type + if(isnum(cooldown)) + finisher_action.cooldown_time = cooldown + finisher_action.Grant(parent) + adopt_controller() + +/datum/component/hv_finisher_user/Destroy(force) + release_controller() + QDEL_NULL(finisher_action) + return ..() + +/// Sets up the mob's current AI controller: the ability on its blackboard and the subtree in its plan +/datum/component/hv_finisher_user/proc/adopt_controller() + var/atom/movable/pawn = parent + var/datum/ai_controller/controller = pawn?.ai_controller + if(QDELETED(controller) || controller == watched_controller || QDELETED(finisher_action)) + return + release_controller() + watched_controller = controller + controller.set_blackboard_key(BB_HV_FINISHER_ACTION, finisher_action) + hv_finisher_add_subtree(controller) + RegisterSignal(controller, COMSIG_AI_CONTROLLER_UNPOSSESSED_PAWN, PROC_REF(on_controller_lost)) + +/datum/component/hv_finisher_user/proc/release_controller() + if(watched_controller) + UnregisterSignal(watched_controller, COMSIG_AI_CONTROLLER_UNPOSSESSED_PAWN) + watched_controller = null + +/// The mob's controller let go of it, usually because a replacement is being built right now +/datum/component/hv_finisher_user/proc/on_controller_lost(datum/ai_controller/source) + SIGNAL_HANDLER + release_controller() + addtimer(CALLBACK(src, PROC_REF(adopt_controller)), 1) + +/** + * Puts the finisher subtree into `controller`'s plan, after the leading subtrees that escape, + * pick or hold a target, and before any attack or ability. Does nothing if it is already there. + * Returns TRUE if it added it. + */ +/proc/hv_finisher_add_subtree(datum/ai_controller/controller) + var/static/list/goes_first = typecacheof(list( + /datum/ai_planning_subtree/call_reinforcements, + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/pirate_crew_find_target, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/pet_planning, + /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/target_retaliate, + /datum/ai_planning_subtree/aggressive_find_target, + )) + var/list/new_plan = list() + var/insert_at = 1 + for(var/datum/ai_planning_subtree/subtree in controller.planning_subtrees) + if(istype(subtree, /datum/ai_planning_subtree/hv_finisher)) + return FALSE + new_plan += subtree.type + if(insert_at == length(new_plan) && is_type_in_typecache(subtree, goes_first)) + insert_at++ + new_plan.Insert(insert_at, /datum/ai_planning_subtree/hv_finisher) + controller.replace_planning_subtrees(new_plan) + return TRUE + +// ===== The ability ===== + +/// Starts the mob's finisher on someone who is down next to it +/datum/action/cooldown/mob_cooldown/hv_finisher + name = "Finish Them" + desc = "Finish off someone who is down." + button_icon = 'icons/mob/telegraphing/telegraph.dmi' + button_icon_state = "skull" + cooldown_time = 60 SECONDS + melee_cooldown_time = 0 + // Its own clock only: a shared cooldown would put the mob's other abilities on a minute's wait + shared_cooldown = NONE + click_to_activate = TRUE + // The AI's move only. A player who takes the mob over (a sentience potion, an admin) gets no + // button, so it can never be turned on other crews. + owner_has_control = FALSE + /// The /datum/hv_finisher it runs + var/finisher_type = /datum/hv_finisher + /// The one running now + var/datum/hv_finisher/active + /// Cooldown when the victim was pulled clear before it got hold + var/missed_cooldown = 15 SECONDS + /// Cooldown when the AI tried from somewhere it couldn't reach them + var/blocked_cooldown = 5 SECONDS + /// How far the AI walks to finish its own target + var/seek_range = 3 + /// How long the AI tries to reach one victim before giving up on them for [var/missed_cooldown] + var/seek_timeout = 6 SECONDS + /// The victim the AI is walking to, and since when + var/datum/weakref/seek_target + var/seek_started_at = 0 + +/datum/action/cooldown/mob_cooldown/hv_finisher/Destroy() + if(active) + var/datum/hv_finisher/finisher = active + active = null + finisher.on_end = null + finisher.stop(HV_FINISHER_ATTACKER_DOWN) + qdel(finisher) + seek_target = null + return ..() + +/datum/action/cooldown/mob_cooldown/hv_finisher/IsAvailable(feedback = FALSE) + return ..() && isnull(active) + +/datum/action/cooldown/mob_cooldown/hv_finisher/Activate(atom/target) + var/mob/living/living_owner = owner + if(!isliving(living_owner) || !hv_finisher_valid_victim(living_owner, target)) + return FALSE + if(get_dist(living_owner, target) > 1 || !living_owner.Adjacent(target)) + // Something is between them. The AI tries again shortly rather than standing there. + StartCooldown(blocked_cooldown) + return FALSE + var/datum/hv_finisher/finisher = new finisher_type(living_owner, target, CALLBACK(src, PROC_REF(finisher_ended))) + active = finisher + // Unavailable for the whole move; finisher_ended() sets the real cooldown when it ends + StartCooldown(cooldown_time + finisher.longest_duration()) + seek_target = null + if(!finisher.start()) + active = null + qdel(finisher) + StartCooldown(blocked_cooldown) + return FALSE + return TRUE + +/// The finisher ended. A miss before the grip costs a short wait, anything else the full cooldown from now. +/datum/action/cooldown/mob_cooldown/hv_finisher/proc/finisher_ended(datum/hv_finisher/finisher, result) + if(active == finisher) + active = null + if(result == HV_FINISHER_LOST_GRIP && !finisher.ever_gripped) + StartCooldown(missed_cooldown) + else + StartCooldown() + qdel(finisher) + +/** + * The victim for the AI, or null: whoever it was already after, else its current target if they + * are down within [var/seek_range], else anyone down right next to it. It never walks to a + * stranger: someone lying behind glass a few tiles off would otherwise stall it. Gives up on a + * victim it has chased for [var/seek_timeout]. + */ +/datum/action/cooldown/mob_cooldown/hv_finisher/proc/find_victim(datum/ai_controller/controller) + var/mob/living/pawn = owner + if(!isliving(pawn)) + return null + var/mob/living/carbon/victim + var/mob/living/carbon/chased = controller.blackboard[BB_HV_FINISHER_VICTIM] + if(reachable_victim(chased)) + victim = chased + if(isnull(victim)) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(reachable_victim(target)) + victim = target + var/turf/here = get_turf(pawn) + if(isnull(victim) && here) + for(var/mob/living/carbon/candidate in orange(1, here)) + if(pawn.Adjacent(candidate) && hv_finisher_valid_victim(pawn, candidate)) + victim = candidate + break + if(isnull(victim)) + seek_target = null + return null + if(seek_target?.resolve() != victim) + seek_target = WEAKREF(victim) + seek_started_at = world.time + else if(world.time - seek_started_at > seek_timeout) + seek_target = null + StartCooldown(missed_cooldown) + return null + return victim + +/// Whether `candidate` is a valid victim within [var/seek_range] +/datum/action/cooldown/mob_cooldown/hv_finisher/proc/reachable_victim(atom/candidate) + if(!iscarbon(candidate) || get_dist(owner, candidate) > seek_range) + return FALSE + return hv_finisher_valid_victim(owner, candidate) + +// ===== The AI ===== + +/// Walks to someone who is down and finishes them, when the finisher is ready +/datum/ai_planning_subtree/hv_finisher + +/datum/ai_planning_subtree/hv_finisher/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/action/cooldown/mob_cooldown/hv_finisher/finisher = controller.blackboard[BB_HV_FINISHER_ACTION] + if(QDELETED(finisher)) + return + var/mob/living/pawn = controller.pawn + if(!isliving(pawn) || pawn.stat != CONSCIOUS) + return + if(finisher.active) + return SUBTREE_RETURN_FINISH_PLANNING + // Planted for another move, or simply not ready + if(HAS_TRAIT(pawn, TRAIT_IMMOBILIZED) || !finisher.IsAvailable()) + return + var/mob/living/carbon/victim = finisher.find_victim(controller) + if(isnull(victim)) + controller.clear_blackboard_key(BB_HV_FINISHER_VICTIM) + return + controller.set_blackboard_key(BB_HV_FINISHER_VICTIM, victim) + controller.queue_behavior(/datum/ai_behavior/targeted_mob_ability/min_range/hv_finisher, BB_HV_FINISHER_ACTION, BB_HV_FINISHER_VICTIM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Walks next to the victim, then uses the finisher on them +/datum/ai_behavior/targeted_mob_ability/min_range/hv_finisher + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + required_distance = 1 + +/datum/ai_behavior/targeted_mob_ability/min_range/hv_finisher/finish_action(datum/ai_controller/controller, succeeded, ability_key, target_key) + . = ..() + controller.clear_blackboard_key(target_key) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_bar.dm b/voidcrew/modules/hyperviolence/finishers/finisher_bar.dm new file mode 100644 index 00000000000..245fea9f2c5 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_bar.dm @@ -0,0 +1,113 @@ +/** + * # Execution bars + * + * tg's progress bar over someone doing an execution or a finisher, from the start of the move to the kill, + * shown to everyone who can see them. tg's do_after bar is a client image only its user sees; this is an + * object on the executioner's tile instead, so BYOND shows it to whoever has that tile in view (the victim, + * bystanders, ghosts) and to anyone who walks into view while it runs, with nothing to keep track of. + * It draws on tg's progress bar plane, so it sits above every body whatever the rows are. + * + * Users: /datum/hv_finisher (every finisher, chrome or boss), gun executions and the Mantis impale. + */ + +/// Matches tg's PROGRESSBAR_HEIGHT: how far a bar stacks above another +#define HV_EXEC_BAR_HEIGHT 6 +/// Matches tg's PROGRESSBAR_ANIMATION_TIME: the fade in and out +#define HV_EXEC_BAR_FADE 5 + +/obj/effect/abstract/hv_exec_bar + name = "progress" + icon = 'icons/effects/progressbar.dmi' + icon_state = "prog_bar_0" + plane = ABOVE_HUD_PLANE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + appearance_flags = APPEARANCE_UI_IGNORE_ALPHA + anchored = TRUE + /// Who is doing the execution; the bar stands over them + var/mob/living/owner + /// How long from the start to the kill + var/total = 1 + /// world.time it began + var/started_at = 0 + /// Set once it is fading out + var/ending = FALSE + /// The next refill of the bar + var/update_timer + /// Height it rests at over the owner + var/rest_z = 0 + +/obj/effect/abstract/hv_exec_bar/Initialize(mapload, mob/living/executioner, total) + . = ..() + if(QDELETED(executioner) || !isnum(total) || total <= 0) + return INITIALIZE_HINT_QDEL + owner = executioner + src.total = total + started_at = world.time + // Centred over the top of their sprite, however big it is (a 64px boss is drawn up from its tile, shifted left by half the extra width) + var/list/icon_size = executioner.icon ? get_icon_dimensions(executioner.icon) : list("width" = ICON_SIZE_X, "height" = ICON_SIZE_Y) + pixel_w = executioner.base_pixel_x + (icon_size["width"] - ICON_SIZE_X) / 2 + // Above any do_after bars the executioner already has over themselves + var/list/own_bars = LAZYACCESS(executioner.progressbars, executioner) + rest_z = executioner.base_pixel_y + icon_size["height"] + HV_EXEC_BAR_HEIGHT * length(own_bars) + alpha = 0 + animate(src, pixel_z = rest_z, alpha = 255, time = HV_EXEC_BAR_FADE, easing = SINE_EASING) + RegisterSignal(owner, COMSIG_QDELETING, PROC_REF(on_owner_deleted)) + RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_owner_moved)) + tick() + +/obj/effect/abstract/hv_exec_bar/Destroy() + if(update_timer) + deltimer(update_timer) + update_timer = null + if(owner) + UnregisterSignal(owner, list(COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + owner = null + return ..() + +/// Fills the bar to how far along the move is. It holds full if the kill is a beat late. +/obj/effect/abstract/hv_exec_bar/proc/tick() + update_timer = null + if(ending) + return + var/progress = clamp((world.time - started_at) / total, 0, 1) + icon_state = "prog_bar_[round(progress * 100, 5)]" + if(progress >= 1) + return + update_timer = addtimer(CALLBACK(src, PROC_REF(tick)), max(world.tick_lag, total / 20), TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Ends the bar: full when the kill landed, tg's failed look when it was called off. Fades out, then goes. +/obj/effect/abstract/hv_exec_bar/proc/finish(success) + if(ending || QDELETED(src)) + return + ending = TRUE + if(update_timer) + deltimer(update_timer) + update_timer = null + icon_state = success ? "prog_bar_100" : "[icon_state]_fail" + animate(src, alpha = 0, time = HV_EXEC_BAR_FADE) + QDEL_IN(src, HV_EXEC_BAR_FADE) + +/obj/effect/abstract/hv_exec_bar/proc/on_owner_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// Stays over the executioner. Walking off ends the move itself (each user checks that); the bar follows until it does. +/obj/effect/abstract/hv_exec_bar/proc/on_owner_moved(datum/source) + SIGNAL_HANDLER + if(isturf(owner.loc)) + abstract_move(owner.loc) + else + finish(FALSE) + +/** + * Puts a bar over [executioner] that fills over [total] deciseconds, seen by everyone who can see them. + * Returns it, for the caller to finish(); null if there is nowhere to put it. + */ +/proc/hv_exec_bar_start(mob/living/executioner, total) + if(QDELETED(executioner) || !isturf(executioner.loc) || !isnum(total) || total <= 0) + return null + var/obj/effect/abstract/hv_exec_bar/bar = new(executioner.loc, executioner, total) + return QDELETED(bar) ? null : bar + +#undef HV_EXEC_BAR_HEIGHT +#undef HV_EXEC_BAR_FADE diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm b/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm new file mode 100644 index 00000000000..c8a6af698d0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm @@ -0,0 +1,138 @@ +/** + * # Finisher status effects + * + * The finisher (finisher.dm) puts one status effect on each mob and takes it off when it ends. + * Each effect also runs out on its own after the finisher's longest possible length, so a broken + * or forgotten finisher can never leave a victim held or a boss frozen. + */ + +/** + * On the mob doing a finisher: its AI held while it works. A beast is also rooted and takes extra + * damage; a person (a human NPC boss) is neither: it simply stands there busy, as a player would (HV-5). + */ +/datum/status_effect/hv_finishing + id = "hv_finishing" + alert_type = null + duration = 15 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_UNIQUE + /// Multiplier on damage it takes. 1 adds none. + var/vulnerability = 1.5 + /// Rooted where it stands + var/rooted = TRUE + +/datum/status_effect/hv_finishing/on_creation(mob/living/new_owner, duration_override, vulnerability, rooted = TRUE) + if(isnum(duration_override)) + duration = duration_override + if(isnum(vulnerability)) + src.vulnerability = vulnerability + src.rooted = rooted + return ..() + +/datum/status_effect/hv_finishing/on_apply() + ADD_TRAIT(owner, TRAIT_AI_PAUSED, TRAIT_STATUS_EFFECT(id)) + if(rooted) + ADD_TRAIT(owner, TRAIT_IMMOBILIZED, TRAIT_STATUS_EFFECT(id)) + if(vulnerability != 1) + RegisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(on_damage_modifiers)) + return TRUE + +/datum/status_effect/hv_finishing/on_remove() + owner.remove_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + UnregisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS) + +/datum/status_effect/hv_finishing/proc/on_damage_modifiers(mob/living/source, list/damage_mods, damage, damagetype, def_zone, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + damage_mods += vulnerability + +/** + * On the victim of a finisher: marked so nothing else starts one on them, and held down once gripped. + * When a person does the finisher the hold is their real grab, so this only marks the victim. + */ +/datum/status_effect/hv_finisher_hold + id = "hv_finisher_hold" + alert_type = null + duration = 15 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_UNIQUE + /// TRUE while held down + var/gripped = FALSE + /// The attacker holds them with a real grab: no traits of its own + var/real_grip = FALSE + +/datum/status_effect/hv_finisher_hold/on_creation(mob/living/new_owner, duration_override, real_grip = FALSE) + if(isnum(duration_override)) + duration = duration_override + src.real_grip = real_grip + return ..() + +/datum/status_effect/hv_finisher_hold/on_remove() + set_gripped(FALSE) + +/// Holds the victim down, or lets them go. Their hands stay free: a downed victim can still shoot back. +/datum/status_effect/hv_finisher_hold/proc/set_gripped(grip) + if(gripped == grip) + return + gripped = grip + if(real_grip) + return + if(gripped) + owner.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_FLOORED), TRAIT_STATUS_EFFECT(id)) + else + owner.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_FLOORED), TRAIT_STATUS_EFFECT(id)) + +/// On a mob made to let go of a finisher by damage: a moment where it does nothing +/datum/status_effect/hv_finisher_reeling + id = "hv_finisher_reeling" + alert_type = null + duration = 2 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_REFRESH + +/datum/status_effect/hv_finisher_reeling/on_creation(mob/living/new_owner, duration_override) + if(isnum(duration_override)) + duration = duration_override + return ..() + +/datum/status_effect/hv_finisher_reeling/on_apply() + owner.add_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + return TRUE + +/datum/status_effect/hv_finisher_reeling/on_remove() + owner.remove_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + +/** + * Says `line` aloud like any other speech, but the words over the speaker's head fade after `show_time` + * instead of runechat's usual five seconds, so a line opening a finisher doesn't sit over the move. + * The chat log is untouched. + */ +/proc/hv_say_briefly(atom/movable/speaker, line, show_time = 1 SECONDS) + var/said_at = REALTIMEOFDAY + speaker.say(line) + if(!QDELETED(speaker)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(hv_cut_runechat), WEAKREF(speaker), said_at), show_time, TIMER_DELETE_ME) + +/// Fades every viewer's runechat over the speaker that appeared since `said_at`. +/proc/hv_cut_runechat(datum/weakref/speaker_ref, said_at) + var/atom/movable/speaker = speaker_ref?.resolve() + if(QDELETED(speaker)) + return + var/atom/message_loc = get_atom_on_turf(speaker) + for(var/client/viewer as anything in GLOB.clients) + for(var/datum/chatmessage/bubble as anything in LAZYACCESS(viewer.seen_messages, message_loc)) + if(bubble.animate_start >= said_at) + bubble.hv_fade_now() + +/// Starts this message's fade-out now, if it isn't already fading. +/datum/chatmessage/proc/hv_fade_now() + if(isnull(message) || !destruction_timer) + return + var/time_spent = REALTIMEOFDAY - animate_start + if(time_spent >= animate_lifespan - translate_eol_fade) + return + message.alpha = get_current_alpha(time_spent) + animate(message, alpha = 0, time = translate_eol_fade) + animate_lifespan = time_spent + translate_eol_fade + // The timer lives on SSrunechat (see extend_lifespan() in autotranslate/runechat.dm). + deltimer(destruction_timer, SSrunechat) + destruction_timer = addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(qdel), src), translate_eol_fade + translate_grace_period, TIMER_DELETE_ME | TIMER_STOPPABLE, SSrunechat) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm new file mode 100644 index 00000000000..381783c36d2 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm @@ -0,0 +1,366 @@ +/** + * # Finisher stages + * + * One step of a finisher (finisher.dm). A stage runs for [var/duration]; its beat, effect(), fires + * [var/effect_delay] in, or the moment it begins when that is 0. enter() poses the attacker and + * shows the stage's opening line; the beat shows the gory line through hv_add_hit_line(). + * + * The kinds here carry the mechanics. Each boss subtypes them in finisher_users.dm for its own + * words, sounds and timing: + * - windup: rears up over the victim. Holds nobody, so the victim can still be pulled clear. + * - hold: the pin. From here the victim is held down. + * - maul: a bite or a blow, with blood. Deals no damage, so the victim lives to the end. When a person + * does it (host.real_hits) it is a real blow with the weapon in hand instead, at [var/blow_zone]. + * - kill/rip_limb, kill/decapitate, kill/burst_head, kill/bisect: the killing blow. A person's is a real + * blow with a higher wound bonus (real_kill()); tg's wounds and dismemberment decide what it takes. + * - recover: the attacker straightens up after the kill. Lets the victim go. + */ + +/datum/hv_finisher_stage + /// The finisher it belongs to + var/datum/hv_finisher/host + /// How long the stage lasts + var/duration = 1 SECONDS + /// How far into the stage its beat fires + var/effect_delay = 0 + /// TRUE once the beat has fired + var/effect_done = FALSE + /// TRUE once the stage has begun + var/entered = FALSE + /// The victim is held down during this stage + var/pins = TRUE + /// This stage's beat is the kill. Once it lands nothing calls the finisher off. + var/killing_blow = FALSE + /// Pixels the attacker leans toward the victim during this stage + var/lunge = 0 + /// Pixels the attacker rises during this stage + var/rise = 0 + /// Sound when the stage begins, from the attacker + var/enter_sound + var/enter_volume = 60 + /// Playback speed of the opening sound. Null lets it vary as playsound() does; a big beast wants a fixed low one. + var/enter_frequency + /// Sound of the beat, from the victim + var/effect_sound + var/effect_volume = 70 + /// HV_LINE_BOLD or HV_LINE_HUGE for the beat's line. 0 shows it as a plain combat line. + var/effect_severity = HV_LINE_BOLD + /// How far the beat's line carries + var/effect_range = COMBAT_MESSAGE_RANGE + /// How far the opening line carries. The crew should see a boss rear up over one of their own. + var/enter_range = DEFAULT_MESSAGE_RANGE + +/datum/hv_finisher_stage/New(datum/hv_finisher/host) + . = ..() + src.host = host + +/datum/hv_finisher_stage/Destroy() + host = null + return ..() + +/// Begins the stage: pose, sound and its opening line +/datum/hv_finisher_stage/proc/enter() + entered = TRUE + host.user.face_atom(host.victim) + host.pose_user(lunge, rise) + if(enter_sound) + playsound(host.user, enter_sound, enter_volume, TRUE, frequency = enter_frequency) + var/text = enter_message() + if(text) + host.victim.visible_message(span_danger(text), span_userdanger(enter_self_message() || text), vision_distance = enter_range) + +/// The stage's beat +/datum/hv_finisher_stage/proc/effect() + if(effect_sound) + playsound(host.victim, effect_sound, effect_volume, TRUE) + show_effect_line() + +/// Shows [proc/effect_message] at [var/effect_severity] +/datum/hv_finisher_stage/proc/show_effect_line() + var/text = effect_message() + if(!text) + return + if(effect_severity) + // Positional: (text, severity, self_text, range) on the shared hook + host.victim.hv_add_hit_line(text, effect_severity, effect_self_message(), effect_range) + return + var/self_text = effect_self_message() + host.victim.visible_message(span_danger(text), self_text ? span_userdanger(self_text) : null, vision_distance = effect_range) + +/// What the room sees as the stage begins, or null for nothing +/datum/hv_finisher_stage/proc/enter_message() + return null + +/// What the victim sees as the stage begins, or null to see the room's line +/datum/hv_finisher_stage/proc/enter_self_message() + return null + +/// The line of the beat, or null for none +/datum/hv_finisher_stage/proc/effect_message() + return null + +/// What the victim sees of the beat, or null to see the room's line +/datum/hv_finisher_stage/proc/effect_self_message() + return null + +/// Called as the finisher moves on to the next stage +/datum/hv_finisher_stage/proc/exit() + return + +/// Called on every stage that began when the finisher is called off before the kill +/datum/hv_finisher_stage/proc/on_interrupt() + return + +/// Called on every stage that began when the finisher ends, however it ends +/datum/hv_finisher_stage/proc/on_stop() + return + +/// The name of the limb the kill goes for ("left arm"), or "body" if there is none +/datum/hv_finisher_stage/proc/target_limb_name() + var/obj/item/bodypart/part = host.target_zone ? host.victim.get_bodypart(host.target_zone) : null + return part ? part.plaintext_zone : "body" + +/// Which way blood flies: away from the attacker +/datum/hv_finisher_stage/proc/away_dir() + return get_dir(host.user, host.victim) || host.user.dir + +// ===== Wind-up ===== + +/// Rears up over the victim. Holds nobody yet. +/datum/hv_finisher_stage/windup + pins = FALSE + duration = 1.5 SECONDS + rise = 6 + /// Something the attacker says as it winds up, or null. Its runechat is cut short so it doesn't hang over the move. + var/say_line + +/datum/hv_finisher_stage/windup/enter() + . = ..() + host.on_windup(duration) + if(say_line) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(hv_say_briefly), host.user, say_line) + +// ===== Hold ===== + +/// The pin. A beat with no damage. +/datum/hv_finisher_stage/hold + duration = 1.5 SECONDS + lunge = 6 + effect_severity = 0 + +// ===== Maul ===== + +/// A bite or a blow: blood and a shake, but no damage, so the victim lives until the kill. A person's is a +/// real blow with its weapon at blow_zone. +/datum/hv_finisher_stage/maul + duration = 2 SECONDS + effect_delay = 0.5 SECONDS + lunge = 8 + /// How far the blood flies + var/spray_strength = 2 + /// Where a person's blow lands + var/blow_zone = BODY_ZONE_HEAD + +/datum/hv_finisher_stage/maul/effect() + . = ..() + if(host.real_hits) + host.real_blow(blow_zone) + return + host.victim.hv_blood_spray(away_dir(), spray_strength) + host.victim.hv_vis_shudder(2, 1, 0.6 SECONDS) + +// ===== The kill ===== + +/// The killing blow. Subtypes do the gore in gore(); if there is nothing to take, fallback() hurts them instead. +/datum/hv_finisher_stage/kill + killing_blow = TRUE + duration = 1.5 SECONDS + effect_delay = 0.5 SECONDS + lunge = 4 + effect_severity = HV_LINE_HUGE + // Carries as far as tg's own dismemberment line + effect_range = DEFAULT_MESSAGE_RANGE + /// How far the blood flies + var/spray_strength = 5 + /// Brute dealt to the chest when the gore can't happen + var/fallback_damage = 40 + +/datum/hv_finisher_stage/kill/effect() + if(effect_sound) + playsound(host.victim, effect_sound, effect_volume, TRUE) + var/spray_dir = away_dir() + if(host.real_hits) + real_kill(spray_dir) + return + if(!can_gore()) + fallback(spray_dir) + return + // The line names the part, so it goes out while the part is still attached + show_effect_line() + gore(spray_dir) + +/// Where a person's killing blow lands +/datum/hv_finisher_stage/kill/proc/real_zones() + return list(host.target_zone || BODY_ZONE_CHEST) + +/** + * A person's killing blow: a real blow at each of real_zones() with the finisher's extra wound bonus. What + * it takes off, if anything, is tg's to decide. The line said is what happened: the gore line if the part + * came off, else the fallback. + */ +/datum/hv_finisher_stage/kill/proc/real_kill(spray_dir) + var/list/before = real_parts() + for(var/zone in real_zones()) + host.real_blow(zone, host.kill_wound_bonus) + if(QDELETED(host.victim)) + return + var/list/after = real_parts() + if(length(after) < length(before)) + show_effect_line() + return + var/text = fallback_message() + if(text) + host.victim.hv_add_hit_line(text, HV_LINE_BOLD, null, effect_range) + +/// The parts a person's killing blow goes for that the victim still has +/datum/hv_finisher_stage/kill/proc/real_parts() + . = list() + for(var/zone in real_zones()) + var/obj/item/bodypart/part = host.victim.get_bodypart(zone) + if(part) + . += part + +/// Whether the victim still has what gore() takes +/datum/hv_finisher_stage/kill/proc/can_gore() + return FALSE + +/// The gore itself +/datum/hv_finisher_stage/kill/proc/gore(spray_dir) + return + +/// When there is nothing to take: a heavy wound instead +/datum/hv_finisher_stage/kill/proc/fallback(spray_dir) + var/text = fallback_message() + if(text) + host.victim.hv_add_hit_line(text, HV_LINE_BOLD, null, effect_range) + host.victim.apply_damage(fallback_damage, BRUTE, BODY_ZONE_CHEST, wound_bonus = 10, sharpness = SHARP_EDGED, attack_direction = spray_dir) + host.victim.hv_blood_spray(spray_dir, 3) + +/// The line for fallback() +/datum/hv_finisher_stage/kill/proc/fallback_message() + return "[host.user] tears into [host.victim]!" + +/// Tears off the limb at the finisher's target zone, flinging it +/datum/hv_finisher_stage/kill/rip_limb + +/datum/hv_finisher_stage/kill/rip_limb/can_gore() + if(!host.target_zone || host.target_zone == BODY_ZONE_HEAD || host.target_zone == BODY_ZONE_CHEST) + return FALSE + var/obj/item/bodypart/part = host.victim.get_bodypart(host.target_zone) + return part?.can_dismember() + +/datum/hv_finisher_stage/kill/rip_limb/gore(spray_dir) + var/obj/item/bodypart/part = host.victim.get_bodypart(host.target_zone) + host.victim.hv_blood_spray(spray_dir, spray_strength) + if(part) + // Torn off toward whoever tore it: it comes away with the pull and falls between the two of them, + // not off in whatever direction tg picks. It starts where it is drawn, on a body held up off the floor. + part.hv_vis_fling_dir = REVERSE_DIR(spray_dir) + var/list/part_at = host.victim.hv_vis_part_offset(host.target_zone) + part_at[2] += hv_vis_lift + part.hv_vis_fling_from = part_at + part.dismember(BRUTE, TRUE, WOUND_SLASH) + +/// Takes the head off and leaves it at the victim's feet, brain and all +/datum/hv_finisher_stage/kill/decapitate + +/datum/hv_finisher_stage/kill/decapitate/can_gore() + var/obj/item/bodypart/head/head = host.victim.get_bodypart(BODY_ZONE_HEAD) + return head?.can_dismember() + +/datum/hv_finisher_stage/kill/decapitate/gore(spray_dir) + host.victim.hv_blood_spray(spray_dir, spray_strength) + // Where the head is drawn, on a body held up off the floor, read before it comes off. + var/list/head_at = host.victim.hv_vis_head_offset() + head_at[2] += hv_vis_lift + var/lying_turn = host.victim.hv_vis_lying_turn() + var/obj/item/bodypart/head/head = hv_finisher_take_head(host.victim, WOUND_SLASH) + // It falls from the neck toward whoever took it, and goes no further: a head carries the brain. + if(head) + head.hv_vis_fling_turn = lying_turn + head.hv_fling(REVERSE_DIR(spray_dir), 0, null, head_at) + +/// Bursts the head through the shared hook, which keeps the brain intact +/datum/hv_finisher_stage/kill/burst_head + // hv_burst_head() adds its own big line; this stage's line is the blow that does it + effect_severity = HV_LINE_BOLD + +/datum/hv_finisher_stage/kill/burst_head/can_gore() + var/obj/item/bodypart/head/head = host.victim.get_bodypart(BODY_ZONE_HEAD) + return head?.can_dismember() + +/datum/hv_finisher_stage/kill/burst_head/gore(spray_dir) + if(!host.victim.hv_burst_head(spray_dir)) + fallback(spray_dir) + +/datum/hv_finisher_stage/kill/burst_head/real_zones() + return list(BODY_ZONE_HEAD) + +// The blow is the line here, whatever it does: a head that bursts says so itself (brain_burst.dm). +/datum/hv_finisher_stage/kill/burst_head/real_kill(spray_dir) + show_effect_line() + host.real_blow(BODY_ZONE_HEAD, host.kill_wound_bonus) + +/// Cuts through at the hips: both legs come off +/datum/hv_finisher_stage/kill/bisect + +/datum/hv_finisher_stage/kill/bisect/can_gore() + for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + var/obj/item/bodypart/leg = host.victim.get_bodypart(zone) + if(leg?.can_dismember()) + return TRUE + return FALSE + +/datum/hv_finisher_stage/kill/bisect/real_zones() + return list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + +/datum/hv_finisher_stage/kill/bisect/gore(spray_dir) + var/turf/spot = get_turf(host.victim) + host.victim.hv_blood_spray(spray_dir, spray_strength) + host.victim.hv_blood_spray(REVERSE_DIR(spray_dir), 3) + for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + var/obj/item/bodypart/leg = host.victim.get_bodypart(zone) + if(leg?.can_dismember()) + leg.dismember(BRUTE, TRUE, WOUND_SLASH) + if(spot && !QDELETED(host.victim) && host.victim.can_bleed(BLOOD_COVER_TURFS) == BLEED_SPLATTER) + new /obj/effect/decal/cleanable/blood/gibs(spot, host.victim.get_static_viruses(), host.victim.get_blood_dna_list()) + +// ===== Recover ===== + +/// The attacker straightens up after the kill and lets go +/datum/hv_finisher_stage/recover + pins = FALSE + duration = 1 SECONDS + +/** + * Takes `victim`'s head off without flinging it. dismember() throws the part a few tiles, and a + * head carries the brain: in zero gravity it would drift away for good. So this does what + * dismember() does for a head and drops it where the body lies. Returns the head, or null. + */ +/proc/hv_finisher_take_head(mob/living/carbon/victim, wounding_type = WOUND_SLASH) + var/obj/item/bodypart/head/head = victim?.get_bodypart(BODY_ZONE_HEAD) + if(!head?.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return null + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + chest?.receive_damage(15, wound_bonus = CANT_WOUND) + playsound(get_turf(victim), 'sound/effects/dismember.ogg', 80, TRUE) + LAZYSET(victim.body_zone_dismembered_by, BODY_ZONE_HEAD, wounding_type) + var/bleeds = head.can_bleed() + if(bleeds) + victim.bleed(rand(20, 40)) + var/turf/spot = get_turf(victim) + head.drop_limb(FALSE, TRUE) + victim.update_equipment_speed_mods() + if(bleeds && isturf(spot)) + victim.add_splatter_floor(spot) + return head diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_users.dm b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm new file mode 100644 index 00000000000..e87c1ca89c2 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm @@ -0,0 +1,264 @@ +/** + * # Finishers: the bosses that have one + * + * - The hoarfrost matriarch (Big Game Hunt, frozen worlds) pins, bites and tears off an arm. + * - The Black Knight (medieval pirate boss) pins with a boot and cleaves through the hips. + * - The alien empress of a derelict xeno nest pins and bites the head off. + * - The Robust One (grey tide pirate boss) kneels on the chest and beats the head in. + * + * Each comes to about six seconds from the wind-up to the kill, with a minute between tries. + * + * Each boss gets its finisher from a second Initialize() on its type, here, so the files that own + * those bosses stay untouched. DM chains duplicate overrides: both bodies run, and both call ..(). + * The NPC ship bosses are human NPCs now (voidcrew/modules/npc_ships/code/faction_pirates/ + * faction_pirate_humans.dm, which adds the component to the human Black Knight and Robust One): theirs + * is a real grab and real blows (finisher.dm). The basic-mob bosses below are the ruin and planet ones. + */ + +// ===== The hoarfrost matriarch ===== + +/mob/living/basic/hoarfrost_matriarch/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/hoarfrost) + +/datum/hv_finisher/hoarfrost + name = "hoarfrost maul" + // She has 800 health; the crew need a real volley to make her let go + interrupt_damage = 60 + zone_preference = list(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM, BODY_ZONE_R_LEG, BODY_ZONE_L_LEG) + stages = list( + /datum/hv_finisher_stage/windup/hoarfrost, + /datum/hv_finisher_stage/hold/hoarfrost, + /datum/hv_finisher_stage/maul/hoarfrost, + /datum/hv_finisher_stage/kill/rip_limb/hoarfrost, + /datum/hv_finisher_stage/recover, + ) + +/// Either arm, else either leg +/datum/hv_finisher/hoarfrost/pick_zone() + var/list/arms = shuffle(list(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM)) + var/list/legs = shuffle(list(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG)) + zone_preference = arms + legs + return ..() + +/datum/hv_finisher_stage/windup/hoarfrost + // A dog's growl, slowed: the hallucination growl sounded like a zombie. Never pitched up, so it stays a big animal. + enter_sound = 'sound/mobs/non-humanoids/dog/growl2.ogg' + enter_frequency = 0.8 + +/datum/hv_finisher_stage/windup/hoarfrost/enter_message() + return "[host.user] lowers [host.user.p_their()] head over [host.victim] and growls." + +/datum/hv_finisher_stage/hold/hoarfrost + enter_sound = 'sound/effects/gravhit.ogg' + +/datum/hv_finisher_stage/hold/hoarfrost/enter_message() + return "[host.user] slams a paw down on [host.victim] and pins [host.victim.p_them()] to the ground!" + +/datum/hv_finisher_stage/hold/hoarfrost/enter_self_message() + return "A huge paw slams you into the ground!" + +/datum/hv_finisher_stage/maul/hoarfrost + duration = 2.5 SECONDS + lunge = 10 + effect_sound = 'sound/items/weapons/bite.ogg' + +/datum/hv_finisher_stage/maul/hoarfrost/effect_message() + return "[host.user] sinks [host.user.p_their()] teeth into [host.victim]'s [target_limb_name()]!" + +/datum/hv_finisher_stage/maul/hoarfrost/effect_self_message() + return "[host.user] sinks [host.user.p_their()] teeth into your [target_limb_name()]!" + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost + lunge = -2 + rise = 4 + effect_sound = 'sound/effects/wounds/crackandbleed.ogg' + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost/effect_message() + return "[host.user] wrenches [host.user.p_their()] head back and tears [host.victim]'s [target_limb_name()] off!" + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost/effect_self_message() + return "[host.user] tears your [target_limb_name()] off!" + +// ===== The Black Knight ===== + +/mob/living/basic/trooper/pirate/faction/boss/medieval/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/black_knight) + +/datum/hv_finisher/black_knight + name = "black knight cleave" + stages = list( + /datum/hv_finisher_stage/windup/black_knight, + /datum/hv_finisher_stage/hold/black_knight, + /datum/hv_finisher_stage/hold/black_knight_raise, + /datum/hv_finisher_stage/kill/bisect/black_knight, + /datum/hv_finisher_stage/recover, + ) + +/datum/hv_finisher_stage/windup/black_knight + rise = 4 + enter_sound = 'sound/items/unsheath.ogg' + say_line = "None shall pass." + +/datum/hv_finisher_stage/windup/black_knight/enter_message() + return "[host.user] raises [host.user.p_their()] claymore high over [host.victim]." + +/datum/hv_finisher_stage/hold/black_knight + enter_sound = 'sound/effects/hit_kick.ogg' + +/datum/hv_finisher_stage/hold/black_knight/enter_message() + return "[host.user] plants an armoured boot on [host.victim]'s chest." + +/datum/hv_finisher_stage/hold/black_knight/enter_self_message() + return "An armoured boot pins you to the floor!" + +/datum/hv_finisher_stage/hold/black_knight_raise + duration = 2 SECONDS + lunge = 4 + rise = 8 + +/datum/hv_finisher_stage/hold/black_knight_raise/enter_message() + return "[host.user] turns the blade point-down." + +/datum/hv_finisher_stage/kill/bisect/black_knight + effect_delay = 0.4 SECONDS + lunge = 8 + rise = -2 + effect_sound = 'sound/items/weapons/bladeslice.ogg' + +// Over a victim on the row behind him (facing north) he stands flat for the kill. Raised, his helmet reached into +// that row and the shoes, gibs and cut body that land there drew over it until he straightened up. +/datum/hv_finisher_stage/kill/bisect/black_knight/enter() + if(host.victim?.y > host.user?.y) + lunge = 0 + rise = 0 + return ..() + +/datum/hv_finisher_stage/kill/bisect/black_knight/effect_message() + return "[host.user] brings the claymore down and cleaves [host.victim] apart at the hips!" + +/datum/hv_finisher_stage/kill/bisect/black_knight/effect_self_message() + return "The claymore comes down across your hips!" + +/datum/hv_finisher_stage/kill/bisect/black_knight/fallback_message() + return "[host.user] drives the claymore down into [host.victim]!" + +// ===== The alien empress of a derelict nest ===== + +/mob/living/basic/alien/queen/large/derelict/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/alien_empress) + +/datum/hv_finisher/alien_empress + name = "empress bite" + stages = list( + /datum/hv_finisher_stage/windup/alien_empress, + /datum/hv_finisher_stage/hold/alien_empress, + /datum/hv_finisher_stage/hold/alien_empress_jaws, + /datum/hv_finisher_stage/kill/decapitate/alien_empress, + /datum/hv_finisher_stage/recover, + ) + +/datum/hv_finisher_stage/windup/alien_empress + rise = 8 + enter_sound = 'sound/mobs/non-humanoids/hiss/hiss5.ogg' + +/datum/hv_finisher_stage/windup/alien_empress/enter_message() + return "[host.user] rears up over [host.victim] with a long hiss." + +/datum/hv_finisher_stage/hold/alien_empress + enter_sound = 'sound/items/weapons/slash.ogg' + +/datum/hv_finisher_stage/hold/alien_empress/enter_message() + return "[host.user] pins [host.victim] down with [host.user.p_their()] claws." + +/datum/hv_finisher_stage/hold/alien_empress/enter_self_message() + return "Claws pin you to the floor!" + +/datum/hv_finisher_stage/hold/alien_empress_jaws + duration = 2 SECONDS + lunge = 10 + // Dips her head down to the victim's + rise = -4 + enter_sound = 'sound/mobs/non-humanoids/hiss/lowHiss1.ogg' + +/datum/hv_finisher_stage/hold/alien_empress_jaws/enter_message() + return "[host.user]'s jaws open over [host.victim]'s head." + +/datum/hv_finisher_stage/hold/alien_empress_jaws/enter_self_message() + return "[host.user]'s jaws open over your face." + +/datum/hv_finisher_stage/kill/decapitate/alien_empress + effect_delay = 0.4 SECONDS + rise = 4 + effect_sound = 'sound/mobs/non-humanoids/alien/alien_eat.ogg' + +/datum/hv_finisher_stage/kill/decapitate/alien_empress/effect_message() + return "[host.user] bites through [host.victim]'s neck and tears [host.victim.p_their()] head off!" + +/datum/hv_finisher_stage/kill/decapitate/alien_empress/fallback_message() + return "[host.user] bites deep into [host.victim]!" + +// ===== The Robust One ===== + +/mob/living/basic/trooper/pirate/faction/boss/grey/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/robust_one) + +/datum/hv_finisher/robust_one + name = "toolbox curb stomp" + stages = list( + /datum/hv_finisher_stage/windup/robust_one, + /datum/hv_finisher_stage/hold/robust_one, + /datum/hv_finisher_stage/maul/robust_one, + /datum/hv_finisher_stage/maul/robust_one/again, + /datum/hv_finisher_stage/kill/burst_head/robust_one, + /datum/hv_finisher_stage/recover, + ) + +/datum/hv_finisher_stage/windup/robust_one + rise = 4 + +/datum/hv_finisher_stage/windup/robust_one/enter_message() + return "[host.user] raises [host.user.p_their()] toolbox over [host.victim]." + +/datum/hv_finisher_stage/hold/robust_one + duration = 1 SECONDS + enter_sound = 'sound/effects/hit_kick.ogg' + +/datum/hv_finisher_stage/hold/robust_one/enter_message() + return "[host.user] kneels on [host.victim]'s chest." + +/datum/hv_finisher_stage/hold/robust_one/enter_self_message() + return "A knee crushes the air out of you!" + +/datum/hv_finisher_stage/maul/robust_one + duration = 1.5 SECONDS + effect_delay = 0.3 SECONDS + effect_sound = 'sound/items/weapons/smash.ogg' + +/datum/hv_finisher_stage/maul/robust_one/effect_message() + return "[host.user] brings the toolbox down on [host.victim]'s head!" + +/datum/hv_finisher_stage/maul/robust_one/effect_self_message() + return "[host.user] brings the toolbox down on your head!" + +/datum/hv_finisher_stage/maul/robust_one/again + effect_sound = 'sound/effects/wounds/crack1.ogg' + +/datum/hv_finisher_stage/maul/robust_one/again/effect_message() + return "[host.user] brings it down again!" + +/datum/hv_finisher_stage/maul/robust_one/again/effect_self_message() + return null + +/datum/hv_finisher_stage/kill/burst_head/robust_one + effect_delay = 0.3 SECONDS + effect_sound = 'sound/items/weapons/smash.ogg' + +/datum/hv_finisher_stage/kill/burst_head/robust_one/effect_message() + return "[host.user] brings the toolbox down one last time." + +/datum/hv_finisher_stage/kill/burst_head/robust_one/fallback_message() + return "[host.user] caves in [host.victim]'s skull!" diff --git a/voidcrew/modules/hyperviolence/hitlines/hit_lines.dm b/voidcrew/modules/hyperviolence/hitlines/hit_lines.dm new file mode 100644 index 00000000000..ff24b8576ab --- /dev/null +++ b/voidcrew/modules/hyperviolence/hitlines/hit_lines.dm @@ -0,0 +1,240 @@ +// Hyperviolence hit lines: one hit, one chat line. +// +// tg prints the attack ("X slashes Y in the arm!") and then every wound or dismember message as +// its own line. This module holds the attack line for a moment, collects the bold lines queued by +// hv_add_hit_line() while the hit resolves, and sends them as one message: +// "X slashes Y in the neck! **An artery is torn!** ***The arm is severed!***" +// A line queued with no attack line waiting (a wound from an explosion, a fall) goes out as its +// own message on the next tick, merged with anything else queued on the same mob that tick. +// +// The idea comes from Septic Shock's COMSIG_CARBON_ADD_TO_WOUND_MESSAGE (wound text appended to the +// attack message). This is a fresh implementation, not a port. +// +// The held attack line is sent by the outermost wrapper of each attack path (attacked_by, +// bullet_act, hitby, attack_animal and the unarmed harm), so there is no hook to add in the +// upstream procs besides swapping their output for hv_hold_attack_message(). + +/// Longest queue of lines on one hit (twice that for the worst lines). Anything more is dropped so one blast cannot flood chat. +#define HV_MAX_HIT_LINES 6 + +// Indexes into one queued line, see hv_add_hit_line() +#define HV_LINE_TEXT 1 +#define HV_LINE_SELF 2 +#define HV_LINE_SEVERITY 3 +#define HV_LINE_RANGE 4 + +/// The hit being resolved on one mob: the attack line held back and the lines queued behind it. +/datum/hv_hit + /// The attack line as onlookers read it. Null when no attack line is held (lines only). + var/others_text + /// The attack line as the victim reads it. + var/victim_text + /// The attack line as the attacker reads it. Null when the attacker is not told separately. + var/attacker_text + /// What onlookers who cannot see the hit are told. + var/blind_text + /// Who dealt the hit. Cleared when the hit is sent. + var/mob/attacker + /// How far the attack line carries. + var/vision_range = COMBAT_MESSAGE_RANGE + /// Queued lines, each list(text, self_text, severity, range) + var/list/lines = list() + + // What compose() works out + /// How far the first message carries + var/near_range + /// What onlookers within near_range read + var/others_message + /// What the victim reads + var/victim_message + /// What the attacker reads, when told separately + var/attacker_message + /// Lines that carry beyond near_range, for the viewers further out. Null if none do. + var/far_message + /// How far far_message carries + var/far_range + +/// Works out what each reader gets: the held attack line, if any, with the queued lines after it. +/// With no attack line held, the lines are the message and the nearest one sets how far it carries. +/// Returns FALSE when there is nothing to say. +/datum/hv_hit/proc/compose() + near_range = vision_range + if(!others_text) + near_range = INFINITY + for(var/list/line as anything in lines) + near_range = min(near_range, line[HV_LINE_RANGE]) + if(near_range == INFINITY) + return FALSE + + var/others_lines = "" + var/victim_lines = "" + var/far_lines = "" + far_range = near_range + for(var/list/line as anything in lines) + var/severity = line[HV_LINE_SEVERITY] + var/others_line = " [hv_format_hit_line(line[HV_LINE_TEXT], severity)]" + others_lines += others_line + victim_lines += " [hv_format_hit_line(line[HV_LINE_SELF], severity)]" + if(line[HV_LINE_RANGE] > near_range) + far_lines += others_line + far_range = max(far_range, line[HV_LINE_RANGE]) + + if(others_text) + others_message = span_danger("[others_text][others_lines]") + victim_message = span_userdanger("[victim_text][victim_lines]") + if(attacker_text) + attacker_message = span_danger("[attacker_text][others_lines]") + else + others_message = span_danger(copytext(others_lines, 2)) + victim_message = span_userdanger(copytext(victim_lines, 2)) + if(far_lines) + far_message = span_danger(copytext(far_lines, 2)) + return TRUE + +/mob/living + /// The hit being resolved on this mob (voidcrew/modules/hyperviolence/hitlines). Null between hits. + var/datum/hv_hit/hv_hit + +/// Gets the hit being resolved on this mob, starting one if none is. A hit nobody sends is sent on the next tick. +/mob/living/proc/hv_get_hit() + if(!hv_hit) + hv_hit = new + addtimer(CALLBACK(src, PROC_REF(hv_send_stale_hit), hv_hit), 0) + return hv_hit + +/// Sends a hit that nothing sent in the tick it began. A hit sent already (or replaced) is left alone. +/mob/living/proc/hv_send_stale_hit(datum/hv_hit/stale) + if(hv_hit == stale) + hv_flush_hit() + +/** + * Adds a line to the combat message of the hit being resolved on src. + * + * Arguments: + * * text - what bystanders and the attacker read, "The bone in Y's arm snaps!" + * * severity - HV_LINE_BOLD, or HV_LINE_HUGE for a severed limb or a burst head + * * self_text - what src reads in place of text, "Your arm snaps!". Null reads text + * * range - how far the line carries. A line that carries further than the attack line is also + * sent on its own to the extra viewers, so tg's message ranges hold + */ +/mob/living/proc/hv_add_hit_line(text, severity = HV_LINE_BOLD, self_text, range = COMBAT_MESSAGE_RANGE) + if(!text || QDELETED(src)) + return + var/datum/hv_hit/hit = hv_get_hit() + if(length(hit.lines) >= (severity >= HV_LINE_HUGE ? HV_MAX_HIT_LINES * 2 : HV_MAX_HIT_LINES)) + return + hit.lines += list(list(text, self_text || text, severity, range)) + +/** + * Holds an attack line so the wound lines queued behind it join it. The attack path's wrapper sends it + * with hv_flush_hit() once the damage is done. Texts are plain; the danger styling is added when sent. + * + * Arguments: + * * others_text - the line for onlookers + * * victim_text - the line src reads + * * attacker_text - the line the attacker reads. Null if the attacker gets none + * * attacker - who dealt the hit. Skipped when the onlookers are told + * * blind_text - what onlookers who cannot see are told + * * vision_range - how far the line carries + */ +/mob/living/proc/hv_hold_attack_message(others_text, victim_text, attacker_text, mob/attacker, blind_text, vision_range = COMBAT_MESSAGE_RANGE) + if(hv_hit) + // A new hit begins: whatever was still waiting is a different event + hv_flush_hit() + var/datum/hv_hit/hit + if(QDELETED(src)) + // Nothing will send a hit held on a deleted mob, so the line goes out as it is + hit = new + else + hit = hv_get_hit() + hit.others_text = others_text + hit.victim_text = victim_text + hit.attacker_text = attacker_text + hit.blind_text = blind_text + hit.attacker = attacker + hit.vision_range = vision_range + if(QDELETED(src)) + hv_send_hit(hit) + +/// One line as chat markup: bold, or bold italic for the worst of it. It sits inside the danger span of the message. +/proc/hv_format_hit_line(text, severity) + if(severity >= HV_LINE_HUGE) + return "[text]" + return "[text]" + +/// Sends the held attack line and the lines queued behind it, then ends the hit. Safe to call with no hit. +/mob/living/proc/hv_flush_hit() + var/datum/hv_hit/hit = hv_hit + if(!hit) + return + hv_hit = null + hv_send_hit(hit) + +/// Says a hit to everyone who should read it. +/mob/living/proc/hv_send_hit(datum/hv_hit/hit) + var/mob/attacker = hit.attacker + hit.attacker = null + if(QDELETED(attacker)) + attacker = null + if(!hit.compose()) + return + + visible_message( + hit.others_message, + hit.victim_message, + hit.blind_text ? span_hear(hit.blind_text) : null, + hit.near_range, + attacker, + ) + if(attacker && hit.attacker_message) + to_chat(attacker, hit.attacker_message) + + // A line that carries further than the message above also reaches the viewers beyond it, once. + if(hit.far_message) + var/list/already_told = get_hearers_in_view(hit.near_range, src) + already_told += src + if(attacker && hit.attacker_message) + already_told += attacker + visible_message(hit.far_message, vision_distance = hit.far_range, ignored_mobs = already_told) + +/// A mob about to be deleted still says what was done to it. +/mob/living/Destroy() + hv_flush_hit() + return ..() + +/// What follows a blow (a knockdown, a spray) reads after the hit, and so does a death. +/mob/living/attack_effects(damage_done, hit_zone, armor_block, obj/item/attacking_item, mob/living/attacker) + hv_flush_hit() + return ..() + +/mob/living/death(gibbed) + hv_flush_hit() + return ..() + +// Each attack path ends in a wrapper that sends the held line. A voidcrew definition of a proc runs +// outside the upstream one, so these run after the whole hit is resolved. + +/mob/living/attacked_by(obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) + . = ..() + hv_flush_hit() + +/mob/living/bullet_act(obj/projectile/proj, def_zone, piercing_hit = FALSE, blocked = 0) + . = ..() + hv_flush_hit() + +/mob/living/hitby(atom/movable/AM, skipcatch, hitpush = TRUE, blocked = FALSE, datum/thrownthing/throwingdatum) + . = ..() + hv_flush_hit() + +/mob/living/attack_animal(mob/living/simple_animal/user, list/modifiers) + . = ..() + hv_flush_hit() + +/datum/species/harm(mob/living/carbon/human/user, mob/living/carbon/human/target, datum/martial_art/attacker_style) + . = ..() + target?.hv_flush_hit() + +#undef HV_LINE_RANGE +#undef HV_LINE_SEVERITY +#undef HV_LINE_SELF +#undef HV_LINE_TEXT diff --git a/voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi b/voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi new file mode 100644 index 00000000000..00bec0513d0 Binary files /dev/null and b/voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi differ diff --git a/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm b/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm new file mode 100644 index 00000000000..73ab469b7da --- /dev/null +++ b/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm @@ -0,0 +1,157 @@ +// Hyperviolence: NPCs that bleed. +// +// Basic and simple mobs start with blood_volume 0, so a shot or a blow on them leaves nothing on the +// floor and nothing on the attacker. Organic NPCs are given blood on spawn, and an overlay that +// gets bloodier as their health falls. +// +// Blood cannot hurt them: only wounds, dismemberment and a few items on carbon mobs ever call +// bleed(), and handle_blood() (oxygen loss from thin blood) is human-only, so a basic mob's blood +// is only ever read, to colour decals and splatter. +// +// The overlay art, icons/npc_blood.dmi, is adapted from Aurora.3 icons/mob/npc/blood_overlay.dmi +// (Aurorastation/Aurora.3 commit 82a514d97b07, CC BY-SA 3.0): the three states are recoloured white +// so the mob's own blood colour fills them. The overlay idea follows Aurora's simple_animal +// handle_blood(); this code is new. + +/// Overlay stages, by how much of its health the mob has left +#define HV_NPC_BLOOD_NONE 0 +#define HV_NPC_BLOOD_LIGHT 1 +#define HV_NPC_BLOOD_MEDIUM 2 +#define HV_NPC_BLOOD_HEAVY 3 + +/// At or below this share of its health a mob shows light blood +#define HV_NPC_BLOOD_LIGHT_AT 0.75 +/// At or below this share of its health a mob shows medium blood +#define HV_NPC_BLOOD_MEDIUM_AT 0.5 +/// At or below this share of its health a mob shows heavy blood +#define HV_NPC_BLOOD_HEAVY_AT 0.25 + +/// Source of the keep-together flag the inset blood needs +#define HV_NPC_BLOOD_SOURCE "hv_npc_blood" + +/mob/living + /// Which blood overlay stage this NPC shows right now (HV_NPC_BLOOD_*). Only moved by /datum/element/hv_npc_blood. + var/hv_blood_stage = HV_NPC_BLOOD_NONE + +/// Gives an organic basic or simple mob blood and the overlay that shows its wounds. +/mob/living/proc/hv_setup_npc_blood() + if(QDELETED(src) || HAS_TRAIT(src, TRAIT_NOBLOOD)) + return + if(!blood_volume) + if(!hv_npc_default_bleeder()) + return + blood_volume = BLOOD_VOLUME_NORMAL + else if(!hv_npc_shows_blood()) + return + AddElement(/datum/element/hv_npc_blood) + +/// True for an organic body with blood to show: no machine, spirit, plant, slime, rock or undead thing. +/mob/living/proc/hv_npc_shows_blood() + if(!(mob_biotypes & MOB_ORGANIC)) + return FALSE + return !(mob_biotypes & (MOB_ROBOTIC|MOB_UNDEAD|MOB_SPIRIT|MOB_MINERAL|MOB_PLANT|MOB_SLIME)) + +/// True for a mob that should bleed without having been given blood by hand. Bosses and other special +/// things keep what they have, and a mob only counts as a creature if it says what kind it is +/// or is one of the few plain organic animals and people listed here. +/mob/living/proc/hv_npc_default_bleeder() + if(!hv_npc_shows_blood() || (mob_biotypes & MOB_SPECIAL)) + return FALSE + if(mob_biotypes & (MOB_HUMANOID|MOB_BEAST|MOB_BUG|MOB_REPTILE|MOB_AQUATIC|MOB_CRUSTACEAN)) + return TRUE + var/static/list/plain_bleeders = typecacheof(list( + /mob/living/basic/parrot, + /mob/living/basic/regal_rat, + /mob/living/basic/snail, + /mob/living/basic/stoat, + )) + return is_type_in_typecache(src, plain_bleeders) + +/mob/living/basic/Initialize(mapload) + . = ..() + hv_setup_npc_blood() + +/mob/living/simple_animal/Initialize(mapload) + . = ..() + hv_setup_npc_blood() + +/// Overlays a mob with blood as its health falls: light, medium, then heavy. Gone when it is healed. +/datum/element/hv_npc_blood + +/datum/element/hv_npc_blood/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(on_health_update)) + RegisterSignal(target, COMSIG_ATOM_UPDATE_OVERLAYS, PROC_REF(on_update_overlays)) + on_health_update(target) + +/datum/element/hv_npc_blood/Detach(mob/living/host) + UnregisterSignal(host, list(COMSIG_LIVING_HEALTH_UPDATE, COMSIG_ATOM_UPDATE_OVERLAYS)) + if(host.hv_blood_stage != HV_NPC_BLOOD_NONE) + host.hv_blood_stage = HV_NPC_BLOOD_NONE + REMOVE_KEEP_TOGETHER(host, HV_NPC_BLOOD_SOURCE) + if(!QDELETED(host)) + host.update_appearance(UPDATE_OVERLAYS) + return ..() + +/// How bloody a mob with this much health left should look +/datum/element/hv_npc_blood/proc/stage_for(mob/living/source) + if(source.maxHealth <= 0) + return HV_NPC_BLOOD_NONE + var/health_share = source.health / source.maxHealth + if(health_share <= HV_NPC_BLOOD_HEAVY_AT) + return HV_NPC_BLOOD_HEAVY + if(health_share <= HV_NPC_BLOOD_MEDIUM_AT) + return HV_NPC_BLOOD_MEDIUM + if(health_share <= HV_NPC_BLOOD_LIGHT_AT) + return HV_NPC_BLOOD_LIGHT + return HV_NPC_BLOOD_NONE + +/datum/element/hv_npc_blood/proc/on_health_update(mob/living/host) + SIGNAL_HANDLER + + var/stage = stage_for(host) + if(stage == host.hv_blood_stage) + return + // The blood is cut to the mob's own shape, which only works when the mob is drawn as one group + if(host.hv_blood_stage == HV_NPC_BLOOD_NONE) + ADD_KEEP_TOGETHER(host, HV_NPC_BLOOD_SOURCE) + else if(stage == HV_NPC_BLOOD_NONE) + REMOVE_KEEP_TOGETHER(host, HV_NPC_BLOOD_SOURCE) + host.hv_blood_stage = stage + host.update_appearance(UPDATE_OVERLAYS) + +/datum/element/hv_npc_blood/proc/on_update_overlays(mob/living/source, list/overlays) + SIGNAL_HANDLER + + if(source.hv_blood_stage == HV_NPC_BLOOD_NONE) + return + var/blood_color = source.get_bloodtype()?.get_color() + if(isnull(blood_color)) + return + var/state + switch(source.hv_blood_stage) + if(HV_NPC_BLOOD_LIGHT) + state = "light" + if(HV_NPC_BLOOD_MEDIUM) + state = "medium" + else + state = "heavy" + var/mutable_appearance/blood = mutable_appearance('voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi', state, appearance_flags = RESET_COLOR) + blood.blend_mode = BLEND_INSET_OVERLAY + blood.color = blood_color + // The art is one tile: stretch it over a sprite that is bigger than that + var/width = source.get_cached_width() + var/height = source.get_cached_height() + if(width > ICON_SIZE_X || height > ICON_SIZE_Y) + var/matrix/stretch = matrix() + stretch.Scale(max(width, ICON_SIZE_X) / ICON_SIZE_X, max(height, ICON_SIZE_Y) / ICON_SIZE_Y) + stretch.Translate((max(width, ICON_SIZE_X) - ICON_SIZE_X) / 2, (max(height, ICON_SIZE_Y) - ICON_SIZE_Y) / 2) + blood.transform = stretch + overlays += blood + +#undef HV_NPC_BLOOD_SOURCE +#undef HV_NPC_BLOOD_HEAVY_AT +#undef HV_NPC_BLOOD_MEDIUM_AT +#undef HV_NPC_BLOOD_LIGHT_AT diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm new file mode 100644 index 00000000000..5547afef33b --- /dev/null +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -0,0 +1,224 @@ +// Hyperviolence: gun executions. +// +// After CM-SS13's handle_battlefield_execution() (cmss13-devs/cmss13 @ e9b299d266c2be727403751c3474e085803b5934, +// code/modules/projectiles/gun.dm, AGPL-3.0, by the CM-SS13 team), rewritten for tg's guns. +// +// The trigger: a point-blank click with the head or the mouth selected, on a humanoid who can't fight +// back (in crit, out cold, dead, lying down, cuffed, stunned or in stamcrit, or held in an aggressive +// grab: tg's own states, nothing else), is an execution: a short wind-up that both have to hold still +// for, then the gun's own round fired into the head, its damage multiplied by +// HV_EXECUTION_POINT_BLANK_MULT for the barrel against the skull. Everything after that is tg's: the +// round's damage after the head's armour, wounds, dismemberment, and death if the damage kills. If the +// round dealt HV_EXECUTION_BURST_DAMAGE or more to the head, the head bursts (hv_burst_head, which keeps +// the brain, so revival works as usual); a lighter round leaves the skull wound its real damage earns. +// A laser or other burn round never bursts the head: it burns a hole in and out of the skull, with +// hardly any blood, and one that heavy goes through the brain (tg's brain damage, which kills on the +// brain's next Life tick and can be treated like any other). Nothing else is added to finish the target off. +// +// The mouth click reaches us through tg's handle_suicide(); the head click through +// hv_try_head_execution(), called from a marked line in tg's fire_gun(). Anyone the execution does +// not apply to keeps tg's behaviour: a normal point-blank shot to the head, or tg's slow mouth +// version (which is also how you shoot yourself). Holding someone up (right click) and gun bashes are +// unchanged. Human NPCs execute only if they are bosses (owner rulings D4 and OC-9, hv_can_execute()). +// Every human-looking NPC is a real body, so what the round does shows on that body; a basic mob shows +// nothing more than the shot. + +/obj/item/gun + /// While an execution round is being fired: the damage it has dealt to the victim's head + var/hv_execution_head_damage = 0 + +/obj/item/gun/handle_suicide(mob/living/carbon/human/user, mob/living/carbon/human/target, params, bypass_timer) + if(bypass_timer || !hv_execution_possible(user, target)) + return ..() + hv_start_execution(user, target, params) + +/// Called by tg's fire_gun() for a point-blank shot, after its own checks (safeties, firing pins, the +/// mouth). A head-aimed one at someone who can be executed starts the execution instead of firing. +/// Returns TRUE if it did. +/obj/item/gun/proc/hv_try_head_execution(mob/living/user, atom/target, params) + if(!doafter_self_shoot || !isliving(user) || user.zone_selected != BODY_ZONE_HEAD) + return FALSE + if(!hv_execution_possible(user, target)) + return FALSE + hv_start_execution(user, target, params) + return TRUE + +/// Whether a head- or mouth-aimed point-blank click by `user` on `target` should be an execution. +/obj/item/gun/proc/hv_execution_possible(mob/living/user, mob/living/target) + if(!isliving(user) || !isliving(target) || user == target) + return FALSE + // Only players and boss NPCs execute; anyone else just fires a point-blank shot (owner ruling OC-9). + if(!user.hv_can_execute()) + return FALSE + if(!target.hv_can_be_executed() || !user.Adjacent(target)) + return FALSE + return can_shoot() && hv_execution_round_ok() + +/// Whether the chambered round can kill: a harmful brute or burn round, not a toy or a beanbag. +/obj/item/gun/proc/hv_execution_round_ok() + var/obj/projectile/bullet = chambered?.loaded_projectile + if(!bullet || !chambered.harmful) + return FALSE + if(bullet.damage_type != BRUTE && bullet.damage_type != BURN) + return FALSE + if(bullet.stamina > bullet.damage) // less-lethal: beanbags, rubbershot + return FALSE + return bullet.damage * projectile_damage_multiplier * max(1, chambered.pellets) >= HV_EXECUTION_MIN_DAMAGE + +/// Presses the barrel to the target's head and, if both hold still, fires. +/obj/item/gun/proc/hv_start_execution(mob/living/user, mob/living/target, params) + if(fire_cd || firing_burst) + return + if(HAS_TRAIT(user, TRAIT_PACIFISM)) + to_chat(user, span_warning("[src] is lethally chambered! You don't want to risk harming anyone...")) + return + user.visible_message( + span_danger("[user] presses the barrel of [src] to [target]'s head."), + span_danger("You press the barrel of [src] to [target]'s head."), + ignored_mobs = target, + ) + to_chat(target, span_userdanger("[user] presses the barrel of [src] to your head!")) + // Like tg's version: no other shot from this gun while the barrel is held there. + fire_cd = TRUE + // Everyone in view sees the bar over the shooter (finisher_bar.dm), so do_after shows none of its own. + var/obj/effect/abstract/hv_exec_bar/bar = hv_exec_bar_start(user, HV_EXECUTION_WINDUP) + var/held = do_after(user, HV_EXECUTION_WINDUP, target, progress = FALSE, extra_checks = CALLBACK(src, PROC_REF(hv_execution_holds), user, target), interaction_key = DOAFTER_SOURCE_HV_EXECUTION) + fire_cd = FALSE + if(!QDELETED(bar)) + bar.finish(held && !QDELETED(src) && !QDELETED(user) && !QDELETED(target)) + if(!held || QDELETED(src) || QDELETED(user) || QDELETED(target)) + return + hv_fire_execution(user, target, params) + +/// do_after check: the shooter still holds the gun to the head of someone who still can't fight back. +/obj/item/gun/proc/hv_execution_holds(mob/living/user, mob/living/target) + if(!user.is_holding(src) || !target.hv_can_be_executed()) + return FALSE + return user.zone_selected == BODY_ZONE_HEAD || user.zone_selected == BODY_ZONE_PRECISE_MOUTH + +/** + * Fires the execution round: the gun's own shot at the head through its real fire path, with the + * point-blank multiplier on its damage. A point-blank round lands inside process_fire(), so what it + * did to the head is known when that returns. Returns TRUE if the gun went off. + */ +/obj/item/gun/proc/hv_fire_execution(mob/living/user, mob/living/target, params) + if(!user.is_holding(src) || !user.Adjacent(target) || !can_trigger_gun(user)) + return FALSE + if(!can_shoot()) + shoot_with_empty_chamber(user) + return FALSE + if(!hv_execution_round_ok()) // the round changed under the wind-up: just a point-blank shot + return process_fire(target, user, TRUE, params, BODY_ZONE_HEAD) + var/shot_dir = get_dir(user, target) + var/was_dead = target.stat == DEAD + var/burns = chambered.loaded_projectile.damage_type == BURN + hv_execution_head_damage = 0 + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(hv_execution_hit)) + var/normal_multiplier = projectile_damage_multiplier + projectile_damage_multiplier *= HV_EXECUTION_POINT_BLANK_MULT + // Only the first round of a burst is held to the head; the rest are ordinary shots. + var/fired = process_fire(target, user, TRUE, params, BODY_ZONE_HEAD) + projectile_damage_multiplier = normal_multiplier + if(!QDELETED(target)) + UnregisterSignal(target, COMSIG_MOB_APPLY_DAMAGE) + if(!fired) + return FALSE + log_combat(user, target, "executed", src, "([round(hv_execution_head_damage, 0.1)] damage to the head)") + if(!QDELETED(target)) + target.hv_take_execution(hv_execution_head_damage, shot_dir, was_dead, burns) + hv_execution_witnesses(user, target) + return TRUE + +/// Adds up what this gun's rounds do to the victim's head while an execution round is fired. +/obj/item/gun/proc/hv_execution_hit(mob/living/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/attacking_item) + SIGNAL_HANDLER + var/obj/projectile/round = attacking_item + if(!istype(round) || round.fired_from != src || (damagetype != BRUTE && damagetype != BURN)) + return + // A body without limbs (a basic mob) takes the whole round in the head it was pressed to. + if(!iscarbon(source)) + hv_execution_head_damage += damage + return + var/zone + if(isbodypart(def_zone)) + var/obj/item/bodypart/limb = def_zone + zone = limb.body_zone + else + zone = check_zone(def_zone) + if(zone == BODY_ZONE_HEAD) + hv_execution_head_damage += damage + +/// Everyone who saw it close up feels it, bar the shooter, the victim and those who enjoy that sort of thing. +/proc/hv_execution_witnesses(mob/living/shooter, mob/living/victim) + for(var/mob/living/carbon/witness in viewers(7, victim)) + if(witness == shooter || witness == victim || witness.stat != CONSCIOUS || witness.is_blind()) + continue + if(HAS_MIND_TRAIT(witness, TRAIT_MORBID) || HAS_MIND_TRAIT(witness, TRAIT_EVIL)) + continue + witness.add_mood_event("hv_saw_execution", /datum/mood_event/hv_saw_execution) + +/datum/mood_event/hv_saw_execution + description = "I watched someone get executed." + mood_change = -4 + timeout = 5 MINUTES + +// ---- Who can be executed ---- + +/// Whether this mob is humanoid and can't fight back, so a gun to the mouth is an execution. +/mob/living/proc/hv_can_be_executed() + if(!ishuman(src) && !(mob_biotypes & MOB_HUMANOID)) + return FALSE + return hv_is_helpless() + +/// Whether this mob is in crit, out cold, dead, lying down, cuffed, stunned or in stamcrit, or held +/// in an aggressive grab. tg's own states only. +/mob/living/proc/hv_is_helpless() + if(stat >= SOFT_CRIT || body_position == LYING_DOWN) + return TRUE + if(HAS_TRAIT(src, TRAIT_RESTRAINED) || HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_FLOORED)) + return TRUE + return pulledby?.grab_state >= GRAB_AGGRESSIVE + +// ---- What the round did ---- + +/** + * Shows what an execution round did. `head_damage` is what it dealt to the head; `was_dead` whether + * the target was already dead before it; `burns` whether it was a burn round (a laser). A brute round + * adds no damage and kills nothing: the round did that or not. A burn round's is hv_burn_through_head(). + * Only a body with a head shows it (below); anything else just took the shot. + */ +/mob/living/proc/hv_take_execution(head_damage, shot_dir, was_dead, burns = FALSE) + return + +/mob/living/carbon/hv_take_execution(head_damage, shot_dir, was_dead, burns = FALSE) + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(isnull(head) || head_damage <= 0) + return // the round took the head off itself, or never reached it + if(burns) + hv_burn_through_head(head, head_damage, shot_dir) + return + if(head_damage >= HV_EXECUTION_BURST_DAMAGE && hv_burst_head(shot_dir)) + return + // The skull wound the round's real damage earns (gore_overlays.dm); the line only for the worst. + if(head.hv_open_skull() && head.hv_skull_wound >= 3) + hv_blood_spray(shot_dir, 3) + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + +/** + * A beam fired with the barrel to the head: a scorched hole in and a hole out, and next to no blood. As heavy a round + * as would burst a head goes through the brain instead: tg brain damage at its death line, so the brain's own Life + * tick kills (brain_item.dm) and a brain repair can bring the body back, as with a burst head. + */ +/mob/living/carbon/proc/hv_burn_through_head(obj/item/bodypart/head/head, head_damage, shot_dir) + if(head.hv_shows_gore() && !head.hv_burn_hole) + head.hv_burn_hole = TRUE + update_damage_overlays() + if(head_damage < HV_EXECUTION_BURST_DAMAGE) + return + adjustOrganLoss(ORGAN_SLOT_BRAIN, BRAIN_DAMAGE_DEATH) + hv_add_hit_line("The beam burns clean through [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + +/// Whether this mob turns a point-blank head shot on someone helpless into an execution. Players can; +/// human NPCs only at boss rank (human_npcs/human_npc.dm). +/mob/living/proc/hv_can_execute() + return hv_can_land_aimed_crits() diff --git a/voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm b/voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm new file mode 100644 index 00000000000..b622dff72e8 --- /dev/null +++ b/voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm @@ -0,0 +1,224 @@ +// Hyperviolence: stump and skull-wound overlays on human bodies. +// +// Art: icons/gore_overlays.dmi holds the stump and headshot states of CM-SS13's +// icons/mob/humans/dam_human.dmi (cmss13-devs/cmss13 @ e9b299d266c2be727403751c3474e085803b5934), +// made by the CM-SS13 team and licensed CC BY-SA 3.0. Each state comes as a bone layer and a +// greyscale blood layer that is tinted to the mob's blood colour, after CM's +// update_headshot_overlay() (AGPL-3.0, rewritten here for tg's body code). CM's body sprites share +// tg's base, so the art lines up with tg humans without pixel changes. +// +// How it hooks in: tg redraws a carbon's damage overlays (update_damage_overlays) whenever a limb +// comes off or goes back on and whenever a limb's damage state changes, so that is where these are +// rebuilt. They are drawn outside overlays_standing, because a new overlay index would renumber +// every tg layer, on fractional layers between the existing ones (see voidcrew/_DEFINES/hyperviolence.dm). +// - A missing head, arm or leg shows a stump. A limb torn off in the last few minutes leaves a wet +// stump that bleeds through clothing; after that, or after a clean surgical removal, it reads as +// healed over and clothing covers it. The neck stump always shows above the collar. +// - A heavy brute hit to the head opens a skull wound: light, medium or heavy by the head's brute +// damage. It shrinks as the head heals and closes when the damage is mostly gone. + +/mob/living/carbon + /// Hyperviolence: the stump and skull-wound images currently drawn on us. + var/list/hv_gore_overlays + /// Hyperviolence: what hv_gore_overlays was built from, so an unchanged update skips the rebuild. + var/hv_gore_key + /// Hyperviolence: body zone -> world.time that limb was torn off, for wet stumps. Lazy. + var/list/hv_stump_torn_at + +/mob/living/carbon/update_damage_overlays() + . = ..() + hv_update_gore_overlays() + +/mob/living/carbon/regenerate_icons() + // regenerate_icons ends up in update_damage_overlays; forget the key so the gore is redrawn too. + hv_gore_key = null + return ..() + +/// Whether this bodypart is meat on a human-shaped body, the only kind the gore art fits. +/obj/item/bodypart/proc/hv_shows_gore() + if(!(biological_state & BIO_FLESH) || (bodytype & BODYTYPE_ALIEN)) + return FALSE + return (bodyshape & BODYSHAPE_HUMANOID) && !(bodyshape & BODYSHAPE_MONKEY) + +/// Whether a stump torn off at `zone` is still wet. +/mob/living/carbon/proc/hv_stump_is_fresh(zone) + var/torn_at = LAZYACCESS(hv_stump_torn_at, zone) + return !isnull(torn_at) && world.time < torn_at + HV_STUMP_FRESH_TIME + +/// Notes that the limb at `zone` was just torn off, so its stump starts out wet. +/mob/living/carbon/proc/hv_note_torn_off(zone) + LAZYSET(hv_stump_torn_at, zone, world.time) + // Redraw once it has dried. Overriding keeps one timer per mob; every earlier stump is older. + addtimer(CALLBACK(src, PROC_REF(hv_update_gore_overlays)), HV_STUMP_FRESH_TIME + 1, TIMER_UNIQUE|TIMER_OVERRIDE) + +/// Rebuilds the stump and skull-wound overlays if anything they show has changed. +/mob/living/carbon/proc/hv_update_gore_overlays() + var/static/list/stump_zones = list(BODY_ZONE_HEAD, BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + var/list/stumps // zone -> TRUE if the stump is wet + var/skull = 0 + var/burn_hole = FALSE + var/obj/item/bodypart/chest = get_bodypart(BODY_ZONE_CHEST) + if(ishuman(src) && chest?.hv_shows_gore()) + for(var/zone in stump_zones) + var/obj/item/bodypart/part = get_bodypart(zone) + if(isnull(part)) + LAZYSET(stumps, zone, hv_stump_is_fresh(zone)) + continue + LAZYREMOVE(hv_stump_torn_at, zone) + if(zone == BODY_ZONE_HEAD && part.hv_shows_gore()) + var/obj/item/bodypart/head/head = part + skull = head.hv_skull_wound + burn_hole = head.hv_burn_hole + + var/new_key + var/blood_color + if(stumps || skull || burn_hole) + if(can_bleed()) + blood_color = get_bloodtype()?.get_damage_color(src) || BLOOD_COLOR_RED + var/height = HUMAN_HEIGHT_MEDIUM + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + height = as_human.mob_height + new_key = "[skull][burn_hole ? "b" : ""]" + for(var/zone in stumps) + new_key += "|[zone][stumps[zone] ? "+" : "-"]" + new_key += "|[islist(blood_color) ? jointext(blood_color, ",") : blood_color]|[height]" + + if(new_key == hv_gore_key && (new_key || !hv_gore_overlays)) + return + hv_gore_key = new_key + if(hv_gore_overlays) + cut_overlay(hv_gore_overlays) + hv_gore_overlays = null + if(isnull(new_key)) + return + hv_gore_overlays = hv_build_gore_images(stumps, skull, blood_color) + // A burn hole shows only while no skull wound is drawn over the same spot. + if(burn_hole && !skull) + var/list/hole = hv_gore_image_pair(HV_BURN_HOLE_STATE, HV_SKULL_WOUND_LAYER, HV_BURN_HOLE_COLOR) + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + if(as_human.mob_height != HUMAN_HEIGHT_MEDIUM) + for(var/image/gore_image as anything in hole) + as_human.apply_height_filters(gore_image) + hv_gore_overlays += hole + add_overlay(hv_gore_overlays) + +/// Builds the overlay images for `stumps` (zone -> wet) and a skull wound of level `skull`. +/mob/living/carbon/proc/hv_build_gore_images(list/stumps, skull, blood_color) + var/list/images = list() + for(var/zone in stumps) + var/wet = stumps[zone] + // A wet stump bleeds through the jumpsuit; a healed one sits under it. The neck always shows. + var/stump_layer = (wet || zone == BODY_ZONE_HEAD) ? HV_STUMP_FRESH_LAYER : HV_STUMP_LAYER + images += hv_gore_image_pair("stump_[zone]", stump_layer, blood_color, wet ? 255 : HV_STUMP_OLD_ALPHA) + if(skull) + images += hv_gore_image_pair(hv_skull_wound_state(skull), HV_SKULL_WOUND_LAYER, blood_color) + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + if(as_human.mob_height != HUMAN_HEIGHT_MEDIUM) + for(var/image/gore_image as anything in images) + as_human.apply_height_filters(gore_image) + return images + +/// The skull-wound icon state prefix for a wound level of 1 to 3. +/proc/hv_skull_wound_state(level) + var/static/list/states = list("light_headshot", "medium_headshot", "heavy_headshot") + return states[clamp(level, 1, length(states))] + +/// The blood and bone images of one gore state. No blood colour (a body that can't bleed) draws bone only. +/proc/hv_gore_image_pair(state, gore_layer, blood_color, blood_alpha = 255, gore_dir) + var/list/pair = list() + if(blood_color) + var/image/blood = image(HV_GORE_ICON, "[state]_blood", -gore_layer) + blood.appearance_flags = RESET_COLOR + blood.color = blood_color + blood.alpha = blood_alpha + pair += blood + pair += image(HV_GORE_ICON, "[state]_bone", -gore_layer) + if(gore_dir) + for(var/image/gore_image as anything in pair) + gore_image.dir = gore_dir + return pair + +/// Icon states currently drawn by the gore overlays, for tests and debugging. +/mob/living/carbon/proc/hv_gore_states() + var/list/states = list() + for(var/image/gore_image as anything in hv_gore_overlays) + states += gore_image.icon_state + return states + +// ---- Torn-off limbs ---- + +/obj/item/bodypart/drop_limb(special, dismembered, move_to_floor = TRUE) + // Before the parent: it redraws the owner, and the stump should already read as wet. + if(dismembered && !special && iscarbon(owner)) + var/mob/living/carbon/torn_from = owner + torn_from.hv_note_torn_off(body_zone) + return ..() + +// ---- Skull wounds ---- + +/obj/item/bodypart/head + /// Hyperviolence: opened by one heavy brute hit; while open the head shows a skull wound. + var/hv_skull_open = FALSE + /// Hyperviolence: the skull wound shown now, 0 (none) to 3 (heavy). + var/hv_skull_wound = 0 + /// Hyperviolence: a laser execution burnt a hole through this skull. Heals shut with the burns. + var/hv_burn_hole = FALSE + +/obj/item/bodypart/head/receive_damage(brute = 0, burn = 0, blocked = 0, updating_health = TRUE, forced = FALSE, required_bodytype = null, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction = null, damage_source, wound_clothing = TRUE) + var/brute_before = brute_dam + . = ..() + if(hv_skull_open || brute_dam - brute_before < HV_SKULL_OPEN_HIT || !hv_shows_gore()) + return + hv_skull_open = TRUE + // TRUE tells the caller to redraw damage overlays, which redraws the skull wound with them. + if(hv_update_skull_wound()) + return TRUE + +/obj/item/bodypart/head/update_bodypart_damage_state() + . = ..() + if(hv_burn_hole && burn_dam < HV_SKULL_LIGHT_BRUTE) + hv_burn_hole = FALSE + . = TRUE + if(hv_update_skull_wound()) + return TRUE + +/// Opens a skull wound if this head can show one, and redraws its owner. +/obj/item/bodypart/head/proc/hv_open_skull() + if(!hv_shows_gore()) + return FALSE + hv_skull_open = TRUE + if(hv_update_skull_wound()) + owner?.update_damage_overlays() + return TRUE + +/// Sets hv_skull_wound from the head's brute damage. Returns TRUE if it changed. +/obj/item/bodypart/head/proc/hv_update_skull_wound() + var/new_level = 0 + if(hv_skull_open) + if(brute_dam >= HV_SKULL_HEAVY_BRUTE) + new_level = 3 + else if(brute_dam >= HV_SKULL_MEDIUM_BRUTE) + new_level = 2 + else if(brute_dam >= HV_SKULL_LIGHT_BRUTE) + new_level = 1 + else + hv_skull_open = FALSE // healed shut + if(new_level == hv_skull_wound) + return FALSE + hv_skull_wound = new_level + return TRUE + +/obj/item/bodypart/head/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + // On a body the mob draws the wound (hv_update_gore_overlays); a severed head carries its own. + if(!dropped || !islist(.)) + return + if(!hv_skull_wound) + if(hv_burn_hole) + . += hv_gore_image_pair(HV_BURN_HOLE_STATE, HV_SKULL_WOUND_LAYER, HV_BURN_HOLE_COLOR, gore_dir = SOUTH) + return + var/blood_color = get_color_from_blood_list(blood_dna_info) + . += hv_gore_image_pair(hv_skull_wound_state(hv_skull_wound), HV_SKULL_WOUND_LAYER, blood_color, gore_dir = SOUTH) diff --git a/voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi b/voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi new file mode 100644 index 00000000000..1cac5520b25 Binary files /dev/null and b/voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi differ diff --git a/voidcrew/modules/hyperviolence/overlays_exec/icons/muzzle_flash.dmi b/voidcrew/modules/hyperviolence/overlays_exec/icons/muzzle_flash.dmi new file mode 100644 index 00000000000..01618b355eb Binary files /dev/null and b/voidcrew/modules/hyperviolence/overlays_exec/icons/muzzle_flash.dmi differ diff --git a/voidcrew/modules/hyperviolence/overlays_exec/icons/sleeve_masks.dmi b/voidcrew/modules/hyperviolence/overlays_exec/icons/sleeve_masks.dmi new file mode 100644 index 00000000000..700803f3c44 Binary files /dev/null and b/voidcrew/modules/hyperviolence/overlays_exec/icons/sleeve_masks.dmi differ diff --git a/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm b/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm new file mode 100644 index 00000000000..6127afdb77c --- /dev/null +++ b/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm @@ -0,0 +1,76 @@ +// Hyperviolence: no empty sleeves on a stump. +// +// tg masks the gloves over a missing hand (update_worn_gloves() in human_update_icons.dm, a render_target +// alpha mask) but draws the jumpsuit and suit over a missing arm or leg, so every stump sat on an empty +// sleeve or trouser leg. This masks both the same way, for any lost arm or leg however it was lost. The +// masks (icons/sleeve_masks.dmi) are tg's human limb sprites less every other body part, a pixel wider, +// so the torso keeps its own cloth. Bodies of another shape get no mask: theirs would not line up. +// +// Sorts after gore_overlays.dm on purpose: it chains hv_update_gore_overlays(), which runs whenever a +// limb comes off or goes back on. + +#define HV_SLEEVE_MASK_ICON 'voidcrew/modules/hyperviolence/overlays_exec/icons/sleeve_masks.dmi' +/// Filter on a masked uniform or suit overlay. +#define HV_SLEEVE_MASK_FILTER "hv_sleeve_mask" + +/mob/living/carbon/human + /// The lost limbs the uniform and suit were last drawn for, so they are redrawn only when that changes. + var/hv_sleeve_key = "" + +/mob/living/carbon/human/update_worn_undersuit(update_obscured = TRUE) + . = ..() + hv_mask_sleeves(UNIFORM_LAYER) + +/mob/living/carbon/human/update_worn_oversuit(update_obscured = TRUE) + . = ..() + hv_mask_sleeves(SUIT_LAYER) + +/mob/living/carbon/human/hv_update_gore_overlays() + . = ..() + var/key = jointext(hv_sleeve_mask_zones(), ",") + if(key == hv_sleeve_key) + return + hv_sleeve_key = key + update_worn_undersuit() + update_worn_oversuit() + +/// The arms and legs this body has lost, if its clothes are drawn on a human shape. Empty otherwise. +/mob/living/carbon/human/proc/hv_sleeve_mask_zones() + . = list() + if(!(bodyshape & BODYSHAPE_HUMANOID) || (bodyshape & (BODYSHAPE_MONKEY | BODYSHAPE_DIGITIGRADE))) + return + for(var/zone in list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + if(!get_bodypart(zone)) + . += zone + +/// Cuts the lost limbs out of the worn overlay at `cache_index` (the uniform or the suit). +/mob/living/carbon/human/proc/hv_mask_sleeves(cache_index) + var/mutable_appearance/worn = overlays_standing[cache_index] + if(!istype(worn)) + return + var/list/missing = hv_sleeve_mask_zones() + if(!length(missing)) + return + // In profile one leg hides the other, so one lost leg masks nothing there (its state is empty side on) and both + // lost take one mask of the pair: each leg's own mask spares the other leg's outline. + if((BODY_ZONE_L_LEG in missing) && (BODY_ZONE_R_LEG in missing)) + missing = missing - list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + "legs" + cut_overlay(worn) + // Built as tg builds the missing-hand glove mask (update_worn_gloves()): an atom's appearance carries the + // render target. A mutable_appearance carrying it was drawn on screen as a white limb. + var/static/atom/movable/mask + if(isnull(mask)) + mask = new(null) + mask.icon = HV_SLEEVE_MASK_ICON + mask.icon_state = "missing_[missing[1]]" + // Set at once, not through the overlay queue: the appearance is copied below. + mask.overlays.Cut() + for(var/i in 2 to length(missing)) + mask.overlays += mutable_appearance(HV_SLEEVE_MASK_ICON, "missing_[missing[i]]") + mask.render_target = "*hv_sleeves [cache_index] [REF(src)]" + worn.overlays += mask + // Before the height filters (priority 1), so the mask cuts the sprite it was drawn for. + worn.add_filter(HV_SLEEVE_MASK_FILTER, 0, alpha_mask_filter(render_source = mask.render_target, flags = MASK_INVERSE)) + add_overlay(worn) + +#undef HV_SLEEVE_MASK_FILTER diff --git a/voidcrew/modules/hyperviolence/visuals/_defines.dm b/voidcrew/modules/hyperviolence/visuals/_defines.dm new file mode 100644 index 00000000000..a2add2fc026 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/_defines.dm @@ -0,0 +1,158 @@ +// Hyperviolence visuals: defines private to this part. Defines shared between the hyperviolence +// parts live in voidcrew/_DEFINES/hyperviolence.dm. Every number here is a first guess, untuned. +// +// This folder is named so it sorts after the other hyperviolence parts in tgstation.dme: its +// overrides of their procs must come after the procs are declared, or DM silently drops them. +// For the same reason this file and _motion_core.dm sort first inside the folder. + +// ---- Named animation sequences ---- +// Every animation this part plays on a mob is a named animate() sequence. A named sequence runs +// alongside every other animation on the atom (tg's lying turn, its offsets, the floating bob), +// adds only its own change on top, and replaces only an earlier sequence of the same name. Each one +// ends exactly where it began, so the server-side transform and pixel offsets never move. + +#define HV_VIS_TAG_FLINCH "hv_flinch" +#define HV_VIS_TAG_HITSTOP "hv_hitstop" +#define HV_VIS_TAG_LURCH "hv_lurch" +#define HV_VIS_TAG_LIMP "hv_limp" +#define HV_VIS_TAG_FALL "hv_fall" +#define HV_VIS_TAG_SLIDE "hv_slide" +#define HV_VIS_TAG_POSE "hv_pose" +#define HV_VIS_TAG_TWITCH "hv_twitch" +#define HV_VIS_TAG_ARC "hv_arc" +#define HV_VIS_TAG_SPIN "hv_spin" +#define HV_VIS_TAG_PAINT "hv_paint" +#define HV_VIS_TAG_DRIFT "hv_drift" +#define HV_VIS_TAG_TREMBLE "hv_tremble" +#define HV_VIS_TAG_LEAN "hv_lean" +#define HV_VIS_TAG_PRESS "hv_press" +#define HV_VIS_TAG_LIFT "hv_lift" +#define HV_VIS_TAG_SHAKE "hv_shake" +#define HV_VIS_TAG_REAR "hv_rear" +#define HV_VIS_TAG_SHUDDER "hv_shudder" + +/// What ends a held animation (/datum/component/hv_motion_hold). +#define HV_VIS_END_MOVE (1<<0) +#define HV_VIS_END_STAND (1<<1) +#define HV_VIS_END_REVIVE (1<<2) +#define HV_VIS_END_DEATH (1<<3) +#define HV_VIS_END_LIE (1<<4) + +/// Where the feet of a standing 32x32 body are, from the middle of its icon. Leans pivot here. +#define HV_VIS_FEET_Y -16 + +/// The x and y of a direction, each -1, 0 or 1. +#define HV_VIS_DX(dir) (((dir) & EAST) ? 1 : (((dir) & WEST) ? -1 : 0)) +#define HV_VIS_DY(dir) (((dir) & NORTH) ? 1 : (((dir) & SOUTH) ? -1 : 0)) + +// ---- Getting hit ---- + +/// Least damage in one tick that makes a body flinch. +#define HV_VIS_FLINCH_MIN 4 +/// Damage per pixel a flinch pushes the body along the blow, and the most it ever pushes. +#define HV_VIS_FLINCH_DAMAGE_PER_PIXEL 6 +#define HV_VIS_FLINCH_MAX_PUSH 8 +/// Damage per degree a flinch tilts a standing body, and the most it ever tilts. +#define HV_VIS_FLINCH_DAMAGE_PER_DEGREE 3 +#define HV_VIS_FLINCH_MAX_TILT 12 +/// Damage in one tick (a shotgun's pellets together) that turns a flinch into a stagger. +#define HV_VIS_STAGGER_DAMAGE 40 +/// Least damage to the head that whips it back. +#define HV_VIS_HEADSHOT_MIN 10 +/// Tilt of a head snap, at least and at most. +#define HV_VIS_HEADSHOT_MIN_TILT 10 +#define HV_VIS_HEADSHOT_MAX_TILT 22 +/// How long a hit-stop holds both bodies still: three frames at 20 frames a second. +#define HV_VIS_HITSTOP_TIME (0.15 SECONDS) +/// The colour filter that flashes a body pale on a big hit. +#define HV_VIS_FLASH_FILTER "hv_vis_flash" +/// How far a lost limb throws the body toward the missing side, in pixels and degrees. +#define HV_VIS_LURCH_PUSH 4 +#define HV_VIS_LURCH_TILT 14 +/// The dip and lean of each step on one leg. +#define HV_VIS_LIMP_DIP 2 +#define HV_VIS_LIMP_TILT 6 + +// ---- Dying ---- + +/// Damage in the killing tick that slides a body a whole tile. +#define HV_VIS_FULL_SLIDE_DAMAGE 40 +/// How long a whole-tile slide takes. +#define HV_VIS_SLIDE_TIME (0.4 SECONDS) +/// How long a head shot that kills whips the head back before the body drops. +#define HV_VIS_KILL_WHIP_TIME (0.2 SECONDS) +/// How high a heavy killing blow lifts the body as it throws it back a tile. +#define HV_VIS_LAUNCH_LIFT 4 +/// Most a body slides within its own tile, in pixels. +#define HV_VIS_MAX_SLIDE 10 +/// How long a corpse keeps a pose (a slide, a curl) before it eases back. Moving, standing up or +/// being revived ends it at once. +#define HV_VIS_POSE_HOLD (20 MINUTES) +/// How long a held pose takes to ease back when something ends it early (the body is dragged off). +#define HV_VIS_POSE_RELEASE (0.3 SECONDS) +/// The beat an executed body holds before it slumps. +#define HV_VIS_SLUMP_BEAT (0.3 SECONDS) +/// How a body died, which decides how it falls (hv_vis_death_kind()). +/// Nothing that just hit it: poison, suffocation, a slow death in crit. +#define HV_VIS_DEATH_QUIET 0 +/// A blow from a direction killed it. +#define HV_VIS_DEATH_HIT 1 +/// A blow to the head killed it, or the head came off. +#define HV_VIS_DEATH_HEADSHOT 2 +/// Fire or a burning blow killed it. +#define HV_VIS_DEATH_BURNED 3 +/// It ran out of blood. +#define HV_VIS_DEATH_BLED_OUT 4 +/// An execution round killed it. +#define HV_VIS_DEATH_EXECUTED 5 + +// ---- Flying gore ---- + +/// Styles of tumble (/datum/component/hv_tumble). +#define HV_VIS_TUMBLE_LIMB 1 +#define HV_VIS_TUMBLE_HEAD 2 +#define HV_VIS_TUMBLE_BRAIN 3 +#define HV_VIS_TUMBLE_ORGAN 4 +#define HV_VIS_TUMBLE_SLIDE 5 +/// A part that comes off but goes nowhere (a head that bursts where it is). +#define HV_VIS_TUMBLE_NONE 6 +/// How long a tumbling part takes to cross a tile, slow enough to see the arc. +#define HV_VIS_TUMBLE_STEP (0.1 SECONDS) +/// How long a part with nowhere to fly takes to drop to the floor. +#define HV_VIS_TUMBLE_DROP (0.2 SECONDS) +/// The layer of a part in the air: over bodies on the floor, under anyone on their feet. +#define HV_VIS_TUMBLE_LAYER (LYING_MOB_LAYER + 0.05) +/// Most blood streaks one tumbling part leaves along its way. +#define HV_VIS_TUMBLE_MAX_STREAKS 4 + +// ---- Blood ---- + +/// How long a spray takes to reach each further tile. +#define HV_VIS_SPRAY_STEP (0.06 SECONDS) +/// How long each tile of spray takes to paint in. +#define HV_VIS_SPRAY_PAINT (0.15 SECONDS) +/// How long a smear on a wall takes to run down. +#define HV_VIS_DRIP_TIME (6 SECONDS) +/// How long a pool takes to spread to its new size, and a lobe to run onto the next tile. +#define HV_VIS_POOL_CREEP (1.8 SECONDS) +#define HV_VIS_LOBE_CREEP (1.5 SECONDS) +/// How far floating blood drifts from the wound, in pixels, at least and at most. +#define HV_VIS_DRIFT_MIN 5 +#define HV_VIS_DRIFT_MAX 10 + +// ---- Executions and finishers ---- + +/// How far the shooter leans in over the victim while the barrel is pressed to the head. +#define HV_VIS_PRESS_LEAN 4 +/// The still moment at the end of the press, after the trembling stops. +#define HV_VIS_PRESS_PAUSE (0.3 SECONDS) +/// How far off the middle of its tile tg's muzzle flash art sits, toward the way it faces, in pixels. +#define HV_VIS_FLASH_CORE 9 +/// How far short of the head the barrel's end, and so the flash, sits. +#define HV_VIS_FLASH_SHORT 6 +/// Screen shake of a finisher's killing blow, for everyone who sees it: length and strength in tiles. +#define HV_VIS_KILL_SHAKE_TIME 4 +#define HV_VIS_KILL_SHAKE_STRENGTH 0.12 +#define HV_VIS_KILL_SHAKE_RANGE 7 +/// How long each jolt of a mauled body's shudder lasts. +#define HV_VIS_SHUDDER_BEAT (0.02 SECONDS) diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm new file mode 100644 index 00000000000..9b45e989263 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -0,0 +1,387 @@ +// Hyperviolence visuals: the animation pass over the gore. Bodies flinch, snap, lurch and limp when +// hit, fall away from the killing blow and twitch after, severed parts and brains tumble through +// the air, blood paints in, drips, spreads and floats, guts slither, and executions and +// boss finishers get their beats. +// +// Rules every animation here keeps: +// - Code animation only, on the sprites the body already has: animate(), transforms, pixel offsets, +// one short colour filter. Nothing runs on a timer loop over mobs; everything is started by an +// event that already happens (a hit, a death, a lost limb, a throw, a move, a stage of a finisher). +// - Each animation is a named sequence (_visuals_defines.dm) that ends exactly where it began. The +// server-side transform and pixel offsets are never left changed, so tg's lying turn, offsets, +// height filters and mob scaling stay right, and anything that later rebuilds the transform from +// the current one (update_transform) builds on the true value. +// - Poses a corpse keeps (a slide, a curl) are held by the animation itself and ended by +// /datum/component/hv_motion_hold when the body moves, stands up or is revived. +// - Purely visual: nothing here stuns, roots or delays anyone. A hit-stop freezes the pictures only. +// - Offsets stay small (under a third of a tile) so nobody is drawn over someone else's tile. +// - Screen shake only for viewers near a boss finisher's kill, small, and never for players who set +// the screen shake preference (/datum/preference/toggle/screen_shake_darken). + +/mob/living + /// The tick of the hit being resolved, noted before its damage lands so a fall or death it + /// causes can go its way. + var/hv_vis_hit_time = -1 + /// The way that hit travelled, from the attacker toward this mob. + var/hv_vis_hit_dir = 0 + /// The body zone it landed on. + var/hv_vis_hit_zone + /// Its damage type. + var/hv_vis_hit_type + /// All the damage this mob took in hits this tick, so a shotgun's pellets flinch as one blow. + var/hv_vis_hit_damage = 0 + /// Weak reference to whoever landed it, for the hit-stop. + var/datum/weakref/hv_vis_hit_by + /// Whether it was a shot, which rocks a shooter back, rather than a blow, which they lean into. + var/hv_vis_hit_ranged = FALSE + /// How many hits are being resolved on this mob right now, and the tick that count is for. + var/hv_vis_resolving = 0 + var/hv_vis_resolving_time = -1 + /// The tick a sever, burst or crit asked for a hit-stop. + var/hv_vis_big_hit_time = -1 + /// The tick the last hit-stop played, so one blow plays it once. + var/hv_vis_hitstop_time = -1 + /// The tick this mob went from standing to lying. + var/hv_vis_fell_time = -1 + /// The tick this mob died. + var/hv_vis_died_time = -1 + /// The tick an execution round was fired into this mob. + var/hv_vis_execution_time = -1 + +/mob/living/Initialize(mapload) + . = ..() + AddElement(/datum/element/hv_motion_hooks) + +/// Starts the animations on a mob from what happens to it. An element, not the mob registering on +/// itself: a mob holds one handler per signal per listener, so self-registrations collide with the +/// mob's own handlers (see /datum/element/hv_blood_hooks). +/datum/element/hv_motion_hooks + +/datum/element/hv_motion_hooks/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_hit)) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(after_hit)) + RegisterSignal(target, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + RegisterSignal(target, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + RegisterSignal(target, COMSIG_MOB_EMOTED("surrender"), PROC_REF(on_surrender)) + if(iscarbon(target)) + RegisterSignal(target, COMSIG_CARBON_REMOVE_LIMB, PROC_REF(on_limb_lost)) + +/datum/element/hv_motion_hooks/Detach(datum/source) + UnregisterSignal(source, list( + COMSIG_MOB_APPLY_DAMAGE, + COMSIG_MOB_AFTER_APPLY_DAMAGE, + COMSIG_LIVING_DEATH, + COMSIG_MOB_STATCHANGE, + COMSIG_MOB_EMOTED("surrender"), + COMSIG_CARBON_REMOVE_LIMB, + )) + return ..() + +/datum/element/hv_motion_hooks/proc/on_hit(mob/living/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + source.hv_vis_note_hit(damage, damagetype, def_zone, attack_direction, attacking_item) + +/datum/element/hv_motion_hooks/proc/after_hit(mob/living/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + source.hv_vis_after_hit(damagetype, attack_direction) + +/datum/element/hv_motion_hooks/proc/on_death(mob/living/source, gibbed) + SIGNAL_HANDLER + source.hv_vis_on_death(gibbed) + +/datum/element/hv_motion_hooks/proc/on_stat_change(mob/living/source, new_stat, old_stat) + SIGNAL_HANDLER + source.hv_vis_on_stat_change(new_stat, old_stat) + +/datum/element/hv_motion_hooks/proc/on_surrender(mob/living/source) + SIGNAL_HANDLER + source.hv_vis_surrender() + +/datum/element/hv_motion_hooks/proc/on_limb_lost(mob/living/carbon/source, obj/item/bodypart/lost_limb, special, dismembered) + SIGNAL_HANDLER + source.hv_vis_on_limb_lost(lost_limb, special, dismembered) + +// ---- What hit us ---- + +/// Notes a hit before its damage lands: which way it went, where, how hard, and who landed it. +/mob/living/proc/hv_vis_note_hit(damage, damagetype, def_zone, attack_direction, atom/attacking_item) + if(hv_vis_resolving_time != world.time) + hv_vis_resolving_time = world.time + hv_vis_resolving = 0 + hv_vis_resolving++ + if(damagetype != BRUTE && damagetype != BURN && damagetype != STAMINA) + return + if(hv_vis_hit_time != world.time) + hv_vis_hit_time = world.time + hv_vis_hit_damage = 0 + hv_vis_hit_dir = 0 + hv_vis_hit_by = null + hv_vis_hit_ranged = FALSE + if(isprojectile(attacking_item)) + hv_vis_hit_ranged = TRUE + // A baton or disabler hit makes a body jerk, but less than a bullet. + hv_vis_hit_damage += damagetype == STAMINA ? damage * 0.5 : damage + if(attack_direction) + hv_vis_hit_dir = attack_direction + hv_vis_hit_type = damagetype + hv_vis_hit_zone = hv_vis_zone_of(def_zone) + var/mob/living/attacker = hv_vis_attacker_of(attacking_item) + if(attacker && attacker != src) + hv_vis_hit_by = WEAKREF(attacker) + +/// After a hit has landed: the flinch, held for a hit-stop if the hit took a limb, burst a head or +/// landed a crit. A hit that killed is shown by the death instead. +/mob/living/proc/hv_vis_after_hit(damagetype, attack_direction) + if(hv_vis_resolving_time == world.time && hv_vis_resolving > 0) + hv_vis_resolving-- + if(damagetype != BRUTE && damagetype != BURN && damagetype != STAMINA) + return + if(hv_vis_hit_time != world.time || hv_vis_died_time == world.time) + return + var/hit_dir = hv_vis_hit_dir || attack_direction + if(!hit_dir || !isturf(loc)) + return + // A body that lives through a hit only moves for a gore moment (a sever, burst or crit gets its + // hit-stop). Killing blows are shown by the death (death_falls.dm); other hits don't move the body. + if(hv_vis_big_hit_time != world.time) + return + var/headshot = hv_vis_hit_zone == BODY_ZONE_HEAD && damagetype != STAMINA && hv_vis_hit_damage >= HV_VIS_HEADSHOT_MIN + hv_vis_flinch(hv_vis_hit_damage, hit_dir, headshot, HV_VIS_HITSTOP_TIME) + hv_vis_hitstop_extras(hit_dir) + +/// Whether a hit on this mob is being resolved right now, between its two damage signals. +/mob/living/proc/hv_vis_inside_hit() + return hv_vis_resolving_time == world.time && hv_vis_resolving > 0 + +/// A limb came off, a head burst or a crit landed: this blow gets a hit-stop. Inside a hit, the flinch +/// at its end holds; anything else (an execution's burst, a finisher's kill) plays it now. +/mob/living/proc/hv_vis_big_hit(hit_dir, mob/living/attacker) + hv_vis_big_hit_time = world.time + if(attacker && attacker != src) + hv_vis_hit_by = WEAKREF(attacker) + if(hv_vis_inside_hit() || !isturf(loc)) + return + if(!hit_dir) + hit_dir = hv_vis_hit_dir || dir + if(hv_vis_died_time != world.time) + var/damage = hv_vis_hit_time == world.time ? hv_vis_hit_damage : 0 + hv_vis_flinch(max(damage, HV_VIS_STAGGER_DAMAGE), hit_dir, FALSE, HV_VIS_HITSTOP_TIME) + hv_vis_hitstop_extras(hit_dir) + +/// The zone a hit landed on, whether given as a bodypart or a zone name. +/proc/hv_vis_zone_of(def_zone) + if(isbodypart(def_zone)) + var/obj/item/bodypart/limb = def_zone + return limb.body_zone + if(istext(def_zone)) + return check_zone(def_zone) + return null + +/// Who is behind a hit: the firer of a projectile, the holder of a weapon, or a mob itself. +/proc/hv_vis_attacker_of(atom/attacking_item) + if(isprojectile(attacking_item)) + var/obj/projectile/shot = attacking_item + return isliving(shot.firer) ? shot.firer : null + if(isitem(attacking_item)) + var/obj/item/weapon = attacking_item + return isliving(weapon.loc) ? weapon.loc : null + if(isliving(attacking_item)) + return attacking_item + return null + +// ---- Playing animations ---- + +/** + * Plays a named sequence of steps on this atom. Each step is list(pixel_w, pixel_z, transform, time, + * easing), absolute values; the last step must be where the atom stands now, so the sequence ends + * where it began. A named sequence only adds its own change to whatever else is animating. + */ +/atom/proc/hv_vis_play(tag, list/steps, loop = 1) + if(!length(steps)) + return + for(var/i in 1 to length(steps)) + var/list/step = steps[i] + if(i == 1) + animate(src, pixel_w = step[1], pixel_z = step[2], transform = step[3], time = step[4], easing = step[5], loop = loop, tag = tag) + else + animate(pixel_w = step[1], pixel_z = step[2], transform = step[3], time = step[4], easing = step[5]) + +/** + * Plays steps that take over this atom's whole animation: each step is list(pixel_y, transform, time, + * easing). Used only where tg's own lying turn has to wait (a body held up for a beat before it + * falls). The last step must be the atom's real pixel_y and transform. Never on a floating mob: it + * would stop the floating bob. + */ +/atom/proc/hv_vis_play_over(list/steps) + if(!length(steps)) + return + for(var/i in 1 to length(steps)) + var/list/step = steps[i] + if(i == 1) + animate(src, pixel_y = step[1], transform = step[2], time = step[3], easing = step[4]) + else + animate(pixel_y = step[1], transform = step[2], time = step[3], easing = step[4]) + +/// Eases a named sequence back to where the atom really is over `time`, from wherever it has got to. +/atom/proc/hv_vis_release(tag, time = 2) + animate(src, pixel_w = pixel_w, pixel_z = pixel_z, transform = transform, time = time, easing = SINE_EASING, tag = tag) + +/// Stops named sequences at once. +/atom/proc/hv_vis_stop(list/tags) + for(var/tag in tags) + animate(src, tag = tag) + +/// A copy of `base` turned `angle` degrees clockwise about a point `pivot_y` pixels above (or below) +/// and `pivot_x` pixels right (or left) of the middle of the icon, as drawn. +/proc/hv_vis_turned(matrix/base, angle, pivot_y = 0, pivot_x = 0) + var/matrix/turned = matrix(base) + if(!angle) + return turned + turned.Translate(-pivot_x, -pivot_y) + turned.Turn(angle) + turned.Translate(pivot_x, pivot_y) + return turned + +/// `base` with a change made in the body's own frame first: scaled, then moved, then turned, before +/// `base` lays it down. On a lying body this shortens the body along its own length. +/proc/hv_vis_local(matrix/base, scale_x = 1, scale_y = 1, angle = 0, move_x = 0, move_y = 0) + var/matrix/local = matrix() + local.Scale(scale_x, scale_y) + if(move_x || move_y) + local.Translate(move_x, move_y) + if(angle) + local.Turn(angle) + return local * matrix(base) + +/// Where a lean of this mob pivots, from the middle of its icon: the feet of a standing body. +/mob/living/proc/hv_vis_pivot() + if(!iscarbon(src) || body_position == LYING_DOWN) + return 0 + return HV_VIS_FEET_Y * current_size + +/// Whether this mob is so big that blows barely move it. +/mob/living/proc/hv_vis_is_huge() + return mob_size >= MOB_SIZE_LARGE || maxHealth >= 400 + +// ---- Hit-stop ---- + +/// The hit-stop on top of the held flinch, once per blow: the body flashes pale and whoever landed it +/// holds their pose for the same frames. +/mob/living/proc/hv_vis_hitstop_extras(hit_dir, mob/living/attacker) + if(hv_vis_hitstop_time == world.time) + return + hv_vis_hitstop_time = world.time + hv_vis_flash() + if(!attacker) + attacker = hv_vis_hit_by?.resolve() + if(!attacker || attacker == src || QDELETED(attacker) || attacker.z != z || get_dist(attacker, src) > 9) + return + attacker.hv_vis_hold_blow(src, hv_vis_hit_time == world.time && hv_vis_hit_ranged) + +/// A pale flash over the body for the frames of a hit-stop. A colour filter, gone again half a second later. +/mob/living/proc/hv_vis_flash() + if(!get_filter(HV_VIS_FLASH_FILTER)) + add_filter(HV_VIS_FLASH_FILTER, 10, color_matrix_filter(list(1,0,0, 0,1,0, 0,0,1, 0,0,0))) + var/flash = get_filter(HV_VIS_FLASH_FILTER) + if(!flash) + return + animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = 0.5, easing = LINEAR_EASING) + animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = HV_VIS_HITSTOP_TIME, easing = LINEAR_EASING) + animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = 1, easing = LINEAR_EASING) + addtimer(CALLBACK(src, TYPE_PROC_REF(/datum, remove_filter), HV_VIS_FLASH_FILTER), 0.5 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) + +/// The attacker's side of a hit-stop: the blow sticks for a few frames. A melee attacker stays leaned +/// into the victim; a shooter stays rocked back, even with the barrel against them (`shot`). +/mob/living/proc/hv_vis_hold_blow(atom/victim, shot = FALSE) + if(!isturf(loc)) + return + var/toward = get_dir(src, victim) + var/lean = (shot || get_dist(src, victim) > 1) ? -2 : 3 + var/dx = HV_VIS_DX(toward) * lean + var/dy = HV_VIS_DY(toward) * lean + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_HITSTOP, list( + list(pixel_w + dx, pixel_z + dy, base, 0.5, QUAD_EASING|EASE_OUT), + list(pixel_w + dx, pixel_z + dy, base, HV_VIS_HITSTOP_TIME, LINEAR_EASING), + list(pixel_w, pixel_z, base, 2, SINE_EASING), + )) + +/// Shakes the screens of players near `center` who can see it, unless they asked for less shake. +/proc/hv_vis_shake_viewers(atom/center, range, duration, strength) + for(var/mob/viewer in viewers(range, center)) + if(!viewer.client || isobserver(viewer)) + continue + if(viewer.client.prefs?.read_preference(/datum/preference/toggle/screen_shake_darken)) + continue + shake_camera(viewer, duration, strength) + +// ---- Held poses ---- + +/** + * Ends held animations on a mob when something happens to it: it is moved, stands up, lies down, + * is revived or dies. Removes itself when the hold runs out on its own. One per hold; a mob can + * have several. + */ +/datum/component/hv_motion_hold + dupe_mode = COMPONENT_DUPE_ALLOWED + /// The named sequences it stops. + var/list/tags + /// HV_VIS_END_* flags: what ends the hold. + var/end_flags = NONE + +/datum/component/hv_motion_hold/Initialize(list/tags, end_flags, duration) + if(!isliving(parent)) + return COMPONENT_INCOMPATIBLE + src.tags = tags + src.end_flags = end_flags + if(duration > 0) + addtimer(CALLBACK(src, PROC_REF(expire)), duration, TIMER_DELETE_ME) + +/datum/component/hv_motion_hold/RegisterWithParent() + if(end_flags & HV_VIS_END_MOVE) + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_end)) + if(end_flags & (HV_VIS_END_STAND|HV_VIS_END_LIE)) + RegisterSignal(parent, COMSIG_LIVING_SET_BODY_POSITION, PROC_REF(on_body_position)) + if(end_flags & HV_VIS_END_REVIVE) + RegisterSignal(parent, COMSIG_LIVING_REVIVE, PROC_REF(on_end)) + if(end_flags & HV_VIS_END_DEATH) + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_end)) + +/datum/component/hv_motion_hold/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BODY_POSITION, COMSIG_LIVING_REVIVE, COMSIG_LIVING_DEATH)) + +/datum/component/hv_motion_hold/Destroy(force) + tags = null + return ..() + +/datum/component/hv_motion_hold/proc/on_end(datum/source) + SIGNAL_HANDLER + release() + +/datum/component/hv_motion_hold/proc/on_body_position(datum/source, new_position, old_position) + SIGNAL_HANDLER + if(new_position == STANDING_UP && (end_flags & HV_VIS_END_STAND)) + release() + else if(new_position == LYING_DOWN && (end_flags & HV_VIS_END_LIE)) + release() + +/// Lets the held sequences go and leaves. Each eases back to where the body really is from wherever it +/// has got to: a corpse that slid a few pixels as it died, or curled up, does not jump back when it is +/// dragged off. +/datum/component/hv_motion_hold/proc/release() + var/atom/holder = parent + if(!QDELETED(holder)) + for(var/tag in tags) + holder.hv_vis_release(tag, HV_VIS_POSE_RELEASE) + qdel(src) + +/// The hold ran its course: the sequences already ended themselves. +/datum/component/hv_motion_hold/proc/expire() + qdel(src) + +/// Holds this mob's named sequences until one of `end_flags` happens or `duration` passes. +/mob/living/proc/hv_vis_hold(list/tags, end_flags, duration) + AddComponent(/datum/component/hv_motion_hold, tags, end_flags, duration) diff --git a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm new file mode 100644 index 00000000000..7133927011e --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm @@ -0,0 +1,283 @@ +// Hyperviolence visuals: blood that moves. +// 11. A spray paints in along the floor: each tile's streak or splash grows out from the side the +// blood came from, a moment after the tile before it. +// 12. A smear on a wall lands short and runs down the wall to its full length over a few seconds. +// 13. A pool that grows to its next size spreads out to it over a couple of seconds, and the end of a +// big pool running onto the next tile flows out from the pool's side. +// 14. In zero gravity, blood leaving a wound drifts away from it turning over, and some of it splits +// off into a smaller bead that floats off and fades. +// tg stops every animation on a blood decal whenever its colour is worked out again +// (update_blood_color()): each time more blood lands on it, and every few seconds while it dries. Left +// alone that cuts all of these short (a pool pops to its new size, a smear jumps to the foot of the +// wall), so 11 to 13 are kept as keyframes on the decal and picked up from where they had got to +// (hv_vis_paint()). + +/// The turf a spray is being thrown from, while hv_blood_throw() runs, so its decals know their order. +GLOBAL_VAR(hv_vis_spray_origin) + +/obj/effect/decal/cleanable/blood + /// Hyperviolence visuals: the paint-in, drip or creep this decal is in the middle of. Keyframes, each + /// list(transform, alpha, the world.time it is reached, easing); the first is where it starts. + var/list/hv_vis_painting + +/** + * Paints the decal through `keys`, each list(transform, alpha, time to get there, easing): it jumps to + * the first and moves through the rest. The last must be how the decal really is. Remembered, so + * hv_vis_paint_on() can carry on after tg stops the decal's animations. + */ +/obj/effect/decal/cleanable/blood/proc/hv_vis_paint(list/keys) + var/reached = world.time + var/list/timed = list() + for(var/list/key as anything in keys) + reached += key[3] + timed += list(list(key[1], key[2], reached, key[4])) + hv_vis_painting = timed + hv_vis_paint_on() + +/// Plays what is left of the decal's paint, from where it has got to by now. +/obj/effect/decal/cleanable/blood/proc/hv_vis_paint_on() + var/list/keys = hv_vis_painting + if(length(keys) < 2) + hv_vis_painting = null + return + var/now = world.time + // The first keyframe it has not reached yet, and how far it is toward it from the one before. + var/heading = 0 + for(var/i in 2 to length(keys)) + var/list/key = keys[i] + if(now < key[3]) + heading = i + break + if(!heading) + hv_vis_painting = null + return + var/list/behind = keys[heading - 1] + var/list/ahead = keys[heading] + var/span = ahead[3] - behind[3] + var/progress = span > 0 ? hv_vis_eased(clamp((now - behind[3]) / span, 0, 1), ahead[4]) : 1 + var/matrix/from = behind[1] + var/matrix/current = from.Interpolate(ahead[1], progress) + animate(src, transform = current, alpha = behind[2] + (ahead[2] - behind[2]) * progress, time = 0, tag = HV_VIS_TAG_PAINT) + animate(transform = ahead[1], alpha = ahead[2], time = ahead[3] - now, easing = ahead[4]) + for(var/i in heading + 1 to length(keys)) + var/list/key = keys[i] + var/list/before = keys[i - 1] + animate(transform = key[1], alpha = key[2], time = key[3] - before[3], easing = key[4]) + +/// How far along an eased step is, `progress` (0 to 1) of its time in: the easings the paints use. +/proc/hv_vis_eased(progress, easing) + switch(easing) + if(QUAD_EASING|EASE_OUT) + return 1 - (1 - progress) ** 2 + if(SINE_EASING|EASE_OUT) + return sin(progress * 90) + if(SINE_EASING|EASE_IN) + return 1 - cos(progress * 90) + if(SINE_EASING) + return (1 - cos(progress * 180)) / 2 + return progress + +/// tg has just stopped every animation on the decal: carry on with its paint. +/obj/effect/decal/cleanable/blood/update_blood_color() + . = ..() + if(hv_vis_painting) + hv_vis_paint_on() + +/mob/living/hv_blood_throw(spray_dir, strength = 3) + var/turf/previous_origin = GLOB.hv_vis_spray_origin + GLOB.hv_vis_spray_origin = get_turf(src) + . = ..() + GLOB.hv_vis_spray_origin = previous_origin + +/obj/effect/decal/cleanable/blood/hv_spray + /// Set on a decal just made, until hv_aim() turns it the way the blood flew and paints it in. + var/hv_vis_fresh = FALSE + /// How many tiles past the first the spray had come when it made this, for the order tiles paint in. + var/hv_vis_spray_step = 0 + +/obj/effect/decal/cleanable/blood/hv_spray/Initialize(mapload, list/datum/disease/diseases, list/blood_or_dna = get_default_blood_type()) + . = ..() + if(mapload || . == INITIALIZE_HINT_QDEL) + return + hv_vis_fresh = TRUE + var/turf/origin = GLOB.hv_vis_spray_origin + if(origin && origin.z == z) + hv_vis_spray_step = max(get_dist(origin, src) - 1, 0) + +/obj/effect/decal/cleanable/blood/hv_spray/hv_aim(spray_dir) + . = ..() + if(!hv_vis_fresh || istype(src, /obj/effect/decal/cleanable/blood/hv_spray/wall)) + return + hv_vis_fresh = FALSE + hv_vis_paint_in(spray_dir) + +/// Grows the decal out along the floor from the side the blood came from, after the tiles before it. +/obj/effect/decal/cleanable/blood/hv_spray/proc/hv_vis_paint_in(spray_dir) + var/dx = HV_VIS_DX(spray_dir) + var/dy = HV_VIS_DY(spray_dir) + var/full_alpha = alpha + var/matrix/base = matrix(transform) + var/matrix/start + if(dx || dy) + // Squeezed against the edge the blood came in over. + start = hv_vis_local(base, dx ? 0.3 : 1, dy ? 0.3 : 1, 0, -dx * 16 * 0.7, -dy * 16 * 0.7) + else + start = hv_vis_local(base, 0.3, 0.3) + hv_vis_paint(list( + list(start, 0, 0, LINEAR_EASING), + list(start, 0, hv_vis_spray_step * HV_VIS_SPRAY_STEP, LINEAR_EASING), + list(base, full_alpha, HV_VIS_SPRAY_PAINT, QUAD_EASING|EASE_OUT), + )) + +/obj/effect/decal/cleanable/blood/hv_spray/wall/hv_aim(spray_dir) + . = ..() + if(!hv_vis_fresh) + return + hv_vis_fresh = FALSE + hv_vis_drip() + +/// The smear lands short, hanging from its top edge, and runs down the wall to its full length. +/obj/effect/decal/cleanable/blood/hv_spray/wall/proc/hv_vis_drip() + var/full_alpha = alpha + var/matrix/base = matrix(transform) + // Shortened from the top edge, which stays where it is. + var/matrix/splash = hv_vis_local(base, 1, 0.5, 0, 0, 16 * 0.5) + var/matrix/running = hv_vis_local(base, 1, 0.72, 0, 0, 16 * 0.28) + hv_vis_paint(list( + list(splash, 0, 0, LINEAR_EASING), + list(splash, 0, (hv_vis_spray_step + 1) * HV_VIS_SPRAY_STEP, LINEAR_EASING), + list(running, full_alpha, 1.5, QUAD_EASING|EASE_OUT), + list(base, full_alpha, HV_VIS_DRIP_TIME, SINE_EASING|EASE_IN), + )) + +// ---- Pools ---- + +/// How big each pool size is drawn, by its longest reach from the middle, for spreading from one to the next. +GLOBAL_LIST_INIT(hv_vis_pool_reach, list(10, 15, 26, 29)) + +/obj/effect/decal/cleanable/blood + /// Hyperviolence visuals: the share of its full size the pool's last spread started from, and when it + /// started, so a pool that grows again before it has finished spreading goes on from the size it is + /// drawn at. + var/hv_vis_creep_from = 1 + var/hv_vis_creep_started + +/obj/effect/decal/cleanable/blood/hv_pool_show(state) + var/old_tier = icon == HV_BLOOD_POOL_ICON ? hv_vis_pool_state_tier(icon_state) : 0 + // How much of that size it has spread to so far. + var/old_shown = old_tier ? hv_vis_pool_shown() : 1 + // A pool is shown again every time more blood lands on it; only a picture that changed animates. + var/was_shown = icon == HV_BLOOD_POOL_ICON && icon_state == state + var/was_lobe = icon == HV_BLOOD_POOL_ICON && findtext(icon_state, "lobe") == 1 + . = ..() + if(QDELETED(src) || dried || was_shown) + return + if(findtext(state, "lobe") == 1) + // Only a lobe that is new on this tile flows out; a bigger lobe replacing a smaller one just shows. + if(was_lobe || old_tier) + return + hv_vis_lobe_creep() + return + var/new_tier = hv_vis_pool_state_tier(state) + if(!new_tier || new_tier <= old_tier) + return + // A plain splatter reaches about as far as the smallest pool's middle. A pool still spreading to its + // last size has only got part of the way: the new picture starts at the size that one is drawn at, or a + // pool fed quickly jumps out to the last size before it goes on. + var/old_reach = old_tier ? GLOB.hv_vis_pool_reach[old_tier] * old_shown : 7 + hv_vis_pool_creep(old_reach / GLOB.hv_vis_pool_reach[new_tier]) + +/// The pool size a pool_art state shows, or 0. +/proc/hv_vis_pool_state_tier(state) + if(!istext(state) || findtext(state, "pool") != 1) + return 0 + return clamp(text2num(copytext(state, 5, 6)) || 0, 0, length(GLOB.hv_vis_pool_reach)) + +/// How much of its full size the pool is drawn at right now: less than 1 while it is still spreading out. +/obj/effect/decal/cleanable/blood/proc/hv_vis_pool_shown() + if(isnull(hv_vis_creep_started)) + return 1 + var/progress = (world.time - hv_vis_creep_started) / HV_VIS_POOL_CREEP + if(progress >= 1) + return 1 + return hv_vis_creep_from + (1 - hv_vis_creep_from) * hv_vis_eased(max(progress, 0), SINE_EASING|EASE_OUT) + +/// Spreads the pool out from `start_scale` of its size. +/obj/effect/decal/cleanable/blood/proc/hv_vis_pool_creep(start_scale) + hv_vis_creep_from = start_scale + hv_vis_creep_started = world.time + var/matrix/base = matrix(transform) + hv_vis_paint(list( + list(hv_vis_local(base, start_scale, start_scale), alpha, 0, LINEAR_EASING), + list(base, alpha, HV_VIS_POOL_CREEP, SINE_EASING|EASE_OUT), + )) + +/// The end of a pool running onto this tile flows out from the pool's side. +/obj/effect/decal/cleanable/blood/proc/hv_vis_lobe_creep() + if(!hv_pool_lobe_dir) + return + // The pool's middle, from this tile's. + var/from_x = -HV_VIS_DX(hv_pool_lobe_dir) * 32 + var/from_y = -HV_VIS_DY(hv_pool_lobe_dir) * 32 + var/scale = 0.3 + var/matrix/base = matrix(transform) + var/matrix/start = hv_vis_local(base, scale, scale, 0, from_x * (1 - scale), from_y * (1 - scale)) + var/full_alpha = alpha + hv_vis_paint(list( + list(start, 0, 0, LINEAR_EASING), + list(start, 0, HV_VIS_POOL_CREEP * 0.5, LINEAR_EASING), + list(base, full_alpha, HV_VIS_LOBE_CREEP, SINE_EASING|EASE_OUT), + )) + +// ---- Zero gravity ---- + +/obj/effect/decal/cleanable/blood/hv_blood_float_begin() + var/was_floating = hv_floating + . = ..() + if(!was_floating && hv_floating && !QDELETED(src)) + hv_vis_drift() + +/// Drifts off the wound in some direction, turning over once, and sometimes sheds a smaller bead. +/// The drift stays: floating blood hangs wherever it drifted to, until it lands. +/obj/effect/decal/cleanable/blood/proc/hv_vis_drift() + var/angle = rand(0, 359) + var/distance = rand(HV_VIS_DRIFT_MIN, HV_VIS_DRIFT_MAX) + var/end_w = round(distance * sin(angle)) + var/end_z = round(distance * cos(angle)) + animate(src, pixel_w = end_w, pixel_z = end_z, time = 2 SECONDS, easing = SINE_EASING|EASE_OUT, tag = HV_VIS_TAG_DRIFT) + hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(src, 1, 2 SECONDS, pick(-1, 1))) + if(prob(50)) + new /obj/effect/temp_visual/hv_blood_bead(loc, icon_state, color, end_w, end_z) + +/obj/effect/decal/cleanable/blood/hv_blood_land() + if(hv_floating) + hv_vis_stop(list(HV_VIS_TAG_DRIFT, HV_VIS_TAG_SPIN)) + pixel_w = 0 + pixel_z = 0 + return ..() + +/// A small bead of blood split off a floating globule, drifting the other way as it fades. away_w and +/// away_z are the way the globule drifts. +/obj/effect/temp_visual/hv_blood_bead + name = "blood" + icon = HV_BLOOD_FLOAT_ICON + icon_state = "float1" + plane = GAME_PLANE + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 2 SECONDS + +/obj/effect/temp_visual/hv_blood_bead/Initialize(mapload, state, bead_color, away_w = 0, away_z = 0) + . = ..() + if(state) + icon_state = state + if(bead_color && !islist(bead_color)) + color = bead_color + var/matrix/small = matrix() + small.Scale(0.5) + transform = small + var/matrix/gone = matrix() + gone.Scale(0.3) + gone.Turn(pick(-1, 1) * 160) + // Off the other way from the globule it split from. + animate(src, pixel_w = -round(away_w * 0.8) + rand(-2, 2), pixel_z = -round(away_z * 0.8) + rand(-2, 2), transform = gone, alpha = 0, time = duration, easing = SINE_EASING|EASE_OUT) diff --git a/voidcrew/modules/hyperviolence/visuals/death_falls.dm b/voidcrew/modules/hyperviolence/visuals/death_falls.dm new file mode 100644 index 00000000000..667d976d1fd --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/death_falls.dm @@ -0,0 +1,298 @@ +// Hyperviolence visuals: dying. +// 5. A body falls away from the blow that killed it: tg's turn onto its side is pointed that way, and +// it slides along the blow within its tile. A blow of HV_VIS_FULL_SLIDE_DAMAGE or more (a +// shotgun at close range) slides it a whole tile, a real step onto open floor, never into space, +// lava, a chasm, a wall or a standing person. +// 6. Deaths read differently: a head shot drops straight down and lies limp, a body that bled out +// kneels and sways before it collapses, a burned body draws up and curls. +// 7. Most bodies twitch a few times in the seconds after death. +// Every pose is held by the animation itself and let go by /datum/component/hv_motion_hold the moment +// the body is moved, stands up or is revived. + +/// Falls point away from the hit that dropped the body, when one just landed from the side. +/mob/living/carbon/set_lying_angle(new_lying) + var/falling = !lying_angle && new_lying + if(falling && hv_vis_hit_time == world.time && (new_lying == LYING_ANGLE_EAST || new_lying == LYING_ANGLE_WEST)) + var/dx = HV_VIS_DX(hv_vis_hit_dir) + if(dx) + new_lying = dx > 0 ? LYING_ANGLE_EAST : LYING_ANGLE_WEST + // tg can lay a dying body down twice in one tick, the second time on a random side: keep the side + // this tick's hit chose. + else if(lying_angle && new_lying && hv_vis_fell_time == world.time && hv_vis_hit_time == world.time && HV_VIS_DX(hv_vis_hit_dir)) + new_lying = lying_angle + . = ..() + if(falling && lying_angle) + hv_vis_fell_time = world.time + hv_vis_after_fall() + +/// A body that just went down. One collapsing into crit from blood loss kneels and sways first. +/// Deaths are shown by hv_vis_on_death(), a moment later in the same tick. +/mob/living/carbon/proc/hv_vis_after_fall() + if(stat == CONSCIOUS || stat == DEAD || hv_vis_hit_time == world.time) + return + if(blood_volume >= BLOOD_VOLUME_BAD || !can_bleed() || HAS_TRAIT(src, TRAIT_MOVE_FLOATING) || !isturf(loc)) + return + hv_vis_play_over(hv_vis_kneel_collapse_steps()) + +/mob/living/proc/hv_vis_on_death(gibbed) + hv_vis_died_time = world.time + +/mob/living/carbon/hv_vis_on_death(gibbed) + . = ..() + hv_vis_stop(list(HV_VIS_TAG_TREMBLE, HV_VIS_TAG_LEAN, HV_VIS_TAG_LIMP)) + if(gibbed || QDELETED(src) || !isturf(loc)) + return + var/fell = hv_vis_fell_time == world.time + var/hit_dir = hv_vis_hit_time == world.time ? hv_vis_hit_dir : 0 + var/big = hv_vis_big_hit_time == world.time + if(big) + hv_vis_hitstop_extras(hit_dir) + switch(hv_vis_death_kind()) + if(HV_VIS_DEATH_EXECUTED) + hv_vis_death_slump(hit_dir, fell) + if(HV_VIS_DEATH_HEADSHOT) + hv_vis_death_drop(hit_dir, fell, big) + if(HV_VIS_DEATH_BURNED) + hv_vis_death_curl(fell) + if(HV_VIS_DEATH_BLED_OUT) + hv_vis_death_bleed_out(fell) + if(HV_VIS_DEATH_HIT) + hv_vis_death_hit(hit_dir, fell, big) + else + hv_vis_twitches(2, fell ? 5 : 3) + +/// How this body died, from what happened to it this tick (HV_VIS_DEATH_*). +/mob/living/carbon/proc/hv_vis_death_kind() + var/recent = hv_vis_hit_time == world.time + if(hv_vis_execution_time == world.time) + return HV_VIS_DEATH_EXECUTED + if(recent && hv_vis_hit_type != STAMINA && (hv_vis_hit_zone == BODY_ZONE_HEAD || !get_bodypart(BODY_ZONE_HEAD))) + return HV_VIS_DEATH_HEADSHOT + if(hv_vis_big_hit_time == world.time && !get_bodypart(BODY_ZONE_HEAD)) + return HV_VIS_DEATH_HEADSHOT + if((recent && hv_vis_hit_type == BURN) || on_fire || HAS_TRAIT(src, TRAIT_HUSK)) + return HV_VIS_DEATH_BURNED + if(recent && hv_vis_hit_dir) + return HV_VIS_DEATH_HIT + if(blood_volume < BLOOD_VOLUME_BAD && can_bleed()) + return HV_VIS_DEATH_BLED_OUT + return HV_VIS_DEATH_QUIET + +// ---- Upright for a moment, then down ---- + +/// The body's transform and pixel_y as it stood a moment ago, before tg turned it onto its side. +/// Returns list(pixel_y, transform). +/mob/living/carbon/proc/hv_vis_upright() + var/matrix/upright = matrix(transform) + var/angle = round(arctan(upright.a, upright.b)) + if(angle) + upright.Turn(-angle) + return list(pixel_y - has_offset(LYING_DOWN_TRAIT, PIXEL_Y_OFFSET), upright) + +/// Holds the body upright for `hold`, then lets it fall over `fall_time`. Steps for hv_vis_play_over(). +/mob/living/carbon/proc/hv_vis_hold_then_fall_steps(hold, fall_time, fall_easing = QUAD_EASING|EASE_IN) + var/list/upright = hv_vis_upright() + return list( + list(upright[1], upright[2], 0, LINEAR_EASING), + list(upright[1], upright[2], hold, LINEAR_EASING), + list(pixel_y, matrix(transform), fall_time, fall_easing), + ) + +/// Sinks to the knees, sways twice and collapses: a body out of blood. Steps for hv_vis_play_over(). +/mob/living/carbon/proc/hv_vis_kneel_collapse_steps() + var/list/upright = hv_vis_upright() + var/matrix/standing = upright[2] + // Shorter from the feet up: down on the knees. + var/matrix/kneeling = hv_vis_local(standing, 1, 0.86, 0, 0, HV_VIS_FEET_Y * 0.14) + return list( + list(upright[1], standing, 0, LINEAR_EASING), + list(upright[1], kneeling, 3, SINE_EASING), + list(upright[1], hv_vis_turned(kneeling, 5, HV_VIS_FEET_Y), 4, SINE_EASING), + list(upright[1], hv_vis_turned(kneeling, -4, HV_VIS_FEET_Y), 5, SINE_EASING), + list(upright[1], hv_vis_turned(kneeling, 2, HV_VIS_FEET_Y), 3, SINE_EASING), + list(pixel_y, matrix(transform), 3, QUAD_EASING|EASE_IN), + ) + +/// Whether the body may be held up over tg's fall: not while floating, where it would stop the bob. +/mob/living/carbon/proc/hv_vis_can_hold_fall() + return !HAS_TRAIT(src, TRAIT_MOVE_FLOATING) + +// ---- The kinds of death ---- + +/// Killed by a blow from the side: slides along it, a whole tile for a heavy blow, then twitches. +/mob/living/carbon/proc/hv_vis_death_hit(hit_dir, fell, big) + if(big && fell && hv_vis_can_hold_fall()) + hv_vis_play_over(hv_vis_hold_then_fall_steps(HV_VIS_HITSTOP_TIME, 2)) + if(hv_vis_hit_damage >= HV_VIS_FULL_SLIDE_DAMAGE && hv_vis_can_slide_tile(hit_dir)) + // Next tick: the blow that killed it may still be resolving (a shotgun's other pellets). + addtimer(CALLBACK(src, PROC_REF(hv_vis_slide_tile), hit_dir), 1, TIMER_DELETE_ME) + return + hv_vis_slide_in_tile(hit_dir, hv_vis_hit_damage, big ? HV_VIS_HITSTOP_TIME : 0) + hv_vis_twitches(rand(3, 5), big ? 6 : 4) + +/// The steps of a slide along `slide_dir` inside the tile, held as the corpse's pose. +/mob/living/carbon/proc/hv_vis_slide_steps(slide_dir, damage, delay = 0) + var/dx = HV_VIS_DX(slide_dir) + var/dy = HV_VIS_DY(slide_dir) + var/distance = clamp(round(damage / 4), 2, HV_VIS_MAX_SLIDE) + if(dx && dy) + distance = max(2, round(distance * 0.7)) + var/matrix/base = matrix(transform) + return list( + list(pixel_w, pixel_z, base, delay, LINEAR_EASING), + list(pixel_w + dx * distance, pixel_z + dy * distance, base, 3, CUBIC_EASING|EASE_OUT), + list(pixel_w + dx * distance, pixel_z + dy * distance, base, HV_VIS_POSE_HOLD, LINEAR_EASING), + list(pixel_w, pixel_z, base, 10, SINE_EASING), + ) + +/mob/living/carbon/proc/hv_vis_slide_in_tile(slide_dir, damage, delay = 0) + if(!slide_dir) + return + hv_vis_play(HV_VIS_TAG_SLIDE, hv_vis_slide_steps(slide_dir, damage, delay)) + hv_vis_hold(list(HV_VIS_TAG_SLIDE), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, HV_VIS_POSE_HOLD + 3 SECONDS) + +/// Whether a corpse can be slid one tile along `slide_dir`: onto open floor with gravity, nothing +/// solid or standing there, not into space, lava or a chasm, and nobody holding or carrying it. +/mob/living/carbon/proc/hv_vis_can_slide_tile(slide_dir) + if(!slide_dir || buckled || pulledby || throwing || anchored || !isturf(loc)) + return FALSE + if(HAS_TRAIT(src, TRAIT_MOVE_FLOATING) || has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + var/turf/here = loc + if(here.has_gravity() <= 0) + return FALSE + var/turf/next = get_step(here, slide_dir) + if(!next || next.z != here.z || !isopenturf(next) || isgroundlessturf(next) || islava(next) || ischasm(next)) + return FALSE + if(next.has_gravity() <= 0 || next.is_blocked_turf(FALSE, src) || !here.Adjacent(next)) + return FALSE + // A lying body may move into a standing person's tile in tg; a thrown corpse never lands on one. + for(var/mob/living/standing in next) + if(standing.body_position == STANDING_UP) + return FALSE + return TRUE + +/// Slides the corpse a whole tile along the killing blow, a real move with a slow glide. +/mob/living/carbon/proc/hv_vis_slide_tile(slide_dir) + if(QDELETED(src) || stat != DEAD) + return + if(hv_vis_can_slide_tile(slide_dir) && Move(get_step(loc, slide_dir), slide_dir, DELAY_TO_GLIDE_SIZE(HV_VIS_SLIDE_TIME))) + // Thrown back as it falls: a little lift over the slide. + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_FALL, list( + list(pixel_w, pixel_z, base, 0, LINEAR_EASING), + list(pixel_w, pixel_z + HV_VIS_LAUNCH_LIFT, base, HV_VIS_SLIDE_TIME * 0.4, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, HV_VIS_SLIDE_TIME * 0.6, QUAD_EASING|EASE_IN), + )) + hv_vis_twitches(rand(3, 5), HV_VIS_SLIDE_TIME + 2) + +/// Killed by a head shot, or the head came off: straight down, no fall, lying limp. A burst gets its +/// hit-stop first. +/mob/living/carbon/proc/hv_vis_death_drop(hit_dir, fell, big) + var/whip = 0 + if(fell && hv_vis_can_hold_fall()) + if(big) + hv_vis_play_over(hv_vis_hold_then_fall_steps(HV_VIS_HITSTOP_TIME, 0.5)) + else + // The head whips back from the shot, then the body drops. + whip = HV_VIS_KILL_WHIP_TIME + hv_vis_play_over(hv_vis_whip_then_fall_steps(hit_dir)) + var/dx = HV_VIS_DX(hit_dir) + var/dy = HV_VIS_DY(hit_dir) + var/matrix/base = matrix(transform) + // Lands with a small bounce and stays where it fell. + hv_vis_play(HV_VIS_TAG_FALL, list( + list(pixel_w, pixel_z, base, big ? HV_VIS_HITSTOP_TIME + 0.5 : whip + 0.5, LINEAR_EASING), + list(pixel_w + dx * 2, pixel_z + dy * 2 + 2, base, 0.5, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, 1.5, BOUNCE_EASING), + )) + +/// Upright, the head whipped back along the shot, then down. Steps for hv_vis_play_over(). +/mob/living/carbon/proc/hv_vis_whip_then_fall_steps(hit_dir) + var/list/upright = hv_vis_upright() + var/matrix/standing = upright[2] + // Back along the shot. A shot from straight ahead or behind whips it the way the body is about to + // fall, so it never rocks one way and drops the other. + var/back = HV_VIS_DX(hit_dir) || (lying_angle == LYING_ANGLE_WEST ? -1 : 1) + var/matrix/whipped = hv_vis_turned(standing, back * 14, HV_VIS_FEET_Y) + return list( + list(upright[1], standing, 0, LINEAR_EASING), + list(upright[1], whipped, HV_VIS_KILL_WHIP_TIME * 0.5, QUAD_EASING|EASE_OUT), + list(upright[1], whipped, HV_VIS_KILL_WHIP_TIME * 0.5, LINEAR_EASING), + list(pixel_y, matrix(transform), 1, QUAD_EASING|EASE_IN), + ) + +/// Burned to death: the body draws up and curls, and keeps that shape. +/mob/living/carbon/proc/hv_vis_death_curl(fell) + hv_vis_play(HV_VIS_TAG_POSE, hv_vis_curl_steps(fell ? 3 : 0)) + hv_vis_hold(list(HV_VIS_TAG_POSE), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, HV_VIS_POSE_HOLD + 3 SECONDS) + hv_vis_twitches(2, 10) + +/// The steps of a curl: shorter along the body's own length, drawn in a little, held. +/mob/living/carbon/proc/hv_vis_curl_steps(delay = 0) + var/matrix/base = matrix(transform) + var/matrix/curled = hv_vis_local(base, 0.92, 0.8, -8) + return list( + list(pixel_w, pixel_z, base, delay, LINEAR_EASING), + list(pixel_w, pixel_z, curled, 8, SINE_EASING|EASE_OUT), + list(pixel_w, pixel_z, curled, HV_VIS_POSE_HOLD, LINEAR_EASING), + list(pixel_w, pixel_z, base, 10, SINE_EASING), + ) + +/// Bled out: on its feet it kneels and sways before it goes down; already down it sags one last time. +/mob/living/carbon/proc/hv_vis_death_bleed_out(fell) + if(fell && hv_vis_can_hold_fall()) + hv_vis_play_over(hv_vis_kneel_collapse_steps()) + hv_vis_twitches(1, 18) + return + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_FALL, list( + list(pixel_w, pixel_z - 1, hv_vis_local(base, 1, 0.96), 8, SINE_EASING), + list(pixel_w, pixel_z, base, 12, SINE_EASING), + )) + hv_vis_twitches(1, 12) + +/// Executed: the body holds where it was for a beat, then slumps forward along the shot. +/mob/living/carbon/proc/hv_vis_death_slump(shot_dir, fell) + var/beat = HV_VIS_HITSTOP_TIME + HV_VIS_SLUMP_BEAT + if(fell && hv_vis_can_hold_fall()) + hv_vis_play_over(hv_vis_hold_then_fall_steps(beat, 3)) + beat += 3 + if(!shot_dir) + return + var/dx = HV_VIS_DX(shot_dir) + var/dy = HV_VIS_DY(shot_dir) + var/matrix/base = matrix(transform) + var/matrix/slumped = hv_vis_local(base, 1, 0.94) + hv_vis_play(HV_VIS_TAG_SLIDE, list( + list(pixel_w, pixel_z, base, beat, LINEAR_EASING), + list(pixel_w + dx * 4, pixel_z + dy * 4, slumped, 3, QUAD_EASING|EASE_IN), + list(pixel_w + dx * 4, pixel_z + dy * 4, slumped, HV_VIS_POSE_HOLD, LINEAR_EASING), + list(pixel_w, pixel_z, base, 10, SINE_EASING), + )) + hv_vis_hold(list(HV_VIS_TAG_SLIDE), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, HV_VIS_POSE_HOLD + 3 SECONDS) + +// ---- Twitches ---- + +/// `count` twitches after `start_delay`, each smaller than the last, a second or so apart. +/mob/living/carbon/proc/hv_vis_twitch_steps(count, start_delay) + var/matrix/base = matrix(transform) + var/list/steps = list(list(pixel_w, pixel_z, base, start_delay, LINEAR_EASING)) + for(var/i in 1 to count) + var/strength = 1 - (i - 1) / (count + 1) + var/jerk = pick(-1, 1) * max(1, round(2 * strength)) + var/matrix/twitched = hv_vis_turned(base, pick(-1, 1) * 3 * strength) + steps += list(list(pixel_w + jerk, pixel_z, twitched, 0.5, LINEAR_EASING)) + steps += list(list(pixel_w, pixel_z, base, 1, QUAD_EASING|EASE_OUT)) + steps += list(list(pixel_w, pixel_z, base, rand(5, 12), LINEAR_EASING)) + return steps + +/mob/living/carbon/proc/hv_vis_twitches(count, start_delay = 4) + if(count <= 0 || !isturf(loc)) + return + var/list/steps = hv_vis_twitch_steps(count, start_delay) + var/total = 0 + for(var/list/step as anything in steps) + total += step[4] + hv_vis_play(HV_VIS_TAG_TWITCH, steps) + hv_vis_hold(list(HV_VIS_TAG_TWITCH), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, total + 1) diff --git a/voidcrew/modules/hyperviolence/visuals/execution_motion.dm b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm new file mode 100644 index 00000000000..35aa47d1605 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm @@ -0,0 +1,124 @@ +// Hyperviolence visuals: gun executions (overlays_exec/executions.dm). +// 17. The press: the shooter leans in over the victim, the victim trembles, and both go still for a +// short, deliberate moment before the shot. The shot: a big muzzle flash at the head the barrel is +// pressed to, a hit-stop on both (the shooter rocks back from it), and a burst head throws its gibs +// in arcs (flying_gore.dm). The body holds for a beat, then slumps forward along the shot +// (death_falls.dm). +// The press is only drawn over the wind-up the execution already has; nothing here changes its timing. + +/obj/item/gun/hv_start_execution(mob/living/user, mob/living/target, params) + // The same checks that stop the wind-up before it starts. + var/pressing = !fire_cd && !firing_burst && !HAS_TRAIT(user, TRAIT_PACIFISM) && isturf(user.loc) && isturf(target.loc) + if(pressing) + user.hv_vis_press_in(target, HV_EXECUTION_WINDUP) + target.hv_vis_tremble(HV_EXECUTION_WINDUP - HV_VIS_PRESS_PAUSE, 1) + . = ..() + if(!pressing) + return + // Shot or called off, the shooter straightens up. + if(!QDELETED(user)) + user.hv_vis_release(HV_VIS_TAG_PRESS, 1.5) + if(!QDELETED(target) && target.stat != DEAD) + target.hv_vis_release(HV_VIS_TAG_TREMBLE, 1) + +/obj/item/gun/hv_fire_execution(mob/living/user, mob/living/target, params) + // Where the barrel is: against the head, wherever that is on a body on its feet or on the floor. + // Read before the shot, which can drop the body or take the head off. + var/turf/head_turf + var/list/head_at + var/behind_head = FALSE + if(!QDELETED(target)) + target.hv_vis_execution_time = world.time + head_turf = get_turf(target) + head_at = target.hv_vis_head_offset() + // A shot from the far side of someone on their feet goes off behind their head. + behind_head = target.body_position == STANDING_UP && (get_dir(user, target) & SOUTH) + . = ..() + if(!. || QDELETED(user)) + return + hv_vis_muzzle_flash(user, head_turf || target, head_at, behind_head) + if(!QDELETED(target)) + target.hv_vis_big_hit(get_dir(user, target), user) + +/// Where this mob's head is drawn, in pixels from the middle of its tile: list(x, y). +/mob/living/proc/hv_vis_head_offset() + return list(0, 8 * current_size) + +/mob/living/carbon/hv_vis_head_offset() + return hv_vis_part_offset(BODY_ZONE_HEAD) + +/// Leans in toward `target` and stays there for `hold`, as if pressing something to their head. +/mob/living/proc/hv_vis_press_in(atom/target, hold) + var/toward = get_dir(src, target) + var/dx = HV_VIS_DX(toward) * HV_VIS_PRESS_LEAN + var/dy = HV_VIS_DY(toward) * HV_VIS_PRESS_LEAN + var/matrix/base = matrix(transform) + var/matrix/hunched = hv_vis_turned(base, HV_VIS_DX(toward) * 6, hv_vis_pivot()) + hv_vis_play(HV_VIS_TAG_PRESS, list( + list(pixel_w + dx, pixel_z + dy, hunched, 4, SINE_EASING), + list(pixel_w + dx, pixel_z + dy, hunched, hold + 1 SECONDS, LINEAR_EASING), + list(pixel_w, pixel_z, base, 3, SINE_EASING), + )) + +/// Shakes with fear for `duration`, small quick jerks side to side. `strength` in pixels. +/mob/living/proc/hv_vis_tremble(duration, strength = 1) + if(duration <= 0 || !isturf(loc)) + return + var/matrix/base = matrix(transform) + var/loops = max(1, round(duration / 2)) + var/base_w = pixel_w + var/base_z = pixel_z + animate(src, pixel_w = base_w + strength, pixel_z = base_z, transform = base, time = 0.5, loop = loops, tag = HV_VIS_TAG_TREMBLE) + animate(pixel_w = base_w - strength, pixel_z = base_z, transform = base, time = 1) + animate(pixel_w = base_w, pixel_z = base_z, transform = base, time = 0.5) + +/** + * A muzzle flash much bigger than a normal shot's, with a flash of light. It is where the barrel's end + * is: just short of the head it is pressed to, `head_at` pixels from the middle of the target's tile + * (list(x, y)). It stands on the target's tile, so it sorts with the target: a shooter standing nearer + * the camera than the victim is drawn over it, as their body is between us and the gun. `behind_target` + * draws it under the target too: the barrel is on the far side of their head. + */ +/proc/hv_vis_muzzle_flash(mob/living/shooter, atom/target, list/head_at, behind_target = FALSE) + var/turf/shooter_turf = get_turf(shooter) + var/turf/spot = get_turf(target) || shooter_turf + if(!shooter_turf || !spot) + return + new /obj/effect/temp_visual/hv_muzzle_flash(spot, get_dir(shooter_turf, spot) || shooter.dir, head_at, behind_target) + shooter_turf.flash_lighting_fx(3, 2, LIGHT_COLOR_ORANGE, 2) + +/// The flash is one still picture, tg's muzzle flash at its brightest, grown and faded here. tg's own +/// state is a short loop that every flash on screen plays in step, so a flash borrowed from it began +/// on whatever frame the loop had reached: sometimes the bright one, sometimes the last few sparks. +/obj/effect/temp_visual/hv_muzzle_flash + name = "muzzle flash" + icon = 'voidcrew/modules/hyperviolence/overlays_exec/icons/muzzle_flash.dmi' + icon_state = "flash" + plane = GAME_PLANE + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 4 + +/obj/effect/temp_visual/hv_muzzle_flash/Initialize(mapload, shot_dir, list/head_at, behind_target = FALSE) + . = ..() + if(behind_target) + layer = BELOW_MOB_LAYER + setDir(shot_dir || SOUTH) + var/dx = HV_VIS_DX(dir) + var/dy = HV_VIS_DY(dir) + // tg's flash art sits off the middle of its tile, toward the way it faces. It grows about that + // point, and that point goes at the end of the barrel. + var/core_w = dx * HV_VIS_FLASH_CORE + var/core_z = dy * HV_VIS_FLASH_CORE + pixel_w = (head_at ? head_at[1] : 0) - dx * HV_VIS_FLASH_SHORT - core_w + pixel_z = (head_at ? head_at[2] : 0) - dy * HV_VIS_FLASH_SHORT - core_z + transform = hv_vis_scaled_about(2.2, core_w, core_z) + animate(src, transform = hv_vis_scaled_about(2.8, core_w, core_z), alpha = 0, time = duration, easing = QUAD_EASING|EASE_OUT) + +/// A matrix that scales by `scale` about the point (x, y) pixels from the middle of the icon. +/proc/hv_vis_scaled_about(scale, x, y) + var/matrix/scaled = matrix() + scaled.Translate(-x, -y) + scaled.Scale(scale) + scaled.Translate(x, y) + return scaled diff --git a/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm b/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm new file mode 100644 index 00000000000..e53e31f68e0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm @@ -0,0 +1,161 @@ +// Hyperviolence visuals: boss finishers (finishers/). +// 18. The wind-up: the boss rears up, bigger and leaning back, then comes down as the pin lands. +// Stages with a lift raise the victim off the floor (in the matriarch's and the empress's jaws). +// The matriarch shakes her victim like a dog with a rat. When the Black Knight cuts someone in +// half the halves slide apart. The killing blow gets a hit-stop on both, and the screens of +// players who saw it shake a little. +// Everything is let go when the finisher stops, however it stops. + +/datum/hv_finisher_stage + /// Pixels the victim is lifted off the floor during this stage. 0 sets them back down. + var/hv_vis_lift = 0 + +/datum/hv_finisher_stage/maul/hoarfrost + hv_vis_lift = 8 + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost + hv_vis_lift = 8 + +/datum/hv_finisher_stage/hold/alien_empress_jaws + hv_vis_lift = 6 + +/datum/hv_finisher_stage/kill/decapitate/alien_empress + hv_vis_lift = 6 + +/datum/hv_finisher_stage/enter() + . = ..() + if(!host?.running || QDELETED(host.victim)) + return + if(hv_vis_lift) + host.victim.hv_vis_lift(hv_vis_lift, duration + 1 SECONDS) + else + host.victim.hv_vis_release(HV_VIS_TAG_LIFT, 2) + +/datum/hv_finisher_stage/windup/enter() + . = ..() + if(host?.running && !QDELETED(host.user) && !QDELETED(host.victim)) + host.user.hv_vis_rear(host.victim, duration) + +/datum/hv_finisher_stage/maul/hoarfrost/effect() + . = ..() + if(!host?.running || QDELETED(host.victim) || QDELETED(host.user)) + return + host.victim.hv_vis_dog_shake(get_dir(host.user, host.victim)) + host.user.hv_vis_head_shake() + +/// The killing blow: a hit-stop on both, and the screens of those who saw it shake. +/datum/hv_finisher_stage/kill/effect() + . = ..() + if(!host || QDELETED(host.victim) || QDELETED(host.user)) + return + var/kill_dir = get_dir(host.user, host.victim) || host.user.dir + host.victim.hv_vis_big_hit(kill_dir, host.user) + hv_vis_shake_viewers(host.victim, HV_VIS_KILL_SHAKE_RANGE, HV_VIS_KILL_SHAKE_TIME, HV_VIS_KILL_SHAKE_STRENGTH) + +/// Cut in half: the legs slide off one way across the floor and the body the other. +/datum/hv_finisher_stage/kill/bisect/gore(spray_dir) + var/mob/living/carbon/victim = host.victim + // The head end of a body lying on its side points east or west. + var/head_dir = spray_dir + if(victim.lying_angle == LYING_ANGLE_EAST) + head_dir = EAST + else if(victim.lying_angle == LYING_ANGLE_WEST) + head_dir = WEST + // The legs slide off the feet end, or off to the side if the knight stands there. + var/legs_dir = REVERSE_DIR(head_dir) + var/turf/legs_to = get_step(victim, legs_dir) + if(!legs_to || legs_to == get_turf(host.user) || legs_to.is_blocked_turf(FALSE)) + legs_dir = pick(turn(legs_dir, 90), turn(legs_dir, -90)) + for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + var/obj/item/bodypart/leg = victim.get_bodypart(zone) + if(leg?.can_dismember()) + leg.hv_vis_tumble = HV_VIS_TUMBLE_SLIDE + leg.hv_vis_fling_dir = legs_dir + . = ..() + if(!QDELETED(victim) && isturf(victim.loc)) + victim.hv_vis_slide_in_tile(head_dir, 40) + +/datum/hv_finisher/stop(reason) + if(running) + if(user && !QDELETED(user)) + user.hv_vis_release(HV_VIS_TAG_REAR, 2) + user.hv_vis_release(HV_VIS_TAG_SHAKE, 1) + if(victim && !QDELETED(victim)) + victim.hv_vis_release(HV_VIS_TAG_LIFT, 2) + victim.hv_vis_release(HV_VIS_TAG_SHUDDER, 1) + return ..() + +/// Where this mob's feet are, from the middle of its icon, so it grows up from the floor. +/mob/living/proc/hv_vis_feet_y() + var/list/size = get_icon_dimensions(icon) + var/height = size?["height"] || ICON_SIZE_Y + return -height / 2 * current_size + +/// Rears up over `victim` for `duration`: grows a little from the feet up and leans back away from +/// them, then comes down at the end, as the pin lands. +/mob/living/proc/hv_vis_rear(atom/victim, duration) + var/away = HV_VIS_DX(get_dir(victim, src)) + var/feet = hv_vis_feet_y() + var/matrix/base = matrix(transform) + // Bigger from the feet up, then leaned back about the feet. + var/matrix/reared = matrix(base) + reared.Translate(0, -feet) + reared.Scale(1.12) + reared.Turn((away || pick(-1, 1)) * 7) + reared.Translate(0, feet) + var/rise_time = duration * 0.6 + hv_vis_play(HV_VIS_TAG_REAR, list( + list(pixel_w + away * 2, pixel_z + 2, reared, rise_time, SINE_EASING|EASE_OUT), + list(pixel_w + away * 2, pixel_z + 2, reared, max(duration - rise_time - 1.5, 0), LINEAR_EASING), + list(pixel_w, pixel_z, base, 1.5, QUAD_EASING|EASE_IN), + )) + +/// Lifts this mob `height` pixels off the floor for up to `hold`. hv_vis_release() sets it back down. +/mob/living/proc/hv_vis_lift(height, hold) + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_LIFT, list( + list(pixel_w, pixel_z + height, base, 2, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z + height, base, hold, LINEAR_EASING), + list(pixel_w, pixel_z, base, 2, QUAD_EASING|EASE_IN), + )) + +/** + * A short hard shudder round where the body is drawn now, up to `shift_x` and `shift_y` pixels each way for + * `duration`, ending where it began. Not tg's Shake(): its first beat takes pixel_y from pixel_x, which throws + * a body drawn off the middle of its tile (anyone lying down) up the screen for a frame. + */ +/mob/living/proc/hv_vis_shudder(shift_x, shift_y, duration) + if(!isturf(loc)) + return + var/matrix/base = matrix(transform) + var/list/steps = list() + for(var/i in 1 to max(round(duration / HV_VIS_SHUDDER_BEAT) - 1, 2)) + steps += list(list(pixel_w + rand(-shift_x, shift_x), pixel_z + rand(-shift_y, shift_y), base, HV_VIS_SHUDDER_BEAT, LINEAR_EASING)) + steps += list(list(pixel_w, pixel_z, base, HV_VIS_SHUDDER_BEAT, LINEAR_EASING)) + hv_vis_play(HV_VIS_TAG_SHUDDER, steps) + +/// Shaken hard from side to side, as a dog shakes a rat, across the line from the beast holding it. +/mob/living/proc/hv_vis_dog_shake(beast_dir) + // Across the line between them: a beast to the east or west shakes it up and down, and the other way round. + var/across_w = HV_VIS_DX(beast_dir) ? 0 : 1 + var/across_z = across_w ? 0 : 1 + var/matrix/base = matrix(transform) + var/list/swings = list(18, -16, 14, -12, 9, -6, 3) + var/list/steps = list() + for(var/i in 1 to length(swings)) + var/swing = swings[i] + var/shift = round(swing / 3) + steps += list(list(pixel_w + across_w * shift, pixel_z + across_z * shift, hv_vis_turned(base, swing), 1, SINE_EASING)) + steps += list(list(pixel_w, pixel_z, base, 1.5, SINE_EASING)) + hv_vis_play(HV_VIS_TAG_SHAKE, steps) + +/// The beast's side of the shake: its head goes side to side with the victim. +/mob/living/proc/hv_vis_head_shake() + var/matrix/base = matrix(transform) + var/feet = hv_vis_feet_y() + var/list/swings = list(6, -6, 5, -4, 3, -2) + var/list/steps = list() + for(var/swing in swings) + steps += list(list(pixel_w, pixel_z, hv_vis_turned(base, swing, feet), 1, SINE_EASING)) + steps += list(list(pixel_w, pixel_z, base, 1.5, SINE_EASING)) + hv_vis_play(HV_VIS_TAG_SHAKE, steps) diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm new file mode 100644 index 00000000000..60c87030104 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -0,0 +1,569 @@ +// Hyperviolence visuals: flying gore. +// 8. A severed limb or head flies the way the blow went (tg sends it off in a random direction), slowly +// enough to see: up in an arc from where it was on the body, spinning, leaving a blood streak on each +// tile it crosses; it bounces once where it lands, and a head rolls on. +// 9. A burst head's brain arcs out spinning and lands with a wet bounce. Its gibs arc as they fly. +// In zero gravity nothing arcs or bounces: a part just turns slowly as it goes, and leaves no streak. +// +// None of it is thrown. tg's throw hits whatever is in the way: a limb hurts whoever it lands on and +// shoves them a tile, a head can knock its own killer off the body, and anyone in throw mode catches a +// brain. A part here flies by plain steps along a path worked out as it comes off (hv_vis_fling_path()): +// it stops short of anything solid and of anyone on their feet, and touches nobody. tg's dismember() +// still asks for its throw at the end; a part marked to tumble turns that into a flight +// (/obj/item/bodypart/throw_at() below). + +/// Set while a burst head throws its gibs, so they arc: where the head is drawn on its tile +/// (list(x, y) in pixels from the middle), which is where they start from. +GLOBAL_LIST(hv_vis_arcing_gibs) + +/obj/item + /// Hyperviolence visuals: set just before a body part or organ comes off a body, so it tumbles as + /// it flies (HV_VIS_TUMBLE_*). Used up by the flight. + var/hv_vis_tumble + /// The way to fling it. Used up by the flight. + var/hv_vis_fling_dir + /// The blood it streaks along its way. Used up by the flight. + var/list/hv_vis_tumble_dna + /// Where on the body it was, in pixels from the middle of the tile (list(x, y)): the flight starts + /// there. Used up by the flight. + var/list/hv_vis_fling_from + /// Set when it was drawn in front of the body it comes off (the near arm of a body seen side-on): + /// it stays in front of that body until it is clear of it. Used up by the flight. + var/hv_vis_fling_over + /// How far the body it comes off is turned, in degrees clockwise (a body on the floor lies on its side): + /// the part starts turned the same way and rights itself as it goes. Used up by the flight. + var/hv_vis_fling_turn + +/// Forgets a fling that was marked on this part. +/obj/item/proc/hv_vis_forget_fling() + hv_vis_tumble = null + hv_vis_fling_dir = null + hv_vis_tumble_dna = null + hv_vis_fling_from = null + hv_vis_fling_over = null + hv_vis_fling_turn = null + +/// How this kind of part tumbles when nothing marked it (HV_VIS_TUMBLE_*). +/obj/item/proc/hv_vis_tumble_style() + return HV_VIS_TUMBLE_ORGAN + +/obj/item/bodypart/hv_vis_tumble_style() + return HV_VIS_TUMBLE_LIMB + +/obj/item/bodypart/head/hv_vis_tumble_style() + return HV_VIS_TUMBLE_HEAD + +/obj/item/organ/brain/hv_vis_tumble_style() + return HV_VIS_TUMBLE_BRAIN + +/** + * Flies this loose part up to `distance` tiles along `fling_dir`, tumbling. Not a throw (see the top of + * this file): nothing on the way is hit. How it tumbles and the blood it streaks come from the marks + * on it, or from what it is. `on_landed` is called when it comes to rest. The body of hv_fling() + * (_core.dm). Returns TRUE if it took off. + */ +/obj/item/proc/hv_vis_fly(fling_dir, distance = 2, datum/callback/on_landed) + var/style = hv_vis_tumble + var/list/blood_dna = hv_vis_tumble_dna + var/list/from = hv_vis_fling_from + var/starts_over = hv_vis_fling_over + var/starts_turned = hv_vis_fling_turn + hv_vis_forget_fling() + if(QDELETED(src) || !isturf(loc)) + return FALSE + if(!style || style == HV_VIS_TUMBLE_NONE) + style = hv_vis_tumble_style() + if(!fling_dir) + fling_dir = pick(GLOB.alldirs) + var/list/turf/path = hv_vis_fling_path(loc, fling_dir, distance) + // Where it will lie on its last tile is settled now, so nothing jumps when it lands. With nowhere to + // go it comes down on the side of its tile it was heading for. + if(!pixel_x && !pixel_y && !(item_flags & NO_PIXEL_RANDOM_DROP)) + if(length(path)) + pixel_x = base_pixel_x + rand(-6, 6) + pixel_y = base_pixel_y + rand(-6, 6) + else + pixel_x = base_pixel_x + HV_VIS_DX(fling_dir) * 7 + rand(-3, 3) + pixel_y = base_pixel_y + HV_VIS_DY(fling_dir) * 7 + rand(-3, 3) + var/datum/component/hv_tumble/tumble = AddComponent(/datum/component/hv_tumble, style, blood_dna, fling_dir, path, on_landed, from, starts_over, starts_turned) + tumble.launch() + return TRUE + +/// The tiles a part flung from `start` along `fling_dir` crosses, nearest first, up to `distance` of them. +/proc/hv_vis_fling_path(turf/start, fling_dir, distance) + var/list/turf/path = list() + var/turf/here = start + for(var/i in 1 to distance) + var/turf/next = get_step(here, fling_dir) + if(!hv_vis_can_fly_onto(here, next)) + break + path += next + here = next + return path + +/// Whether a flying part can go from `here` onto `next`: open floor on the same level, not space, lava +/// or a chasm, nothing solid on it or between the two (a window, a door, a table), and nobody on their feet. +/proc/hv_vis_can_fly_onto(turf/here, turf/next) + if(!here || !next || next.z != here.z || isgroundlessturf(next) || islava(next) || ischasm(next)) + return FALSE + if(next.is_blocked_turf(exclude_mobs = TRUE) || !here.Adjacent(next)) + return FALSE + for(var/mob/living/in_the_way in next) + if(in_the_way.density) + return FALSE + return TRUE + +/// tg's dismember() ends by throwing the limb. One marked to tumble flies instead; one marked to stay +/// put (a head that bursts where it is) goes nowhere. +/obj/item/bodypart/throw_at(atom/target, range, speed, mob/thrower, spin = 1, diagonals_first = 0, datum/callback/callback, force, gentle = FALSE, quickstart = TRUE, throw_type_path = /datum/thrownthing) + if(!hv_vis_tumble) + return ..() + if(hv_vis_tumble == HV_VIS_TUMBLE_NONE) + hv_vis_forget_fling() + return FALSE + var/fling_dir = hv_vis_fling_dir || get_dir(src, target) || pick(GLOB.cardinals) + var/distance = hv_vis_tumble == HV_VIS_TUMBLE_SLIDE ? 1 : max(get_dist(src, target), 2) + return hv_vis_fly(fling_dir, distance, callback) + +/** + * A part flying off a body: the steps along its path, the arc, the spin, the blood along the way, the + * landing. On the part for the length of the flight only; gone once it lands, if anything else moves it + * (someone picks it up), or after a few seconds whatever happens. + */ +/datum/component/hv_tumble + dupe_mode = COMPONENT_DUPE_HIGHLANDER + /// HV_VIS_TUMBLE_* + var/style + /// Blood it streaks along its way, or null. + var/list/blood_dna + /// The way it was flung. + var/fling_dir + /// The tiles still ahead of it, nearest first. + var/list/turf/path + /// Called when it comes to rest. + var/datum/callback/on_landed + /// Where on the body it came from, in pixels from the middle of the tile, or null. + var/list/from + /// How long it takes to cross a tile. + var/step_time = HV_VIS_TUMBLE_STEP + /// Streaks left so far. + var/streaks = 0 + /// Whether it flies where gravity pulls it down. + var/grounded = TRUE + /// The timer of its next step. + var/step_timer + /// Set while it takes one of its own steps, to tell those from anything else moving it. + var/stepping = FALSE + /// The layer it had before it took off. + var/rest_layer + /// The layer it flies at. + var/flight_layer = HV_VIS_TUMBLE_LAYER + /// Whether it leaves the body drawn in front of it, until its first step is done. + var/starts_over = FALSE + /// How many of its own steps it has taken. + var/steps_taken = 0 + /// How far it is turned as it comes off, in degrees clockwise: the way the body it was on lay. + var/starts_turned = 0 + +/datum/component/hv_tumble/Initialize(style, list/blood_dna, fling_dir, list/turf/path, datum/callback/on_landed, list/from, starts_over = FALSE, starts_turned = 0) + if(!isitem(parent)) + return COMPONENT_INCOMPATIBLE + var/obj/item/part = parent + src.style = style + src.blood_dna = blood_dna + src.fling_dir = fling_dir + src.path = path || list() + src.on_landed = on_landed + src.from = from + src.starts_over = starts_over + src.starts_turned = starts_turned || 0 + grounded = part.has_gravity() > 0 + // A slide along the floor is slower than a flight, and so is a drift with nothing to pull it down. + if(style == HV_VIS_TUMBLE_SLIDE) + step_time *= 2 + else if(!grounded) + step_time *= 3 + rest_layer = part.layer + addtimer(CALLBACK(src, PROC_REF(give_up)), 5 SECONDS, TIMER_DELETE_ME) + +/datum/component/hv_tumble/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + +/datum/component/hv_tumble/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_MOVABLE_MOVED) + +/datum/component/hv_tumble/Destroy(force) + if(step_timer) + deltimer(step_timer) + step_timer = null + var/obj/item/part = parent + if(!QDELETED(part) && !isnull(rest_layer) && part.layer == flight_layer) + part.layer = rest_layer + blood_dna = null + path = null + on_landed = null + from = null + return ..() + +/// How long the whole flight takes: the moment it hangs where it came off, then its steps. With nowhere +/// to go (someone or something right in its way) it only drops where the body stands. +/datum/component/hv_tumble/proc/flight_time() + return length(path) ? world.tick_lag + length(path) * step_time : HV_VIS_TUMBLE_DROP + +/// How high it rises on the way. A part that only drops barely rises at all. +/datum/component/hv_tumble/proc/arc_height() + var/height = 0 + switch(style) + if(HV_VIS_TUMBLE_HEAD) + height = 12 + if(HV_VIS_TUMBLE_BRAIN) + height = 14 + if(HV_VIS_TUMBLE_LIMB) + height = 10 + if(HV_VIS_TUMBLE_ORGAN) + height = 7 + return length(path) ? height : round(height * 0.3) + +/// Takes off: starts the arc and the spin, and takes the first step a tick later. A part stepped in the +/// tick it comes off is first seen a whole tile from the body, already on its way; one that waits a tick +/// is seen where it was on the body first, and glides out from there. With nowhere to go it comes down +/// where it is once it has dropped. +/datum/component/hv_tumble/proc/launch() + start_flight() + step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), length(path) ? world.tick_lag : flight_time(), TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/component/hv_tumble/proc/start_flight() + var/obj/item/part = parent + var/matrix/base = matrix(part.transform) + var/time = flight_time() + // Where it was on the body, from where it will lie on its tile. + var/from_w = from ? from[1] - (part.pixel_x - part.base_pixel_x) : 0 + var/from_z = from ? from[2] - (part.pixel_y - part.base_pixel_y) : 0 + if(style == HV_VIS_TUMBLE_SLIDE) + // The legs of a body cut in half: a slide across the floor from where they were, with a slight turn. + // Off a body on the floor they start as they lay and come round as they slide. + var/turn = starts_turned ? starts_turned * 0.3 : pick(-1, 1) * 20 + var/list/pivot = part.hv_vis_spin_pivot() + part.hv_vis_play(HV_VIS_TAG_SPIN, list( + list(part.pixel_w + from_w, part.pixel_z + from_z, hv_vis_turned(base, starts_turned, pivot[2], pivot[1]), 0, LINEAR_EASING), + list(part.pixel_w, part.pixel_z, hv_vis_turned(base, turn, pivot[2], pivot[1]), time, CUBIC_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, base, 4, SINE_EASING), + )) + return + if(!grounded) + // Nothing to pull it down: it drifts straight out from where it was, turning slowly. + if(from_w || from_z) + part.hv_vis_play(HV_VIS_TAG_ARC, list( + list(part.pixel_w + from_w, part.pixel_z + from_z, base, 0, LINEAR_EASING), + list(part.pixel_w, part.pixel_z, base, time, LINEAR_EASING), + )) + part.hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(part, 2, 30, pick(-1, 1), starts_turned)) + return + // In the air it passes over bodies on the floor and behind anyone standing. A part tg already draws + // over everyone (a brain, so it can be found) keeps its own layer. One that was in front of the body + // it came off stays in front of it until its first step is done. + if(starts_over) + flight_layer = ABOVE_MOB_LAYER + if(part.layer < flight_layer) + part.layer = flight_layer + var/height = arc_height() + // From where it was on the body, over the top of the arc, down onto its own spot on the last tile. + part.hv_vis_play(HV_VIS_TAG_ARC, list( + list(part.pixel_w + from_w, part.pixel_z + from_z, base, 0, LINEAR_EASING), + list(part.pixel_w + from_w / 2, part.pixel_z + from_z / 2 + height, base, time / 2, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, base, time / 2, QUAD_EASING|EASE_IN), + )) + // It tumbles as it flies; a part that only drops keeps the way it lies, or comes round from the way the + // body it came off lay. + if(!length(path)) + if(starts_turned) + var/list/pivot = part.hv_vis_spin_pivot() + part.hv_vis_play(HV_VIS_TAG_SPIN, list( + list(part.pixel_w, part.pixel_z, hv_vis_turned(base, starts_turned, pivot[2], pivot[1]), 0, LINEAR_EASING), + list(part.pixel_w, part.pixel_z, base, time, SINE_EASING), + )) + return + var/turns = style == HV_VIS_TUMBLE_ORGAN ? 1 : 2 + part.hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(part, turns, time, HV_VIS_DX(fling_dir) || pick(-1, 1), starts_turned)) + +/// One tile further along its path, or down if there is no more of it or something got in the way. +/datum/component/hv_tumble/proc/take_step() + step_timer = null + var/obj/item/part = parent + if(QDELETED(part) || !isturf(part.loc)) + give_up() + return + var/turf/next = length(path) ? path[1] : null + if(!next || !hv_vis_can_fly_onto(part.loc, next)) + land() + return + // Clear of the body it came off: from here it flies behind anyone standing. + if(steps_taken && flight_layer != HV_VIS_TUMBLE_LAYER) + if(part.layer == flight_layer) + part.layer = HV_VIS_TUMBLE_LAYER + flight_layer = HV_VIS_TUMBLE_LAYER + steps_taken++ + path.Cut(1, 2) + stepping = TRUE + part.set_glide_size(DELAY_TO_GLIDE_SIZE(step_time)) + part.forceMove(next) + stepping = FALSE + if(QDELETED(src)) + return + step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), step_time, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Blood on each tile it crosses. Anything else moving it (a hand, a conveyor) ends the flight. +/datum/component/hv_tumble/proc/on_moved(obj/item/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(!stepping) + give_up() + return + if(!grounded || !length(blood_dna) || streaks >= HV_VIS_TUMBLE_MAX_STREAKS) + return + var/turf/crossed = old_loc + if(!isturf(crossed) || isgroundlessturf(crossed) || crossed.has_gravity() <= 0) + return + streaks++ + // A few drops under it as it passes, not a line. + var/obj/effect/decal/cleanable/blood/hv_spray/streak/trail = hv_blood_lay_spray(crossed, /obj/effect/decal/cleanable/blood/hv_spray/streak/drops, fling_dir, blood_dna) + if(istype(trail)) + trail.hv_set_look(TRUE, rand(-3, 3)) + +/// It comes down: the bounce, a head's roll, and whoever asked to know. +/datum/component/hv_tumble/proc/land() + var/obj/item/part = parent + if(part.layer == flight_layer) + part.layer = lands_on_a_body() ? HV_VIS_TUMBLE_LAYER : rest_layer + // Settled here: Destroy() leaves the layer alone. + rest_layer = null + if(grounded && isturf(part.loc)) + switch(style) + if(HV_VIS_TUMBLE_HEAD) + part.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_bounce_steps(part, 4)) + hv_vis_roll(part, fling_dir) + if(HV_VIS_TUMBLE_BRAIN) + part.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_wet_bounce_steps(part)) + if(HV_VIS_TUMBLE_LIMB) + part.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_bounce_steps(part, 4)) + if(HV_VIS_TUMBLE_ORGAN) + part.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_bounce_steps(part, 2)) + var/datum/callback/landed = on_landed + on_landed = null + qdel(src) + landed?.Invoke() + +/// Whether someone lies on the tile it has come down on. tg draws anything on the floor under a body lying +/// there, so a head dropped beside its own body would be out of sight: it stays drawn over them instead, as +/// tg's brain always is. Picking it up puts its layer back. +/datum/component/hv_tumble/proc/lands_on_a_body() + var/obj/item/part = parent + for(var/mob/living/lying in part.loc) + if(lying.body_position == LYING_DOWN) + return TRUE + return FALSE + +/// Something else has the part now, or the flight never ended: let it go as it is. +/datum/component/hv_tumble/proc/give_up() + var/obj/item/part = parent + if(!QDELETED(part)) + part.hv_vis_stop(list(HV_VIS_TAG_ARC, HV_VIS_TAG_SPIN)) + qdel(src) + +/// Where this thing's picture sits inside its icon, in pixels from the middle: list(x, y). A turn has to be +/// about that point, or the picture swings round the middle of the icon instead of turning on the spot. +/atom/movable/proc/hv_vis_spin_pivot() + return list(0, 0) + +/// tg draws a severed limb shifted (px_x, px_y) so that it sits on the middle of its tile: the limb +/// itself is drawn that far the other way inside its icon. +/obj/item/bodypart/hv_vis_spin_pivot() + return list(-px_x, -px_y) + +/// `turns` whole turns over `time`, clockwise for `spin_sign` 1, ending as the part really lies. With +/// `from_angle` it starts turned that many degrees clockwise (as the body it came off lay) and the turns +/// bring it round from there. +/proc/hv_vis_spin_steps(atom/movable/part, turns, time, spin_sign = 1, from_angle = 0) + var/matrix/base = matrix(part.transform) + var/list/pivot = part.hv_vis_spin_pivot() + var/list/steps = list() + var/segments = max(3, turns * 3) + // The whole way round it has to go, the way it spins, to end as it really lies. + var/sweep = spin_sign * 360 * turns - from_angle + if(from_angle) + steps += list(list(part.pixel_w, part.pixel_z, hv_vis_turned(base, from_angle, pivot[2], pivot[1]), 0, LINEAR_EASING)) + for(var/i in 1 to segments) + var/matrix/turned = i == segments ? matrix(base) : hv_vis_turned(base, from_angle + sweep * i / segments, pivot[2], pivot[1]) + steps += list(list(part.pixel_w, part.pixel_z, turned, time / segments, LINEAR_EASING)) + return steps + +/// One bounce off the floor, `height` pixels. +/proc/hv_vis_bounce_steps(atom/movable/part, height) + var/matrix/base = matrix(part.transform) + return list( + list(part.pixel_w, part.pixel_z + height, base, 1, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, base, 1.5, BOUNCE_EASING), + ) + +/// A brain hitting the floor: it squashes flat, springs up, squashes again and wobbles still. +/proc/hv_vis_wet_bounce_steps(atom/movable/part) + var/matrix/base = matrix(part.transform) + // Squashed from the bottom edge, so it spreads on the floor rather than sinking into it. + var/matrix/flat = hv_vis_local(base, 1.3, 0.7, 0, 0, -16 * 0.3) + var/matrix/tall = hv_vis_local(base, 0.85, 1.15, 0, 0, 16 * 0.15) + var/matrix/settle = hv_vis_local(base, 1.12, 0.88, 0, 0, -16 * 0.12) + return list( + list(part.pixel_w, part.pixel_z, flat, 0.5, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z + 4, tall, 1.5, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, settle, 1.5, QUAD_EASING|EASE_IN), + list(part.pixel_w, part.pixel_z, base, 2, ELASTIC_EASING), + ) + +/// A head rolling on after it lands: one turn, a few pixels further along. It keeps its new spot on +/// the tile, as any dropped item does. +/proc/hv_vis_roll(obj/item/part, roll_dir) + var/dx = HV_VIS_DX(roll_dir) + var/dy = HV_VIS_DY(roll_dir) + var/spin = dx || pick(-1, 1) + var/end_x = clamp(part.pixel_x + dx * 6, -12, 12) + var/end_y = clamp(part.pixel_y + dy * 6, -12, 12) + var/matrix/base = matrix(part.transform) + var/list/pivot = part.hv_vis_spin_pivot() + var/start_x = part.pixel_x + var/start_y = part.pixel_y + animate(part, pixel_x = start_x, pixel_y = start_y, transform = base, time = 2.5, tag = HV_VIS_TAG_SPIN) + animate(pixel_x = start_x + (end_x - start_x) / 3, pixel_y = start_y + (end_y - start_y) / 3, transform = hv_vis_turned(base, spin * 120, pivot[2], pivot[1]), time = 1.5, easing = LINEAR_EASING) + animate(pixel_x = start_x + (end_x - start_x) * 2 / 3, pixel_y = start_y + (end_y - start_y) * 2 / 3, transform = hv_vis_turned(base, spin * 240, pivot[2], pivot[1]), time = 2, easing = LINEAR_EASING) + animate(pixel_x = end_x, pixel_y = end_y, transform = base, time = 3, easing = QUAD_EASING|EASE_OUT) + +// ---- Limbs and heads coming off ---- + +/// Whether this part, coming off, stays where the body is rather than flying. +/obj/item/bodypart/proc/hv_vis_stays_put() + return FALSE + +/// A head that bursts comes apart where it is (braingut/brain_burst.dm). +/obj/item/bodypart/head/hv_vis_stays_put() + return hv_bursting + +/// A limb or head taken off whole flies the way the blow went, tumbling. Burned-off parts and the +/// chest (which spills, braingut/gut_spill.dm) are left to tg. +/obj/item/bodypart/dismember(dam_type = BRUTE, silent = TRUE, wounding_type) + var/mob/living/carbon/limb_owner = owner + var/marked = FALSE + if(limb_owner && dam_type != BURN && body_zone != BODY_ZONE_CHEST) + marked = TRUE + if(hv_vis_stays_put()) + hv_vis_tumble = HV_VIS_TUMBLE_NONE + else if(!hv_vis_tumble) + hv_vis_tumble = hv_vis_tumble_style() + if(!hv_vis_fling_dir) + hv_vis_fling_dir = limb_owner.hv_blood_recent_hit_dir() + if(!hv_vis_tumble_dna && can_bleed()) + hv_vis_tumble_dna = limb_owner.get_blood_dna_list() + if(!hv_vis_fling_from) + hv_vis_fling_from = limb_owner.hv_vis_part_offset(body_zone) + if(isnull(hv_vis_fling_over)) + hv_vis_fling_over = limb_owner.hv_vis_part_in_front(body_zone) + if(isnull(hv_vis_fling_turn)) + hv_vis_fling_turn = limb_owner.hv_vis_lying_turn() + . = ..() + // Not sent flying after all (it burned away, or it was never let go): forget the fling. + if(marked && !QDELETED(src)) + hv_vis_forget_fling() + +/** + * Where the part at `zone` is drawn on this body as it faces and lies now, in pixels from the middle of + * its tile: list(x, y). Left and right swap on screen when a body turns, a body seen side-on has both + * arms and legs on its middle line, and a body on the floor is turned onto its side. + */ +/mob/living/carbon/proc/hv_vis_part_offset(zone) + var/x = 0 + var/y = 0 + var/side = hv_vis_zone_side(zone) + // Measured off the human body sprites (icons/mob/human/bodyparts_greyscale.dmi), each facing. + switch(zone) + if(BODY_ZONE_HEAD) + y = 9 + if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) + // Side-on, the arm we can see hangs a little behind the middle of the body. + x = side ? side * 6 : -HV_VIS_DX(dir) * 3 + y = 1 + if(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + x = side * 3 + y = -12 + // tg lays a body down by turning it clockwise. + switch(lying_angle) + if(90) + . = list(y, -x) + if(270) + . = list(-y, x) + else + . = list(x, y) + .[2] += pixel_y - base_pixel_y + +/// How far this body is drawn turned from upright as it lies now, in degrees clockwise, the short way round +/// (tg lays a body on its side, head to the east or to the west). 0 on its feet, 0 for a body on the floor +/// that a move draws upright, and 0 for one that went down this very tick: nobody has seen it fall yet. +/mob/living/carbon/proc/hv_vis_lying_turn() + if(body_position != LYING_DOWN || hv_vis_fell_time == world.time) + return 0 + var/matrix/drawn = transform + return round(arctan(drawn.a, drawn.b), 1) + +/// Whether the part at `zone` is drawn in front of the rest of this body as it stands now: the arm and +/// leg on our side of a body seen side-on (its right ones facing east, its left ones facing west). +/mob/living/carbon/proc/hv_vis_part_in_front(zone) + if(body_position == LYING_DOWN) + return FALSE + switch(dir) + if(EAST) + return zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG + if(WEST) + return zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_L_LEG + return FALSE + +// ---- Burst heads ---- + +/// The brain and the other loose organs of a burst head tumble out, the gibs arc, and the blow gets a hit-stop. +/mob/living/carbon/hv_burst_head_apart(burst_dir, announce = TRUE) + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + var/list/obj/item/organ/marked = list() + if(head?.can_dismember() && !HAS_TRAIT(src, TRAIT_GODMODE)) + var/list/blood_dna = can_bleed() ? get_blood_dna_list() : null + // Everything comes out of the head, wherever that is on a body standing or lying. + var/list/head_at = hv_vis_part_offset(BODY_ZONE_HEAD) + for(var/obj/item/organ/organ in head) + organ.hv_vis_tumble = organ.hv_vis_tumble_style() + organ.hv_vis_tumble_dna = blood_dna + organ.hv_vis_fling_from = head_at + marked += organ + GLOB.hv_vis_arcing_gibs = head_at + // Before the burst, so the death it causes sees the blow was a big one. + hv_vis_big_hit(burst_dir) + . = ..() + GLOB.hv_vis_arcing_gibs = null + for(var/obj/item/organ/organ as anything in marked) + if(!QDELETED(organ)) + organ.hv_vis_forget_fling() + +/// Gibs thrown by a burst head leave from the head, rise and fall as they fly, and turn over once. +/obj/effect/decal/cleanable/blood/gibs/streak(list/directions, mapload = FALSE) + var/list/head_at = mapload ? null : GLOB.hv_vis_arcing_gibs + var/turf/thrown_from = head_at ? get_turf(src) : null + . = ..() + if(head_at && !QDELETED(src) && isturf(loc) && has_gravity() > 0) + var/matrix/base = matrix(transform) + var/height = rand(8, 14) + // tg steps a gib off its tile the moment it is made, so it is first seen on the next one: the arc + // starts back at the head it came out of. + var/from_w = head_at[1] + (thrown_from ? (thrown_from.x - x) * ICON_SIZE_X : 0) + var/from_z = head_at[2] + (thrown_from ? (thrown_from.y - y) * ICON_SIZE_Y : 0) + hv_vis_play(HV_VIS_TAG_ARC, list( + list(pixel_w + from_w, pixel_z + from_z, base, 0, LINEAR_EASING), + list(pixel_w + from_w / 2, pixel_z + from_z / 2 + height, base, 2, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, 2.5, QUAD_EASING|EASE_IN), + list(pixel_w, pixel_z + 2, base, 0.7, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, 1.5, BOUNCE_EASING), + )) + // One whole turn over the flight, so it lands as it lies: no turning back at the end. + hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(src, 1, 4.5, pick(-1, 1))) diff --git a/voidcrew/modules/hyperviolence/visuals/gut_motion.dm b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm new file mode 100644 index 00000000000..1aac864718a --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm @@ -0,0 +1,24 @@ +// Hyperviolence visuals: guts. +// 16. Guts dragged along the floor, by someone pulling them or by the strand, slither as they go. + +/obj/item/hv_intestines/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + if(!isturf(loc) || !isturf(old_loc) || throwing) + return + hv_vis_slither(movement_dir || get_dir(old_loc, loc)) + +/// One slither along the floor: a wiggle from side to side across the way it is going. +/obj/item/hv_intestines/proc/hv_vis_slither(going) + // Across the way it moves: east-west movement wiggles north-south, and the other way round. + var/side_w = HV_VIS_DY(going) ? 2 : 0 + var/side_z = HV_VIS_DX(going) ? 2 : 0 + if(!side_w && !side_z) + side_w = 2 + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_SHAKE, list( + list(pixel_w + side_w, pixel_z + side_z, hv_vis_local(base, 1.1, 0.9, 12), 1, SINE_EASING), + list(pixel_w - side_w, pixel_z - side_z, hv_vis_local(base, 0.92, 1.06, -12), 1.2, SINE_EASING), + list(pixel_w + round(side_w / 2), pixel_z + round(side_z / 2), hv_vis_local(base, 1.04, 0.97, 6), 1, SINE_EASING), + list(pixel_w, pixel_z, base, 1, SINE_EASING), + )) + diff --git a/voidcrew/modules/hyperviolence/visuals/hit_reactions.dm b/voidcrew/modules/hyperviolence/visuals/hit_reactions.dm new file mode 100644 index 00000000000..e8164a8037b --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/hit_reactions.dm @@ -0,0 +1,186 @@ +// Hyperviolence visuals: getting hit. +// 1. A flinch along the blow, scaled by its damage: a pistol round twitches a body, a shotgun's +// pellets together stagger it back past where it stood. +// 2. A head hit whips the body back and tilts it hard. +// 3. A sever, a burst head or a crit holds the flinch at its peak for a few frames (hit-stop), with a +// pale flash, and whoever landed it holds their pose too (_motion_core.dm). +// 4. Losing a limb lurches the body toward the missing side; with a leg gone, every step dips and +// leans that way. +// The idea of a flinch on apply_damage() is Shiptest's shake_animation() (code/__HELPERS/matrices.dm, +// AGPL-3.0); this is a rewrite with the direction of the blow and named sequences. + +/** + * The steps of a flinch: out along the blow and tilted away from it, held for `hold` if this is a + * hit-stop, then back. A big enough blow (a stagger) reels back past the start before it settles. + * A lying body only jolts. Returns the steps for hv_vis_play(), ending where the body is now. + */ +/mob/living/proc/hv_vis_flinch_steps(damage, attack_dir, headshot = FALSE, hold = 0) + var/dx = HV_VIS_DX(attack_dir) + var/dy = HV_VIS_DY(attack_dir) + var/push = clamp(round(damage / HV_VIS_FLINCH_DAMAGE_PER_PIXEL), 1, HV_VIS_FLINCH_MAX_PUSH) + var/tilt = clamp(damage / HV_VIS_FLINCH_DAMAGE_PER_DEGREE, 2, HV_VIS_FLINCH_MAX_TILT) + var/stagger = damage >= HV_VIS_STAGGER_DAMAGE + if(headshot) + push = min(push + 2, HV_VIS_FLINCH_MAX_PUSH) + tilt = clamp(tilt * 1.8, HV_VIS_HEADSHOT_MIN_TILT, HV_VIS_HEADSHOT_MAX_TILT) + if(body_position == LYING_DOWN || stat == DEAD) + push = max(1, round(push / 2)) + tilt = 0 + stagger = FALSE + if(hv_vis_is_huge()) + push = max(1, round(push / 2)) + tilt /= 2 + // A diagonal push covers as much ground as a straight one. + if(dx && dy) + push = max(1, round(push * 0.7)) + // Tilted away from the blow; a blow from straight above or below tips it a little either way. + var/tilt_sign = dx + if(!dx) + tilt_sign = pick(-1, 1) + tilt *= 0.5 + var/base_w = pixel_w + var/base_z = pixel_z + var/matrix/base = matrix(transform) + var/pivot = hv_vis_pivot() + var/matrix/peak = hv_vis_turned(base, tilt * tilt_sign, pivot) + var/list/steps = list() + var/out_time = stagger ? 1 : (headshot ? 0.4 : 0.6) + steps += list(list(base_w + dx * push, base_z + dy * push, peak, out_time, QUAD_EASING|EASE_OUT)) + if(hold > 0) + steps += list(list(base_w + dx * push, base_z + dy * push, peak, hold, LINEAR_EASING)) + if(stagger) + var/matrix/rebound = hv_vis_turned(base, -tilt * tilt_sign * 0.35, pivot) + steps += list(list(base_w - round(dx * push * 0.35), base_z - round(dy * push * 0.35), rebound, 2.5, SINE_EASING)) + steps += list(list(base_w, base_z, base, 2.5, SINE_EASING)) + else + steps += list(list(base_w, base_z, base, headshot ? 4 : 3, ELASTIC_EASING)) + return steps + +/// Flinches from a blow of `damage` travelling `attack_dir`. See hv_vis_flinch_steps(). +/mob/living/proc/hv_vis_flinch(damage, attack_dir, headshot = FALSE, hold = 0) + if(damage < HV_VIS_FLINCH_MIN || !attack_dir || !isturf(loc)) + return + hv_vis_play(HV_VIS_TAG_FLINCH, hv_vis_flinch_steps(damage, attack_dir, headshot, hold)) + +// ---- Crits ask for a hit-stop ---- + +/mob/living/carbon/hv_try_aimed_crit(obj/item/bodypart/limb, aimed_zone, wounding_type, damage, wound_bonus, exposed_wound_bonus, attack_direction, atom/source, mob/living/attacker, forced_roll) + . = ..() + if(.) + hv_vis_big_hit(attack_direction, attacker) + +// ---- Lost limbs ---- + +/mob/living/proc/hv_vis_on_limb_lost(obj/item/bodypart/lost_limb, special, dismembered) + return + +/mob/living/carbon/hv_vis_on_limb_lost(obj/item/bodypart/lost_limb, special, dismembered) + if(special || QDELETED(src)) + return + var/zone = lost_limb.body_zone + if(dismembered) + hv_vis_big_hit(hv_blood_recent_hit_dir()) + if(stat == DEAD || zone == BODY_ZONE_CHEST) + return + if(zone != BODY_ZONE_HEAD) + hv_vis_lurch(zone) + if((zone == BODY_ZONE_L_LEG || zone == BODY_ZONE_R_LEG) && default_num_legs >= 2) + AddComponent(/datum/component/hv_limp) + +/// Which way, on screen, the side of this body that `zone` is on lies: -1 left, 1 right, 0 neither +/// (seen side-on). Facing south, a body's left arm is drawn on the right of the screen. +/mob/living/carbon/proc/hv_vis_zone_side(zone) + var/left = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_L_LEG) + switch(dir) + if(SOUTH) + return left ? 1 : -1 + if(NORTH) + return left ? -1 : 1 + return 0 + +/// The steps of a lurch toward the side a limb was lost from. A body seen side-on pitches forward. +/mob/living/carbon/proc/hv_vis_lurch_steps(zone) + var/side = hv_vis_zone_side(zone) + var/dx = side + var/dy = 0 + if(!side) + dx = HV_VIS_DX(dir) + dy = HV_VIS_DY(dir) + var/tilt = HV_VIS_LURCH_TILT * (side || dx || pick(-1, 1)) + var/standing = body_position == STANDING_UP + if(!standing) + tilt = 0 + var/matrix/base = matrix(transform) + var/matrix/lurched = hv_vis_turned(base, tilt, hv_vis_pivot()) + var/matrix/caught = hv_vis_turned(base, -tilt * 0.3, hv_vis_pivot()) + return list( + list(pixel_w + dx * HV_VIS_LURCH_PUSH, pixel_z + dy * HV_VIS_LURCH_PUSH, lurched, 1, QUAD_EASING|EASE_OUT), + list(pixel_w + dx * HV_VIS_LURCH_PUSH, pixel_z + dy * HV_VIS_LURCH_PUSH, lurched, 1.5, LINEAR_EASING), + list(pixel_w - dx, pixel_z - dy, caught, 2.5, SINE_EASING), + list(pixel_w, pixel_z, base, 3, SINE_EASING), + ) + +/mob/living/carbon/proc/hv_vis_lurch(zone) + if(!isturf(loc)) + return + hv_vis_play(HV_VIS_TAG_LURCH, hv_vis_lurch_steps(zone)) + +/** + * A body standing on one leg: every step dips and leans toward the missing one. Listens to the + * body's own moves only while a leg is gone, and goes when both legs are back or the body dies. + */ +/datum/component/hv_limp + dupe_mode = COMPONENT_DUPE_UNIQUE + +/datum/component/hv_limp/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/hv_limp/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_CARBON_POST_ATTACH_LIMB, PROC_REF(on_limb_attached)) + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/hv_limp/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_CARBON_POST_ATTACH_LIMB, COMSIG_LIVING_DEATH)) + +/// The leg that is missing, or null if both are there. +/datum/component/hv_limp/proc/missing_leg() + var/mob/living/carbon/body = parent + var/has_left = !!body.get_bodypart(BODY_ZONE_L_LEG) + var/has_right = !!body.get_bodypart(BODY_ZONE_R_LEG) + if(has_left == has_right) + return null // both there, or both gone and crawling + return has_left ? BODY_ZONE_R_LEG : BODY_ZONE_L_LEG + +/datum/component/hv_limp/proc/on_moved(mob/living/carbon/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(forced || !isturf(source.loc) || source.body_position != STANDING_UP || source.buckled || source.stat != CONSCIOUS) + return + var/zone = missing_leg() + if(!zone) + if(source.get_bodypart(BODY_ZONE_L_LEG) && source.get_bodypart(BODY_ZONE_R_LEG)) + qdel(src) + return + source.hv_vis_play(HV_VIS_TAG_LIMP, source.hv_vis_limp_steps(zone)) + +/datum/component/hv_limp/proc/on_limb_attached(datum/source) + SIGNAL_HANDLER + if(!missing_leg()) + var/mob/living/carbon/body = parent + if(body.get_bodypart(BODY_ZONE_L_LEG) && body.get_bodypart(BODY_ZONE_R_LEG)) + qdel(src) + +/datum/component/hv_limp/proc/on_death(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// One limping step: down and over toward the missing leg, and back up. +/mob/living/carbon/proc/hv_vis_limp_steps(zone) + var/side = hv_vis_zone_side(zone) + var/matrix/base = matrix(transform) + var/matrix/leaned = hv_vis_turned(base, HV_VIS_LIMP_TILT * (side || pick(-1, 1)), HV_VIS_FEET_Y * current_size) + return list( + list(pixel_w + side, pixel_z - HV_VIS_LIMP_DIP, leaned, 1.5, SINE_EASING), + list(pixel_w, pixel_z, base, 1.5, SINE_EASING), + ) diff --git a/voidcrew/modules/hyperviolence/visuals/npc_motion.dm b/voidcrew/modules/hyperviolence/visuals/npc_motion.dm new file mode 100644 index 00000000000..1afc22618e6 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/npc_motion.dm @@ -0,0 +1,91 @@ +// Hyperviolence visuals: people who are losing. These read the same on players and human NPCs: an +// NPC that surrenders, pleads or injects itself does so through the same tg actions. +// 19. Surrender (tg's *surrender emote): down on the floor with hands on head, trembling until they +// get up. +// 20. In soft crit, anything a body says +// comes with a pleading lean up toward whoever last hurt it. Injecting yourself with a medipen +// or hypospray while badly hurt is shaky. + +/// How long the surrender tremble runs: tg's surrender paralyses for 20 seconds. +#define HV_VIS_SURRENDER_TIME (20 SECONDS) + +/mob/living/proc/hv_vis_surrender() + if(stat == DEAD) + return + hv_vis_tremble(HV_VIS_SURRENDER_TIME, 1) + hv_vis_hold(list(HV_VIS_TAG_TREMBLE), HV_VIS_END_STAND|HV_VIS_END_DEATH|HV_VIS_END_MOVE, HV_VIS_SURRENDER_TIME + 1 SECONDS) + +/mob/living/proc/hv_vis_on_stat_change(new_stat, old_stat) + return + +/mob/living/carbon/hv_vis_on_stat_change(new_stat, old_stat) + if(new_stat == SOFT_CRIT && old_stat != SOFT_CRIT) + AddComponent(/datum/component/hv_downed) + +/** + * A body in soft crit: whatever it says comes with a lean up toward whoever last hurt it. Goes when the body leaves soft crit. + */ +/datum/component/hv_downed + dupe_mode = COMPONENT_DUPE_UNIQUE + +/datum/component/hv_downed/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/hv_downed/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOB_SAY, PROC_REF(on_say)) + RegisterSignal(parent, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + +/datum/component/hv_downed/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOB_SAY, COMSIG_MOB_STATCHANGE)) + +/datum/component/hv_downed/proc/on_stat_change(mob/living/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != SOFT_CRIT) + source.hv_vis_stop(list(HV_VIS_TAG_LEAN)) + qdel(src) + +/datum/component/hv_downed/proc/on_say(mob/living/carbon/source, list/speech_args) + SIGNAL_HANDLER + if(source.stat != SOFT_CRIT || !isturf(source.loc)) + return + source.hv_vis_plead(source.hv_vis_hit_by?.resolve()) + +/// Leans up toward `toward` (or the way it faces) for a moment: a body on the floor begging. +/mob/living/proc/hv_vis_plead(atom/toward) + var/plead_dir = dir + if(toward && get_dist(src, toward) <= 7 && toward.z == z) + plead_dir = get_dir(src, toward) || dir + var/dx = HV_VIS_DX(plead_dir) + var/dy = HV_VIS_DY(plead_dir) + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_LEAN, list( + list(pixel_w + dx * 3, pixel_z + dy * 3 + 2, base, 4, SINE_EASING), + list(pixel_w + dx * 3, pixel_z + dy * 3 + 2, base, 8, LINEAR_EASING), + list(pixel_w, pixel_z, base, 5, SINE_EASING), + )) + +// ---- Shaky self-injection ---- + +/obj/item/reagent_containers/hypospray/inject(mob/living/affected_mob, mob/user) + . = ..() + if(!. || user != affected_mob || !isliving(user)) + return + var/mob/living/patient = user + if(patient.stat >= SOFT_CRIT || patient.health < patient.maxHealth * 0.5) + patient.hv_vis_shaky_inject() + +/// Hands shaking as they jab themselves, then a sag of relief. +/mob/living/proc/hv_vis_shaky_inject() + if(!isturf(loc)) + return + var/matrix/base = matrix(transform) + var/list/steps = list() + for(var/i in 1 to 6) + var/side = i % 2 ? 1 : -1 + steps += list(list(pixel_w + side, pixel_z, hv_vis_turned(base, side * 2, hv_vis_pivot()), 0.6, LINEAR_EASING)) + steps += list(list(pixel_w, pixel_z - 1, hv_vis_local(base, 1, 0.97), 4, SINE_EASING)) + steps += list(list(pixel_w, pixel_z, base, 5, SINE_EASING)) + hv_vis_play(HV_VIS_TAG_SHAKE, steps) + +#undef HV_VIS_SURRENDER_TIME diff --git a/voidcrew/modules/hyperviolence/wire/_wire_defines.dm b/voidcrew/modules/hyperviolence/wire/_wire_defines.dm new file mode 100644 index 00000000000..82e4214f29e --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/_wire_defines.dm @@ -0,0 +1,83 @@ +// Widowline monowire x hyperviolence (voidcrew/modules/hyperviolence/wire/): defines private to this part. +// The folder sorts after cyberware/ and every other hyperviolence part on purpose: it overrides procs both +// declare, and an override included before its declaration is silently dead. + +/// Clean cuts, seams, the through-cut and blood beams, the garrote loop and taut wire. +#define WIRE_GORE_ICON 'voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi' +/// The wire swinging out of its spool and reeling back in, with blood-flick twins. +#define WIRE_HELD_ANIM_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi' +/// The held wire's spool on its own, as each hand draws it ("_left", "_right"): what is left in +/// the hand while the wire is out of it as a line. +#define WIRE_SPOOL_ICON 'voidcrew/modules/hyperviolence/wire/icons/wire_spool.dmi' + +/// On a body a Widowline is cutting right now: no flying hitsplatter, drips instead of floor splatter, +/// and a limb tg's own wound roll would tear off is left for the wire to take off clean. +#define TRAIT_HV_CLEAN_CUT "hv_clean_cut" + +/// An aimed cut takes off an arm or leg already this damaged (percent of its max damage) before the hit. +/// Whole percents: in DM's floats 50 * 0.6 is a hair over 30, and a limb at exactly 60% would never qualify. +#define WIRE_SEVER_AT 60 +/// The same on someone already down. +#define WIRE_SEVER_AT_DOWN 20 +/// A crossing cut takes a limb off at this share of the usual damage (percent). +#define WIRE_CROSS_SEVER_SHARE 50 +/// With a wire out in each hand, every this many landed hits cross both through the target. +#define WIRE_CROSS_HITS 3 +/// Landed hits further apart than this start the count again. +#define WIRE_CROSS_WINDOW (3 SECONDS) + +/// From the hit to the severed limb sliding off the body. +#define WIRE_CUT_DELAY (0.5 SECONDS) +/// The slide along the cut, and the fall after it. +#define WIRE_SLIDE_TIME (0.2 SECONDS) +#define WIRE_FALL_TIME (0.15 SECONDS) +/// Someone on their feet whose leg the wire took goes down once the leg is on the floor... +#define WIRE_LEG_FALL_DELAY (WIRE_CUT_DELAY + WIRE_SLIDE_TIME + WIRE_FALL_TIME) +/// ...knocked down as long as a shove knocks a person down. +#define WIRE_LEG_KNOCKDOWN SHOVE_KNOCKDOWN_HUMAN +/// Blood the Arc's seared cut loses, as a fraction of a plain sever's. +#define WIRE_ARC_BLEED_MULT 0.25 +/// How long a seared stump smokes. +#define WIRE_ARC_SMOKE_TIME (3 SECONDS) +/// A seared stump's blood layer and bone. +#define WIRE_BURNT_STUMP_COLOR "#2a1b15" +#define WIRE_BURNT_BONE_COLOR "#7a6a60" + +/// Blood on the wire: wet and dripping for this long after a cut, stained for this long. +#define WIRE_WET_TIME (15 SECONDS) +#define WIRE_STAINED_TIME (30 SECONDS) +/// Most drips one wet spell leaves. +#define WIRE_MAX_DRIPS 8 +/// Drops a wet wire throws when it cracks back into the spool. +#define WIRE_FLICK_DROPS 6 + +/// The swing out of the spool and back in. +#define WIRE_SWING_TIME (0.4 SECONDS) +/// A lash's line fades out over this long. +#define WIRE_LASH_FADE (0.5 SECONDS) +/// The wire hanging in the hand isn't drawn for this long into a lash, while the line out of the grip stands in for it, +/// and is back as the line fades: never two wires from one hand, never an empty one. +#define WIRE_LASH_HIDE (0.45 SECONDS) +/// The alpha a lash's line is down to by then, held until it goes. +#define WIRE_LASH_TRACE 40 + +/// The garrote's wait after an attempt that got a grip, and after one that didn't. +#define WIRE_GARROTE_COOLDOWN (20 SECONDS) +#define WIRE_GARROTE_MISS_COOLDOWN (5 SECONDS) +/// Worn and plated cover at the neck (crit gate rating, the wire's penetration not counted) that turns the garrote. +#define WIRE_GARROTE_COVER 25 +/// World layer between a body lying on the floor and one standing: the garrote's wire facing away, which runs from the +/// hands hidden behind the garroter to the neck of the body lying beyond them. +#define WIRE_BETWEEN_LAYER (LYING_MOB_LAYER + 0.05) +/// World layer of a body the garrote holds up in front of a garroter facing the viewer: over the garroter, under the +/// near side of the loop. +#define WIRE_PULLED_LAYER (MOB_LAYER + 0.02) +/// Most blood the garrote pours into the pool under the neck: a pool that keeps to its tile (blood/blood_pools.dm). +#define WIRE_GARROTE_POOL_CAP 190 + +/// Mob overlay layers. The held limb: over the jumpsuit and the stump, under the ID, belt, suit and hands. +#define WIRE_GHOST_LAYER (-(HV_STUMP_FRESH_LAYER - 0.1)) +/// The wire swinging out of or into a hand: just over the held item it turns into. +#define WIRE_SWING_LAYER (-(HANDS_LAYER - 0.5)) +/// Flashes, seams and loops: over everything the body wears. +#define WIRE_FX_LAYER (-(HIGHEST_LAYER - 0.5)) diff --git a/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi b/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi new file mode 100644 index 00000000000..934ff94ec8e Binary files /dev/null and b/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi differ diff --git a/voidcrew/modules/hyperviolence/wire/icons/wire_spool.dmi b/voidcrew/modules/hyperviolence/wire/icons/wire_spool.dmi new file mode 100644 index 00000000000..9cdd5983610 Binary files /dev/null and b/voidcrew/modules/hyperviolence/wire/icons/wire_spool.dmi differ diff --git a/voidcrew/modules/hyperviolence/wire/wire_cut.dm b/voidcrew/modules/hyperviolence/wire/wire_cut.dm new file mode 100644 index 00000000000..a6a78e85757 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_cut.dm @@ -0,0 +1,317 @@ +/** + * # The Widowline's cut + * + * What a landed wire swing does to the bodies it passes through, on top of its damage + * (cyberware/ware_military_arms.dm): + * - It cuts clean (wire_seams.dm): no fan of blood, a thin line and a few drops. + * - An aimed cut takes off an arm or leg that is already badly hurt (WIRE_SEVER_AT of its max damage before + * the hit), or less hurt on someone already down (WIRE_SEVER_AT_DOWN). Never a corpse, never through an + * Atlas frame (tg's can_dismember()), never an ally. The limb is off at the hit; it stays drawn on for a + * beat and slides off after (wire_sever_fx.dm). Someone on their feet who loses a leg goes down once it has + * dropped (wire_leg_fall()): tg's own knockdown, so the fall is tg's and they get up again on one leg. + * - The Arc's charged wire sears the stump shut: little blood, no spray (wire_sever_fx.dm). + * - It carries on into one body standing right behind the target, along the swing's line, and nobody else, as a + * line of the one hit message. Hurting someone at a trader outpost this way counts as an attack there. + * - With a wire out in each hand, every third landed hit in a row crosses both through the target: both wires + * lash out, and the sever takes a limb at half the usual damage. + * - The aimed sever, the crossing cut and the garrote are the wielder's moves, open to whoever + * hv_wire_moves_allowed() lets use them (wire_users.dm); the rest belongs to the weapon. + * + * The hooks run inside the hit: wire_cut_begin() before the parent attack(), on_cut() from the target's + * own damage signal while tg resolves it, wire_cut_end() after. + */ + +/obj/item/melee/cyberware_monowire + /// The body this swing is cutting, from wire_cut_begin() to wire_cut_end(). + var/mob/living/cutting + /// Whether that body was already dead when the swing began. + var/cut_on_corpse = FALSE + /// Whether that body was already down when the swing began. + var/cut_on_downed = FALSE + /// The zone this swing was aimed at, read when it began: the target doll, or what wire_aimed_swing() was told. + var/cut_aim + /// The next landed hit crosses, whatever the count (wire_crossing_swing()). + var/force_cross = FALSE + /// The body zone the last landed cut went into, for the lash drawn after the hit (wire_lash_at()). + var/lash_zone + /// The next lash this wire draws is its half of a crossing cut, through the middle of the chest (wire_lash_at()). + var/lash_crossing = FALSE + /// Who the last landed cut carried on into, right behind its target (wire_cleave()): the lash drawn after the hit + /// runs on to them (wire_lash_through()). + var/datum/weakref/lash_through + +/mob/living + /// Landed Widowline hits in a row with a wire out in each hand, and when the last one landed. + var/wire_cross_hits = 0 + var/wire_cross_last = 0 + +/// Opens the cut on `target`: it cuts clean, and the damage signal tells us where it landed. +/obj/item/melee/cyberware_monowire/proc/wire_cut_begin(mob/living/target, mob/living/user) + if(!isliving(target) || !isliving(user) || QDELETED(target)) + return + cutting = target + cut_on_corpse = target.stat == DEAD + cut_on_downed = hv_finisher_is_down(target) + cut_aim = user.zone_selected + // A crossing cut whose lash was never drawn (its target gone) doesn't carry over to this swing. Nor a through-cut's. + lash_crossing = FALSE + lash_through = null + ADD_TRAIT(target, TRAIT_HV_CLEAN_CUT, REF(src)) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_cut), override = TRUE) + +/// Closes the cut on `target`. +/obj/item/melee/cyberware_monowire/proc/wire_cut_end(mob/living/target) + if(isliving(target)) + UnregisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE) + REMOVE_TRAIT(target, TRAIT_HV_CLEAN_CUT, REF(src)) + target.hv_wire_owed_sever = null + cutting = null + cut_aim = null + +/// The swing landed: the wire's brute damage was just applied to `source`. +/obj/item/melee/cyberware_monowire/proc/on_cut(mob/living/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + if(source != cutting || attacking_item != src || damagetype != BRUTE) + return + // One cut per swing: the burn rider and anything the hit sets off are not cuts. + UnregisterSignal(source, COMSIG_MOB_AFTER_APPLY_DAMAGE) + var/mob/living/user = loc + if(!isliving(user)) + return + var/obj/item/bodypart/cut_part = isbodypart(def_zone) ? def_zone : null + lash_zone = cut_part ? cut_part.body_zone : check_zone(def_zone) + var/cross = wire_count_landed(user) + var/obj/item/bodypart/severed = wire_try_sever(source, user, def_zone, damage_dealt, attack_direction || get_dir(user, source), cross) + // The lash meets the limb that comes off, whichever part the blow landed on. + if(severed) + lash_zone = severed.body_zone + if(cross) + wire_cross_lash(user, source) + if(severed) + // The cut shows when the limb slides off (wire_sever_fx.dm), and the wire comes away wet then too. + addtimer(CALLBACK(src, PROC_REF(wire_bloody), source), WIRE_CUT_DELAY) + else + wire_bloody(source, def_zone) + wire_cleave(source, user, clean = !isnull(severed) || cross) + +// ---- The crossing cut ---- + +/// How many Widowlines `user` has out. +/proc/wire_count_held(mob/living/user) + . = 0 + for(var/obj/item/melee/cyberware_monowire/wire in user.held_items) + .++ + +/// Whether the next landed hit is the crossing one. +/obj/item/melee/cyberware_monowire/proc/wire_cross_due(mob/living/user) + if(!hv_wire_moves_allowed(user) || wire_count_held(user) < 2) + return FALSE + if(force_cross) + return TRUE + return user.wire_cross_hits >= WIRE_CROSS_HITS - 1 && world.time - user.wire_cross_last <= WIRE_CROSS_WINDOW + +/// Counts a landed hit. Returns TRUE if it is the crossing one. +/obj/item/melee/cyberware_monowire/proc/wire_count_landed(mob/living/user) + if(wire_count_held(user) < 2 || !hv_wire_moves_allowed(user)) + user.wire_cross_hits = 0 + return FALSE + if(force_cross) + user.wire_cross_hits = 0 + return TRUE + if(world.time - user.wire_cross_last > WIRE_CROSS_WINDOW) + user.wire_cross_hits = 0 + user.wire_cross_hits++ + user.wire_cross_last = world.time + if(user.wire_cross_hits < WIRE_CROSS_HITS) + return FALSE + user.wire_cross_hits = 0 + return TRUE + +/// The other wire's lash in a crossing cut follows this wire's by this much. +#define WIRE_CROSS_LASH_LAG (0.1 SECONDS) + +/** + * The crossing cut: the other hand's wire lashes into the target a beat after this one's. Each line runs from its own + * hand through the middle of the target's chest and a little past it, so the two cross there (wire_lash_at()). + */ +/obj/item/melee/cyberware_monowire/proc/wire_cross_lash(mob/living/swinger, mob/living/target) + swinger.mob_light(2, 1, cross_light_color(), 0.3 SECONDS) + lash_crossing = TRUE + for(var/obj/item/melee/cyberware_monowire/other in swinger.held_items) + if(other == src) + continue + addtimer(CALLBACK(other, PROC_REF(wire_lash_at), swinger, target, lash_zone, TRUE), WIRE_CROSS_LASH_LAG) + playsound(target, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) + +/obj/item/melee/cyberware_monowire/proc/cross_light_color() + return "#ff8cf8" + +/obj/item/melee/cyberware_monowire/live/cross_light_color() + return "#9fe4ff" + +// ---- Severing ---- + +/// Whether this wire may take `limb` off: an arm or leg, on a body that was alive when the swing began +/// (`corpse`), that can lose it (an Atlas frame can't), and that isn't the wielder's ally. +/obj/item/melee/cyberware_monowire/proc/wire_can_sever(obj/item/bodypart/limb, mob/living/user, corpse) + if(isnull(corpse)) + corpse = cut_on_corpse + var/mob/living/carbon/victim = limb?.owner + if(!victim || corpse || !(limb.body_zone in GLOB.hv_wire_sever_zones)) + return FALSE + if(!limb.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + return !cyberware_is_ally(user, victim) + +/** + * Takes the limb the cut landed on, if it was aimed there and was hurt enough before the hit, or the limb tg's + * own roll wanted off this hit. Returns the severed limb, or null. + */ +/obj/item/melee/cyberware_monowire/proc/wire_try_sever(mob/living/source, mob/living/user, def_zone, damage_dealt, spray_dir, cross) + if(!iscarbon(source)) + return null + var/mob/living/carbon/victim = source + var/obj/item/bodypart/owed = victim.hv_wire_owed_sever + victim.hv_wire_owed_sever = null + if(owed?.owner == victim && wire_can_sever(owed, user)) + return wire_sever(owed, user, spray_dir, cross) + // The aimed sever is a move of the wielder's (wire_users.dm); tg's own roll above is the weapon's. + if(!hv_wire_moves_allowed(user)) + return null + var/obj/item/bodypart/limb = isbodypart(def_zone) ? def_zone : victim.get_bodypart(check_zone(def_zone)) + if(!limb || limb.owner != victim || check_zone(cut_aim) != limb.body_zone) + return null + if(!wire_can_sever(limb, user)) + return null + var/needed = cut_on_downed ? WIRE_SEVER_AT_DOWN : WIRE_SEVER_AT + if(cross) + needed = needed * WIRE_CROSS_SEVER_SHARE / 100 + if(round((limb.get_damage() - damage_dealt) * 100) < round(limb.max_damage * needed)) + return null + return wire_sever(limb, user, spray_dir, cross) + +// Its full penetration is for its cuts. At #509's crit gates a collar, helmet or plating counts against it as +// against any blade (owner D33): the crit roll treats it as no penetration at all. +/obj/item/melee/cyberware_monowire/hv_crit_armour_pen() + return 0 + +/// Whether this wire sears what it cuts on `victim` shut. Only the Arc's charge, and only where its shock reaches. +/obj/item/melee/cyberware_monowire/proc/wire_sears(mob/living/victim) + return FALSE + +/obj/item/melee/cyberware_monowire/live/wire_sears(mob/living/victim) + return !HAS_TRAIT(victim, TRAIT_SHOCKIMMUNE) + +/** + * Takes `limb` off at the hit. The body is real at once (the arm is gone, its hand's grip with it); the picture + * lags: the limb stays drawn on until it slides off (wire_sever_fx.dm). Returns the severed limb, or null. + */ +/obj/item/melee/cyberware_monowire/proc/wire_sever(obj/item/bodypart/limb, mob/living/user, spray_dir, cross) + var/mob/living/carbon/victim = limb.owner + var/zone = limb.body_zone + var/zone_name = limb.plaintext_zone + var/seared = wire_sears(victim) + // Drawn while the limb is still on, so it can be held there. + var/obj/effect/abstract/cyberware_anim/wire_ghost/ghost = wire_ghost_limb(victim, limb, seared, spray_dir) + LAZYSET(victim.hv_stump_reveal_at, zone, world.time + WIRE_CUT_DELAY) + if(seared) + LAZYSET(victim.hv_stump_burnt, zone, TRUE) + // tg pulls the shoes off a leg that comes off; they go with the leg. + var/obj/item/shoes = (zone == BODY_ZONE_L_LEG || zone == BODY_ZONE_R_LEG) && ishuman(victim) ? victim.get_item_by_slot(ITEM_SLOT_FEET) : null + // No blood on the floor yet: it starts when the limb slides off (wire_ghost/slide()). + ADD_TRAIT(victim, TRAIT_HV_CLEAN_CUT, "wire_sever") + var/obj/item/bodypart/severed = hv_wire_take_part(limb, seared ? WOUND_BURN : WOUND_SLASH, seared ? WIRE_ARC_BLEED_MULT : 1, splatter = FALSE) + REMOVE_TRAIT(victim, TRAIT_HV_CLEAN_CUT, "wire_sever") + if(!severed) + LAZYREMOVE(victim.hv_stump_reveal_at, zone) + LAZYREMOVE(victim.hv_stump_burnt, zone) + qdel(ghost) + return null + if(seared) + severed.wire_char() + else + severed.wire_clean_look() + ghost?.hold_severed(severed) + if(shoes) + ghost?.hold_shoes(shoes) + wire_seam_fx(victim, zone, seared) + if(seared) + victim.hv_add_hit_line("[victim]'s [zone_name] drops away, the stump seared shut!", HV_LINE_HUGE, "Your [zone_name] drops away, the stump seared shut!", DEFAULT_MESSAGE_RANGE) + else + victim.hv_add_hit_line("[victim]'s [zone_name] comes away clean!", HV_LINE_HUGE, "Your [zone_name] comes away clean!", DEFAULT_MESSAGE_RANGE) + log_combat(user, victim, "severed the [zone_name] of", src, cross ? "(crossing cut)" : null) + if(zone == BODY_ZONE_L_LEG || zone == BODY_ZONE_R_LEG) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_leg_fall), victim, zone), WIRE_LEG_FALL_DELAY) + return severed + +/// Someone still on their feet without the leg in `zone` goes down: tg's knockdown, as a shove gives. +/proc/wire_leg_fall(mob/living/carbon/victim, zone) + if(QDELETED(victim) || victim.stat == DEAD || victim.body_position != STANDING_UP || victim.get_bodypart(zone)) + return + if(victim.movement_type & (FLYING | FLOATING)) + return + victim.Knockdown(WIRE_LEG_KNOCKDOWN) + +// ---- Through ---- + +/** + * The wire carries on past the target into one body at most: the first one standing on the tile right behind it, along + * the swing, that the swing's lash can run on through the target to (wire_through_line()). It is picked by that same + * line before anything is cut, so whoever the wire carries into always gets the line (wire_lash_through()), even when + * the target's own cut shows as well (`clean`: a limb coming off, a crossing cut). Nobody else beside the target is + * touched. The cut deals the wire's cleave share, cuts clean, and never takes a limb off. Its line joins the hit's message. + */ +/obj/item/melee/cyberware_monowire/proc/wire_cleave(mob/living/target, mob/living/user, clean = FALSE) + var/mob/living/behind = wire_through_target(target, user) + if(!behind) + return + var/direction = get_dir(user, target) + wire_cleave_cut(behind, user) + if(QDELETED(behind)) + return + wire_through_fx(user, target, behind, direction, clean) + lash_through = WEAKREF(behind) + target.hv_add_hit_line("The wire carries through into [behind]!", HV_LINE_BOLD) + to_chat(behind, span_userdanger("The wire carries through into you!")) + playsound(target, 'sound/items/weapons/bladeslice.ogg', 50, TRUE) + +/// Who a swing at `target` carries on into (wire_cleave()): the first living body, not the wielder's crew, on the tile +/// right behind it along the swing that a line can run to from the hand through `target`, or null. Not a garrote's blow. +/obj/item/melee/cyberware_monowire/proc/wire_through_target(mob/living/target, mob/living/user) + if(garrote_no_cleave || !isliving(target) || !isliving(user)) + return null + var/turf/from = get_turf(user) + var/turf/first = get_turf(target) + if(!from || !first || from == first) + return null + var/turf/behind_turf = get_step(first, get_dir(from, first)) + if(!behind_turf) + return null + for(var/mob/living/candidate in behind_turf) + if(candidate == user || candidate == target || candidate.stat == DEAD) + continue + if(!can_see(target, candidate, 1)) + continue + if(cyberware_is_ally(user, candidate)) + continue + if(!wire_through_line(user, target, candidate)) + continue + return candidate + return null + +/// One cut on `behind`: both halves of the wire's damage at the cleave share, one armour roll, cut clean. A limb tg's +/// wound roll wanted stays on: only the swing's own target loses limbs to the wire. +/obj/item/melee/cyberware_monowire/proc/wire_cleave_cut(mob/living/behind, mob/living/user) + // A cleave is an attack like the swing itself, so trader outposts see it. + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(behind, user) + var/cleave_block = behind.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = armour_penetration) + ADD_TRAIT(behind, TRAIT_HV_CLEAN_CUT, REF(src)) + behind.apply_damage(force * cleave_mult, BRUTE, BODY_ZONE_CHEST, cleave_block, wound_bonus = wound_bonus, sharpness = SHARP_EDGED, attack_direction = get_dir(user, behind)) + if(!QDELETED(behind)) + behind.apply_damage(charge_burn * cleave_mult, BURN, BODY_ZONE_CHEST, cleave_block) + if(QDELETED(behind)) + return + REMOVE_TRAIT(behind, TRAIT_HV_CLEAN_CUT, REF(src)) + behind.hv_wire_owed_sever = null + +#undef WIRE_CROSS_LASH_LAG diff --git a/voidcrew/modules/hyperviolence/wire/wire_garrote.dm b/voidcrew/modules/hyperviolence/wire/wire_garrote.dm new file mode 100644 index 00000000000..41ec6366450 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_garrote.dm @@ -0,0 +1,1415 @@ +/** + * # The garrote + * + * A Widowline wielder standing over someone lying on the floor who can't fight back (in crit, or stunned: + * lying down alone isn't enough; on their feet, or strapped into a chair or bed, never) loops the wire round + * their neck with a combat click at the head or mouth. It is #509's finisher engine (hyperviolence/finishers/) + * run by a person: a real aggressive grab, interruptible the same ways. + * + * - Wind-up (1 s): the wire loops round the neck and glints. No mark over the victim (owner D34): the loop + * and the struggle are the tell. It holds nobody, so pulling the victim clear, or them coming round, ends it. + * - Hold (1.5 s): the grab, the wire goes taut and bites, the victim thrashes. The garroter stays leaned in over + * them from the throw to the cut, and leans further in for the kill: never back. + * - Kill (0.5 s into the stage): in hard crit the head comes off (an Atlas frame keeps it); otherwise the + * throat is slit, #509's carotid crit. A sealed helmet, an armoured collar or plating that would stop + * the throat crit turns the wire, and it lands as an ordinary blow at the head instead. + * - The Widowline Arc's wire takes the head off whatever state the victim is in, unless an Atlas frame keeps it + * or a helmet, collar or plating turns the wire as above. A garroter with a wire in each hand counts as the + * Arc's if either wire is (arc_wire()). + * - Broken by #509's rules: the grab broken or pulled apart, the garroter hurt (WIRE_GARROTE_INTERRUPT + * health), stunned, cuffed or out of their wire, the victim dying first, the timeout. Also by the victim + * getting up off the floor or being strapped to something, at any point before the kill. + * + * Only wielders that hv_wire_moves_allowed() lets use the wire's moves (wire_users.dm) can start one. It + * refuses allies, starts the outpost's self-defence clock like any attack, and the wielder waits + * WIRE_GARROTE_COOLDOWN after one that got a grip, WIRE_GARROTE_MISS_COOLDOWN after one that didn't. + */ + +/// Health the garroter can lose before letting go. +#define WIRE_GARROTE_INTERRUPT 20 +/// The wind-up: the wire shoots from the hand to the neck, then winds round it. +#define WIRE_GARROTE_REACH_TIME (0.3 SECONDS) +/// The garroter leans in (the wind-up's lunge, drawn over tg's offset time) before the wire leaves the hand. +#define WIRE_GARROTE_LEAN_TIME UPDATE_TRANSFORM_ANIMATION_TIME +#define WIRE_GARROTE_WIND_TIME (0.3 SECONDS) +/// Steps the flying wire is drawn out in. +#define WIRE_GARROTE_THROW_BEATS 6 +/// Steps a strand slides in with the body in, as the hold pulls it to the garroter's feet. +#define WIRE_GARROTE_SLIDE_BEATS 8 +/// A body turned end for end at the wind-up swings round over this long, not in one frame. +#define WIRE_GARROTE_TURN_TIME (0.24 SECONDS) +/// The pool under the neck spreads from a spot to its full size over this long. +#define WIRE_GARROTE_POOL_TIME (1.8 SECONDS) +/// The head's drop from the neck to the floor. +#define WIRE_GARROTE_HEAD_FALL_TIME (0.2 SECONDS) +/// Pixels below the neck the pool goes facing away, where the dropped head lies against the stump. +#define WIRE_GARROTE_POOL_DROP_NORTH 6 +/// One beat of the victim's thrashing in the hold. +#define WIRE_GARROTE_JOLT_TIME (0.05 SECONDS) +/// A garroted body's last twitches begin this long after it has slumped back onto its tile. +#define WIRE_GARROTE_TWITCH_WAIT (0.6 SECONDS) +/// A line of wire starts this far before the middle of the pixel at its base, so that pixel is always drawn. +#define WIRE_LINE_BACK 0.5 +/// Trait source: a body held still from bobbing while wire_shake() shakes it. +#define WIRE_SHAKE_TRAIT "wire_shake" + +/mob/living + /// The garrote can be tried again from this world.time. + var/wire_garrote_ready_at = 0 + +/obj/item/melee/cyberware_monowire + /// The garrote this wire is in, if any. + var/datum/hv_finisher/garrote/garrote_running + /// TRUE while the garrote swings the wire for real, so the swing isn't taken for a new garrote. + var/garrote_blow = FALSE + /// TRUE while the garrote's own blow lands: it cuts the victim alone, never anyone behind (wire_through_target()). + var/garrote_no_cleave = FALSE + +/obj/item/melee/cyberware_monowire/pre_attack(atom/target, mob/living/user, list/modifiers, list/attack_modifiers) + . = ..() + if(. || garrote_blow) + return + // Busy round someone's neck. + if(garrote_running) + return TRUE + if(wire_try_garrote(target, user)) + return TRUE + +/// Someone the wire can be looped round: lying on the floor (wire_garrote_on_floor()) and unable to fight it off, in +/// crit or stunned. Lying down alone doesn't count, and neither does a stun that leaves them on their feet. +/proc/wire_garrote_helpless(mob/living/victim) + return wire_garrote_on_floor(victim) && (victim.stat >= SOFT_CRIT || HAS_TRAIT(victim, TRAIT_INCAPACITATED)) + +/// Lying on the floor, not strapped into a chair or bed: where a garrote's victim has to stay from the first loop to the cut. +/proc/wire_garrote_on_floor(mob/living/victim) + return victim.body_position == LYING_DOWN && !victim.buckled + +/// Starts a garrote on `target` if this click asks for one (combat mode, the head or mouth on the target doll) +/// and it can begin. Returns TRUE if it began. +/obj/item/melee/cyberware_monowire/proc/wire_try_garrote(atom/target, mob/living/user) + if(!isliving(user) || !user.combat_mode) + return FALSE + if(user.zone_selected != BODY_ZONE_HEAD && user.zone_selected != BODY_ZONE_PRECISE_MOUTH) + return FALSE + return wire_start_garrote(user, target) + +/** + * The garrote itself, with no click behind it: the entry point for anything that isn't a mouse (an NPC's AI, + * later). Same rules as a player's: the wielder's moves gate (wire_users.dm), no pacifists, the wait between + * tries, and the finisher's own can_start(). Returns TRUE if it began. + */ +/obj/item/melee/cyberware_monowire/proc/wire_start_garrote(mob/living/user, mob/living/target) + if(!iscarbon(target) || !iscarbon(user) || HAS_TRAIT(user, TRAIT_PACIFISM) || garrote_running) + return FALSE + if(!hv_wire_moves_allowed(user)) + return FALSE + if(world.time < user.wire_garrote_ready_at) + return FALSE + var/datum/hv_finisher/garrote/garrote = new(user, target, CALLBACK(src, PROC_REF(garrote_ended))) + garrote.wire = src + if(!garrote.start()) + qdel(garrote) + return FALSE + garrote_running = garrote + return TRUE + +/// The garrote ended, however it ended. +/obj/item/melee/cyberware_monowire/proc/garrote_ended(datum/hv_finisher/garrote/garrote, result) + if(garrote_running == garrote) + garrote_running = null + // Not from inside its own stop(): the engine still has a line or two to run. + QDEL_IN(garrote, 1) + +// ---- The finisher ---- + +/datum/hv_finisher/garrote + name = "garrote" + interrupt_damage = WIRE_GARROTE_INTERRUPT + zone_preference = list(BODY_ZONE_HEAD) + stages = list( + /datum/hv_finisher_stage/windup/garrote, + /datum/hv_finisher_stage/hold/garrote, + /datum/hv_finisher_stage/kill/garrote, + /datum/hv_finisher_stage/recover, + ) + /// The wire round the neck + var/obj/item/melee/cyberware_monowire/wire + /// The loop drawn round the neck: its far side, behind the neck, and its near side + var/list/loop_refs + /// The wire in flight from the hand to the neck: one straight line. It lies there, slack, until the hold pulls it taut. + var/list/reach_refs + /// Timers of the wind-up's beats + var/list/windup_timers + /// The strands of wire from the garroter's hands to the neck + var/list/strand_refs + /// The far side of the loop, drawn among the victim's own overlays under the body + var/mutable_appearance/loop_back + /// The victim's layer before the hold drew them over a garroter facing the viewer, or null + var/victim_old_layer + /// Timer of the victim's struggle in the hold + var/struggle_timer + /// Where the two were drawn as the grip was taken, before the hold drew them into place: + /// list(garroter pixel_x, pixel_y, victim pixel_x, pixel_y) + var/list/pull_from + /// TRUE from the hold's strands until the wire comes off the neck: no wire is drawn hanging in the garroter's hands + var/hands_hidden = FALSE + /// Other Widowlines the garroter holds, hidden from their hands meanwhile + var/list/off_hand_refs + +/datum/hv_finisher/garrote/Destroy() + . = ..() + clear_wire_fx() + wire = null + +/** + * The finisher's own victim check (hv_finisher_valid_victim()) refuses anyone who shares a faction with the + * attacker, which is every player with every other. This is the same check without that: alive, helpless, + * on the floor beside the garroter with a head, not strapped down, not already being finished, not an ally. + */ +/datum/hv_finisher/garrote/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + if(!hv_wire_moves_allowed(user)) + return FALSE + if(QDELETED(wire) || !user.is_holding(wire)) + return FALSE + if(!iscarbon(victim) || QDELETED(victim) || victim == user || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!wire_garrote_helpless(victim) || victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(!victim.get_bodypart(BODY_ZONE_HEAD)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + if(cyberware_is_ally(user, victim)) + return FALSE + return get_dist(user, victim) <= 1 && user.Adjacent(victim) + +/datum/hv_finisher/garrote/start() + . = ..() + if(!.) + return + user.wire_garrote_ready_at = world.time + WIRE_GARROTE_MISS_COOLDOWN + // A garrote is an attack: the trader outpost's guards see it from the first loop. + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(victim, user) + cyberware_camo_drop(user) + +/datum/hv_finisher/garrote/take_grip() + if(!gripped && !QDELETED(user) && !QDELETED(victim)) + pull_from = drawn_offsets() + . = ..() + if(. && !QDELETED(user)) + user.wire_garrote_ready_at = world.time + WIRE_GARROTE_COOLDOWN + +/datum/hv_finisher/garrote/safety_check() + . = ..() + if(. || completed) + return + if(QDELETED(wire) || !user.is_holding(wire)) + return HV_FINISHER_ATTACKER_DOWN + // Up off the floor or strapped to something, they're out of it, held or not: the grab alone doesn't keep them down. + if(!wire_garrote_on_floor(victim)) + return HV_FINISHER_LOST_GRIP + // The wind-up holds nobody: come round enough to fight it off, they're out of it. + if(!gripped && !wire_garrote_helpless(victim)) + return HV_FINISHER_LOST_GRIP + +/datum/hv_finisher/garrote/stop(reason) + clear_wire_fx() + restore_victim_layer() + return ..() + +/** + * Letting go puts the body back all at once: the grab's pull, the hold's drag and the layer it was drawn on go + * together, so nothing moves again when the finisher ends. The kill lets go itself (let_go_at_kill()); this is for + * any other way the grip ends. + */ +/datum/hv_finisher/garrote/release_grip() + . = ..() + if(!QDELETED(victim)) + victim.remove_offsets(HV_FINISHER_OFFSET) + restore_victim_layer() + +/** + * The kill lets the body go as the wire goes through: the grab's pull and the hold's drag come off together, and + * it slumps back onto its own tile in one short move. The garroter straightens out of the lean over the same move, + * here rather than as the finisher ends, so neither of them moves again after it. Returns how long the move takes: + * the head, the pool and anything else laid out after the kill wait for it and go where the body rests. + */ +/datum/hv_finisher/garrote/proc/let_go_at_kill() + if(QDELETED(victim)) + return 0 + var/old_x = victim.pixel_x + var/old_y = victim.pixel_y + victim.remove_offsets(HV_FINISHER_OFFSET, animate = FALSE) + // Taken off here rather than by tg's let-go, which would slide it back on its own clock. + if(user?.pulling == victim) + victim.remove_offsets(GRABBING_TRAIT, animate = FALSE) + // A body drawn up over a garroter facing the viewer stays over their legs until it has dropped clear. + var/drawn_up_from = victim_old_layer + victim_old_layer = null + release_grip() + var/new_x = victim.pixel_x + var/new_y = victim.pixel_y + var/settle_time = 0 + if(new_x != old_x || new_y != old_y) + settle_time = clamp(round(sqrt((new_x - old_x) ** 2 + (new_y - old_y) ** 2) / 6, 0.5), 2, 4) + victim.pixel_x = old_x + victim.pixel_y = old_y + // Parallel, like tg's own offset moves, so the turn of a body falling or a death shudder runs on beside it. + animate(victim, pixel_x = new_x, pixel_y = new_y, time = settle_time, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + if(!isnull(drawn_up_from)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_garrote_lay_back), victim, drawn_up_from), settle_time) + // The lean off now: the recover stage's pose and the finisher's end then find nothing left to move. + if(!QDELETED(user)) + var/user_old_x = user.pixel_x + var/user_old_y = user.pixel_y + user.add_offsets(HV_FINISHER_OFFSET, null, 0, 0, animate = FALSE) + if(user.pixel_x != user_old_x || user.pixel_y != user_old_y) + var/user_new_x = user.pixel_x + var/user_new_y = user.pixel_y + user.pixel_x = user_old_x + user.pixel_y = user_old_y + animate(user, pixel_x = user_new_x, pixel_y = user_new_y, time = max(settle_time, 2), easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + return settle_time + +/// A body the garrote drew up over its garroter goes back to `old_layer`, unless something else has changed it since. +/proc/wire_garrote_lay_back(mob/living/body, old_layer) + if(!QDELETED(body) && body.layer == WIRE_PULLED_LAYER) + body.layer = old_layer + +/datum/hv_finisher/garrote/proc/restore_victim_layer() + if(isnull(victim_old_layer)) + return + if(!QDELETED(victim) && victim.layer == WIRE_PULLED_LAYER) + victim.layer = victim_old_layer + victim_old_layer = null + +/// The wire's blow, never the heaviest thing in the garroter's hands. +/datum/hv_finisher/garrote/real_blow(zone, extra_wound_bonus = 0) + if(QDELETED(user) || QDELETED(victim) || QDELETED(wire) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || !user.is_holding(wire)) + return FALSE + var/mob/living/carbon/attacker = user + if(attacker.get_active_held_item() != wire) + var/index = attacker.get_held_index_of_item(wire) + if(!index) + return FALSE + attacker.swap_hand(index, silent = TRUE) + user.zone_selected = zone + user.face_atom(victim) + var/old_combat_mode = user.combat_mode + user.set_combat_mode(TRUE) + var/old_wound_bonus = wire.wound_bonus + wire.wound_bonus += extra_wound_bonus + wire.garrote_blow = TRUE + wire.garrote_no_cleave = TRUE + wire.melee_attack_chain(user, victim) + if(!QDELETED(wire)) + wire.garrote_blow = FALSE + wire.garrote_no_cleave = FALSE + wire.wound_bonus = old_wound_bonus + if(!QDELETED(user)) + user.set_combat_mode(old_combat_mode) + user.changeNext_move(CLICK_CD_MELEE) + return TRUE + +/** + * No mark over the victim (owner D34). The garroter leans in (the stage's lunge) and the wire shoots straight from the + * hand to the neck. Once it is there it drags the body round head first to them, if it lay the other way, and winds + * round the neck; then it hangs there loose, glinting, while they struggle. + */ +/datum/hv_finisher/garrote/on_windup(duration) + // Thrown once the lean has carried the hand to where the throw starts. + throw_wire(WIRE_GARROTE_LEAN_TIME) + // The wire leaves its hand: from then on it is drawn in flight and round the neck, not hanging there. A wire in the + // other hand hangs on until the hold runs a strand from that hand too (draw_wire()). + LAZYADD(windup_timers, addtimer(CALLBACK(src, PROC_REF(hide_thrown_wire)), WIRE_GARROTE_LEAN_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME)) + LAZYADD(windup_timers, addtimer(CALLBACK(src, PROC_REF(wire_arrives), duration - WIRE_GARROTE_LEAN_TIME - WIRE_GARROTE_REACH_TIME), WIRE_GARROTE_LEAN_TIME + WIRE_GARROTE_REACH_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME)) + +/** + * The throw: the thrown wire is drawn in flight and then round the neck, so its hand doesn't draw it hanging there. Only + * that hand's: a second Widowline in the other hand hangs there through the throw and the wind-up, until the hold runs a + * strand from that hand too (hide_hands()). show_hands() puts it back, however the garrote ends. + */ +/datum/hv_finisher/garrote/proc/hide_thrown_wire() + if(hands_hidden || !running || QDELETED(user) || QDELETED(wire) || !user.is_holding(wire)) + return + hide_held_wire(wire) + +/** + * From the hold on, a strand runs from each hand to the neck, so neither hand draws a wire hanging in it: not the one + * thrown, nor a second Widowline in the other hand, held now or taken up while the garrote lasts. Hidden as the strands + * go up (draw_wire()), so a wire never shows in a hand and as a strand from it at once, nor in neither. show_hands() + * puts them back, however the garrote ends. + */ +/datum/hv_finisher/garrote/proc/hide_hands() + if(hands_hidden || !running || QDELETED(user)) + return + hands_hidden = TRUE + for(var/obj/item/melee/cyberware_monowire/held in user.held_items) + hide_held_wire(held) + RegisterSignal(user, COMSIG_MOB_EQUIPPED_ITEM, PROC_REF(on_user_equipped)) + +/datum/hv_finisher/garrote/proc/hide_held_wire(obj/item/melee/cyberware_monowire/held) + // The timeout only backs show_hands() up. Its spool stays in the hand: the wire runs out of it. + held.hide_inhand_for(longest_duration(), spool = TRUE) + if(held != wire) + LAZYOR(off_hand_refs, WEAKREF(held)) + +/// A Widowline swung out into a hand while the strands run isn't drawn there either: a strand runs from that hand. +/datum/hv_finisher/garrote/proc/on_user_equipped(mob/living/source, obj/item/equipped, slot) + SIGNAL_HANDLER + if((slot & ITEM_SLOT_HANDS) && istype(equipped, /obj/item/melee/cyberware_monowire)) + hide_held_wire(equipped) + +/// The wire is off the neck: every wire the garroter holds is drawn in the hand again. +/datum/hv_finisher/garrote/proc/show_hands() + if(hands_hidden) + hands_hidden = FALSE + if(user) + UnregisterSignal(user, COMSIG_MOB_EQUIPPED_ITEM) + if(!QDELETED(wire)) + wire.show_inhand() + for(var/datum/weakref/held_ref as anything in off_hand_refs) + var/obj/item/melee/cyberware_monowire/held = held_ref.resolve() + if(!QDELETED(held)) + held.show_inhand() + off_hand_refs = null + +/// TRUE while the garrote is still in its wind-up, the wire loose round the neck. +/datum/hv_finisher/garrote/proc/winding_up() + return running && !QDELETED(user) && !QDELETED(victim) && istype(current_stage, /datum/hv_finisher_stage/windup) + +/** + * The thrown wire reaches the neck: it drags the body round head first to the garroter, if it lay the other way, and + * then winds round the neck. `rest` is what is left of the wind-up. + */ +/datum/hv_finisher/garrote/proc/wire_arrives(rest) + if(!winding_up()) + return + var/turn_time = face_victim_in() + if(!turn_time) + wind_loop(rest) + return + LAZYADD(windup_timers, addtimer(CALLBACK(src, PROC_REF(wind_loop), rest - turn_time), turn_time, TIMER_STOPPABLE | TIMER_DELETE_ME)) + +/** + * The pixel the wire leaves the garroter's `side` hand from (wire_hand_point(), wire_held.dm): the one the held wire's + * spool lights for each facing, by its lower left corner in pixels from the middle of the tile. A line of wire is + * drawn from the middle of this pixel. Facing the viewer both hands show at the sides. Facing away they show at the + * sides too, but the wire runs forward from them, through the garroter's body, so both count as behind it; in profile + * the far hand does. list(x, y, behind) + */ +/datum/hv_finisher/garrote/proc/hand_point(side) + return wire_hand_point(user, side) + +/** + * Where a line of wire from the garroter's `side` hand is drawn from: the hand (hand_point()), except facing away. + * Facing away it runs from the hands, hidden in front of the garroter, to the neck beyond their head; drawn from the + * hands it showed through the gaps between head and shoulders as a stray pixel each. It is drawn from behind the head + * instead, on the same line, so it shows only once it clears the head. list(x, y, behind) + */ +/datum/hv_finisher/garrote/proc/line_origin(side) + if(user.dir == NORTH) + return side == "left" ? list(-4, 8, TRUE) : list(2, 8, TRUE) + return hand_point(side) + +/** + * The world layer for wire leaving a hand. Wire from a hand in front goes over both bodies. Wire from a hand behind + * the garroter goes under them, so their torso, back and head hide it: + * - Facing away (both hands) it goes over the one lying beyond them, so it shows from where it clears the garroter to + * the neck. + * - In profile (the far hand) it goes round the back of the neck: under the one lying there too, so it shows between + * the hand and the head, and their head and neck hide the rest, while the near hand's runs over them. + */ +/datum/hv_finisher/garrote/proc/strand_layer(behind) + if(!behind) + return wire_line_layer(FALSE) + return wire_behind_neck(behind) ? BELOW_MOB_LAYER : WIRE_BETWEEN_LAYER + +/// TRUE for wire from a hand `behind` the garroter that goes round the back of the neck, under the body lying there: +/// the far hand's, in profile. +/datum/hv_finisher/garrote/proc/wire_behind_neck(behind) + return behind && (user.dir & (EAST | WEST)) + +/** + * A new line of wire from a hand `behind` or not. It goes on the garroter's turf, except wire round the back of the + * neck (wire_behind_neck()) with the body on the row behind the garroter's (lying a tile up and to the side): that goes + * on the body's row, since the map sorts a row in front over a row behind whatever their layers. pixel_z draws it back + * where it would be on the garroter's turf, so its pixel_x and pixel_y count from there as ever. + */ +/datum/hv_finisher/garrote/proc/new_line(behind) + var/turf/from = get_turf(user) + if(!from) + return null + if(wire_behind_neck(behind) && !QDELETED(victim) && victim.y > user.y) + from = locate(user.x, victim.y, user.z) || from + var/obj/effect/abstract/wire_reach/line = new(from) + line.pixel_z = (user.y - from.y) * ICON_SIZE_Y + return line + +/// "left" or "right": the hand the wire is in. +/datum/hv_finisher/garrote/proc/wire_side() + return wire?.held_side(user) || "right" + +/** + * The wire in flight: thrown from the hand, its end flies straight to the neck, the line paying out behind it. One + * sprite, stretched and turned every beat, so the line stays as thin as the hanging filament. It lies there, slack, + * until the hold. Unseen for `delay` first. + */ +/datum/hv_finisher/garrote/proc/throw_wire(delay = 0) + clear_reach() + var/turf/from = get_turf(user) + if(!from || QDELETED(victim)) + return + var/side = wire_side() + var/list/hand = line_origin(side) + var/hand_x = user.pixel_x + hand[1] + var/hand_y = user.pixel_y + hand[2] + // Thrown at the pixel of the loop this hand's strand will meet, so it lands on the loop as it winds on. + var/list/meet = loop_point(side) + var/span_x = meet[1] - (hand_x + 0.5) + var/span_y = meet[2] - (hand_y + 0.5) + var/step = WIRE_GARROTE_REACH_TIME / WIRE_GARROTE_THROW_BEATS + var/obj/effect/abstract/wire_reach/line = new_line(hand[3]) + line.icon_state = "wire_reach[wire_suffix()]" + // In flight, wire from a hand behind the garroter passes over the body lying there and under the garroter. In profile + // it goes round the back of the neck once it winds on (land_reach()). + line.layer = hand[3] ? WIRE_BETWEEN_LAYER : strand_layer(FALSE) + line.add_overlay(emissive_appearance(line.icon, line.icon_state, line)) + line.pixel_x = hand_x + line.pixel_y = hand_y + // A pixel of wire leaving the hand toward the neck. + line.transform = wire_throw_shape(span_x, span_y, 1) + if(delay) + line.alpha = 0 + animate(line, alpha = 0, time = delay) + for(var/beat in 1 to WIRE_GARROTE_THROW_BEATS) + var/flown = beat / WIRE_GARROTE_THROW_BEATS + var/matrix/shape = wire_throw_shape(span_x, span_y, flown * wire_line_length(span_x, span_y)) + if(beat == 1 && !delay) + animate(line, transform = shape, time = step) + else + animate(alpha = 255, transform = shape, time = step) + LAZYADD(reach_refs, WEAKREF(line)) + // Never outlives the garrote, whatever ends it. + QDEL_IN(line, longest_duration()) + +/datum/hv_finisher/garrote/proc/clear_reach() + for(var/datum/weakref/line_ref as anything in reach_refs) + qdel(line_ref.resolve()) + reach_refs = null + +/** + * The garrote's thrown wire (wire_reach, half a tile of line running up a half pixel left of the middle) drawn from + * the middle of the pixel at its base to the middle of the pixel (`span_x`, `span_y`) away and half a pixel on, so it + * covers the pixel it lands on. `length` draws only that much of it along the same bearing. It starts half a pixel + * before the middle of its base pixel too: a line that starts on the middle itself draws that pixel on some bearings + * and not on others, and the wire would leave the hand a pixel out. + */ +/proc/wire_throw_shape(span_x, span_y, length) + var/matrix/shape = matrix() + shape.Translate(0.5, 0) + shape.Scale(1, (max(isnull(length) ? wire_line_length(span_x, span_y) : length, 1) + WIRE_LINE_BACK) / 16) + shape.Translate(0, -WIRE_LINE_BACK) + shape.Turn(arctan(span_y, span_x)) + shape.Translate(0.5, 0.5) + return shape + +/// How long a line of wire from the middle of one pixel to the middle of another (`span_x`, `span_y`) away is drawn: +/// half a pixel more, to the far side of the pixel it ends on. +/proc/wire_line_length(span_x, span_y) + return sqrt(span_x ** 2 + span_y ** 2) + 0.5 + +/** + * The thrown wire's end put on the loop where the neck is drawn as it lands. It was aimed where the neck was drawn as + * it left the hand, and anything that moves the body in flight leaves it short of the loop (recorded once, two pixels + * short facing the viewer). Wound on, it is drawn as the hold's strand from its hand will be: from the far hand in + * profile, round the back of the neck, under the body it flew over. + */ +/datum/hv_finisher/garrote/proc/land_reach() + var/datum/weakref/line_ref = LAZYACCESS(reach_refs, 1) + var/obj/effect/abstract/wire_reach/line = line_ref?.resolve() + if(!line) + return + // The throw's beats end here anyway; ended now, it is drawn as they leave it (whole, lit) before it is moved. + animate(line) + line.transform = reach_to_neck(line, victim.transform) + line.layer = strand_layer(line_origin(wire_side())[3]) + +/// The end of the throw: the wire winds round the neck and is held there, loose, for the rest of the wind-up (`rest`). +/datum/hv_finisher/garrote/proc/wind_loop(rest) + if(!winding_up()) + return + land_reach() + show_loop("draw") + // Wound on, it hangs loose until the hold pulls it tight. With no time left for that, the hold takes it from here. + if(rest > WIRE_GARROTE_WIND_TIME) + LAZYADD(windup_timers, addtimer(CALLBACK(src, PROC_REF(show_loop), "loop"), WIRE_GARROTE_WIND_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME)) + var/obj/effect/abstract/cyberware_anim/glint = cyberware_anim_play(user, WIRE_GORE_ICON, "wire_glint[wire_suffix()]", WIRE_FX_LAYER, 0.3 SECONDS, glow = TRUE) + if(glint) + // On the hand's pixel: the glint's picture is centred on the pixel down and right of its icon's middle. + var/list/hand = hand_point(wire_side()) + glint.pixel_x = hand[1] + glint.pixel_y = hand[2] + 1 + +/// The wire in flight. Nothing to click. +/obj/effect/abstract/wire_reach + name = "wire" + icon = WIRE_GORE_ICON + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + appearance_flags = PIXEL_SCALE | TILE_BOUND + /// A taut strand's far end, on the neck, from its base in the hand: list(x, y) pixels. Null for any other line. + var/list/span + /// "left" or "right": the hand a taut strand runs from. Null for any other line. + var/hand_side + +/** + * A taut strand on a neck that thrashes: its far end jolts by each of `jolts` (wire_jolts()) in turn, a beat apart, + * as the body does, while its base stays in the hand. Linear, so the end runs straight to each spot, as the body does. + */ +/obj/effect/abstract/wire_reach/proc/jolt_end(list/jolts) + if(!span) + return + var/first = TRUE + for(var/list/jolt as anything in jolts) + var/matrix/shape = wire_strand_shape(span[1] + jolt[1], span[2] + jolt[2]) + if(first) + animate(src, transform = shape, time = WIRE_GARROTE_JOLT_TIME, flags = ANIMATION_LINEAR_TRANSFORM) + first = FALSE + else + animate(transform = shape, time = WIRE_GARROTE_JOLT_TIME, flags = ANIMATION_LINEAR_TRANSFORM) + +/// "" for the plain wire's colours, "_live" for the Arc's. +/datum/hv_finisher/garrote/proc/wire_suffix() + return istype(wire, /obj/item/melee/cyberware_monowire/live) ? "_live" : "" + +/** + * A body lying beside the garroter is turned head first to them, as tg turns a body dragged sideways: the wire on its + * neck drags it round. tg turns one lying the other way round in a single frame; this swings it round instead, through + * head toward the viewer, and the thrown wire's end stays on the neck as it goes. Returns how long the turn takes, 0 if + * it already lay that way. + */ +/datum/hv_finisher/garrote/proc/face_victim_in() + if(QDELETED(victim) || victim.body_position != LYING_DOWN) + return 0 + var/toward = get_dir(victim, user) + var/new_angle + if(toward & EAST) + new_angle = LYING_ANGLE_EAST + else if(toward & WEST) + new_angle = LYING_ANGLE_WEST + if(isnull(new_angle) || victim.lying_angle == new_angle) + return 0 + var/end_for_end = REVERSE_ANGLE(new_angle) == victim.lying_angle + var/matrix/before = matrix(victim.transform) + victim.set_lying_angle(new_angle) + var/matrix/after = matrix(victim.transform) + var/datum/weakref/line_ref = LAZYACCESS(reach_refs, 1) + var/obj/effect/abstract/wire_reach/line = line_ref?.resolve() + if(!end_for_end) + // tg's own turn. + if(line) + animate(line, transform = reach_to_neck(line, after), time = UPDATE_TRANSFORM_ANIMATION_TIME) + return UPDATE_TRANSFORM_ANIMATION_TIME + var/matrix/halfway = matrix(before) + halfway.Turn(new_angle == LYING_ANGLE_WEST ? 90 : -90) + victim.transform = before + animate(victim, transform = halfway, time = WIRE_GARROTE_TURN_TIME / 2, easing = SINE_EASING | EASE_IN) + animate(transform = after, time = WIRE_GARROTE_TURN_TIME / 2, easing = SINE_EASING | EASE_OUT) + if(line) + animate(line, transform = reach_to_neck(line, halfway), time = WIRE_GARROTE_TURN_TIME / 2, easing = SINE_EASING | EASE_IN) + animate(transform = reach_to_neck(line, after), time = WIRE_GARROTE_TURN_TIME / 2, easing = SINE_EASING | EASE_OUT) + return WIRE_GARROTE_TURN_TIME + +/// The thrown wire `line` drawn from where it left the hand to the loop on the neck of the victim turned to `body_shape`. +/datum/hv_finisher/garrote/proc/reach_to_neck(obj/effect/abstract/wire_reach/line, matrix/body_shape) + var/list/meet = loop_point(wire_side(), body_shape) + return wire_throw_shape(meet[1] - (line.pixel_x + 0.5), meet[2] - (line.pixel_y + 0.5)) + +/** + * Where the strand from the garroter's `side` hand meets the loop, as the loop is drawn now (or with the body turned + * to `shape`): the middle of one of the near half's pixels, in pixels from the middle of the garroter's tile. + * list(x, y) + * + * On a body lying down the loop shows as a short line up and down across the neck. In profile the near hand's strand + * meets its lower end over the head, and the far hand's its upper end round the back of the neck, the head hiding it + * (strand_layer()), so the two show as two strands. Facing the viewer both meet its top, the end toward the garroter; + * facing away both meet it at the neck, since its lower end is hidden behind the garroter's head. On a body on its feet + * (none is garroted: only for one getting up before the garrote lets go) the loop lies across the neck: facing toward or + * away each strand meets the end on its own side, and in profile the near one the end toward the garroter, the far one + * the middle. + */ +/datum/hv_finisher/garrote/proc/loop_point(side, matrix/shape) + if(!shape) + shape = victim.transform + var/list/drawn = list() + for(var/list/spot as anything in wire_loop_line(victim.dir)) + drawn += list(wire_pixel_drawn(shape, spot[1], spot[2])) + var/list/first = drawn[1] + var/list/middle = drawn[2] + var/list/last = drawn[3] + var/behind = hand_point(side)[3] + var/list/meet + if(abs(first[2] - last[2]) > abs(first[1] - last[1])) + var/list/upper = first[2] > last[2] ? first : last + var/list/lower = upper == first ? last : first + switch(user.dir) + if(NORTH) + meet = middle + if(SOUTH) + meet = upper + else + meet = behind ? upper : lower + else + var/list/leftmost = first[1] < last[1] ? first : last + var/list/rightmost = leftmost == first ? last : first + if(user.dir & (EAST | WEST)) + meet = behind ? middle : ((user.dir & EAST) ? leftmost : rightmost) + else + meet = hand_point(side)[1] < 0 ? leftmost : rightmost + return list(meet[1] + victim.pixel_x + (victim.x - user.x) * ICON_SIZE_X, meet[2] + victim.pixel_y + (victim.y - user.y) * ICON_SIZE_Y) + +/** + * The loop's near half as wire_gore.dmi draws it (garrote_tight_front): a line of wire one pixel high and seven long + * across the neck, a pixel below where the jaw meets it, on the row the slit throat is drawn on. The middles of its + * end pixels and of its middle one, in pixels from the middle of the icon, for the body facing `facing` (its picture + * facing east is a pixel to the right). list(list(x, y) first, middle, last) + */ +/proc/wire_loop_line(facing) + var/left = facing == EAST ? -2.5 : -3.5 + return list(list(left, 5.5), list(left + 3, 5.5), list(left + 6, 5.5)) + +/** + * The middle of the pixel that a picture's pixel, its middle (`x`, `y`) from the middle of the icon, is drawn on once + * `shape` turns and moves it, from the same middle. list(x, y) + * Rounded down, not to the nearest: a quarter turn leaves the middle of a pixel on the middle of another, give or take + * the rounding in the turn. + */ +/proc/wire_pixel_drawn(matrix/shape, x, y) + return list(round(shape.a * x + shape.b * y + shape.c) + 0.5, round(shape.d * x + shape.e * y + shape.f) + 0.5) + +/// Where the victim's neck is drawn, in pixels from the middle of its tile before its own offsets: 6 px above the +/// middle of an upright body, turned with a lying one (or as `shape`, a transform, would turn it). list(x, y) +/datum/hv_finisher/garrote/proc/neck_offset(matrix/shape) + if(!shape) + shape = victim.transform + return list(round(shape.b * 6 + shape.c), round(shape.e * 6 + shape.f)) + +/** + * Where the neck should be in the hold, in pixels from the middle of the garroter's tile. list(x, y) + * - In profile the body stays on the floor: drawn in until its head lies just clear of the garroter's feet, never + * lifted, so the wire runs taut down from the hands to the neck. + * - Facing away the neck is beyond the garroter, drawn just over their head, not hidden behind it. + * - Facing the viewer the body is drawn up against the garroter's shins, the neck between their feet, under the + * hands held apart. + */ +/datum/hv_finisher/garrote/proc/hold_point() + if(user.dir & (EAST | WEST)) + return list((user.dir & EAST ? 17 : -17) + user.pixel_x, neck_point()[2]) + if(user.dir & NORTH) + return list(user.pixel_x, 16 + user.pixel_y) + return list(user.pixel_x, -13 + user.pixel_y) + +/// Where the neck is drawn now (or with the body turned to `shape`), in pixels from the middle of the garroter's tile. +/// list(x, y) +/datum/hv_finisher/garrote/proc/neck_point(matrix/shape) + var/list/neck = neck_offset(shape) + return list(neck[1] + victim.pixel_x + (victim.x - user.x) * ICON_SIZE_X, neck[2] + victim.pixel_y + (victim.y - user.y) * ICON_SIZE_Y) + +/// Where the neck is drawn now, in pixels from the middle of the victim's own tile. list(x, y) +/datum/hv_finisher/garrote/proc/neck_on_tile() + var/list/neck = neck_offset() + return list(neck[1] + victim.pixel_x, neck[2] + victim.pixel_y) + +/** + * The hold draws the neck to hold_point(). The grab already pulls the body 12 px toward the garroter (and turns a + * body dragged sideways head first); this corrects it from there, either way. Drawn only, and taken off with the + * grab's pull at the kill (let_go_at_kill()). + */ +/datum/hv_finisher/garrote/proc/pull_close() + if(QDELETED(user) || QDELETED(victim)) + return + var/list/neck = neck_point() + var/list/target = hold_point() + victim.add_offsets(HV_FINISHER_OFFSET, x_add = clamp(target[1] - neck[1], -12, 12), y_add = clamp(target[2] - neck[2], -12, 12), animate = FALSE) + slide_in() + // Facing the viewer, the body is drawn up in front of the garroter's legs, not behind them. + if(user.dir == SOUTH && victim.y < user.y && isnull(victim_old_layer)) + victim_old_layer = victim.layer + victim.layer = WIRE_PULLED_LAYER + +/** + * The body slides in as one move, from where it was drawn before the grab (pull_from) to where the grab's pull and the + * hold's drag put it, over the time the strands slide in with it (draw_wire()). tg slides the grab's pull (passive, + * then aggressive) and the drag as separate moves on top of each other, and recorded, the body fell a pixel behind + * the strands' ends on the neck for the first frames of the longest pull. + */ +/datum/hv_finisher/garrote/proc/slide_in() + if(!pull_from) + return + var/to_x = victim.pixel_x + var/to_y = victim.pixel_y + if(to_x == pull_from[3] && to_y == pull_from[4]) + return + // Ends tg's own slides, so they don't run on top of this one. + animate(victim) + // No floating bob meanwhile, as tg's own offset moves hold it off. + ADD_TRAIT(victim, TRAIT_NO_FLOATING_ANIM, WIRE_SHAKE_TRAIT) + addtimer(TRAIT_CALLBACK_REMOVE(victim, TRAIT_NO_FLOATING_ANIM, WIRE_SHAKE_TRAIT), UPDATE_TRANSFORM_ANIMATION_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) + victim.pixel_x = pull_from[3] + victim.pixel_y = pull_from[4] + animate(victim, pixel_x = to_x, pixel_y = to_y, time = UPDATE_TRANSFORM_ANIMATION_TIME) + +/// The victim thrashes in the wire from `delay` on, for `duration`. +/datum/hv_finisher/garrote/proc/struggle(delay, duration) + if(struggle_timer) + deltimer(struggle_timer) + struggle_timer = addtimer(CALLBACK(src, PROC_REF(thrash), duration), delay, TIMER_STOPPABLE | TIMER_DELETE_ME) + +/** + * The victim thrashes in the wire for `duration`: the body jolts round wherever it is drawn now, and the ends of the + * strands on its neck jolt with it, beat for beat, so the wire stays on the neck. The hands' ends stay put. + */ +/datum/hv_finisher/garrote/proc/thrash(duration) + struggle_timer = null + if(QDELETED(victim) || !running) + return + var/list/jolts = wire_jolts(2, 1, duration) + wire_shake(victim, jolts = jolts) + for(var/datum/weakref/strand_ref as anything in strand_refs) + var/obj/effect/abstract/wire_reach/strand = strand_ref.resolve() + strand?.jolt_end(jolts) + +/// A shake's beats, one each WIRE_GARROTE_JOLT_TIME for `duration`: list(x, y) offsets of up to `shift_x` and `shift_y` +/// pixels from where the shaken thing is drawn, the last one back where it was. +/proc/wire_jolts(shift_x, shift_y, duration) + . = list() + for(var/i in 1 to max(round(duration / WIRE_GARROTE_JOLT_TIME) - 1, 2)) + . += list(list(rand(-shift_x, shift_x), rand(-shift_y, shift_y))) + . += list(list(0, 0)) + +/** + * tg's Shake() without its slip (its first beat sets pixel_y from pixel_x, a jump on a body drawn off its tile's + * middle): jitters `body` up to `shift_x` and `shift_y` pixels round where it is drawn now, then puts it back there. + * `jolts` (wire_jolts()) are the beats to use, for something else drawn on the body to follow them. + * + * Its own animation, not one run in parallel as tg's is: recorded on a lying body, a parallel shake (absolute or + * relative steps alike) showed each jolt at about twice its size, so nothing following the jolts could keep with it. + * Being its own animation it would stop a floating body's bob, so the bob is held off until the shake is over. + */ +/proc/wire_shake(atom/movable/body, shift_x, shift_y, duration, list/jolts) + if(QDELETED(body)) + return + if(!length(jolts)) + jolts = wire_jolts(shift_x, shift_y, duration) + ADD_TRAIT(body, TRAIT_NO_FLOATING_ANIM, WIRE_SHAKE_TRAIT) + addtimer(TRAIT_CALLBACK_REMOVE(body, TRAIT_NO_FLOATING_ANIM, WIRE_SHAKE_TRAIT), length(jolts) * WIRE_GARROTE_JOLT_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) + var/start_x = body.pixel_x + var/start_y = body.pixel_y + var/first = TRUE + for(var/list/jolt as anything in jolts) + if(first) + animate(body, pixel_x = start_x + jolt[1], pixel_y = start_y + jolt[2], time = WIRE_GARROTE_JOLT_TIME) + first = FALSE + else + animate(pixel_x = start_x + jolt[1], pixel_y = start_y + jolt[2], time = WIRE_GARROTE_JOLT_TIME) + +/** + * Draws the loop round the victim's neck: `kind` "draw" (winding on), "loop" (hanging loose) or "tight" (drawing + * tight over the hold). Its far side is drawn under the body, so the neck hides it and only its ends show; its near + * side over the body. + */ +/datum/hv_finisher/garrote/proc/show_loop(kind) + clear_loop() + if(QDELETED(victim) || !running) + return + var/lifetime = longest_duration() + // One of the body's own overlays, so the neck and head drawn over it hide it. + loop_back = mutable_appearance(WIRE_GORE_ICON, "garrote_[kind]_back[wire_suffix()]", -(TOTAL_LAYERS + 0.5)) + victim.add_overlay(loop_back) + // Over the garroter's legs, which the body lies against; facing away, over the neck but under the garroter. + var/near_layer = user.dir == NORTH ? WIRE_BETWEEN_LAYER : ABOVE_MOB_LAYER + var/obj/effect/abstract/cyberware_anim/near_side = cyberware_anim_play(victim, WIRE_GORE_ICON, "garrote_[kind]_front[wire_suffix()]", near_layer, lifetime, glow = TRUE) + loop_refs = list(WEAKREF(near_side)) + +/datum/hv_finisher/garrote/proc/clear_loop() + for(var/datum/weakref/loop_ref as anything in loop_refs) + qdel(loop_ref.resolve()) + loop_refs = null + if(loop_back) + victim?.cut_overlay(loop_back) + loop_back = null + +/// Where the garroter and the victim are drawn now: list(garroter pixel_x, pixel_y, victim pixel_x, pixel_y) +/datum/hv_finisher/garrote/proc/drawn_offsets() + return list(user.pixel_x, user.pixel_y, victim.pixel_x, victim.pixel_y) + +/** + * The wire from the garroter's hands to the neck, one strand a hand, in the wire's own colour: one line of wire + * each, stretched from the hand to the neck. A strand from a hand behind the garroter's body (the far hand in profile, + * both facing away) is drawn under the garroter, so the torso hides it (strand_layer()). Whatever wire was drawn before (the + * slack throw, or the strands before a lean) goes as these go up. + * + * `slide_from` (drawn_offsets(), or null) is where the two were drawn before a move that is sliding them into place + * now, over tg's offset time: the strands start from there and slide with them, so they stay on the hands and the + * neck all the way. + */ +/datum/hv_finisher/garrote/proc/draw_wire(duration, list/slide_from) + clear_reach() + clear_strands() + var/turf/from = get_turf(user) + if(!from || QDELETED(user) || QDELETED(victim)) + return + // Never a strand from a hand that still draws a wire hanging in it. + hide_hands() + var/list/start = slide_from || drawn_offsets() + for(var/side in list("left", "right")) + var/list/hand = line_origin(side) + var/hand_x = user.pixel_x + hand[1] + var/hand_y = user.pixel_y + hand[2] + // From the middle of the hand's pixel to the middle of the loop's, where the loop is drawn: whole pixels. + var/list/meet = loop_point(side) + var/span_x = meet[1] - (hand_x + 0.5) + var/span_y = meet[2] - (hand_y + 0.5) + var/start_hand_x = hand_x + start[1] - user.pixel_x + var/start_hand_y = hand_y + start[2] - user.pixel_y + var/start_span_x = span_x + (start[3] - victim.pixel_x) - (start[1] - user.pixel_x) + var/start_span_y = span_y + (start[4] - victim.pixel_y) - (start[2] - user.pixel_y) + var/obj/effect/abstract/wire_reach/strand = new_line(hand[3]) + strand.icon_state = "wire_taut[wire_suffix()]" + strand.layer = strand_layer(hand[3]) + strand.add_overlay(emissive_appearance(strand.icon, strand.icon_state, strand)) + strand.pixel_x = start_hand_x + strand.pixel_y = start_hand_y + strand.span = list(span_x, span_y) + strand.hand_side = side + strand.transform = wire_strand_shape(start_span_x, start_span_y) + if(start_hand_x != hand_x || start_hand_y != hand_y || start_span_x != span_x || start_span_y != span_y) + // Linear, as the body's slide in is (slide_in()), so the far end keeps step with the neck all the way. In beats: + // each beat ends on the strand's true shape for where its two ends are by then, and the matrix is blended + // straight from one beat's shape to the next. Blended in one go from the first shape to the last, a strand that + // turns far on the way (40 degrees, facing the viewer) is drawn thinner than a pixel in between, and recorded + // it showed a pixel missing for the first frames of the pull. + var/beat_time = UPDATE_TRANSFORM_ANIMATION_TIME / WIRE_GARROTE_SLIDE_BEATS + for(var/beat in 1 to WIRE_GARROTE_SLIDE_BEATS) + var/done = beat / WIRE_GARROTE_SLIDE_BEATS + var/beat_x = start_hand_x + (hand_x - start_hand_x) * done + var/beat_y = start_hand_y + (hand_y - start_hand_y) * done + var/matrix/shape = wire_strand_shape(start_span_x + (span_x - start_span_x) * done, start_span_y + (span_y - start_span_y) * done) + if(beat == 1) + animate(strand, pixel_x = beat_x, pixel_y = beat_y, transform = shape, time = beat_time, flags = ANIMATION_LINEAR_TRANSFORM) + else + animate(pixel_x = beat_x, pixel_y = beat_y, transform = shape, time = beat_time, flags = ANIMATION_LINEAR_TRANSFORM) + LAZYADD(strand_refs, WEAKREF(strand)) + QDEL_IN(strand, duration) + +/** + * A taut strand of wire from the middle of the pixel at its base to the middle of the pixel (`span_x`, `span_y`) away, + * and half a pixel past each, so it covers the pixel it starts on and the pixel it ends on whatever its bearing (a line + * that starts on the middle of a pixel draws that pixel on some bearings and not on others: recorded, the strand's end + * in the hand blinked a pixel off the hand as its other end thrashed with the neck). The sprite is a tile-long line + * running up a half pixel left of the middle: it is moved onto the middle and up to start there, stretched, moved back + * half a pixel and turned, then moved to the middle of the base pixel. The base pixel stays covered under any blend of + * two of these, so a strand tweened between two keeps its base in the hand and runs its far end straight from one spot + * to the other. + */ +/proc/wire_strand_shape(span_x, span_y) + var/matrix/shape = matrix() + shape.Translate(0.5, ICON_SIZE_Y / 2) + shape.Scale(1, (wire_line_length(span_x, span_y) + WIRE_LINE_BACK) / ICON_SIZE_Y) + shape.Translate(0, -WIRE_LINE_BACK) + shape.Turn(arctan(span_y, span_x)) + shape.Translate(0.5, 0.5) + return shape + +/** + * The garroter has just been leaned somewhere else (the kill's lean), sliding there over tg's offset time: each strand's + * end in the hand slides with the hand, and its end on the neck stays put. The strands already up are moved, not taken + * down and drawn again. Returns FALSE if there are none to move. + */ +/datum/hv_finisher/garrote/proc/lean_wire() + if(QDELETED(user) || QDELETED(victim)) + return FALSE + var/moved = FALSE + for(var/datum/weakref/strand_ref as anything in strand_refs) + var/obj/effect/abstract/wire_reach/strand = strand_ref.resolve() + if(QDELETED(strand) || !strand.hand_side) + continue + var/list/hand = line_origin(strand.hand_side) + var/hand_x = user.pixel_x + hand[1] + var/hand_y = user.pixel_y + hand[2] + var/list/meet = loop_point(strand.hand_side) + strand.span = list(meet[1] - (hand_x + 0.5), meet[2] - (hand_y + 0.5)) + animate(strand, pixel_x = hand_x, pixel_y = hand_y, transform = wire_strand_shape(strand.span[1], strand.span[2]), time = UPDATE_TRANSFORM_ANIMATION_TIME, flags = ANIMATION_LINEAR_TRANSFORM) + moved = TRUE + return moved + +/datum/hv_finisher/garrote/proc/clear_strands() + for(var/datum/weakref/strand_ref as anything in strand_refs) + qdel(strand_ref.resolve()) + strand_refs = null + +/datum/hv_finisher/garrote/proc/clear_wire_fx() + for(var/timer in windup_timers) + deltimer(timer) + windup_timers = null + if(struggle_timer) + deltimer(struggle_timer) + struggle_timer = null + clear_reach() + clear_loop() + clear_strands() + show_hands() + +/// TRUE if the garrote is the Arc's: the wire round the neck, or the one in the garroter's other hand. A pair with +/// one Arc wire beheads as the Arc does. +/datum/hv_finisher/garrote/proc/arc_wire() + if(istype(wire, /obj/item/melee/cyberware_monowire/live)) + return TRUE + return !isnull(locate(/obj/item/melee/cyberware_monowire/live) in user?.held_items) + +/** + * The kill: the head off in hard crit (with the Arc's wire, whenever no helmet, collar or plating turns it), else + * the throat slit, else (turned by a helmet, collar or plating) an ordinary blow with the wire. Lines go out while + * the head is still on, since they name it. + */ +/datum/hv_finisher/garrote/proc/kill(spray_dir) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + if(!head) + return + var/seared = wire.wire_sears(victim) + // Where the body is drawn while the wire goes through, for where the head comes off and the blood bursts from. + var/list/body_offsets = list(victim.pixel_x, victim.pixel_y, victim.pixel_w, victim.pixel_z) + var/list/cut_neck = neck_on_tile() + var/head_side = (get_dir(victim, user) & (EAST | NORTH)) ? 1 : -1 + var/facing = user.dir + clear_wire_fx() + // The garroter lets go as the wire goes through, and the body drops back once, here. The pool and the smoke go + // where it comes to rest, once it is there. + var/settle_time = let_go_at_kill() + var/list/rest_neck = neck_on_tile() + // Facing away, the snap is between the two bodies, not over the garroter's head. + cyberware_anim_play(victim, WIRE_GORE_ICON, "garrote_snap[wire_suffix()]", facing == NORTH ? WIRE_BETWEEN_LAYER : ABOVE_MOB_LAYER, 0.6 SECONDS, glow = TRUE) + var/datum/hv_crit/throat/throat = locate() in GLOB.hv_crits + var/blocker = wire_turned_by(head, throat) + if(head.can_dismember() && (victim.stat >= HARD_CRIT || (!blocker && arc_wire()))) + victim.hv_add_hit_line("[user] draws the wire through [victim]'s neck!", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + if(seared) + LAZYSET(victim.hv_stump_burnt, BODY_ZONE_HEAD, TRUE) + // What tg pulls off a head as it comes off: laid by the dropped head, not stood up on the body. + var/list/worn = list() + for(var/obj/item/thing in list(victim.glasses, victim.ears, victim.wear_mask, victim.head)) + worn += thing + // Dropped, brain and all, as #509's finishers drop a head: where the neck was, at the garroter's feet. None of + // tg's own blood on the floor (a seared neck doesn't bleed there at all); the garrote lays its own. + if(hv_wire_take_part(head, seared ? WOUND_BURN : WOUND_SLASH, 0, FALSE)) + // The animation pass twitches a fresh corpse a few times, starting at once. Not while the body is still + // slumping back onto its tile: its twitches begin once it lies still. + if(victim.stat == DEAD) + victim.hv_vis_twitches(2, settle_time + WIRE_GARROTE_TWITCH_WAIT) + wire_neck_blood_loss(victim, seared) + wire_drop_head(head, victim, body_offsets, head_side, facing, worn, list(rest_neck[1] - cut_neck[1], rest_neck[2] - cut_neck[2])) + // Facing away, the head lies against the stump, below the body's line, with the garroter just beyond it: a pool + // centred on the neck would show only above the shoulders. It goes a little lower, and shows round the head. + var/list/pool_at = facing == NORTH ? list(rest_neck[1], rest_neck[2] - WIRE_GARROTE_POOL_DROP_NORTH) : rest_neck + finish_blood(spray_dir, seared, cut_neck, rest_neck, settle_time, pool_at) + log_combat(user, victim, "garroted the head off", wire) + return + LAZYREMOVE(victim.hv_stump_burnt, BODY_ZONE_HEAD) + if(!blocker && throat) + // No direction: tg would throw its flying splatter down a straight line. The garrote's blood is finish_blood(). + var/datum/wound/slit = hv_crit_apply_wound(head, throat.wound_type, null, wire) + if(slit) + victim.hv_add_hit_line("[user] jerks the wire across [victim]'s throat!") + throat.announce(victim, spray_dir) + wire_open_throat(victim, slit) + finish_blood(spray_dir, seared, cut_neck, rest_neck, settle_time) + log_combat(user, victim, "garroted the throat of", wire) + return + if(blocker) + victim.hv_add_hit_line("The wire catches on [victim]'s [blocker]!", HV_LINE_BOLD, "The wire catches on your [blocker]!") + real_blow(BODY_ZONE_HEAD, kill_wound_bonus) + +/// The blood a head coming off loses, as tg's dismemberment bleeds it, without the floor splatter tg's bleed() lays +/// (the garrote lays its own blood). A seared neck loses a little. +/proc/wire_neck_blood_loss(mob/living/carbon/body, seared) + if(QDELETED(body) || HAS_TRAIT(body, TRAIT_GODMODE) || !body.can_bleed()) + return + body.blood_volume = max(body.blood_volume - round(rand(40, 80) * (seared ? WIRE_ARC_BLEED_MULT : 1)), 0) + +/** + * The head comes off the end of the neck where the wire cut it, a few pixels clear of it, and drops to the floor at + * the garroter's feet: it doesn't follow the body as that falls back (`settle`, the body's move after the cut), except + * facing away, where the garroter's feet are the wrong side of them and it would lie against their own head. Turned + * as the body lay; an ordinary head again once moved. What the head wore (`worn`, already on the floor) lies beside + * it. `facing` is the garroter's. + */ +/proc/wire_drop_head(obj/item/bodypart/head/head, mob/living/body, list/body_offsets, side, facing, list/worn, list/settle) + if(QDELETED(head) || !isturf(head.loc)) + return + // On the body its middle is 9 px up; it lies 4 px further out, off the end of the neck. Its picture is + // shifted by (px_x, px_y) to sit mid-tile, after its transform. + head.transform = wire_part_pose(list(0, 9), side * 10, list(side * 2, 13), body, head.px_x, head.px_y) + head.pixel_x = body_offsets[1] + head.pixel_y = body_offsets[2] + head.pixel_w = body_offsets[3] + head.pixel_z = body_offsets[4] + // Down to the floor: furthest facing the viewer, where the hold drew the body up off it. + var/shift_x = 0 + var/fall = facing == SOUTH ? 8 : 5 + if(facing == NORTH) + shift_x = LAZYACCESS(settle, 1) || 0 + fall = 2 - (LAZYACCESS(settle, 2) || 0) + animate(head, pixel_x = head.pixel_x + shift_x, pixel_y = head.pixel_y - fall, time = WIRE_GARROTE_HEAD_FALL_TIME, easing = QUAD_EASING | EASE_IN) + head.RegisterSignal(head, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/obj/item/bodypart, wire_stand_up), override = TRUE) + if(!length(worn)) + return + // Where its middle lands, and which way along the floor the head points from the neck. + var/matrix/shape = body.transform + var/head_x = shape.a * side * 2 + shape.b * 13 + shape.c + body_offsets[1] + shift_x + var/head_y = shape.d * side * 2 + shape.e * 13 + shape.f + body_offsets[2] - fall + var/away_x = shape.b > 0.5 ? 1 : (shape.b < -0.5 ? -1 : 0) + var/laid = 0 + for(var/obj/item/thing as anything in worn) + if(QDELETED(thing) || thing.loc != head.loc) + continue + wire_lay_by_head(thing, round(head_x - away_x * (7 + laid * 5)), round(head_y - 3), away_x) + laid++ + +/** + * Something that came off with the head lies on the floor at (`spot_x`, `spot_y`) on its tile, on its side and a little + * smaller than tg's dropped-item picture, unseen until the head has landed. Drawn as any item again once moved. + */ +/proc/wire_lay_by_head(obj/item/thing, spot_x, spot_y, away_x) + var/matrix/lying = matrix() + lying.Scale(0.7) + lying.Turn(away_x < 0 ? -90 : 90) + thing.transform = lying + thing.pixel_x = spot_x + thing.pixel_y = spot_y + thing.alpha = 0 + animate(thing, alpha = 0, time = WIRE_GARROTE_HEAD_FALL_TIME) + animate(alpha = 255, time = 0.1 SECONDS) + thing.RegisterSignal(thing, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/obj/item, wire_garrote_pick_up), override = TRUE) + +/// An item wire_lay_by_head() laid out, once moved: drawn as any item. +/obj/item/proc/wire_garrote_pick_up() + SIGNAL_HANDLER + UnregisterSignal(src, COMSIG_MOVABLE_MOVED) + animate(src, alpha = 255, time = 0) + transform = matrix() + pixel_x = base_pixel_x + pixel_y = base_pixel_y + +/** + * The open neck empties. Blood bursts out all round where the wire cut it (`cut_neck`, from the middle of the victim's + * tile) and lands in a rough ring of drops and splats over the floor about the neck, the garroter included; it wets + * the garroter's hands, then their clothes and feet as the drops come down; and once the body has come to rest + * (`settle_time` on), a pool spreads under the neck there (`rest_neck`, or `pool_at`) over the next couple of seconds. + * A seared one only smokes, from there. + */ +/datum/hv_finisher/garrote/proc/finish_blood(spray_dir, seared, list/cut_neck, list/rest_neck, settle_time = 0, list/pool_at) + if(QDELETED(victim)) + return + if(seared) + if(settle_time) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_neck_smoke), victim, rest_neck), settle_time) + else + wire_neck_smoke(victim, rest_neck) + playsound(victim, 'sound/effects/wounds/sizzle1.ogg', 60, TRUE) + return + var/turf/neck_turf = get_turf(victim) + if(!neck_turf || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/list/blood_dna = victim.get_blood_dna_list() + var/list/viruses = victim.get_static_viruses() + if(!pool_at) + pool_at = rest_neck + if(settle_time) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_pool_under), neck_turf, pool_at[1], pool_at[2], blood_dna, viruses), settle_time) + else + wire_pool_under(neck_turf, pool_at[1], pool_at[2], blood_dna, viruses) + wire_blood_burst(neck_turf, cut_neck[1], cut_neck[2], wire_blood_color(victim), blood_dna) + if(QDELETED(user)) + return + // The hands are at the neck: wet at once. The clothes and feet as the drops come down on them. + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_garrote_soak), user, neck_turf, blood_dna, viruses, 1), 0.15 SECONDS) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_garrote_soak), user, neck_turf, blood_dna, viruses, 2), 0.45 SECONDS) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_garrote_soak), user, neck_turf, blood_dna, viruses, 3), 0.8 SECONDS) + +/// One beat of the garroter getting the neck's blood on them: 1 the hands, 2 the front of the clothes and a few +/// splats round their feet, 3 the rest of them and their shoes. +/proc/wire_garrote_soak(mob/living/soaked, turf/neck_turf, list/blood_dna, list/viruses, beat) + if(QDELETED(soaked)) + return + var/mob/living/carbon/human/dressed = ishuman(soaked) ? soaked : null + switch(beat) + if(1) + if(dressed) + dressed.add_blood_DNA_to_items(blood_dna, ITEM_SLOT_GLOVES | ITEM_SLOT_HANDS, viruses) + else + soaked.add_blood_DNA(blood_dna, viruses) + if(2) + dressed?.add_blood_DNA_to_items(blood_dna, ITEM_SLOT_ICLOTHING | ITEM_SLOT_OCLOTHING, viruses) + var/turf/feet = get_turf(soaked) + if(feet && feet != neck_turf && !isgroundlessturf(feet)) + for(var/i in 1 to 2) + var/obj/effect/decal/cleanable/blood/drip/wire_flick/drop = new(feet, null, blood_dna) + if(!QDELETED(drop)) + drop.pixel_x = rand(-9, 9) + drop.pixel_y = rand(-12, 0) + if(3) + soaked.hv_blood_drench(blood_dna, viruses) + dressed?.add_blood_DNA_to_items(blood_dna, ITEM_SLOT_FEET, viruses) + +/// A seared neck smokes a little: thin wisps rising off the stump where the body lies, not turned with the body. +/obj/effect/abstract/wire_neck_smoke + name = "smoke" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + // Over the body on the floor, under anyone standing. + layer = WIRE_BETWEEN_LAYER + +/obj/effect/abstract/wire_neck_smoke/proc/stop_smoking() + particles?.spawning = 0 + +/particles/smoke/steam/mild/wire_neck + color = "#a29c98" + scale = 0.4 + grow = 0.02 + spawning = 0.6 + lifespan = 1.2 SECONDS + fade = 0.8 SECONDS + position = list(0, 0, 0) + velocity = list(0, 0.5, 0) + gravity = list(0, 0.5) + friction = 0.15 + drift = generator(GEN_SPHERE, 0, 0.5, NORMAL_RAND) + +/// Smoke off a seared neck at (`neck[1]`, `neck[2]`) from the middle of `body`'s tile, for a second, gone soon after. +/proc/wire_neck_smoke(mob/living/body, list/neck) + var/turf/spot = get_turf(body) + if(!spot) + return + var/obj/effect/abstract/wire_neck_smoke/smoke = new(spot) + smoke.pixel_x = neck[1] + smoke.pixel_y = neck[2] + smoke.particles = new /particles/smoke/steam/mild/wire_neck + addtimer(CALLBACK(smoke, TYPE_PROC_REF(/obj/effect/abstract/wire_neck_smoke, stop_smoking)), 1.2 SECONDS) + QDEL_IN(smoke, 2.4 SECONDS) + +/** + * The pool under the neck: tg's blood decal for the tile, filled to a pool size that keeps to the tile + * (blood/blood_pools.dm). A fresh one is centred on the neck and spreads out from a spot under it over + * WIRE_GARROTE_POOL_TIME; a pool already on the tile stays put and steps up through the sizes as it fills. + */ +/proc/wire_pool_under(turf/neck_turf, neck_x, neck_y, list/blood_dna, list/viruses) + if(isgroundlessturf(neck_turf)) + return + var/obj/effect/decal/cleanable/blood/pool + for(var/obj/effect/decal/cleanable/blood/found in neck_turf) + if(found.type == /obj/effect/decal/cleanable/blood && !found.dried && !QDELETED(found)) + pool = found + break + if(pool) + pool.add_blood_DNA(blood_dna) + pool.hv_pool_lobe_tried |= (NORTH | SOUTH | EAST | WEST) + for(var/step in 0 to 3) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_pool_fill), pool), step * 0.45 SECONDS) + return + pool = new /obj/effect/decal/cleanable/blood(neck_turf, viruses, blood_dna) + if(QDELETED(pool)) + return + pool.base_pixel_x = clamp(neck_x, -8, 8) + pool.base_pixel_y = clamp(neck_y, -12, 8) + pool.pixel_x = pool.base_pixel_x + pool.pixel_y = pool.base_pixel_y + // It keeps to the neck's tile: it never runs onto the tiles beside it, however much the body bleeds into it. + pool.hv_pool_lobe_tried |= (NORTH | SOUTH | EAST | WEST) + // Its whole size at once, so the picture doesn't change as it spreads; the spreading is drawn. + pool.adjust_bloodiness(WIRE_GARROTE_POOL_CAP - pool.bloodiness) + var/matrix/spot = matrix() + spot.Scale(0.15) + pool.transform = spot + // Parallel: tg's drying runs on the decal's own animation. + animate(pool, transform = matrix(), time = WIRE_GARROTE_POOL_TIME, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + +/// One beat of the garrote's pool filling, never past WIRE_GARROTE_POOL_CAP. +/proc/wire_pool_fill(obj/effect/decal/cleanable/blood/pool) + if(QDELETED(pool) || pool.bloodiness >= WIRE_GARROTE_POOL_CAP) + return + pool.adjust_bloodiness(min(40, WIRE_GARROTE_POOL_CAP - pool.bloodiness)) + +/** + * Blood bursts from a wound at (`from_x`, `from_y`) pixels from the middle of `origin` and lands round it: `count` drops + * thrown at uneven reach (`reach` pixels, give or take a few), landing as drips, and every third a heavier splat. With + * no `bearing` they go every way, no two in a row alike, so it lands as a broken ring; with one (degrees, 0 north) + * they go within `spread` degrees either side of it, a short spray rather than a line. + */ +/proc/wire_blood_burst(turf/origin, from_x, from_y, blood_color, list/blood_dna, count = 10, bearing = null, spread = 0, list/reach = list(9, 13, 17, 22, 26)) + var/start = rand(0, 359) + var/list/splatted = list() + for(var/i in 1 to count) + var/angle = isnull(bearing) ? start + i * (360 / count) + rand(-14, 14) : bearing + rand(-spread, spread) + var/distance = pick(reach) + rand(-2, 2) + var/land_x = from_x + round(sin(angle) * distance) + var/land_y = from_y + round(cos(angle) * distance) + var/height = 3 + rand(0, 5) + var/flight = round(1.5 + distance / 14, 0.5) + new /obj/effect/temp_visual/wire_blood_drop(origin, list(from_x, from_y, land_x, land_y, height, flight, 0), blood_color, blood_dna) + if(i % 3) + continue + // One splat to a tile. + var/tile_x = floor((land_x + 16) / 32) + var/tile_y = floor((land_y + 16) / 32) + var/turf/landing = locate(origin.x + tile_x, origin.y + tile_y, origin.z) + if(!landing || (landing in splatted) || isgroundlessturf(landing) || (landing != origin && hv_blood_spray_blocked(landing))) + continue + splatted += landing + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_ring_splat), landing, land_x - tile_x * 32, land_y - tile_y * 32, blood_dna), flight) + +/// A heavy splat of the ring, tg's small splatter, landing at (`pixel_x`, `pixel_y`) on `landing`. +/proc/wire_ring_splat(turf/landing, pixel_x, pixel_y, list/blood_dna) + if(!landing) + return + var/obj/effect/decal/cleanable/blood/splatter/splat = new(landing, null, blood_dna) + if(QDELETED(splat)) + return + // tg's splatter art is drawn round the middle of its tile. + splat.pixel_x = clamp(pixel_x, -12, 12) + splat.pixel_y = clamp(pixel_y, -12, 12) + +/// The open throat, drawn on the neck for as long as the wound in it is there. +/obj/effect/abstract/cyberware_anim/wire_throat + name = "slit throat" + icon = WIRE_GORE_ICON + icon_state = "throat_open" + /// The wound it shows + var/datum/wound/slit + +/obj/effect/abstract/cyberware_anim/wire_throat/Destroy() + slit = null + return ..() + +/obj/effect/abstract/cyberware_anim/wire_throat/proc/on_lose_wound(datum/source, datum/wound/lost) + SIGNAL_HANDLER + if(lost == slit) + qdel(src) + +/obj/effect/abstract/cyberware_anim/wire_throat/proc/on_lose_limb(datum/source, obj/item/bodypart/lost) + SIGNAL_HANDLER + if(lost?.body_zone == BODY_ZONE_HEAD) + qdel(src) + +/// Draws the throat `slit` opened on `victim`, in their blood's colour, until the wound closes or the head is gone. +/proc/wire_open_throat(mob/living/carbon/victim, datum/wound/slit) + if(QDELETED(victim) || !slit) + return + var/obj/effect/abstract/cyberware_anim/wire_throat/mark = new(null) + mark.slit = slit + // Over the collar of whatever they wear, under anything worn round the neck. + mark.layer = -(NECK_LAYER + 0.5) + mark.appearance_flags |= RESET_COLOR + mark.color = wire_blood_color(victim) + mark.bearer = victim + mark.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + mark.RegisterSignal(victim, COMSIG_CARBON_LOSE_WOUND, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_throat, on_lose_wound)) + mark.RegisterSignal(victim, COMSIG_CARBON_REMOVE_LIMB, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_throat, on_lose_limb)) + victim.vis_contents += mark + +/// What stops the wire at the throat ("helmet", "collar", "plating"), or null. The same gates as #509's +/// throat crit, with the wire's penetration not counted (the crit gates never count it). +/datum/hv_finisher/garrote/proc/wire_turned_by(obj/item/bodypart/head/head, datum/hv_crit/throat/throat) + var/mob/living/carbon/human/dressed = ishuman(victim) ? victim : null + if(throat?.blocked_on_carbon(victim, head, wire)) + if(victim.is_mouth_covered(ITEM_SLOT_HEAD)) + return "helmet" + return "collar" + if(hv_crit_cover_rating(victim, head, MELEE, 0) >= WIRE_GARROTE_COVER) + if(dressed?.head || dressed?.wear_mask) + return "helmet" + if(dressed?.wear_neck) + return "collar" + return "plating" + return null + +// ---- Stages ---- + +/datum/hv_finisher_stage/windup/garrote + duration = 1 SECONDS + rise = 0 + // Leans in to throw the wire. + lunge = 3 + enter_range = COMBAT_MESSAGE_RANGE + +// The gore pass rears every finisher's attacker up for its wind-up, bigger and tipped back away from the victim +// (visuals/finisher_motion.dm): a beast looming over its kill. A garroter leans in to throw the wire, and the wire +// leaves the hand where the hand is drawn, so that picture is taken straight back off, before any client sees it. +/datum/hv_finisher_stage/windup/garrote/enter() + . = ..() + if(host && !QDELETED(host.user)) + host.user.hv_vis_stop(list(HV_VIS_TAG_REAR)) + +/datum/hv_finisher_stage/windup/garrote/enter_message() + return "[host.user] loops the wire around [host.victim]'s neck." + +/datum/hv_finisher_stage/windup/garrote/enter_self_message() + return "A wire loops around your neck!" + +/datum/hv_finisher_stage/hold/garrote + duration = 1.5 SECONDS + // Stays leaned in over the body, into the pull. + lunge = 3 + enter_sound = 'voidcrew/sound/monowire2.ogg' + enter_volume = 50 + enter_range = COMBAT_MESSAGE_RANGE + +/datum/hv_finisher_stage/hold/garrote/enter() + . = ..() + var/datum/hv_finisher/garrote/garrote = host + garrote.pull_close() + garrote.show_loop("tight") + // Taut until the kill beat cuts it (kill() takes them down; the time is only the garrote's own backstop), sliding in + // with the bodies from where they were before the grab. + garrote.draw_wire(garrote.longest_duration(), garrote.pull_from) + // Drawn in to the hands first, then the thrashing. + garrote.struggle(UPDATE_TRANSFORM_ANIMATION_TIME, duration - UPDATE_TRANSFORM_ANIMATION_TIME) + +/datum/hv_finisher_stage/hold/garrote/enter_message() + return "[host.user] pulls the wire tight!" + +/datum/hv_finisher_stage/hold/garrote/enter_self_message() + return "The wire bites into your neck!" + +/datum/hv_finisher_stage/kill/garrote + duration = 1 SECONDS + effect_delay = 0.5 SECONDS + // Leans further in as the wire goes through. + lunge = 5 + +/datum/hv_finisher_stage/kill/garrote/enter() + var/datum/hv_finisher/garrote/garrote = host + var/list/before_lean = garrote.drawn_offsets() + . = ..() + // The strands stay on the hands as the lean takes them forward: the hold's own strands, which last until the cut. + if(garrote.running && !garrote.lean_wire()) + garrote.draw_wire(effect_delay + 0.1 SECONDS, before_lean) + +/datum/hv_finisher_stage/kill/garrote/effect() + var/datum/hv_finisher/garrote/garrote = host + garrote.kill(away_dir()) + +#undef WIRE_GARROTE_INTERRUPT +#undef WIRE_GARROTE_REACH_TIME +#undef WIRE_GARROTE_LEAN_TIME +#undef WIRE_GARROTE_WIND_TIME +#undef WIRE_GARROTE_THROW_BEATS +#undef WIRE_GARROTE_SLIDE_BEATS +#undef WIRE_GARROTE_TURN_TIME +#undef WIRE_GARROTE_POOL_TIME +#undef WIRE_GARROTE_HEAD_FALL_TIME +#undef WIRE_GARROTE_POOL_DROP_NORTH +#undef WIRE_GARROTE_JOLT_TIME +#undef WIRE_GARROTE_TWITCH_WAIT +#undef WIRE_LINE_BACK +#undef WIRE_SHAKE_TRAIT diff --git a/voidcrew/modules/hyperviolence/wire/wire_held.dm b/voidcrew/modules/hyperviolence/wire/wire_held.dm new file mode 100644 index 00000000000..690dbcfc46a --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_held.dm @@ -0,0 +1,856 @@ +/** + * # The Widowline in the hand + * + * - Deploying and stowing: the wire swings out of the spool and reels back in (WIRE_HELD_ANIM_ICON), played + * on the wielder while the held wire itself is not drawn, like the Mantis unfold. + * - While the wire is out of the hand as a line (a lash, the garrote's thrown wire and strands) the hanging wire isn't + * drawn there, but its spool is (WIRE_SPOOL_ICON): the line comes out of the spool's lit pixel. + * - Blood: a cut that draws blood leaves the wire wet (dripping) for WIRE_WET_TIME, then stained until + * WIRE_STAINED_TIME, drawn from greyscale states tinted to the blood. Stowing a wet wire cracks a fan of + * drops off it that land spread over the floor ahead. Only the picture clears: the blood's DNA stays on the wire until it is washed. + * tg's generic item stain is not drawn on the wire, which draws its own. + * - The Arc's charged wire burns its blood off: it never shows any. + */ + +/obj/item/melee/cyberware_monowire + /// Blood on the wire: wet and dripping until this world.time, stained until this one. + var/wet_until = 0 + var/stained_until = 0 + /// The blood look drawn now: 0 clean, 1 stained, 2 wet. + var/shown_blood_stage = 0 + /// Drips this wet spell still has in it. + var/drips_left = 0 + /// Timer of the next drip, and of the next change of look. + var/drip_timer + var/dry_timer + /// Until this world.time the wire isn't drawn hanging in the hand: its swing is, or a line of it out to something. + var/inhand_hidden_until = 0 + /// Until this world.time its spool isn't drawn in the hand either: the swing out of the spool draws its own. + var/inhand_bare_until = 0 + +/obj/item/melee/cyberware_monowire/Initialize(mapload) + . = ..() + RegisterSignal(src, COMSIG_COMPONENT_CLEAN_ACT, PROC_REF(on_wash)) + +/obj/item/melee/cyberware_monowire/Destroy() + if(garrote_running) + QDEL_NULL(garrote_running) + if(drip_timer) + deltimer(drip_timer) + drip_timer = null + if(dry_timer) + deltimer(dry_timer) + dry_timer = null + return ..() + +// ---- Blood on the wire ---- + +/// Whether this wire never shows blood. +/obj/item/melee/cyberware_monowire/proc/wire_stays_clean() + return FALSE + +/obj/item/melee/cyberware_monowire/live/wire_stays_clean() + return TRUE + +/// 2 while the wire is wet, 1 while it is stained, else 0. +/obj/item/melee/cyberware_monowire/proc/blood_stage() + if(world.time < wet_until) + return 2 + if(world.time < stained_until) + return 1 + return 0 + +/// A landed cut on `victim` at `def_zone`: if it drew blood, the wire is wet with it. +/obj/item/melee/cyberware_monowire/proc/wire_bloody(mob/living/victim, def_zone) + if(wire_stays_clean() || QDELETED(victim)) + return + if(isbodypart(def_zone)) + var/obj/item/bodypart/limb = def_zone + if(!limb.can_bleed()) + return + if(victim.can_bleed(BLOOD_COVER_ITEMS) != BLEED_SPLATTER) + return + add_mob_blood(victim) + wet_until = world.time + WIRE_WET_TIME + stained_until = world.time + WIRE_STAINED_TIME + drips_left = WIRE_MAX_DRIPS + if(!drip_timer) + drip_timer = addtimer(CALLBACK(src, PROC_REF(drip)), rand(1 SECONDS, 2 SECONDS), TIMER_STOPPABLE) + update_blood_look() + +/// Redraws the blood if its look changed, and waits for the next change. +/obj/item/melee/cyberware_monowire/proc/update_blood_look() + if(dry_timer) + deltimer(dry_timer) + dry_timer = null + var/stage = blood_stage() + if(stage != shown_blood_stage) + shown_blood_stage = stage + update_appearance(UPDATE_OVERLAYS) + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + // Wet dries to a stain, a stain to clean: wait for whichever comes next. + if(stage) + dry_timer = addtimer(CALLBACK(src, PROC_REF(update_blood_look)), (stage == 2 ? wet_until : stained_until) - world.time + 1, TIMER_STOPPABLE) + +/// A drop off the tip while the wire is out and wet. +/obj/item/melee/cyberware_monowire/proc/drip() + drip_timer = null + if(blood_stage() < 2 || drips_left <= 0) + return + var/mob/living/holder = loc + if(!isliving(holder)) + return + var/turf/below = holder.loc + if(isturf(below) && !isgroundlessturf(below) && below.has_gravity() && length(GET_ATOM_BLOOD_DNA(src))) + // Off the tip, which hangs to the floor: not wherever tg's drip would land, which can be the wielder's hip. + var/obj/effect/decal/cleanable/blood/drip/wire_flick/drop = new(below, null, GET_ATOM_BLOOD_DNA(src)) + if(!QDELETED(drop)) + drop.wire_place(holder.pixel_x + wire_tip_x(holder, held_side(holder) || "right") + rand(-2, 2), holder.pixel_y - 13 + rand(-1, 1)) + drips_left-- + drip_timer = addtimer(CALLBACK(src, PROC_REF(drip)), rand(1 SECONDS, 2 SECONDS), TIMER_STOPPABLE) + +/// Where the tip of a wire held in the `side` hand ("left" or "right") of `holder` touches the floor, in pixels across +/// from the middle of its tile, as the held wire is drawn (cyberware_lefthand.dmi, cyberware_righthand.dmi). +/proc/wire_tip_x(mob/living/holder, side) + var/right = side != "left" + switch(holder.dir) + if(EAST) + return right ? 1 : 6 + if(WEST) + return right ? -7 : -1 + if(NORTH) + return right ? 9 : -10 + return right ? -10 : 9 + +/// A wash takes the blood off, look and all. +/obj/item/melee/cyberware_monowire/proc/on_wash(datum/source, clean_types) + SIGNAL_HANDLER + if(!(clean_types & CLEAN_TYPE_BLOOD) || !(wet_until || stained_until)) + return NONE + clear_blood_look() + return COMPONENT_CLEANED + +/// The blood stops showing. Its DNA stays. +/obj/item/melee/cyberware_monowire/proc/clear_blood_look() + wet_until = 0 + stained_until = 0 + drips_left = 0 + if(drip_timer) + deltimer(drip_timer) + drip_timer = null + update_blood_look() + +/obj/item/melee/cyberware_monowire/update_overlays() + . = ..() + var/stage = blood_stage() + if(!stage) + return + var/mutable_appearance/blood = mutable_appearance(icon, "monowire_blood[stage]", appearance_flags = RESET_COLOR) + blood.color = get_blood_dna_color() || BLOOD_COLOR_RED + . += blood + +/obj/item/melee/cyberware_monowire/worn_overlays(mutable_appearance/standing, isinhands = FALSE, icon_file) + . = ..() + if(!isinhands || world.time < inhand_hidden_until) + return + var/stage = blood_stage() + if(!stage || !icon_exists(icon_file, "monowire_blood[stage]")) + return + var/mutable_appearance/blood = mutable_appearance(icon_file, "monowire_blood[stage]", appearance_flags = RESET_COLOR) + blood.color = get_blood_dna_color() || BLOOD_COLOR_RED + . += blood + +// tg's stain over the whole item would sit on the spool long after the wire has dried; the wire draws its own. +/datum/forensics/check_blood() + if(istype(parent, /obj/item/melee/cyberware_monowire)) + return + return ..() + +// ---- Out of the spool and back ---- + +/** + * The wire isn't drawn hanging in the hand for `duration`. With `spool` its spool still is: the wire is out of the hand + * as a line (a lash, the garrote's), which comes out of the spool. Without, nothing of it is: its swing out of the + * spool is being played, spool and all. + */ +/obj/item/melee/cyberware_monowire/proc/hide_inhand_for(duration, spool = FALSE) + // A shorter hide (a swing out of the spool) never cuts a longer one (a garrote's) short. + inhand_hidden_until = max(inhand_hidden_until, world.time + duration) + if(!spool) + inhand_bare_until = max(inhand_bare_until, world.time + duration) + // A hide that runs on past a swing's draws the spool from the end of the swing. + if(inhand_bare_until > world.time && inhand_bare_until < inhand_hidden_until) + addtimer(CALLBACK(src, PROC_REF(redraw_inhand)), inhand_bare_until - world.time, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_NO_HASH_WAIT) + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + addtimer(CALLBACK(src, PROC_REF(show_inhand)), inhand_hidden_until - world.time, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_NO_HASH_WAIT) + +/obj/item/melee/cyberware_monowire/proc/show_inhand() + if(!inhand_hidden_until) + return + inhand_hidden_until = 0 + inhand_bare_until = 0 + redraw_inhand() + +/obj/item/melee/cyberware_monowire/proc/redraw_inhand() + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + +// The wire is in the hand from the first tick; only its drawing waits for the swing, or for the line of it that is out. +/obj/item/melee/cyberware_monowire/build_worn_icon(default_layer = 0, default_icon_file = null, isinhands = FALSE, female_uniform = NO_FEMALE_UNIFORM, override_state = null, override_file = null) + if(!isinhands || world.time >= inhand_hidden_until) + return ..() + // Out of the hand as a line: the spool it comes out of stays. + var/spool_state = "[inhand_icon_state]_[default_icon_file == lefthand_file ? "left" : "right"]" + if(world.time >= inhand_bare_until && icon_exists(WIRE_SPOOL_ICON, spool_state)) + return ..(default_layer, default_icon_file, isinhands, female_uniform, spool_state, WIRE_SPOOL_ICON) + . = ..() + var/mutable_appearance/standing = . + standing.alpha = 0 + +/// "left" or "right": the hand `user` holds this wire in, or null. +/obj/item/melee/cyberware_monowire/proc/held_side(mob/living/user) + var/index = user?.get_held_index_of_item(src) + if(!index) + return null + return IS_LEFT_INDEX(index) ? "left" : "right" + +/** + * The pixel the wire leaves the `side` hand ("left" or "right") of `holder` from: the one the held wire's spool lights, + * where the wire comes out of it, for each facing (the bright pixel in the spool of the "monowire" states in + * cyberware_lefthand.dmi and cyberware_righthand.dmi; the far hand in profile shows no spool, only the hand's edge). + * By its lower left corner, in pixels from the middle of the tile: a line of wire is drawn from the middle of this + * pixel. Also whether that hand is behind the body as the body faces: the far hand in profile, and both hands of a body + * facing away. The hands hang at the hips; in profile the near hand is at the middle of the body and the far one just + * shows at its front edge. The garrote's wire leaves the same pixels. list(x, y, behind) + */ +/proc/wire_hand_point(mob/living/holder, side) + var/right = side != "left" + switch(holder.dir) + if(EAST) + return right ? list(-2, -3, FALSE) : list(4, -3, TRUE) + if(WEST) + return right ? list(-5, -3, TRUE) : list(1, -3, FALSE) + if(NORTH) + return right ? list(7, -3, TRUE) : list(-8, -3, TRUE) + return right ? list(-8, -3, FALSE) : list(7, -3, FALSE) + +/// The world layer for a line of wire leaving a hand: over the bodies, or under them for a hand behind its body. +/proc/wire_line_layer(behind) + return behind ? BELOW_MOB_LAYER : ABOVE_ALL_MOB_LAYER + +/** + * A segment of a line of wire drawn with Beam(). tg's segments all sit at ABOVE_ALL_MOB_LAYER whatever layer the beam + * is given (their picture inherits the segment's layer), so a line that has to pass behind a body takes its beam's. + */ +/obj/effect/ebeam/wire_line + +/obj/effect/ebeam/wire_line/Initialize(mapload, beam_owner) + . = ..() + // Turned to the bearing without smoothing, so the line stays one pixel wide. + appearance_flags |= PIXEL_SCALE + if(owner?.beam_layer) + layer = owner.beam_layer + +/// The overlay layer on its wielder for the wire swinging in a hand: over the hands, or under the whole body for +/// the far hand in profile, so the torso hides what passes behind it. +/proc/wire_swing_layer(mob/living/holder, side) + if(holder.dir & (EAST | WEST)) + var/list/hand = wire_hand_point(holder, side) + if(hand[3]) + return -(TOTAL_LAYERS + 0.5) + return WIRE_SWING_LAYER + +/** + * The pixel the `zone` part of `body` is drawn on, by its lower left corner in pixels from the middle of its tile: as + * it stands and faces (wire_part_offset()), turned with it when it lies, then moved by its offsets. list(x, y) + */ +/proc/wire_zone_point(mob/living/body, zone) + var/list/at = wire_part_offset(zone, body.dir) + var/matrix/shape = body.transform + // The pixel its middle lands in. + var/middle_x = at[1] + 0.5 + var/middle_y = at[2] + 0.5 + return list(floor(shape.a * middle_x + shape.b * middle_y + shape.c) + body.pixel_x, floor(shape.d * middle_x + shape.e * middle_y + shape.f) + body.pixel_y) + +/** + * The pixel of the `zone` part of a standing human body facing `facing` that a line of wire lands on, by its lower left + * corner in pixels from the middle of its tile. An arm or leg: the pixel in the middle of the cut across its shoulder + * or hip, the middle of the "wire_cut_" seam's line, where the limb comes away. The body's right is the screen's + * left facing south and its right facing north. Side on, the far arm and leg (wire_limb_far()) are hidden behind the + * near ones, the leg drawn a pixel ahead of it; the chest is the middle of the torso. list(x, y) + */ +/proc/wire_part_offset(zone, facing) + switch(zone) + if(BODY_ZONE_HEAD) + return list(-1, 9) + if(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM) + switch(facing) + if(EAST) + return list(-3, 3) + if(WEST) + return list(2, 3) + // Facing north the body's right is on the screen's right. + var/screen_right = (zone == BODY_ZONE_R_ARM) == (facing == NORTH) + return list(screen_right ? 5 : -7, 1) + if(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG) + switch(facing) + if(EAST) + return list(zone == BODY_ZONE_R_LEG ? 0 : 1, -9) + if(WEST) + return list(zone == BODY_ZONE_L_LEG ? 0 : -1, -9) + var/screen_right = (zone == BODY_ZONE_R_LEG) == (facing == NORTH) + return list(screen_right ? 2 : -4, -7) + return list(facing == WEST ? 0 : -1, -1) + +/** + * A landed swing: the wire lashes from the hand holding it to the part of `target` it cut (`zone`, else the part the + * wielder aimed at), ending on the shoulder or hip of a limb it takes off (wire_zone_point()): one straight thin line in + * its own colour that fades out over half a second. It is the wire: the one hanging in the hand isn't drawn until the + * line fades (wire_lash_hide()). A stretched line, not tg's Beam(): a beam's segments are drawn over every body + * whatever layer they are given, and this one must pass behind some. `cross`: this wire's half of a crossing cut, + * which runs on through the middle of the chest so that it crosses the other hand's there (also set for the next lash + * by wire_cross_lash()). The line starts where the hand rests and doesn't follow it through tg's lunge + * (do_attack_animation()): a client plays that lean already under way when it sees it, and plays the new line from its + * start, so a line moved along with the lean ran ahead of the hand and stopped short of it. + * + * The map draws whatever stands lower on the screen (its tile plus pixel_y) over what stands higher, whatever the + * layer, and orders things level with each other by layer. The line is drawn in two pieces that meet where it leaves + * the wielder's tile, each sorted against its own body, a few pixels in front of it or behind it: + * - The hand's piece, on the wielder's tile: over him from a hand in front of his body, under him from one behind it + * (the far hand side on, both hands facing away). + * - The target's piece, on the target's tile: under the target where the line meets it behind its body + * (wire_lash_behind()), else over it. Up a row from the wielder, a piece over a leg goes at once, as the line snaps + * through it: left on, it runs along the leg and paints it. + * From a wielder facing the viewer, a line that would slant across his own legs lands out to the hand's side on the + * limb it cuts, if it can clear them there (wire_lash_clear_legs()). + * + * The line runs from the middle of the pixel the wire leaves the hand from (wire_hand_point()) to the middle of the + * pixel it lands on (wire_zone_point()), and covers both, whichever way it runs. Both pieces are cut from that one + * line (wire_lash_shape()), so they meet without a step; only the target's piece carries the wire's bright end. + * + * A swing that carried on into someone standing right behind its target draws wire_lash_through() instead. + */ +/// How fast the piece of a lash that runs along a leg beyond the wielder goes. +#define WIRE_LASH_SNAP_FADE (0.25 SECONDS) +/// How far past the middle of the chest each line of a crossing cut runs, in pixels. +#define WIRE_CROSS_PAST 6 +/// tg's lunge moves the wielder up to 8 pixels along pixel_y, which moves him in the map's front-to-back order: the +/// hand's piece sorts this far in front of him (or behind), so a lean toward or away from the viewer never crosses it. +#define WIRE_LASH_ROW_HAND 9 +/// The target's piece sorts this far in front of the target (or behind): a body lying down is drawn a few pixels lower +/// (tg's lying offset), and so in front of anything placed level with its tile. +#define WIRE_LASH_ROW_TARGET 4 +/// The line starts this far before the middle of the pixel it leaves the hand from, so that pixel is always drawn. +#define WIRE_LASH_BACK 0.5 + +/obj/item/melee/cyberware_monowire/proc/wire_lash_at(mob/living/user, atom/target, zone, cross = FALSE) + var/turf/from = get_turf(user) + var/turf/hit = get_turf(target) + cross = cross || lash_crossing + lash_crossing = FALSE + var/mob/living/through = lash_through?.resolve() + lash_through = null + if(!from || !hit || from == hit || from.z != hit.z) + return + // The swing carried on into someone right behind its target: the one line runs on to them, past a limb it took off + // or the middle of a crossing cut, so they see what cut them. The other wire of a crossing cut still crosses. + if(through && isliving(target) && wire_lash_through(user, target, through)) + return + zone = zone || check_zone(user.zone_selected) + var/list/hand = wire_hand_point(user, held_side(user) || "right") + // The pixel the line leaves and the pixel it lands on, by their lower left corners: the first from the middle of the + // wielder's tile, the second from the middle of the target's. + var/hand_x = user.pixel_x + hand[1] + var/hand_y = user.pixel_y + hand[2] + var/tile_x = (hit.x - from.x) * ICON_SIZE_X + var/tile_y = (hit.y - from.y) * ICON_SIZE_Y + var/end_x = target.pixel_x + var/end_y = target.pixel_y + var/behind = FALSE + if(isliving(target)) + var/list/part = wire_zone_point(target, cross ? BODY_ZONE_CHEST : zone) + end_x = part[1] + end_y = part[2] + behind = wire_lash_behind(user, target, zone, hand[3], cross) + if(cross) + var/run_x = tile_x + end_x - hand_x + var/run_y = tile_y + end_y - hand_y + var/run = max(sqrt(run_x ** 2 + run_y ** 2), 1) + end_x += round(run_x * WIRE_CROSS_PAST / run) + end_y += round(run_y * WIRE_CROSS_PAST / run) + else if(hit.y < from.y && user.dir == SOUTH && user.body_position != LYING_DOWN) + end_x = wire_lash_clear_legs(user, hand_x, hand_y + 0.5, tile_x + end_x, tile_y + end_y + 0.5) - tile_x + wire_lash_hide(user) + // From the middle of the one pixel to the middle of the other. + var/span_x = tile_x + end_x - hand_x + var/span_y = tile_y + end_y - hand_y + var/length = sqrt(span_x ** 2 + span_y ** 2) + if(length < 1) + return + // How far along it leaves the wielder's tile: by its side beside him, else by its foot or head. + var/along = 1 + if(hit.y == from.y) + if(span_x) + along = (SIGN(tile_x) * ICON_SIZE_X / 2 - (hand_x + 0.5)) / span_x + else if(span_y) + along = (SIGN(tile_y) * ICON_SIZE_Y / 2 - (hand_y + 0.5)) / span_y + along = clamp(along, 0, 1) + // The hand's piece: from just before the middle of the hand's pixel, so that pixel is always drawn, to the edge. + var/hand_row = user.pixel_y + (hand[3] ? WIRE_LASH_ROW_HAND : -WIRE_LASH_ROW_HAND) + if(along * length >= 0.5) + wire_line_piece(from, hand_x, hand_y, wire_lash_shape(span_x, span_y, -WIRE_LASH_BACK, along * length, 0.5, 0.5), lash_run_state(), wire_line_layer(hand[3]), hand_row) + // The target's piece: on from the edge, to just past the middle of the pixel it lands on. It is placed on the pixel + // the edge point falls in, and the rest of the way to that point is in its shape, so the two pieces are one line. + var/edge_x = hand_x + 0.5 + span_x * along - tile_x + var/edge_y = hand_y + 0.5 + span_y * along - tile_y + var/base_x = floor(edge_x) + var/base_y = floor(edge_y) + var/snap = hit.y > from.y && !behind && !cross && (zone == BODY_ZONE_L_LEG || zone == BODY_ZONE_R_LEG) + var/target_row = target.pixel_y + (behind ? WIRE_LASH_ROW_TARGET : -WIRE_LASH_ROW_TARGET) + wire_line_piece(hit, base_x, base_y, wire_lash_shape(span_x, span_y, 0, (1 - along) * length + 0.5, edge_x - base_x, edge_y - base_y), lash_state(), behind ? BELOW_MOB_LAYER : ABOVE_MOB_LAYER, target_row, fade = snap ? WIRE_LASH_SNAP_FADE : WIRE_LASH_FADE) + +/** + * The lash of a swing that carried on through `target` into `behind`, standing right behind it (wire_cleave()): the one + * line runs on, straight from the hand to the middle of `behind`'s chest, the last part it cut, through `target` on the + * way. No light of its own and no line of blood: it is the wire. Cut from one line like any lash (wire_lash_shape()), + * in four pieces, each sorted against its own body: + * - The hand's piece, on the wielder's tile, as in any lash. + * - On `target`'s tile, up to where the line passes its chest: over it, or under one standing nearer the viewer than + * the wielder, met on its far side (wire_lash_behind()). + * - On from there to the edge of `behind`'s tile the wire has gone through `target` and out of its other side: under + * it where it went in over it, over it where it went in under it. Inside a body or behind one, it isn't seen. Over + * a body lying on the floor all the way. + * - On `behind`'s tile, to its chest, with the wire's bright end: over it or under it by the same rule. + * Returns FALSE, with nothing drawn, if the three don't stand so that a line can run through them in that order. + */ +/obj/item/melee/cyberware_monowire/proc/wire_lash_through(mob/living/user, mob/living/target, mob/living/behind) + var/list/line = wire_through_line(user, target, behind) + if(!line) + return FALSE + var/turf/from = get_turf(user) + var/turf/first = get_turf(target) + var/turf/last = get_turf(behind) + var/list/hand = wire_hand_point(user, held_side(user) || "right") + var/start_x = line[1] + var/start_y = line[2] + var/span_x = line[3] + var/span_y = line[4] + var/length = line[5] + var/leaves = line[6] + var/passes = line[7] + var/enters = line[8] + // The hand's pixel, by its lower left corner. + var/hand_x = start_x - 0.5 + var/hand_y = start_y - 0.5 + wire_lash_hide(user) + if(leaves >= 0.5) + wire_line_piece(from, hand_x, hand_y, wire_lash_shape(span_x, span_y, -WIRE_LASH_BACK, leaves, 0.5, 0.5), lash_run_state(), wire_line_layer(hand[3]), user.pixel_y + (hand[3] ? WIRE_LASH_ROW_HAND : -WIRE_LASH_ROW_HAND)) + var/under = wire_lash_behind(user, target, BODY_ZONE_CHEST, hand[3], FALSE) + var/under_after = target.body_position == LYING_DOWN ? under : !under + wire_lash_stretch(first, from, start_x, start_y, span_x, span_y, length, leaves, passes, lash_run_state(), under, target.pixel_y) + wire_lash_stretch(first, from, start_x, start_y, span_x, span_y, length, passes, enters, lash_run_state(), under_after, target.pixel_y) + wire_lash_stretch(last, from, start_x, start_y, span_x, span_y, length, enters, length + 0.5, lash_state(), wire_lash_behind(user, behind, BODY_ZONE_CHEST, hand[3], FALSE), behind.pixel_y) + return TRUE + +/** + * The line a through-cut's lash runs along (wire_lash_through()): straight from the middle of the pixel the wire leaves + * `user`'s hand from to the middle of the pixel in the middle of `behind`'s chest, through `target` on the way. Null if + * the three don't stand so that a line can run through them in that order. list(start_x, start_y, span_x, span_y, + * length, leaves, passes, enters): where it starts and how far it runs, in pixels from the middle of the wielder's + * tile, its length, and how far along it, in pixels, it leaves the wielder's tile, passes the first body's chest and + * enters the second's tile. The wire's through-cut picks who it carries into by this same line (wire_through_target()). + */ +/obj/item/melee/cyberware_monowire/proc/wire_through_line(mob/living/user, mob/living/target, mob/living/behind) + var/turf/from = get_turf(user) + var/turf/first = get_turf(target) + var/turf/last = get_turf(behind) + if(QDELETED(behind) || !from || !first || !last || from.z != first.z || from.z != last.z || from == first || first == last || from == last) + return null + // The second body stands straight on along the swing: the step from the first body to it runs the same way as the + // way from the wielder to the first body. A swing at a knight's move has no body straight behind its target. + var/reach_x = first.x - from.x + var/reach_y = first.y - from.y + var/past_x = last.x - first.x + var/past_y = last.y - first.y + if(reach_x * past_y != reach_y * past_x || reach_x * past_x + reach_y * past_y <= 0) + return null + var/list/hand = wire_hand_point(user, held_side(user) || "right") + var/hand_x = user.pixel_x + hand[1] + var/hand_y = user.pixel_y + hand[2] + // The pixel it passes on the first body and the pixel it lands on, by their lower left corners from the middle of + // each body's own tile; the tiles, from the middle of the wielder's. + var/list/chest = wire_zone_point(target, BODY_ZONE_CHEST) + var/list/end = wire_zone_point(behind, BODY_ZONE_CHEST) + var/first_x = (first.x - from.x) * ICON_SIZE_X + var/first_y = (first.y - from.y) * ICON_SIZE_Y + var/last_x = (last.x - from.x) * ICON_SIZE_X + var/last_y = (last.y - from.y) * ICON_SIZE_Y + // From the middle of the hand's pixel to the middle of the one it lands on. + var/start_x = hand_x + 0.5 + var/start_y = hand_y + 0.5 + var/span_x = last_x + end[1] - hand_x + var/span_y = last_y + end[2] - hand_y + var/length = sqrt(span_x ** 2 + span_y ** 2) + if(length < 1) + return null + var/leaves = length * wire_line_crosses(start_x, start_y, span_x, span_y, first.y == from.y, SIGN(first_x) * ICON_SIZE_X / 2, SIGN(first_y) * ICON_SIZE_Y / 2) + var/enters = length * wire_line_crosses(start_x, start_y, span_x, span_y, last.y == first.y, last_x - SIGN(last_x - first_x) * ICON_SIZE_X / 2, last_y - SIGN(last_y - first_y) * ICON_SIZE_Y / 2) + if(enters < leaves) + return null + var/passes = clamp(((first_x + chest[1] - hand_x) * span_x + (first_y + chest[2] - hand_y) * span_y) / length, leaves, enters) + return list(start_x, start_y, span_x, span_y, length, leaves, passes, enters) + +/** + * Where a through-cut's line (wire_through_line()) cuts the two bodies: the pixel it passes on `target`, level with the + * middle of its chest, and the pixel it ends on in the middle of `behind`'s chest, each by its lower left corner from + * the middle of that body's own tile. Their blood leaves from there (wire_through_fx()). list(list(x, y), list(x, y)), + * or null if no line runs through the three. + */ +/obj/item/melee/cyberware_monowire/proc/wire_through_cuts(mob/living/user, mob/living/target, mob/living/behind) + var/list/line = wire_through_line(user, target, behind) + if(!line) + return null + var/turf/from = get_turf(user) + var/turf/first = get_turf(target) + var/along = line[7] / line[5] + var/pass_x = line[1] + line[3] * along - (first.x - from.x) * ICON_SIZE_X + var/pass_y = line[2] + line[4] * along - (first.y - from.y) * ICON_SIZE_Y + return list(list(floor(pass_x), floor(pass_y)), wire_zone_point(behind, BODY_ZONE_CHEST)) + +/** + * One stretch of a lash's line beyond the wielder's tile, from `from_length` to `to_length` pixels along it, on the + * tile `where` of the body it is sorted against: `under` it or over it, `row` being that body's pixel_y. The line + * starts (`start_x`, `start_y`) from the middle of the wielder's tile `from` and runs (`span_x`, `span_y`), `length` + * long. The piece is placed on the pixel its first point falls in, and the rest of the way to that point is in its + * shape, so that consecutive stretches are one line (wire_lash_shape()). + */ +/obj/item/melee/cyberware_monowire/proc/wire_lash_stretch(turf/where, turf/from, start_x, start_y, span_x, span_y, length, from_length, to_length, state, under, row) + if(to_length - from_length < 0.5) + return + var/at_x = start_x + span_x * from_length / length - (where.x - from.x) * ICON_SIZE_X + var/at_y = start_y + span_y * from_length / length - (where.y - from.y) * ICON_SIZE_Y + var/base_x = floor(at_x) + var/base_y = floor(at_y) + wire_line_piece(where, base_x, base_y, wire_lash_shape(span_x, span_y, 0, to_length - from_length, at_x - base_x, at_y - base_y), state, under ? BELOW_MOB_LAYER : ABOVE_MOB_LAYER, row + (under ? WIRE_LASH_ROW_TARGET : -WIRE_LASH_ROW_TARGET)) + +/// How far along a line from (`start_x`, `start_y`) running (`span_x`, `span_y`) it crosses the edge between two tiles: +/// the upright edge at `edge_x` for tiles side by side (`level`), else the level one at `edge_y`. From 0 to 1. +/proc/wire_line_crosses(start_x, start_y, span_x, span_y, level, edge_x, edge_y) + if(level) + return span_x ? clamp((edge_x - start_x) / span_x, 0, 1) : 1 + return span_y ? clamp((edge_y - start_y) / span_y, 0, 1) : 1 + +#undef WIRE_LASH_SNAP_FADE +#undef WIRE_CROSS_PAST +#undef WIRE_LASH_ROW_HAND +#undef WIRE_LASH_ROW_TARGET +#undef WIRE_LASH_BACK + +/** + * A stretch of a lash's line, as a transform for one of its pieces (the "wire_lash" states of WIRE_GORE_ICON: half a + * tile of line running up from the middle of the icon, a half pixel left of it). The whole line runs from a point + * toward one (`span_x`, `span_y`) away; this piece is the stretch from `from_length` to `to_length` pixels along it, + * the point itself being (`at_x`, `at_y`) from the lower left corner of the pixel the piece is placed on: (0.5, 0.5) + * for the middle of that pixel. Two pieces of one line given the same bearing and consecutive stretches meet exactly. + */ +/proc/wire_lash_shape(span_x, span_y, from_length, to_length, at_x, at_y) + var/matrix/shape = matrix() + shape.Translate(0.5, 0) + shape.Scale(1, max(to_length - from_length, 0.5) / 16) + shape.Translate(0, from_length) + shape.Turn(arctan(span_y, span_x)) + shape.Translate(at_x, at_y) + return shape + +/** + * Whether a lash from `user` meets `body` behind it, so its end is drawn under the body: + * - A body standing nearer the viewer than the wielder (a row down the screen) is met on its far side, whatever way it + * faces: the line runs down from the hand to its outline and on behind it, its head, back and other leg, to the part + * it cuts. A crossing cut's two wires cross behind it too. + * - Side on, an arm or leg on the far side of a standing body (wire_limb_far()), behind the near limbs and the torso. + * In a crossing cut, the wire from the wielder's far hand side on (`hand_behind`) runs through behind the near + * hand's, under both bodies. + * - A body further off than the wielder is met on the side facing the viewer: over it. So is one lying on the floor. + */ +/proc/wire_lash_behind(mob/living/user, mob/living/body, zone, hand_behind, cross) + var/turf/body_turf = get_turf(body) + var/turf/user_turf = get_turf(user) + if(body.body_position != LYING_DOWN && body_turf && user_turf && body_turf.y < user_turf.y) + return TRUE + if(cross) + return hand_behind && (user.dir & (EAST | WEST)) + return body.body_position != LYING_DOWN && wire_limb_far(zone, body.dir) + +/** + * While its lash shows, the wire hanging in the hand isn't drawn, whichever way `user` faces: the line out of the grip + * is the wire, out to the target. It is back as the line fades, never both at once and never neither. Its spool stays + * in the hand: the line comes out of it. Every lash goes through here (wire_lash_at()): an ordinary swing, the aimed + * sever, the Arc's, and both halves of a crossing cut, each wire for its own hand. A longer hide already running (the + * swing out of the spool, the garrote) is left as it is. + */ +/obj/item/melee/cyberware_monowire/proc/wire_lash_hide(mob/living/user) + if(inhand_hidden_until >= world.time + WIRE_LASH_HIDE) + return + hide_inhand_for(WIRE_LASH_HIDE, spool = TRUE) + +/// The middle of a body facing the viewer, in pixels from the middle of its tile as a lash's line is placed: tg draws +/// bodies a pixel left of the tile's middle. +#define WIRE_BODY_MIDDLE -1 +/// A lash's line keeps at least this far off that middle down the trouser legs (rows 6 to 13 below the tile's middle)... +#define WIRE_CLEAR_TROUSERS 5.75 +/// ...and this far down the shoes (rows 14 to 16), which stand wider. Either way its soft edge stays off them. +#define WIRE_CLEAR_SHOES 6.7 +/// A lash's end moves no further than this to clear the legs: it still lands on the shoulder or hip it cuts. +#define WIRE_CLEAR_REACH 3 + +/** + * Where across a lash from a hand at (`hand_x`, `hand_y`) to a body a row down lands, given that it would land at + * (`end_x`, `end_y`) (all in pixels off the middle of the wielder's tile), so that it runs down beside the legs of a + * wielder facing the viewer, not across them: moved out to the hand's side as far as it has to go and no further. One + * that can't clear them without landing more than WIRE_CLEAR_REACH off the limb's joint, a limb across the body from + * the hand, is left where it was: the line goes to the limb that comes off. + */ +/proc/wire_lash_clear_legs(mob/living/user, hand_x, hand_y, end_x, end_y) + var/span_y = end_y - hand_y + if(span_y >= 0) + return end_x + var/middle = user.pixel_x + WIRE_BODY_MIDDLE + var/side = hand_x < middle ? -1 : 1 + var/span_x = end_x - hand_x + // Each band's top and bottom rows (a straight line is furthest in at one of them), and how far off the middle it keeps. + var/static/list/bands = list(list(-6, -13, WIRE_CLEAR_TROUSERS), list(-14, -16, WIRE_CLEAR_SHOES)) + for(var/list/band as anything in bands) + for(var/row in list(band[1], band[2])) + // How far along the line that row's middle is. + var/along = (user.pixel_y + row + 0.5 - hand_y) / span_y + if(along <= 0 || along > 1) + continue + var/most = (middle + side * band[3] - hand_x) / along + span_x = side < 0 ? min(span_x, most) : max(span_x, most) + var/cleared = hand_x + span_x + if(abs(cleared - end_x) > WIRE_CLEAR_REACH) + return end_x + return cleared + +#undef WIRE_BODY_MIDDLE +#undef WIRE_CLEAR_TROUSERS +#undef WIRE_CLEAR_SHOES +#undef WIRE_CLEAR_REACH + +/** + * One straight piece of a lash on `where`: placed on the pixel (`base_x`, `base_y`) off the middle of that tile (its + * lower left corner), drawn as `shape` (wire_lash_shape()) in `state`, at world `line_layer`; it fades out over `fade`. + * Over the usual WIRE_LASH_FADE it is down to a trace as the wire comes back into the hand (wire_lash_hide()) and + * holds that until it goes: both are the server's timers, so a server running behind keeps the line in the hand until + * the wire is drawn. It is placed with pixel_w and pixel_z, which only move the picture; `row` (pixel_y) is where it + * sorts among the things on its tile's row, which the map orders front to back before it looks at layers + * (wire_lash_at()). + */ +/obj/item/melee/cyberware_monowire/proc/wire_line_piece(turf/where, base_x, base_y, matrix/shape, state, line_layer, row = 0, fade = WIRE_LASH_FADE) + if(!where || !shape) + return + var/obj/effect/abstract/wire_reach/lash = new(where) + lash.icon_state = state + lash.layer = line_layer + lash.add_overlay(emissive_appearance(lash.icon, lash.icon_state, lash)) + lash.pixel_y = row + lash.pixel_w = base_x + lash.pixel_z = base_y - row + lash.transform = shape + if(fade == WIRE_LASH_FADE) + animate(lash, alpha = WIRE_LASH_TRACE, time = WIRE_LASH_HIDE, easing = QUAD_EASING | EASE_IN) + else + animate(lash, alpha = 0, time = fade, easing = QUAD_EASING | EASE_IN) + QDEL_IN(lash, fade) + +/// The line of a lash that ends on what it hit: the wire's bright end at its far end. +/obj/item/melee/cyberware_monowire/proc/lash_state() + return "wire_lash" + +/obj/item/melee/cyberware_monowire/live/lash_state() + return "wire_lash_live" + +/// The line of a lash that runs on into another piece: no bright end. +/obj/item/melee/cyberware_monowire/proc/lash_run_state() + return "wire_lash_run" + +/obj/item/melee/cyberware_monowire/live/lash_run_state() + return "wire_lash_run_live" + +/// The light at the spool while the wire runs out or snaps back: the wire's own colour. +/obj/item/melee/cyberware_monowire/proc/spool_light_color() + return "#f86cfa" + +/obj/item/melee/cyberware_monowire/live/spool_light_color() + return "#7fe0ff" + +/// The wire swings out of the spool into the hand. +/obj/item/melee/cyberware_monowire/proc/play_deploy(mob/living/user) + var/side = held_side(user) + if(!side) + return + cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "[icon_state]_deploy_[side]", wire_swing_layer(user, side), WIRE_SWING_TIME) + hide_inhand_for(WIRE_SWING_TIME) + user.mob_light(1.5, 0.6, spool_light_color(), 0.3 SECONDS) + +/// The wire reels back into the spool from the `side` hand. A wet wire cracks its blood off first. +/obj/item/melee/cyberware_monowire/proc/play_retract(mob/living/user, side, stage) + if(QDELETED(user) || !side) + return + var/swing_layer = wire_swing_layer(user, side) + cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "[icon_state]_retract_[side]", swing_layer, WIRE_SWING_TIME) + user.mob_light(1.5, 0.6, spool_light_color(), 0.3 SECONDS) + if(stage) + var/obj/effect/abstract/cyberware_anim/blood = cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "monowire_retract_blood[stage]_[side]", swing_layer + 0.05, WIRE_SWING_TIME) + if(blood) + blood.appearance_flags |= RESET_COLOR + blood.color = get_blood_dna_color() || BLOOD_COLOR_RED + if(stage >= 2) + flick_blood(user, side) + clear_blood_look() + +/** + * The fan a wet wire throws as it cracks back into the spool: WIRE_FLICK_DROPS drops leave its tip, fly an arc + * and land spread over the floor ahead and to the sides, each as a drip. Drawn on temporary visuals; only the + * drips stay. + */ +/obj/item/melee/cyberware_monowire/proc/flick_blood(mob/living/user, side) + var/list/blood_dna = GET_ATOM_BLOOD_DNA(src) + var/turf/feet = get_turf(user) + if(!length(blood_dna) || !feet || !feet.has_gravity()) + return + var/blood_color = get_blood_dna_color() || BLOOD_COLOR_RED + for(var/list/drop as anything in wire_fan_plan(user.dir, side)) + new /obj/effect/temp_visual/wire_blood_drop(feet, drop, blood_color, blood_dna) + +/** + * Where each drop of the fan starts and lands, in pixels from the middle of the wielder's tile: + * list(start x, start y, land x, land y, arc height, flight time). It leaves the wire's tip, which hangs by the + * `side` hand ("left" or "right"), and lands up to a tile and a bit ahead of `facing`, spread to both sides. + */ +/proc/wire_fan_plan(facing, side) + . = list() + var/forward_x = (facing & EAST) ? 1 : ((facing & WEST) ? -1 : 0) + var/forward_y = (facing & NORTH) ? 1 : ((facing & SOUTH) ? -1 : 0) + if(!forward_x && !forward_y) + forward_y = -1 + // The tip hangs by the hand: screen left or right of the body facing north or south, in front of it in profile. + var/start_x = forward_x * 3 + if(!forward_x) + var/screen_left = (side == "right") == (facing != NORTH) + start_x = screen_left ? -6 : 6 + var/start_y = -13 + var/bearing = forward_x ? (forward_x > 0 ? 90 : 270) : (forward_y > 0 ? 0 : 180) + // Spread evenly over a cone ahead and over the reach, each with some give, and dealt out at random so that no two + // neighbours in the cone are also neighbours in reach: the drops land as a scatter, never a row or a line. + var/list/reaches = list() + for(var/i in 1 to WIRE_FLICK_DROPS) + reaches += 12 + round(24 * i / WIRE_FLICK_DROPS) + shuffle_inplace(reaches) + var/slice = 90 / WIRE_FLICK_DROPS + for(var/i in 1 to WIRE_FLICK_DROPS) + var/angle = bearing - 45 + slice * (i - 0.5) + rand(-slice, slice) / 2 + var/reach = reaches[i] + rand(-3, 3) + var/land_x = start_x + round(sin(angle) * reach) + rand(-3, 3) + var/land_y = start_y + round(cos(angle) * reach) + rand(-3, 3) + // Facing away, every drop lands past the head, ahead of him: on the floor beside it, it reads as blood by his ear. + if(forward_y > 0) + land_y = max(land_y, (abs(land_x) < 10 ? 26 : 22) + rand(0, 6)) + var/height = 5 + rand(0, 6) + var/flight = round(2 + reach / 12, 0.5) + . += list(list(start_x, start_y, land_x, land_y, height, flight)) + +/// One drop of the fan in flight. It lands as a drip, unless where it lands has no floor to hold it. +/obj/effect/temp_visual/wire_blood_drop + name = "blood" + icon = WIRE_GORE_ICON + icon_state = "wire_drop" + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 0.6 SECONDS + +/obj/effect/temp_visual/wire_blood_drop/Initialize(mapload, list/drop, blood_color, list/blood_dna, list/viruses) + var/start_x = drop[1] + var/start_y = drop[2] + var/land_x = drop[3] + var/land_y = drop[4] + var/height = drop[5] + var/flight = drop[6] + // Held back a beat (a 7th entry, else the flick's): the flick's drops leave on the crack, the retract's second frame. + var/crack = length(drop) >= 7 ? drop[7] : 0.5 + duration = crack + flight + . = ..() + // Thrown away from the viewer, it flies behind whoever it leaves, not over their head. An 8th entry says so outright, + // for drops that leave from above the floor; else it is read from where it lands. + if(length(drop) >= 8 ? drop[8] : (land_y > start_y + 2)) + layer = BELOW_MOB_LAYER + color = blood_color + pixel_x = start_x + pixel_y = start_y + alpha = 0 + if(crack) + animate(src, alpha = 0, time = crack) + animate(alpha = 255, pixel_x = (start_x + land_x) / 2, pixel_y = max(start_y, land_y) + height, time = flight / 2, easing = SINE_EASING | EASE_OUT) + else + alpha = 255 + animate(src, pixel_x = (start_x + land_x) / 2, pixel_y = max(start_y, land_y) + height, time = flight / 2, easing = SINE_EASING | EASE_OUT) + animate(pixel_x = land_x, pixel_y = land_y, time = flight / 2, easing = SINE_EASING | EASE_IN) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_drop_land), get_turf(src), land_x, land_y, blood_dna, viruses), duration - 0.1) + +/// A drop of the fan comes down at (`land_x`, `land_y`) pixels from the middle of `origin`. +/proc/wire_drop_land(turf/origin, land_x, land_y, list/blood_dna, list/viruses) + if(!origin) + return + var/tile_x = floor((land_x + 16) / 32) + var/tile_y = floor((land_y + 16) / 32) + var/turf/landing = locate(origin.x + tile_x, origin.y + tile_y, origin.z) + if(!landing || isgroundlessturf(landing) || !landing.has_gravity() || (landing != origin && hv_blood_spray_blocked(landing))) + return + var/obj/effect/decal/cleanable/blood/drip/wire_flick/drop = new(landing, viruses, blood_dna) + if(QDELETED(drop)) + return + drop.wire_place(land_x - tile_x * 32, land_y - tile_y * 32) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/Extend(obj/item/augment) + . = ..() + var/obj/item/melee/cyberware_monowire/wire = active_item + if(istype(wire) && owner?.is_holding(wire)) + wire.play_deploy(owner) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/Retract() + var/mob/living/carbon/stowing_owner = owner + var/obj/item/melee/cyberware_monowire/wire = active_item + var/side + var/stage = 0 + if(istype(wire) && stowing_owner?.is_holding(wire)) + side = wire.held_side(stowing_owner) + stage = wire.blood_stage() + . = ..() + if(. && side && !QDELETED(wire)) + wire.play_retract(stowing_owner, side, stage) + +/// A drop of the fan. Each stays where it fell instead of pooling with the others on its tile; a fan is a handful. +/obj/effect/decal/cleanable/blood/drip/wire_flick + mergeable_decal = FALSE + +/// Puts the drop's blood, not its picture, at (`x`, `y`) pixels off the middle of its tile: each of tg's drop +/// pictures sits off the middle its own way, by up to eight pixels. +/obj/effect/decal/cleanable/blood/drip/wire_flick/proc/wire_place(x, y) + var/static/list/offsets = list("drip1" = list(-2, 0), "drip2" = list(4, -1), "drip3" = list(6, -5), "drip4" = list(1, -8), "drip5" = list(-6, -6)) + var/list/offset = offsets[icon_state] || list(0, 0) + pixel_x = x - offset[1] + pixel_y = y - offset[2] + // tg's five drops are all one size of blob. These land smaller or larger, turned and flipped, about their own middle. + var/size = pick(0.5, 0.75, 1, 1, 1.25) + var/matrix/shape = matrix() + shape.Translate(-offset[1], -offset[2]) + shape.Scale(size * pick(-1, 1), size) + shape.Turn(pick(0, 90, 180, 270)) + shape.Translate(offset[1], offset[2]) + transform = shape + appearance_flags |= PIXEL_SCALE diff --git a/voidcrew/modules/hyperviolence/wire/wire_seams.dm b/voidcrew/modules/hyperviolence/wire/wire_seams.dm new file mode 100644 index 00000000000..0e7e6f3f3d2 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_seams.dm @@ -0,0 +1,275 @@ +// Widowline x hyperviolence: the hooks the wire needs in the gore parts, chained here rather than edited +// into them. Everything a wire sever changes about blood and stumps is in this file: +// - A clean cut (TRAIT_HV_CLEAN_CUT, held only while a wire's damage is being applied) throws no flying +// hitsplatter, no heavy-hit or crit spray and no puff off a wound it reopens, puts no blood on the floor or on +// the wielder at the hit. The wounds and the blood lost are tg's as ever. A limb it takes +// off bleeds when it slides off (wire_sever_fx.dm); otherwise the wound bleeds as any does. +// - tg's own wound roll never tears a limb off mid wire cut; the wire takes that limb off clean after. +// - A limb taken by the wire sprays nothing when it leaves (hv_sever_spray); the wire lays its own streak. +// - Nor does its body reel, flash or lurch as the animation pass has a lost limb do (hv_vis_on_limb_lost()). +// - A wire stump can be held back for the beat the severed arm is still drawn on, and can be seared shut. +// - A neck the wire took the head off shows its own stump: a clean flat cut, not #509's ragged one. +// - hv_wire_take_part(): tg's dismemberment without the throw and the fan of blood. + +/mob/living + /// While not null, a limb coming off throws no spray of its own (blood/blood_spray.dm). Set around a wire sever. + var/hv_sever_spray + /// A limb tg's wound roll would have torn off during a wire cut. The wire takes it off clean once the hit is done. + var/obj/item/bodypart/hv_wire_owed_sever + +/mob/living/carbon + /// Body zone -> world.time its stump may be drawn. A wire cut holds the stump back while the limb still shows. Lazy. + var/list/hv_stump_reveal_at + /// Body zones whose stump a hot wire seared shut. Lazy. + var/list/hv_stump_burnt + /// Body zones a wire cut through, whose stump is drawn with the wire's own art (GLOB.hv_wire_stump_zones). Lazy. + var/list/hv_stump_wire_cut + +/// The stumps the wire has its own art for: "stump__wire_blood" and "stump__wire_bone" in WIRE_GORE_ICON. +/// Only the neck the garrote cuts; every other stump, and a head taken any other way, keeps #509's. +GLOBAL_LIST_INIT(hv_wire_stump_zones, list(BODY_ZONE_HEAD)) + +// ---- Clean cuts ---- + +/mob/living/carbon/spray_blood(splatter_direction, splatter_strength = 3) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return + return ..() + +/mob/living/hv_blood_on_damaged(datum/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return + return ..() + +/mob/living/add_splatter_floor(turf/splatter_turf, small_drip = FALSE) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return null + return ..() + +// tg's puff of blood off a wound that is hit again (an open puncture), and the heavy spray of a crit landing. +/mob/living/create_splatter(splatter_dir = pick(GLOB.cardinals)) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return + return ..() + +/mob/living/hv_blood_throw(spray_dir, strength = 3) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return FALSE + return ..() + +// tg's hit puts blood on the floor and on whoever stands next to the body at the moment of the blow. The wire is a +// pace away and cuts clean: the blood comes after (wire_sever_fx.dm), and the wire carries its own (wire_held.dm). +/mob/living/attack_effects(damage_done, hit_zone, armor_block, obj/item/attacking_item, mob/living/attacker) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT) && istype(attacking_item, /obj/item/melee/cyberware_monowire)) + // The hit's message still reads before what follows it (hit_lines.dm). + hv_flush_hit() + return FALSE + return ..() + +/mob/living/carbon/hv_blood_on_limb_removed(datum/source, obj/item/bodypart/removed_limb, special, dismembered) + SIGNAL_HANDLER + if(!isnull(hv_sever_spray)) + return + return ..() + +// tg tears an arm or leg off when its wound roll says so, flinging it with a fan of blood. Mid wire cut it +// stays on for now and the wire takes it off clean once the hit is resolved (hv_wire_settle_owed_sever()). +/datum/wound/loss/apply_dismember(obj/item/bodypart/dismembered_part, wounding_type = WOUND_SLASH, outright = FALSE, attack_direction) + var/mob/living/carbon/cut = dismembered_part?.owner + if(cut && HAS_TRAIT(cut, TRAIT_HV_CLEAN_CUT) && (dismembered_part.body_zone in GLOB.hv_wire_sever_zones) && dismembered_part.can_dismember()) + cut.hv_wire_owed_sever = dismembered_part + qdel(src) + return FALSE + return ..() + +// The gore's animation pass (visuals/hit_reactions.dm) reels a body that loses a limb: a stagger along the blow, held +// for a hit-stop with a pale flash while whoever landed it holds a lean in, and a lurch toward the missing side. All at +// the moment the limb is lost, which for a wire is the hit: its limb stays drawn on for a beat after, and the lash still +// runs to it. None of that plays for a limb the wire takes (hv_wire_take_part()): the cut is clean and the body stands +// as it stood; the garrote, taking a head, lets its own body go in its own one move (let_go_at_kill()). A leg gone +// still limps from then on, and puts someone on their feet down once it has dropped (wire_leg_fall()). +/mob/living/carbon/hv_vis_on_limb_lost(obj/item/bodypart/lost_limb, special, dismembered) + if(isnull(hv_sever_spray)) + return ..() + if(special || QDELETED(src) || stat == DEAD) + return + var/zone = lost_limb.body_zone + if((zone == BODY_ZONE_L_LEG || zone == BODY_ZONE_R_LEG) && default_num_legs >= 2) + AddComponent(/datum/component/hv_limp) + +/// The limbs a wire takes off. The head is the garrote's; the chest never comes off. +GLOBAL_LIST_INIT(hv_wire_sever_zones, list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + +/** + * The short spray of a clean cut: a few drops thrown from src toward spray_dir, landing within a tile or so in a + * narrow fan. No streak along the floor; the shared spray (hv_blood_throw()) lays a straight line, a wire's never + * does. `tiles` sets how far the furthest drops reach. From the stump of `zone` if given, shorter, and never straight + * away from the viewer: drops thrown that way would read as leaving the head. With no `zone`, from the pixel `from` + * (x, y of its lower left corner, off the middle of src's tile), where a line of wire cut the body, or from the middle + * of the body if that isn't given either. Returns TRUE if anything was sprayed. + */ +/mob/living/proc/hv_wire_spray(spray_dir, tiles = 2, zone, list/from) + if(QDELETED(src) || !isturf(loc) || can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return FALSE + tiles = clamp(round(tiles), 1, 2) + if(!spray_dir) + spray_dir = pick(GLOB.alldirs) + hv_blood_spray_until = world.time + 0.4 SECONDS + hv_blood_spray_strength = max(hv_blood_spray_strength, tiles) + if(!zone) + var/list/reach = tiles > 1 ? list(10, 16, 22, 30, 38) : list(8, 12, 16, 20) + // A drop's picture is the pixel under the one it is placed on. + var/from_x = from ? from[1] : pixel_x + var/from_y = from ? from[2] + 1 : pixel_y - 2 + wire_blood_burst(loc, from_x, from_y, wire_blood_color(src), get_blood_dna_list(), 6, dir2angle(spray_dir), 45, reach) + return TRUE + // From the stump: drops only, no splat; a clean cut. Short, so they land by the body, on the floor round the feet + // of a body standing. + var/list/stump = wire_zone_point(src, zone) + var/lying = body_position == LYING_DOWN + var/floor_y = lying ? stump[2] : -10 + // A limb on the far side of a body side on: its stump is behind the body, and so are the drops leaving it. + var/behind = !lying && wire_limb_far(zone, dir) + if(!(spray_dir & NORTH)) + hv_wire_drops(stump, floor_y, 6, dir2angle(spray_dir), 50, tiles > 1 ? 22 : 14, behind) + return TRUE + // Away from the viewer: out to the sides instead, short, landing on the floor beside the feet. + if(!lying) + floor_y = -13 + if(spray_dir & (EAST | WEST)) + hv_wire_drops(stump, floor_y, 6, (spray_dir & EAST) ? 90 : 270, 20, 16, behind) + return TRUE + hv_wire_drops(stump, floor_y, 3, 95, 15, 16, behind) + hv_wire_drops(stump, floor_y, 3, 265, 15, 16, behind) + return TRUE + +/// How far from the middle of a body side on drops leaving a stump behind it first show: past its back or chest. +#define WIRE_DROPS_CLEAR_X 10 + +/** + * `count` drops thrown from `from` (x, y pixels off the middle of src's tile) on `bearing` (degrees from north), give + * or take `spread`, each landing as a drop on the floor up to `reach` pixels from (from x, `floor_y`). Spread evenly + * over the cone and over the reach, each with a little give, and dealt out at random so that no two neighbours in + * the cone are also neighbours in reach: they land as a scatter, not a line. They leave a beat apart. `behind` draws + * them all behind the bodies they pass. + */ +/mob/living/proc/hv_wire_drops(list/from, floor_y, count, bearing, spread, reach, behind = FALSE) + // From a stump behind a body side on, they come out past its back or front, not over its outline: a drop crossing + // the edge of the body, half hidden, reads as a speck on it. + if(behind && round(sin(bearing), 0.01)) + from = list(pixel_x + SIGN(sin(bearing)) * WIRE_DROPS_CLEAR_X, from[2]) + var/blood_color = wire_blood_color(src) + var/list/blood_dna = get_blood_dna_list() + var/list/reaches = list() + for(var/i in 1 to count) + reaches += round(reach * (0.3 + 0.7 * i / count)) + shuffle_inplace(reaches) + var/slice = count > 1 ? 2 * spread / count : 0 + for(var/i in 1 to count) + var/angle = bearing - spread + slice * (i - 0.5) + rand(-slice, slice) / 2 + var/distance = reaches[i] + rand(-3, 3) + var/land_x = from[1] + round(sin(angle) * distance) + rand(-3, 3) + var/land_y = floor_y + round(cos(angle) * distance) + rand(-3, 3) + var/flight = round(1.5 + distance / 14, 0.5) + // Never all at once: a burst of drops leaving one point at once reads as a red star on the stump. + var/delay = pick(0.05, 0.1, 0.15, 0.2) SECONDS + new /obj/effect/temp_visual/wire_blood_drop(loc, list(from[1], from[2], land_x, land_y, 2 + rand(0, 4), flight, delay, behind || cos(angle) > 0.3), blood_color, blood_dna) + +#undef WIRE_DROPS_CLEAR_X + +// ---- Stumps: held back, and seared ---- + +/mob/living/carbon/hv_update_gore_overlays() + // A limb back on forgets how its stump was cut: that is done as it goes on (try_attach_limb(), wire_sever_fx.dm), + // not here. A redraw while a limb is still coming off (tg's limb-removal signals go out before it leaves the + // body) would forget the cut that is taking it. + // A held-back stump whose time has come shows now, whatever else changed. + for(var/zone in hv_stump_reveal_at?.Copy()) + if(get_bodypart(zone) || world.time >= hv_stump_reveal_at[zone]) + LAZYREMOVE(hv_stump_reveal_at, zone) + hv_gore_key = null + return ..() + +/mob/living/carbon/hv_build_gore_images(list/stumps, skull, blood_color) + var/list/shown = stumps + if(LAZYLEN(hv_stump_reveal_at) && length(stumps)) + shown = stumps.Copy() + for(var/zone in hv_stump_reveal_at) + if(world.time < hv_stump_reveal_at[zone]) + shown -= zone + . = ..(shown, skull, blood_color) + if(!LAZYLEN(hv_stump_burnt) && !LAZYLEN(hv_stump_wire_cut)) + return + for(var/zone in shown) + var/wire_cut = LAZYACCESS(hv_stump_wire_cut, zone) && (zone in GLOB.hv_wire_stump_zones) + var/burnt = LAZYACCESS(hv_stump_burnt, zone) + if(!wire_cut && !burnt) + continue + for(var/image/gore_image as anything in .) + var/layer_part + if(gore_image.icon_state == "stump_[zone]_blood") + layer_part = "blood" + else if(gore_image.icon_state == "stump_[zone]_bone") + layer_part = "bone" + else + continue + // Same layer, alpha, colour and height filters as #509's stump; the wire's own picture. + if(wire_cut) + gore_image.icon = WIRE_GORE_ICON + gore_image.icon_state = "stump_[zone]_wire_[layer_part]" + if(!burnt) + continue + if(layer_part == "blood") + gore_image.color = WIRE_BURNT_STUMP_COLOR + gore_image.alpha = 255 + else + gore_image.color = WIRE_BURNT_BONE_COLOR + +/// Draws a stump a wire cut held back, now that the limb has left the body. +/mob/living/carbon/proc/hv_reveal_stump(zone) + LAZYREMOVE(hv_stump_reveal_at, zone) + hv_gore_key = null + hv_update_gore_overlays() + +// ---- Taking a limb off ---- + +/** + * Takes `part` off its owner as tg's dismember() does (the chest takes a share of the limb's damage, a scream, + * the mood and memory, blood lost) but drops it where the body stands instead of flinging it, and throws no + * spray: a wire's cut is clean. `bleed_mult` scales the blood lost; `splatter` leaves a drip under the body. + * Returns the part, or null if it can't come off. + */ +/proc/hv_wire_take_part(obj/item/bodypart/part, wounding_type = WOUND_SLASH, bleed_mult = 1, splatter = TRUE) + var/mob/living/carbon/victim = part?.owner + if(!victim || !part.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return null + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + if(chest && chest != part) + chest.receive_damage(clamp(part.brute_dam / 2 * chest.body_damage_coeff, 15, 50), clamp(part.burn_dam / 2 * chest.body_damage_coeff, 0, 50), wound_bonus = CANT_WOUND) + if(victim.stat != DEAD) + INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") + playsound(get_turf(victim), 'sound/effects/dismember.ogg', 80, TRUE) + victim.add_mood_event("dismembered_[part.body_zone]", /datum/mood_event/dismembered, part) + victim.add_mob_memory(/datum/memory/was_dismembered, lost_limb = part) + LAZYSET(victim.body_zone_dismembered_by, part.body_zone, wounding_type) + var/bleeds = part.can_bleed() + var/turf/spot = get_turf(victim) + var/old_value = victim.hv_sever_spray + victim.hv_sever_spray = 0 + // Before the drop, which draws the stump: the wire's own clean cut where it has one (the garrote's neck). + var/zone = part.body_zone + if(zone in GLOB.hv_wire_stump_zones) + LAZYSET(victim.hv_stump_wire_cut, zone, TRUE) + part.drop_limb(FALSE, TRUE) + if(victim.get_bodypart(zone) == part) + LAZYREMOVE(victim.hv_stump_wire_cut, zone) + victim.hv_sever_spray = old_value + victim.update_equipment_speed_mods() + if(bleeds && !QDELETED(victim)) + // tg bleeds a limb loss twice, once each side of the drop + victim.bleed(round(rand(20, 40) * bleed_mult)) + victim.bleed(round(rand(20, 40) * bleed_mult)) + if(splatter && isturf(spot) && victim.can_bleed(BLOOD_COVER_TURFS) == BLEED_SPLATTER) + new /obj/effect/decal/cleanable/blood/drip(spot, victim.get_static_viruses(), victim.get_blood_dna_list()) + return part diff --git a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm new file mode 100644 index 00000000000..72eb7fb8ee9 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm @@ -0,0 +1,559 @@ +/** + * # The Widowline's cut, drawn + * + * Visual only: the sever, the sear and the through-cut have already happened (wire_cut.dm) when these run. + * + * - The delayed cut. The limb is off at the hit, but for WIRE_CUT_DELAY it is still drawn on its body: + * a held copy (the limb's own sprite, plus the jumpsuit, suit, glove or shoe cut to its shape) rides the + * body in its vis_contents, over the stump the gore overlays hold back. Then it slides along the cut, + * turns, falls and fades while the severed limb on the floor fades in, and the stump shows. The held copy + * deletes itself on a hard timer, and with its body; if the limb is back on, or the body was carried off + * meanwhile, it just fades. + * - The seam: a white flash across the limb that cools to the wire's colour and beads with blood, or on a + * seared cut glows white to orange to embers, with a little light and smoke. + * - The through-cut: a fan of drops from each of the two bodies. Its line is the lash's own (wire_lash_through()). + */ + +/// The colour a body's blood is drawn in, as the gore overlays draw it. +/proc/wire_blood_color(mob/living/bleeder) + return bleeder.get_bloodtype()?.get_damage_color(bleeder) || BLOOD_COLOR_RED + +/// Which way a limb in `zone` falls away from a body facing `facing`, cut by a swing going `swing_dir`: +/// -1 to screen left, 1 to screen right. Seen from the front or back it falls out on its own side; in profile +/// it falls the way the swing carries it, away from the wielder. +/proc/wire_limb_side(zone, facing, swing_dir) + if(facing == EAST || facing == WEST) + if(swing_dir & EAST) + return 1 + if(swing_dir & WEST) + return -1 + return facing == EAST ? 1 : -1 + if(facing == NORTH) + return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? 1 : -1 + return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? -1 : 1 + +/// The middle of a human limb in `zone` as a body facing `facing` draws it, in pixels from the middle of the +/// tile. An arm on the far side of a body in profile is hidden, so it is placed as the near one. list(x, y) +/proc/wire_limb_centre(zone, facing) + var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) + var/right = (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) + var/y = arm ? 0 : -11 + switch(facing) + if(EAST) + return list(arm ? -2 : 0, y) + if(WEST) + return list(arm ? 2 : 0, y) + if(NORTH) + return list(arm ? (right ? 6 : -7) : (right ? 3 : -4), y) + return list(arm ? (right ? -7 : 6) : (right ? -4 : 3), y) + +/** + * Where a cut limb comes to rest, as a transform for something drawing it with its middle at `centre`. Upright, it + * has toppled over `side` and lies on the floor beside the feet; off a body already lying down it only slides out a + * little, since it is on the floor already. For something drawn apart from the body, `body` places and turns it as + * that body is drawn. A dropped limb shifts its own picture by (`shift_x`, `shift_y`) after any transform, so that + * is taken back off at the end. + */ +/proc/wire_limb_rest(list/centre, side, lying, mob/living/body, shift_x = 0, shift_y = 0) + var/list/target = lying ? list(centre[1] + side * 7, centre[2] - 2) : list(side * 10, -13) + return wire_part_pose(centre, lying ? side * 10 : side * 90, target, body, shift_x, shift_y) + +/** + * A transform that draws a part whose middle is at `centre` turned `turn` degrees about that middle, with the middle + * at `target` (both in its body's own frame), then placed and turned as `body` is drawn, if given; less a shift + * the picture applies after its transform. + */ +/proc/wire_part_pose(list/centre, turn, list/target, mob/living/body, shift_x = 0, shift_y = 0) + var/list/spot = target + if(body) + turn += body.body_position == LYING_DOWN ? body.lying_angle : 0 + var/matrix/shape = body.transform + spot = list(shape.a * target[1] + shape.b * target[2] + shape.c, shape.d * target[1] + shape.e * target[2] + shape.f) + var/matrix/pose = matrix() + pose.Translate(-centre[1], -centre[2]) + pose.Turn(turn) + pose.Translate(spot[1] - shift_x, spot[2] - shift_y) + return pose + +// ---- The held limb ---- + +/// A severed limb, still drawn on its body for the beat before it slides off. +/obj/effect/abstract/cyberware_anim/wire_ghost + name = "severed limb" + appearance_flags = KEEP_TOGETHER | TILE_BOUND | PIXEL_SCALE + /// The body zone it was cut from + var/zone + /// Cut by a hot wire: no blood when it goes + var/seared = FALSE + /// Which way the blood goes when it slides off: along the swing + var/spray_dir + /// Where the body stood when it was cut. Moved since, and there's no slide. + var/turf/cut_at + /// The severed limb lying on the floor + var/datum/weakref/severed_ref + /// Timer of the slide + var/slide_timer + /// Which way the body faced when it was cut + var/facing = SOUTH + /// Which way the limb falls: -1 to screen left, 1 to screen right + var/side = 1 + /// The body was lying down when it was cut + var/lying = FALSE + /// A far arm in profile: drawn as the near arm of a body facing the other way, flipped back + var/mirrored = FALSE + /// Copies of what the body wore over the limb (uniform, suit, gloves or shoes) when it was cut, uncut + var/list/worn + /// The other foot's shoe, still drawn on while the leg is held: tg takes both shoes off with a leg + var/obj/effect/abstract/cyberware_anim/kept_shoe + +/obj/effect/abstract/cyberware_anim/wire_ghost/Destroy() + if(slide_timer) + deltimer(slide_timer) + slide_timer = null + cut_at = null + severed_ref = null + worn = null + QDEL_NULL(kept_shoe) + return ..() + +/** + * Draws `limb` held on `victim` until it slides off. Called while the limb is still attached, since what it + * draws (the limb and the clothes over it) is read off the body. Returns the held copy, or null. + */ +/proc/wire_ghost_limb(mob/living/carbon/victim, obj/item/bodypart/limb, seared, spray_dir) + if(QDELETED(victim) || !isturf(victim.loc) || limb?.owner != victim) + return null + var/obj/effect/abstract/cyberware_anim/wire_ghost/ghost = new(null) + ghost.zone = limb.body_zone + ghost.seared = seared + ghost.spray_dir = spray_dir + ghost.cut_at = get_turf(victim) + ghost.facing = victim.dir + ghost.side = wire_limb_side(ghost.zone, victim.dir, spray_dir) + ghost.lying = victim.body_position == LYING_DOWN + ghost.dress(victim, limb) + ghost.bearer = victim + ghost.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + victim.vis_contents += ghost + ghost.keep_other_shoe(victim) + ghost.slide_timer = addtimer(CALLBACK(ghost, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_ghost, slide)), WIRE_CUT_DELAY, TIMER_STOPPABLE) + // Whatever happens to the body meanwhile, it never outlives its beat. + QDEL_IN(ghost, WIRE_CUT_DELAY + WIRE_SLIDE_TIME + WIRE_FALL_TIME + 0.3 SECONDS) + return ghost + +/// Whether a limb in `zone` is on the far side of a body facing `facing`, drawn behind its chest: the left arm and +/// leg of a body facing east, the right of one facing west. +/proc/wire_limb_far(zone, facing) + if(facing == EAST) + return zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_L_LEG + if(facing == WEST) + return zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG + return FALSE + +/// Builds the picture: the limb as the body drew it, with the clothes over it cut to its shape. +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/dress(mob/living/carbon/victim, obj/item/bodypart/limb) + icon = null + var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) + if(wire_limb_far(zone, victim.dir)) + // A far limb is behind the body, so it goes under all of it until it falls clear. Visual contents don't sort + // among their body's own overlays; as an underlay it is under every one of them. + layer = FLOAT_LAYER + vis_flags |= VIS_UNDERLAY + // Side on, the body hides a far arm all but a sliver. It is drawn as the near arm of a body facing the other + // way, flipped back, as the severed arm on the floor is (hold_severed()), so the arm that falls is the one + // that lands. + if(arm) + mirrored = TRUE + vis_flags &= ~VIS_INHERIT_DIR + dir = REVERSE_DIR(victim.dir) + transform = matrix(-1, 0, 0, 0, 1, 0) + else + layer = WIRE_GHOST_LAYER + var/mob/living/carbon/human/dressed = ishuman(victim) ? victim : null + var/reshaped = dressed && dressed.mob_height != HUMAN_HEIGHT_MEDIUM + // A clean cut: no bruising (tg draws it as a dither the clothes normally hide). A seared limb shows burns. + var/list/parts = limb.get_limb_icon(TRUE, victim) + for(var/image/part_image in parts) + if(part_image.layer == -DAMAGE_LAYER) + parts -= part_image + if(seared) + parts += image('icons/mob/effects/dam_mob.dmi', "[limb.dmg_overlay_type]_[zone]_0[max(limb.burnstate, 2)]", -DAMAGE_LAYER) + for(var/image/part_image in parts) + if(PLANE_TO_TRUE(part_image.plane) == EMISSIVE_PLANE) + continue + if(reshaped) + dressed.apply_height_filters(part_image) + overlays += part_image + if(!dressed || (dressed.bodyshape & (BODYSHAPE_MONKEY | BODYSHAPE_DIGITIGRADE))) + return + worn = wire_worn_over(dressed, arm ? list(UNIFORM_LAYER, SUIT_LAYER, GLOVES_LAYER) : list(UNIFORM_LAYER, SUIT_LAYER, SHOES_LAYER)) + for(var/mutable_appearance/clothes as anything in wire_dress_limb(worn, "limb_[zone]", "ghost [REF(src)]")) + overlays += clothes + +/// Copies of the overlays `body` wears at `layers` (UNIFORM_LAYER and so on), as they are now. list(layer = copy) +/proc/wire_worn_over(mob/living/carbon/human/body, list/layers) + . = list() + for(var/index in layers) + var/mutable_appearance/worn_overlay = body.overlays_standing[index] + if(istype(worn_overlay)) + .["[index]"] = new /mutable_appearance(worn_overlay) + +/** + * One picture of the `worn` overlays (from wire_worn_over()) cut to the shape of a limb: `mask_state` in the sleeve + * masks. `key` names its mask; no two pictures on screen at once may share one. Null if nothing is worn. + */ +/proc/wire_cut_clothes(list/worn, mask_state, key) + if(!length(worn)) + return null + var/mutable_appearance/clothes = mutable_appearance(appearance_flags = KEEP_TOGETHER) + for(var/index in worn) + clothes.overlays += worn[index] + // Built as tg builds the missing-hand glove mask (update_worn_gloves()), and the sleeve mask: an atom's appearance + // carries the render target. A mutable_appearance carrying one draws nothing through the filter. A new atom for + // each picture: one shared atom given a second picture's target for the same limb took the first one's mask away, + // and that picture drew nothing (a cut leg's trouser, made just before its shoe's picture). + var/atom/movable/cutout = new(null) + cutout.icon = HV_SLEEVE_MASK_ICON + cutout.icon_state = mask_state + cutout.render_target = "*wire_cut [key] [mask_state]" + clothes.overlays += cutout + clothes.add_filter("wire_cut_clothes", 0, alpha_mask_filter(render_source = cutout.render_target)) + qdel(cutout) + return clothes + +/** + * The `worn` overlays cut to a limb's shape (wire_cut_clothes()), layered as tg layers them on a body: the jumpsuit + * under the hand, the suit, gloves and shoes over it. Side on, a jumpsuit has cloth behind the hand that would + * otherwise cover it. list() if nothing is worn. + */ +/proc/wire_dress_limb(list/worn, mask_state, key) + . = list() + var/uniform_key = "[UNIFORM_LAYER]" + var/list/over = worn.Copy() + if(worn[uniform_key]) + over -= uniform_key + var/mutable_appearance/under = wire_cut_clothes(list("[UNIFORM_LAYER]" = worn[uniform_key]), mask_state, "[key] under") + under.layer = -UNIFORM_LAYER + . += under + var/mutable_appearance/rest = wire_cut_clothes(over, mask_state, "[key] over") + if(rest) + . += rest + +/// The leg's own shoe goes with the leg, but tg takes the other one off too. It stays drawn on its foot until the leg +/// slides off. +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/keep_other_shoe(mob/living/carbon/victim) + if(!worn || !worn["[SHOES_LAYER]"] || (zone != BODY_ZONE_L_LEG && zone != BODY_ZONE_R_LEG)) + return + var/other = zone == BODY_ZONE_L_LEG ? BODY_ZONE_R_LEG : BODY_ZONE_L_LEG + if(!victim.get_bodypart(other)) + return + var/mutable_appearance/shoe = wire_cut_clothes(list("[SHOES_LAYER]" = worn["[SHOES_LAYER]"]), "limb_[other]", "shoe [REF(src)]") + if(!shoe) + return + kept_shoe = new(null) + kept_shoe.icon = null + // Over the body, and under the held leg if that is the near one. + kept_shoe.layer = WIRE_GHOST_LAYER - 0.05 + kept_shoe.overlays += shoe + kept_shoe.bearer = victim + kept_shoe.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + victim.vis_contents += kept_shoe + +/** + * The severed limb on the floor stays unseen until this lands, then shows where this came to rest: facing the way + * it hung on the body, lying on the floor beside the feet, turned as far as this fell. + */ +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/hold_severed(obj/item/bodypart/severed) + if(QDELETED(severed) || !isturf(severed.loc)) + return + severed_ref = WEAKREF(severed) + var/mob/living/body = bearer + // A dropped limb is drawn facing south otherwise. An arm on the far side of a body in profile is not drawn at + // all from that side, so that one lies as the near arm would: the other way round, flipped. + var/far_arm = (facing == EAST && zone == BODY_ZONE_L_ARM) || (facing == WEST && zone == BODY_ZONE_R_ARM) + severed.setDir(far_arm ? REVERSE_DIR(facing) : facing) + var/list/centre = wire_limb_centre(zone, facing) + // The dropped limb's picture is shifted by (px_x, px_y) to sit mid-tile, after its transform. + var/matrix/rest = wire_limb_rest(centre, side, lying, body, severed.px_x, severed.px_y) + if(far_arm) + var/matrix/flipped = matrix(-1, 0, 0, 0, 1, 0) + flipped.Multiply(rest) + rest = flipped + severed.transform = rest + // It keeps the sleeve, trouser leg or shoe it was cut with. tg took both shoes off; the pair lies beside it + // (hold_shoes()), as the other foot's. + if(worn) + severed.hv_wire_dressing = wire_dress_limb(worn, "limb_[zone]", "limb [REF(severed)]") + severed.update_icon_dropped() + severed.pixel_x = body ? body.pixel_x : 0 + severed.pixel_y = body ? body.pixel_y : 0 + severed.pixel_w = body ? body.pixel_w : 0 + severed.pixel_z = body ? body.pixel_z : 0 + // Picked up, kicked or put back on, it is an ordinary limb again. + severed.RegisterSignal(severed, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/obj/item/bodypart, wire_stand_up), override = TRUE) + // The server value is 255 at once; only the picture waits for the landing. + severed.alpha = 0 + animate(severed, alpha = 0, time = WIRE_CUT_DELAY + WIRE_SLIDE_TIME + WIRE_FALL_TIME) + animate(alpha = 255, time = 0.5) + +/// Shoes lying by a cut leg are drawn at this scale: tg's shoe on the floor is as big as a leg. +#define WIRE_SHOE_SCALE 0.6 + +/** + * The shoe tg pulls off a leg that comes away goes with the leg: unseen until the leg lands, then lying on the floor + * just past it, not hanging in the air at the hip where tg drops it. + */ +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/hold_shoes(obj/item/shoes) + if(QDELETED(shoes) || !isturf(shoes.loc)) + return + var/mob/living/body = bearer + // By the foot end of the leg as it lies (wire_limb_rest()), not the hip end. + var/list/spot = lying ? list(side * 12, -9) : list(side * 6, -13) + if(body) + var/matrix/shape = body.transform + spot = list(shape.a * spot[1] + shape.b * spot[2] + shape.c + body.pixel_x, shape.d * spot[1] + shape.e * spot[2] + shape.f + body.pixel_y) + shoes.pixel_x = round(spot[1]) + shoes.pixel_y = round(spot[2]) + // tg draws a shoe on the floor as big as a leg. These lie by a cut leg at the leg's scale. + shoes.transform = matrix(WIRE_SHOE_SCALE, 0, 0, 0, WIRE_SHOE_SCALE, 0) + shoes.RegisterSignal(shoes, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/obj/item, wire_show_shoes), override = TRUE) + shoes.alpha = 0 + animate(shoes, alpha = 0, time = WIRE_CUT_DELAY + WIRE_SLIDE_TIME + WIRE_FALL_TIME) + animate(alpha = 255, time = 0.5) + +/// Shoes that came off with a cut leg, once moved: drawn as any item. +/obj/item/proc/wire_show_shoes() + SIGNAL_HANDLER + UnregisterSignal(src, COMSIG_MOVABLE_MOVED) + animate(src, alpha = 255, time = 0) + transform = matrix() + pixel_x = base_pixel_x + pixel_y = base_pixel_y + +/// A limb the wire cut, once it is moved: drawn as any dropped limb. +/obj/item/bodypart/proc/wire_stand_up() + SIGNAL_HANDLER + UnregisterSignal(src, COMSIG_MOVABLE_MOVED) + animate(src, alpha = 255, time = 0) + transform = matrix() + setDir(SOUTH) + pixel_x = base_pixel_x + pixel_y = base_pixel_y + pixel_w = base_pixel_w + pixel_z = base_pixel_z + +/obj/item/bodypart + /// The sleeve or trouser leg a wire cut this limb off with, drawn on it while it lies severed (wire_dress_limb()) + var/list/hv_wire_dressing + +/obj/item/bodypart/update_icon_dropped() + . = ..() + if(!length(hv_wire_dressing) || owner) + return + // Shifted as get_limb_icon()'s pieces are, to sit on the limb. + for(var/mutable_appearance/piece as anything in hv_wire_dressing) + var/mutable_appearance/dressing = new(piece) + dressing.pixel_w += px_x + dressing.pixel_z += px_y + add_overlay(dressing) + +// Back on a body, it wears that body's clothes again. +/obj/item/bodypart/try_attach_limb(mob/living/carbon/new_limb_owner, special, lazy) + . = ..() + if(.) + hv_wire_dressing = null + // A limb back on forgets how its stump was cut (wire_seams.dm) + if(iscarbon(new_limb_owner)) + LAZYREMOVE(new_limb_owner.hv_stump_burnt, body_zone) + LAZYREMOVE(new_limb_owner.hv_stump_wire_cut, body_zone) + +/// A limb cut clean by a wire: no bruising on the picture of it. Only the picture; its damage is as it was. +/obj/item/bodypart/proc/wire_clean_look() + brutestate = 0 + update_icon_dropped() + +/// A limb cut by a hot wire: burnt, not bloody. Only the picture; its damage is as it was. +/obj/item/bodypart/proc/wire_char() + brutestate = 0 + burnstate = max(burnstate, 2) + // The blood it caught coming off is burnt away too. A later wash brings the body's own back, as for any limb. + var/list/kept = blood_dna_info + blood_dna_info = null + wash(CLEAN_TYPE_BLOOD) + blood_dna_info = kept + update_icon_dropped() + +/// The limb leaves: it slides along the cut, topples and falls to the floor. The stump shows, and the blood goes. +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/slide() + slide_timer = null + var/mob/living/carbon/victim = bearer + if(QDELETED(victim)) + qdel(src) + return + victim.hv_reveal_stump(zone) + // The other shoe is off now, as it has been since the cut. + QDEL_NULL(kept_shoe) + if(victim.get_bodypart(zone) || get_turf(victim) != cut_at) + // Put back on, or carried off: nothing slides, the picture just goes. + animate(src, alpha = 0, time = 0.1 SECONDS) + QDEL_IN(src, 0.1 SECONDS) + return + // Drawn in its body's own frame, which turns it with a lying body: so no body_shape here. + var/list/centre = wire_limb_centre(zone, facing) + var/matrix/slid = matrix() + slid.Translate(-centre[1], -centre[2]) + slid.Turn(side * (lying ? 4 : 15)) + slid.Translate(centre[1] + side * 2, centre[2] - 1) + var/matrix/rest = wire_limb_rest(centre, side, lying) + // Halfway over: the turn is split so it turns rather than squashes on the way. + var/matrix/tipping = matrix() + tipping.Translate(-centre[1], -centre[2]) + tipping.Turn(side * (lying ? 7 : 50)) + if(lying) + tipping.Translate(centre[1] + side * 5, centre[2] - 1) + else + tipping.Translate((centre[1] + side * 12) / 2, (centre[2] - 13) / 2) + if(mirrored) + // Flipped first, then moved, as the arm on the floor is. + var/list/poses = list(slid, tipping, rest) + for(var/i in 1 to length(poses)) + var/matrix/flipped = matrix(-1, 0, 0, 0, 1, 0) + flipped.Multiply(poses[i]) + poses[i] = flipped + slid = poses[1] + tipping = poses[2] + rest = poses[3] + animate(src, transform = slid, time = WIRE_SLIDE_TIME, easing = SINE_EASING | EASE_IN) + animate(transform = tipping, time = WIRE_FALL_TIME / 2, easing = QUAD_EASING | EASE_IN) + animate(transform = rest, time = WIRE_FALL_TIME / 2, easing = QUAD_EASING | EASE_IN) + // Down: the limb on the floor takes over. + animate(alpha = 0, time = 0.5) + // The blood starts now, as the limb leaves: a short spray from the stump and a drop falling under it. + if(!seared) + victim.hv_wire_spray(spray_dir, 2, zone) + wire_stump_drip(victim, zone, side) + QDEL_IN(src, WIRE_SLIDE_TIME + WIRE_FALL_TIME + 0.1 SECONDS) + +/// The drop under a fresh stump leaves it this long after the limb starts to slide, as the stump shows... +#define WIRE_STUMP_DRIP_WAIT 1 +/// ...and falls for this long (both in deciseconds, as the drop's flight is given). +#define WIRE_STUMP_DRIP_FALL 2 + +/// A drop of blood falls from a fresh stump and lands on the floor under it: where the feet stand on a body standing +/// (not at the foot of the tile, where it would sit on the head of anyone standing below), at the stump of one lying. +/// Side on, it lands clear of the feet, away from the limb that fell to `side`: under them, it shows past the legs as a +/// red bar. From a limb on the far side it falls behind the body. +/proc/wire_stump_drip(mob/living/carbon/victim, zone, side = 1) + var/turf/floor = get_turf(victim) + if(!floor || isgroundlessturf(floor) || !floor.has_gravity() || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/list/spot = wire_zone_point(victim, zone) + var/start_x = spot[1] + var/start_y = spot[2] + var/land_x + var/land_y + var/behind = FALSE + if(victim.body_position == LYING_DOWN) + // Down already: it only runs off the stump onto the floor. + land_x = clamp(spot[1], -12, 12) + land_y = clamp(spot[2], -12, 12) + start_x = land_x + start_y = land_y + 3 + else + // On the floor at the feet: higher, it shows through the gap a leg leaves as a blot at the hip. + land_x = (victim.dir & (EAST | WEST)) ? victim.pixel_x - side * 9 : clamp(spot[1], -12, 12) + land_y = -13 + behind = wire_limb_far(zone, victim.dir) + new /obj/effect/temp_visual/wire_blood_drop(floor, list(start_x, start_y, land_x, land_y, 1, WIRE_STUMP_DRIP_FALL, WIRE_STUMP_DRIP_WAIT, behind), wire_blood_color(victim), victim.get_blood_dna_list(), victim.get_static_viruses()) + +#undef WIRE_STUMP_DRIP_WAIT +#undef WIRE_STUMP_DRIP_FALL + +// ---- The seam ---- + +/// The cut across `zone`: a flash that cools and beads with blood, or a seared seam that glows down to embers. +/proc/wire_seam_fx(mob/living/carbon/victim, zone, seared) + if(QDELETED(victim)) + return + if(seared) + cyberware_anim_play(victim, WIRE_GORE_ICON, "wire_cut_hot_[zone]", WIRE_FX_LAYER, 1.1 SECONDS, glow = TRUE) + victim.mob_light(1.5, 0.8, "#ff9a3c", 0.6 SECONDS) + playsound(victim, 'sound/effects/wounds/sizzle1.ogg', 60, TRUE) + wire_stump_smoke(victim, zone) + return + // Gone as the limb slides: its last frame left behind would hang in the air where the limb was. + cyberware_anim_play(victim, WIRE_GORE_ICON, "wire_cut_[zone]", WIRE_FX_LAYER, WIRE_CUT_DELAY, glow = TRUE) + // No blood until the limb comes away. + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_seam_beads), victim, zone), WIRE_CUT_DELAY) + +/// The seam beads with blood as the limb slides off it. The beads keep to the body's side of the cut (the stump): +/// past it there is only air now. +/proc/wire_seam_beads(mob/living/carbon/victim, zone) + if(QDELETED(victim) || victim.get_bodypart(zone) || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/obj/effect/abstract/cyberware_anim/beads = cyberware_anim_play(victim, WIRE_GORE_ICON, "wire_cut_blood_[zone]", WIRE_FX_LAYER - 0.1, 0.9 SECONDS) + if(beads) + beads.appearance_flags |= RESET_COLOR + beads.color = wire_blood_color(victim) + +/// A seared stump smokes for a few seconds. +/obj/effect/abstract/cyberware_anim/wire_smoke + name = "smoke" + icon = null + +/obj/effect/abstract/cyberware_anim/wire_smoke/proc/stop_smoking() + particles?.spawning = 0 + +/proc/wire_stump_smoke(mob/living/carbon/victim, zone) + var/obj/effect/abstract/cyberware_anim/wire_smoke/smoke = new(null) + // On the body, over the stump, not over everything on the tile. + smoke.layer = WIRE_GHOST_LAYER + var/particles/smoke/steam/mild/wire_stump/puff = new + puff.position = wire_stump_joint(zone, victim.dir) + // Side on, the face is just above a shoulder: the smoke leans back over the shoulders, away from it. + if(victim.dir & (EAST | WEST)) + var/back = victim.dir == EAST ? -1 : 1 + puff.velocity = list(back * 0.3, 0.15, 0) + puff.gravity = list(back * 0.12, 0.18) + smoke.particles = puff + smoke.bearer = victim + smoke.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + victim.vis_contents += smoke + addtimer(CALLBACK(smoke, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_smoke, stop_smoking)), WIRE_ARC_SMOKE_TIME - 1 SECONDS) + QDEL_IN(smoke, WIRE_ARC_SMOKE_TIME) + +/// Where a limb in `zone` joins a body facing `facing` (the shoulder, the hip, the neck), in pixels from the middle +/// of the tile, as the body draws it standing. list(x, y, 0) +/proc/wire_stump_joint(zone, facing) + if(zone == BODY_ZONE_HEAD) + return list(0, 6, 0) + var/list/centre = wire_limb_centre(zone, facing) + var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) + return list(centre[1], arm ? 4 : -6, 0) + +/// Thin wisps off a seared stump: small, rising slowly from the joint. +/particles/smoke/steam/mild/wire_stump + scale = 0.4 + grow = 0.02 + spawning = 0.6 + lifespan = 1.2 SECONDS + fade = 0.8 SECONDS + velocity = list(0, 0.2, 0) + gravity = list(0, 0.25) + drift = generator(GEN_SPHERE, 0, 0.6, NORMAL_RAND) + +// ---- Through two bodies ---- + +/** + * The wire of `user` went through `target` and on into `behind`: each throws a short fan of drops the way it went, from + * where it was cut: the pixel the swing's lash passes on `target` and the one it ends on in `behind`'s chest + * (wire_through_cuts()). No line of light and no line of blood: that lash runs on to `behind` (wire_lash_through()). + * `clean`: `target` throws none here, its own cut showing another way (a limb coming off, a crossing cut). + */ +/obj/item/melee/cyberware_monowire/proc/wire_through_fx(mob/living/user, mob/living/target, mob/living/behind, direction, clean = FALSE) + var/list/cuts = wire_through_cuts(user, target, behind) + if(!clean) + target.hv_wire_spray(direction, 2, from = cuts ? cuts[1] : null) + behind.hv_wire_spray(direction, 2, from = cuts ? cuts[2] : wire_zone_point(behind, BODY_ZONE_CHEST)) + +#undef WIRE_SHOE_SCALE diff --git a/voidcrew/modules/hyperviolence/wire/wire_users.dm b/voidcrew/modules/hyperviolence/wire/wire_users.dm new file mode 100644 index 00000000000..a78220a8615 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_users.dm @@ -0,0 +1,55 @@ +/** + * Who may use the Widowline's own moves: the aimed sever, the crossing cut and the garrote. + * + * Players can. Human NPCs can't yet: the owner wants bounty criminals to use them later, and this is the one + * place that opens them, for example `return wielder.client || wielder.npc_rank >= HUMAN_NPC_RANK_BOSS` + * for bosses only. Everything else a wire does (its damage, the through-cut, clean cuts, blood on + * the wire, tg's own dismemberment drawn clean) belongs to the weapon and works for any wielder, as it did + * before these moves existed. + * + * Each move also has an entry point that needs no mouse and no target doll, for an AI to call once this + * lets it: wire_aimed_swing() (the aimed sever), wire_crossing_swing() (the crossing cut) and + * wire_start_garrote() (the garrote, wire_garrote.dm). Each still asks this proc first. + */ +/proc/hv_wire_moves_allowed(mob/living/wielder) + if(!isliving(wielder)) + return FALSE + if(istype(wielder, /mob/living/carbon/human/npc)) + return !!wielder.client + return TRUE + +/** + * An aimed swing at `victim`'s `zone`, as a player's click with that zone on the target doll: the wire's cut, + * and the sever if that arm or leg is hurt enough. Where it lands is tg's aim roll, as for anyone. Never starts + * a garrote. Returns TRUE if it swung. + */ +/obj/item/melee/cyberware_monowire/proc/wire_aimed_swing(mob/living/user, mob/living/victim, zone) + if(!isliving(user) || !isliving(victim) || !user.is_holding(src) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + if(!user.CanReach(victim, src)) + return FALSE + if(user.get_active_held_item() != src) + user.swap_hand(user.get_held_index_of_item(src), silent = TRUE) + var/old_zone = user.zone_selected + var/old_combat_mode = user.combat_mode + user.zone_selected = zone + user.set_combat_mode(TRUE) + garrote_blow = TRUE + melee_attack_chain(user, victim) + if(!QDELETED(src)) + garrote_blow = FALSE + if(!QDELETED(user)) + user.zone_selected = old_zone + user.set_combat_mode(old_combat_mode) + user.changeNext_move(CLICK_CD_MELEE) + return TRUE + +/// The crossing cut on demand: with a wire out in each hand, this aimed swing crosses both through `victim` if it +/// lands, whatever the count. Returns TRUE if it swung as a crossing cut. +/obj/item/melee/cyberware_monowire/proc/wire_crossing_swing(mob/living/user, mob/living/victim, zone = BODY_ZONE_CHEST) + if(!hv_wire_moves_allowed(user) || wire_count_held(user) < 2) + return FALSE + force_cross = TRUE + . = wire_aimed_swing(user, victim, zone) + if(!QDELETED(src)) + force_cross = FALSE diff --git a/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm new file mode 100644 index 00000000000..eb9372465b9 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm @@ -0,0 +1,803 @@ +/** + * # The puppet rig ("arm rig") + * + * tg bodies have no poses, so a chrome finisher that needs a real arm swing, a body held up by the + * collar or folded over a knee draws one: the real body is hidden for a few seconds and stand-ins + * ("puppets") cut from one flat picture of it are posed in its place. + * + * - One getFlatIcon() of the body, facing the way the finisher needs, at the start. Whatever tg draws on the + * hidden body after that (blood on its clothes, a stump) is hidden with it, so a move that bloodies or + * wounds a rigged body takes the picture again at that moment (snapshot(), redraw_arms()). + * - The picture is cut with masks drawn from tg's human base sprite (gorilla_gore.dmi, rig_*): the arms + * (with their sleeves, gloves and chrome, whatever is worn), the body without one or both arms, and + * the upper and lower halves at the belt. In a side view the near arm covers the torso, so the torso + * under it is filled from the torso just beside it. + * - Each puppet is a plain object on the body's tile, posed with its own transform and offsets, so + * nothing ever touches the real body's transform, offsets or layer; tg's lying turn, grab offsets and + * height filters stay right. A puppet turns about a pivot (a shoulder, the belt) with chrome_rig_turn(). + * - The real body is hidden with a colour filter, and made clickable over its whole tile while hidden, + * so the crew can still click the body to stop it. Puppets never take clicks. + * - The pictures are made at runtime, and each client has to fetch them before it can draw a puppet. Until + * they land a puppet is drawn as nothing: a body hidden at once was simply gone for that moment, and a + * bearer whose body had landed stood there with no arms. So a copy of the body's own look, which needs + * no new picture, stands where it was drawn (the stand-in), cut out by a solid box kept in the same + * file as the puppets' pictures. Every rig that goes up in one tick shares one file (the sheet), so on + * each client the stand-ins go and all the puppets show in the same frame, the frame the file lands. + * - Off: the filter and the click area come back and every puppet is deleted. That happens when the rig + * is deleted, which its owner does on every way a finisher ends, and which the rig does itself if the + * body is deleted. + * + * A body whose flat picture isn't 32x32 (something huge worn or held) gets no rig: the move still + * happens, just without the poses. + */ + +/// Mask states and pivots below are for tg's human base body, medium height. +#define CHROME_RIG_HIDE_FILTER "chrome_rig_hide" +/// The filter that cuts a body's stand-in out once the puppets' pictures have reached a client. +#define CHROME_RIG_SWAP_FILTER "chrome_rig_swap" +/// The solid box's name in a sheet. +#define CHROME_RIG_BOX_STATE "box" + +/** + * Shoulder pivots (pixels from the icon's centre, x right, y up) per facing, from the human base + * sprite. list(dir = list(left_x, left_y, right_x, right_y)). + */ +GLOBAL_LIST_INIT(chrome_rig_shoulders, list( + "[SOUTH]" = list(4, 4, -6, 4), + "[NORTH]" = list(-6, 4, 4, 4), + "[EAST]" = list(-4, 2, -4, 2), + "[WEST]" = list(4, 2, 4, 2), +)) + +/// Pixels from a shoulder pivot to the fist on a hanging arm, per facing. +GLOBAL_LIST_INIT(chrome_rig_arm_length, list( + "[SOUTH]" = 10, + "[NORTH]" = 10, + "[EAST]" = 7, + "[WEST]" = 7, +)) + +/** + * Pixels from a shoulder pivot to the middle of the hand on a hanging arm, per facing, from the rig masks. + * list(dir = list(left_x, left_y, right_x, right_y)). In a side view the far arm is drawn as the near one a + * pixel further back (arm_picture()), so its hand is a pixel further back too. + */ +GLOBAL_LIST_INIT(chrome_rig_hands, list( + "[SOUTH]" = list(2, -9, -1, -9), + "[NORTH]" = list(-1, -9, 2, -9), + "[EAST]" = list(0.5, -6, 1.5, -6), + "[WEST]" = list(-1.5, -6, -0.5, -6), +)) + +/// The soles of a standing human, from the icon's centre: a body bends forward about them. +#define CHROME_RIG_FEET_Y -15 +/// The lowest pixel row (from the bottom, 1-based) of a human head; the neck is under it. +#define CHROME_RIG_NECK_Y 23 +/// The neck line, from the icon's centre: the bottom edge of the head's lowest row. A head squashed or +/// turned about it stays on the shoulders. +#define CHROME_RIG_NECK_PIVOT_Y (CHROME_RIG_NECK_Y - 1 - ICON_SIZE_Y / 2) + +/// Most degrees an arm turns in one animation step. +#define CHROME_RIG_SWING_STEP 12 +/// How long a body takes to go once its puppets are certain to be drawn (conceal_after()). +#define CHROME_RIG_SWAP (0.01 SECONDS) +/// The shortest an arm is drawn, as a share of its length. +#define CHROME_RIG_MIN_ARM_LENGTH 0.4 + +/// Where the torso under a side view's near arm is filled from: a pixel takes the one this far behind +/// it along x (negative: from in front). list(dir = shift). +GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( + "[EAST]" = -4, + "[WEST]" = 4, +)) + +/** + * A matrix turning a puppet by [angle] (clockwise) about [pivot_x], [pivot_y] (pixels from the centre), + * then moving it by [shift_x], [shift_y]. + */ +/proc/chrome_rig_turn(angle, pivot_x = 0, pivot_y = 0, shift_x = 0, shift_y = 0, scale_x = 1, scale_y = 1) + var/matrix/turned = matrix() + turned.Translate(-pivot_x, -pivot_y) + if(scale_x != 1 || scale_y != 1) + turned.Scale(scale_x, scale_y) + turned.Turn(angle) + turned.Translate(pivot_x + shift_x, pivot_y + shift_y) + return turned + +/// A hidden body's own look, standing where it was drawn until its puppets' pictures reach a client (conceal()). +/obj/effect/abstract/chrome_rig_stand_in + name = "body" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + animate_movement = NO_STEPS + +/// The solid box a stand-in is cut out by, riding on it and never drawn itself. It comes from the puppets' file. +/obj/effect/abstract/chrome_rig_cutout + name = "" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + // Upright whatever the stand-in's own turn: a filter cuts it before it is turned + appearance_flags = RESET_TRANSFORM | RESET_COLOR | RESET_ALPHA + vis_flags = VIS_INHERIT_PLANE | VIS_INHERIT_LAYER + +/** + * The one picture file every puppet of the rigs that go up in the same tick is drawn from: a solid box (what + * the stand-ins are cut out by) and each puppet's picture, every one under a name of its own. One file lands + * on a client at one moment, so nothing of those rigs is drawn before the rest. + */ +/datum/chrome_rig_sheet + /// The world.time it was started in + var/made_at + /// The rigs drawn from it + var/list/datum/chrome_rig/rigs = list() + /// The file + var/icon/pictures + +/// The sheet for rigs going up in this tick. +/proc/chrome_rig_sheet_now() + var/static/datum/chrome_rig_sheet/current + if(isnull(current) || current.made_at != world.time) + current = new + current.made_at = world.time + return current + +/// Builds the file again from every puppet its rigs have now, and points them and the stand-ins' cut-outs at it. +/datum/chrome_rig_sheet/proc/redraw() + var/static/icon/box + if(isnull(box)) + box = icon('icons/blanks/32x32.dmi', "nothing") + box.DrawBox("#ffffff", 1, 1, ICON_SIZE_X, ICON_SIZE_Y) + var/icon/file = icon(box) + file.Insert(box, CHROME_RIG_BOX_STATE) + var/list/states = list() + for(var/datum/chrome_rig/rig as anything in rigs) + for(var/key in rig.puppets) + var/obj/effect/abstract/chrome_puppet/puppet = rig.puppets[key] + if(QDELETED(puppet) || (isnull(puppet.picture) && isnull(puppet.icon))) + continue + // One stood up by its owner, not add_puppet(): its picture is whatever it shows + if(isnull(puppet.picture)) + puppet.picture = icon(puppet.icon, puppet.icon_state) + var/state = "puppet_[length(states) + 1]" + file.Insert(puppet.picture, state) + // BYOND drops an Insert whose picture is pixel for pixel one already in the file while the file's inserts + // are still pending (a back view's arm is often the front view's), and the puppet would show the file's + // first state, the white box. Reading the file's states after each Insert makes every one land. One that + // still didn't keeps the picture it has. + if(!(state in icon_states(file))) + continue + states[puppet] = state + pictures = file + for(var/obj/effect/abstract/chrome_puppet/puppet as anything in states) + puppet.icon = file + puppet.icon_state = states[puppet] + for(var/datum/chrome_rig/rig as anything in rigs) + if(rig.cutout) + rig.cutout.icon = file + rig.cutout.icon_state = CHROME_RIG_BOX_STATE + +/// One stand-in picture on a hidden body's tile. +/obj/effect/abstract/chrome_puppet + name = "body" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + appearance_flags = PIXEL_SCALE | TILE_BOUND | LONG_GLIDE + // A puppet moved to the next tile is re-offset by a tile at once; a glide would draw it a tile out and slide it back + animate_movement = NO_STEPS + plane = GAME_PLANE + layer = MOB_LAYER + vis_flags = NONE + /// The picture it was given, as it was before it went into a sheet + var/icon/picture + +/// Hides a body and stands puppets in its place. +/datum/chrome_rig + /// The hidden body + var/mob/living/body + /// The flat picture puppets are cut from + var/icon/flat + /// The facing the picture was taken in + var/facing + /// Key -> /obj/effect/abstract/chrome_puppet + var/list/puppets = list() + /// The body's click area before it was hidden + var/old_mouse_opacity + /// TRUE while the body is hidden + var/hidden = FALSE + /// The body's own look, standing in for it until the puppets' pictures reach a client + var/obj/effect/abstract/chrome_rig_stand_in/stand_in + /// What the stand-in is cut out by + var/obj/effect/abstract/chrome_rig_cutout/cutout + /// The file this rig's puppets are drawn from + var/datum/chrome_rig_sheet/sheet + /// Side -> list(turn, length) the arm was last sent to + var/list/arm_poses = list() + /// The body's own transform (a lying body's turn) under every pose of its arms, or null for none + var/matrix/base_transform + /// Degrees the body is bent forward from the feet (negative: leaning back), seen from the side + var/bend_angle = 0 + /// Called when the hidden body moves or changes posture, for an owner that asked (watch_body()) + var/datum/callback/on_disturbed + +/datum/chrome_rig/New(mob/living/body, facing) + . = ..() + src.body = body + src.facing = facing || body.dir + if(!(src.facing in GLOB.cardinals)) + src.facing = (src.facing & (EAST|WEST)) || SOUTH + RegisterSignal(body, COMSIG_QDELETING, PROC_REF(on_body_deleted)) + +/datum/chrome_rig/Destroy() + reveal() + QDEL_NULL(stand_in) + QDEL_NULL(cutout) + if(sheet) + sheet.rigs -= src + sheet = null + for(var/key in puppets) + qdel(puppets[key]) + puppets = null + if(body) + UnregisterSignal(body, list(COMSIG_QDELETING, COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BODY_POSITION, COMSIG_ATOM_POST_DIR_CHANGE)) + body = null + flat = null + on_disturbed = null + return ..() + +/datum/chrome_rig/proc/on_body_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/** + * Calls [disturbed] when the hidden body moves or is knocked down, so its owner can take the rig down. The rig + * listens itself: an owner that already listens to the body for its own reasons (an action does) keeps its handlers. + */ +/datum/chrome_rig/proc/watch_body(datum/callback/disturbed, turns = FALSE) + if(QDELETED(body)) + return + on_disturbed = disturbed + RegisterSignals(body, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BODY_POSITION), PROC_REF(on_body_disturbed)) + // With [turns], a body that turns to face another way too: its puppets are pictures of the old facing + if(turns) + RegisterSignal(body, COMSIG_ATOM_POST_DIR_CHANGE, PROC_REF(on_body_turned)) + +/datum/chrome_rig/proc/on_body_disturbed(datum/source) + SIGNAL_HANDLER + on_disturbed?.InvokeAsync() + +/datum/chrome_rig/proc/on_body_turned(datum/source, old_dir, new_dir) + SIGNAL_HANDLER + if(old_dir != new_dir) + on_disturbed?.InvokeAsync() + +/// Takes the flat picture. Returns FALSE if the body can't be rigged (gone, or not a 32x32 picture). +/datum/chrome_rig/proc/snapshot() + if(QDELETED(body) || !isturf(body.loc)) + return FALSE + var/icon/picture = getFlatIcon(body, facing, no_anim = TRUE) + if(!picture || picture.Width() != ICON_SIZE_X || picture.Height() != ICON_SIZE_Y) + return FALSE + flat = picture + return TRUE + +/// A copy of the flat picture cut by [mask_state] (white keeps, clear cuts) from the rig masks. +/datum/chrome_rig/proc/cut(mask_state) + var/icon/piece = icon(flat) + piece.Blend(icon(GORILLA_GORE_ICON, mask_state, facing), ICON_MULTIPLY) + return piece + +/// The flat picture with the arm on [side] ("l", "r" or "both") cut out, the torso under it filled. +/datum/chrome_rig/proc/cut_without_arms(side = "both") + var/icon/piece = cut(side == "both" ? "rig_keep" : "rig_keep_[side]") + var/shift = GLOB.chrome_rig_fill_shift["[facing]"] + if(!shift) + return piece + for(var/fill_side in (side == "both" ? list("l", "r") : list(side))) + var/icon/fill = icon(flat) + fill.Shift(shift > 0 ? EAST : WEST, abs(shift)) + fill.Blend(icon(GORILLA_GORE_ICON, "rig_fill_[fill_side]", facing), ICON_MULTIPLY) + piece.Blend(fill, ICON_OVERLAY) + return piece + +/** + * The flat picture above the neck (the head, with its hair and hat) if [head], else below it. The head keeps + * the body's top row too, so the two overlap at the neck and no seam opens while the head is squashed. + */ +/datum/chrome_rig/proc/cut_at_neck(head = TRUE) + var/icon/piece = icon(flat) + if(head) + piece.DrawBox(null, 1, 1, ICON_SIZE_X, CHROME_RIG_NECK_Y - 2) + else + piece.DrawBox(null, 1, CHROME_RIG_NECK_Y, ICON_SIZE_X, ICON_SIZE_Y) + return piece + +/** + * Hides the real body. Its whole tile still takes clicks. Call it once the rig's puppets are up: they go into + * the tick's sheet here. Until that file reaches a client the body's stand-in shows, [shift_x], [shift_y] + * pixels from where the body is drawn: where its puppets start, if not on the body. + */ +/datum/chrome_rig/proc/conceal(shift_x = 0, shift_y = 0) + if(hidden || QDELETED(body)) + return + hidden = TRUE + var/turf/spot = get_turf(body) + if(spot) + // Taken before the body is hidden, or it would be a copy of nothing + stand_in = new(spot) + stand_in.appearance = body.appearance + // A look carries no facing: without the body's own it would stand facing the viewer + stand_in.dir = body.dir + stand_in.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + stand_in.pixel_x = body.pixel_x + shift_x + stand_in.pixel_y = body.pixel_y + shift_y + stand_in.pixel_w = body.pixel_w + stand_in.pixel_z = body.pixel_z + // The body's own filters stay on its copy when the cut-out goes on + stand_in.filter_data = body.filter_data?.Copy() + cutout = new(null) + cutout.render_target = "*chrome_rig_[REF(src)]" + stand_in.vis_contents += cutout + stand_in.add_filter(CHROME_RIG_SWAP_FILTER, 1, alpha_mask_filter(render_source = cutout.render_target, flags = MASK_INVERSE)) + old_mouse_opacity = body.mouse_opacity + body.mouse_opacity = MOUSE_OPACITY_OPAQUE + body.add_filter(CHROME_RIG_HIDE_FILTER, 1, color_matrix_filter(list(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0))) + pack() + +/** + * Puts this rig's puppets in the sheet of the tick it goes up in (conceal() does). A rig that is never + * concealed, because another rig of the same body is, calls this itself. + */ +/datum/chrome_rig/proc/pack() + if(sheet && sheet.made_at != world.time) + // Its file has gone out: a puppet added now is a picture of its own + return + sheet = chrome_rig_sheet_now() + sheet.rigs |= src + sheet.redraw() + +/** + * Hides the real body [hold] from now by the client's own clock, in step with its puppets. Until then the puppets must + * stand exactly over the body, so nothing changes on screen while their pictures are still on their way to a client, + * and a late picture never shows a missing body. + */ +/datum/chrome_rig/proc/conceal_after(hold) + conceal() + if(hold <= 0 || QDELETED(body)) + return + var/hide = body.get_filter(CHROME_RIG_HIDE_FILTER) + if(!hide) + return + var/static/list/hidden = list(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0) + animate(hide, color = COLOR_MATRIX_IDENTITY, time = 0) + animate(color = COLOR_MATRIX_IDENTITY, time = hold) + animate(color = hidden, time = CHROME_RIG_SWAP) + +/// Shows the real body again. +/datum/chrome_rig/proc/reveal() + if(!hidden) + return + hidden = FALSE + QDEL_NULL(stand_in) + QDEL_NULL(cutout) + if(QDELETED(body)) + return + body.mouse_opacity = old_mouse_opacity + body.remove_filter(CHROME_RIG_HIDE_FILTER) + +/** + * Stands a puppet showing [picture] on the body's tile under [key], replacing any under that key. + * It starts where the body is drawn now: the body's offsets and, with [copy_transform], its transform. + */ +/datum/chrome_rig/proc/add_puppet(key, icon/picture, copy_transform = FALSE, puppet_layer) + var/turf/spot = get_turf(body) + if(isnull(spot)) + return null + qdel(puppets[key]) + var/obj/effect/abstract/chrome_puppet/puppet = new(spot) + puppet.name = body.name + puppet.icon = picture + puppet.picture = picture + puppet.dir = SOUTH + puppet.pixel_x = body.pixel_x + puppet.pixel_y = body.pixel_y + puppet.pixel_w = body.pixel_w + puppet.pixel_z = body.pixel_z + // The body's own plane: a level drawn on offset planes has its bodies on one of those + puppet.plane = body.plane + puppet.layer = puppet_layer || body.layer + if(copy_transform) + puppet.transform = body.transform + puppets[key] = puppet + // Stood up after the rig was packed, in the same tick: into the same file + if(sheet?.made_at == world.time) + sheet.redraw() + return puppet + +/// The puppet under [key], or null. +/datum/chrome_rig/proc/puppet(key) + return puppets?[key] + +/// Deletes the puppet under [key]. +/datum/chrome_rig/proc/drop_puppet(key) + var/obj/effect/abstract/chrome_puppet/puppet = puppets?[key] + if(puppet) + puppets -= key + qdel(puppet) + +/// The pose (list(turn, length)) of the arm on [side] raised [angle] degrees from hanging with [reach] pixels of piston, as swing_arm() takes them. +/datum/chrome_rig/proc/pose_of(side, angle, reach = 0) + return list(angle * raise_sign(side), max(CHROME_RIG_MIN_ARM_LENGTH, 1 + reach / (GLOB.chrome_rig_arm_length["[facing]"] || 10))) + +/// The pivot of the shoulder on [side] ("l" or "r") in this rig's facing. +/datum/chrome_rig/proc/shoulder(side) + var/list/pivots = GLOB.chrome_rig_shoulders["[facing]"] + return side == "l" ? list(pivots[1], pivots[2]) : list(pivots[3], pivots[4]) + +/** + * Which way the arm on [side] turns to come up and out from the body: clockwise (1) or anticlockwise + * (-1). In a side view both arms come up toward the facing. + */ +/datum/chrome_rig/proc/raise_sign(side) + switch(facing) + if(SOUTH) + return side == "r" ? 1 : -1 + if(NORTH) + return side == "l" ? 1 : -1 + if(EAST) + return -1 + if(WEST) + return 1 + return 1 + +/** + * Builds the standard bearer rig: the body without arms and both arms on their own puppets. Hides the + * real body. Returns TRUE if it is up. + */ +/datum/chrome_rig/proc/rig_arms(hold = 0) + if(!snapshot()) + return FALSE + add_puppet("body", cut_without_arms("both")) + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = add_puppet("arm_[side]", arm_picture(side)) + arm.layer = body.layer + (behind_body(side) ? -0.01 : 0.01) + // With a [hold], the body stays on show that long under its puppets (conceal_after()) + conceal_after(hold) + return TRUE + +/** + * Takes the flat picture again and gives the standard bearer puppets their new cuts, where they stand and as + * they are posed. Blood that lands on the hidden body mid-move shows on its stand-ins at once, not only when + * the rig comes down. Returns FALSE if the picture can't be taken; the old one stays. + */ +/datum/chrome_rig/proc/redraw_arms() + if(!snapshot()) + return FALSE + repaint("body", cut_without_arms("both")) + for(var/side in list("l", "r")) + repaint("arm_[side]", arm_picture(side)) + return TRUE + +/** + * Draws [picture] over the puppet under [key], in place of anything drawn over it before. Not a swap of its icon: + * a picture made at runtime takes a moment to reach each client, and a puppet whose only picture is still on its + * way is drawn as nothing, so it would blink out. The old picture stays under the new one until that lands. + */ +/datum/chrome_rig/proc/repaint(key, icon/picture) + var/obj/effect/abstract/chrome_puppet/puppet = puppets?[key] + if(!puppet || !picture) + return + puppet.cut_overlays() + puppet.add_overlay(mutable_appearance(picture)) + +/// Whether the arm on [side] is the far one in a side view, under the body. Seen from the front or back both arms are over it. +/datum/chrome_rig/proc/behind_body(side) + return (facing in list(EAST, WEST)) && side != near_side() + +/** + * The picture of the arm on [side]. The far arm is hidden behind the body in a side view, so its cut is + * empty: it is drawn as the near arm, a pixel further back and in shadow, so a raised pair reads as two arms. + */ +/datum/chrome_rig/proc/arm_picture(side) + if(!behind_body(side)) + return cut("rig_arm_[side]") + var/icon/picture = cut("rig_arm_[near_side()]") + picture.Shift(facing == EAST ? WEST : EAST, 1) + picture.Blend(rgb(170, 170, 178), ICON_MULTIPLY) + return picture + +/** + * The torso that shows where the arm on [side] hangs once that arm is gone: filled from the torso just beside it, as + * cut_without_arms() fills it. Null when nothing is under the arm (seen from the front or back, or the far arm). + */ +/datum/chrome_rig/proc/fill_picture(side) + var/shift = GLOB.chrome_rig_fill_shift["[facing]"] + if(!shift || behind_body(side)) + return null + var/icon/fill = icon(flat) + fill.Shift(shift > 0 ? EAST : WEST, abs(shift)) + fill.Blend(icon(GORILLA_GORE_ICON, "rig_fill_[side]", facing), ICON_MULTIPLY) + return fill + +/** + * Every puppet takes the offsets the hidden body has now: eased as tg eases a tracked offset (add_offsets()) with + * [eased], else at once. For a body that leans or trembles under its puppets, so it is where they are when they go. + */ +/datum/chrome_rig/proc/follow_offsets(eased = TRUE) + if(QDELETED(body)) + return + for(var/key in puppets) + var/obj/effect/abstract/chrome_puppet/puppet = puppets[key] + if(puppet.pixel_w == body.pixel_w && puppet.pixel_x == body.pixel_x && puppet.pixel_y == body.pixel_y && puppet.pixel_z == body.pixel_z) + continue + if(eased) + animate(puppet, pixel_w = body.pixel_w, pixel_x = body.pixel_x, pixel_y = body.pixel_y, pixel_z = body.pixel_z, easing = (EASE_IN|EASE_OUT), flags = ANIMATION_PARALLEL, time = UPDATE_TRANSFORM_ANIMATION_TIME) + else + puppet.pixel_w = body.pixel_w + puppet.pixel_x = body.pixel_x + puppet.pixel_y = body.pixel_y + puppet.pixel_z = body.pixel_z + +/// Pixels from the shoulder pivot on [side] to the middle of its hand, on a hanging arm. +/datum/chrome_rig/proc/hand_offset(side) + var/list/hands = GLOB.chrome_rig_hands["[facing]"] + return side == "l" ? list(hands[1], hands[2]) : list(hands[3], hands[4]) + +/// The length of the arm on [side]: shoulder pivot to the middle of the hand. +/datum/chrome_rig/proc/arm_reach(side) + var/list/hand = hand_offset(side) + return sqrt(hand[1] * hand[1] + hand[2] * hand[2]) + +/** + * The matrix posing the arm on [side]: turned [turn] degrees about its shoulder, [length] times as long, then + * turned with the body ([base], else the rig's base_transform). + */ +/datum/chrome_rig/proc/arm_matrix(side, turn, length, matrix/base) + var/list/pivot = shoulder(side) + var/matrix/pose = chrome_rig_turn(turn, pivot[1], pivot[2], 0, 0, 1, length) + var/matrix/under = base || base_transform + if(under) + pose.Multiply(under) + return pose + +/// The body bent forward [angle] degrees from the feet, seen from the side; null when it doesn't turn (no angle, or seen from the front or back). +/datum/chrome_rig/proc/bend_matrix(angle) + if(!angle || !(facing in list(EAST, WEST))) + return null + return chrome_rig_turn(facing == WEST ? -angle : angle, 0, CHROME_RIG_FEET_Y) + +/** + * Bends the body to [to_bend] degrees from the feet (see bend_matrix()) while both arms swing to [angle], as + * swing_arm() does, step for step so the arms stay on the shoulders, over [time]. + */ +/datum/chrome_rig/proc/bend_and_swing(to_bend, angle, time, reach = 0) + var/steps = swing_steps(bend_angle, to_bend) + var/list/targets = list() + for(var/side in list("l", "r")) + var/list/from = arm_poses[side] || list(0, 1) + var/to_turn = angle * raise_sign(side) + targets[side] = list(to_turn, max(CHROME_RIG_MIN_ARM_LENGTH, 1 + reach / (GLOB.chrome_rig_arm_length["[facing]"] || 10))) + steps = max(steps, swing_steps(from[1], to_turn)) + var/list/bases = list() + for(var/i in 1 to steps) + bases += list(bend_matrix(bend_angle + (to_bend - bend_angle) * (1 - cos(180 * i / steps)) / 2)) + for(var/side in list("l", "r")) + var/list/from = arm_poses[side] || list(0, 1) + var/list/to_pose = targets[side] + var/list/poses = list() + for(var/i in 1 to steps) + var/progress = (1 - cos(180 * i / steps)) / 2 + poses += list(list(from[1] + (to_pose[1] - from[1]) * progress, from[2] + (to_pose[2] - from[2]) * progress, bases[i])) + pose_arm_through(side, poses, time / steps) + body_through(bases, time / steps) + bend_angle = to_bend + base_transform = bend_matrix(to_bend) + +/// Sends the body puppet through [bases] (matrices, null for upright), one straight step of [step_time] each (or of step_time[i], given a list), as its arms go through theirs. +/datum/chrome_rig/proc/body_through(list/bases, step_time) + var/obj/effect/abstract/chrome_puppet/body_puppet = puppet("body") + if(!body_puppet) + return + for(var/i in 1 to length(bases)) + var/matrix/base = bases[i] || matrix() + var/this_step = islist(step_time) ? step_time[i] : step_time + if(i == 1) + animate(body_puppet, transform = base, time = this_step) + else + animate(transform = base, time = this_step) + +/// [x], [y] (pixels from the middle of the picture) put through [transform]. +/proc/chrome_rig_apply(matrix/transform, x, y) + return list(transform.a * x + transform.b * y + transform.c, transform.d * x + transform.e * y + transform.f) + +/// [flat] (a body's own turn, a lying one's) turned on by [angle] degrees (clockwise) about the body's feet as drawn under it. +/proc/chrome_rig_turn_about_feet(matrix/flat, angle) + var/matrix/turned = matrix(flat) + if(!angle) + return turned + var/list/feet = chrome_rig_apply(turned, 0, CHROME_RIG_FEET_Y) + turned.Multiply(chrome_rig_turn(angle, feet[1], feet[2])) + return turned + +/// Where the shoulder on [side] is drawn under [base] (else base_transform): pixels from the middle of the puppet, before its offsets. +/datum/chrome_rig/proc/shoulder_point(side, matrix/base) + var/list/pivot = shoulder(side) + var/matrix/under = base || base_transform + return under ? chrome_rig_apply(under, pivot[1], pivot[2]) : pivot + +/// Where the hand on [side] is drawn in the arm's last pose: pixels from the middle of the puppet, before its offsets. +/datum/chrome_rig/proc/hand_point(side) + return hand_at(side, arm_poses[side] || list(0, 1)) + +/// Where the hand on [side] is drawn in [pose] (list(turn, length), or list(turn, length, base)): pixels from the middle of the puppet, before its offsets. +/datum/chrome_rig/proc/hand_at(side, list/pose) + var/list/pivot = shoulder(side) + var/list/hand = hand_offset(side) + return chrome_rig_apply(arm_matrix(side, pose[1], pose[2], length(pose) > 2 ? pose[3] : null), pivot[1] + hand[1], pivot[2] + hand[2]) + +/** + * The pose (list(turn, length)) that puts the hand on [side] at [x], [y] (pixels from the middle of the + * puppet, as drawn, before its offsets) with the body turned by [base] (else base_transform): the arm turned + * about its shoulder and stretched or shortened along its length. Turned the short way round from [from_turn]. + */ +/datum/chrome_rig/proc/pose_reaching(side, x, y, from_turn = 0, matrix/base) + // Back through the body's turn into the flat picture's own pixels + var/matrix/body_turn = base || base_transform + if(body_turn) + var/det = body_turn.a * body_turn.e - body_turn.b * body_turn.d + if(det) + var/rel_x = x - body_turn.c + var/rel_y = y - body_turn.f + x = (body_turn.e * rel_x - body_turn.b * rel_y) / det + y = (body_turn.a * rel_y - body_turn.d * rel_x) / det + var/list/pivot = shoulder(side) + var/list/hand = hand_offset(side) + var/to_x = x - pivot[1] + var/to_y = y - pivot[2] + var/reach = sqrt(to_x * to_x + to_y * to_y) + if(reach < 1) + return list(from_turn, CHROME_RIG_MIN_ARM_LENGTH) + // Only the length stretches, so the hand keeps its sideways offset from the shoulder + var/length = max(CHROME_RIG_MIN_ARM_LENGTH, sqrt(max(0, reach * reach - hand[1] * hand[1])) / abs(hand[2])) + var/turn = arctan(hand[1], hand[2] * length) - arctan(to_x, to_y) + while(turn - from_turn > 180) + turn -= 360 + while(turn - from_turn < -180) + turn += 360 + return list(turn, length) + +/// How many straight steps a swing from [from_turn] to [to_turn] takes, so the shoulder stays on its pivot. +/datum/chrome_rig/proc/swing_steps(from_turn, to_turn) + return max(1, CEILING(abs(to_turn - from_turn) / CHROME_RIG_SWING_STEP, 1)) + +/** + * Sends the arm on [side] through [poses] (each list(turn, length), or list(turn, length, base) with the + * body's turn at that step), one straight step of [step_time] to each (or of step_time[i] to the i-th, given a + * list). Arms sent through matching poses in the same steps move together, hand on hand. + */ +/datum/chrome_rig/proc/pose_arm_through(side, list/poses, step_time) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(!arm || !length(poses)) + return + for(var/i in 1 to length(poses)) + var/list/pose = poses[i] + var/matrix/posed = arm_matrix(side, pose[1], pose[2], length(pose) > 2 ? pose[3] : null) + var/this_step = islist(step_time) ? step_time[i] : step_time + if(i == 1) + animate(arm, transform = posed, time = this_step) + else + animate(transform = posed, time = this_step) + var/list/last = poses[length(poses)] + arm_poses[side] = list(last[1], last[2]) + +/** + * Sends the hand on [side] to each of [points] in turn (list(x, y), pixels from the middle of the puppet, as + * pose_reaching() takes them), times[i] to the i-th, each leg cut into short steps so the shoulder stays put. The + * arm is never stretched past [max_length] times its length: the hand stops short instead. + */ +/datum/chrome_rig/proc/reach_through(side, list/points, list/times, max_length = 2) + var/list/from = arm_poses[side] || list(0, 1) + var/list/poses = list() + var/list/step_times = list() + for(var/i in 1 to length(points)) + var/list/point = points[i] + var/list/to_pose = pose_reaching(side, point[1], point[2], from[1]) + to_pose[2] = min(to_pose[2], max_length) + var/steps = swing_steps(from[1], to_pose[1]) + for(var/j in 1 to steps) + var/progress = (1 - cos(180 * j / steps)) / 2 + poses += list(list(from[1] + (to_pose[1] - from[1]) * progress, from[2] + (to_pose[2] - from[2]) * progress)) + step_times += times[i] / steps + from = to_pose + pose_arm_through(side, poses, step_times) + +/** + * Counts each arm's turn the short way round from hanging, without moving it (a full turn more or less is the same + * pose). An arm swung on past straight up then comes down the way it points, not back up over the head. + */ +/datum/chrome_rig/proc/unwind_arms() + for(var/side in arm_poses) + var/list/pose = arm_poses[side] + while(pose[1] > 180) + pose[1] -= 360 + while(pose[1] <= -180) + pose[1] += 360 + +/// The arm nearer the viewer in a side view ("l" or "r"); the right arm in other views. +/datum/chrome_rig/proc/near_side() + return facing == WEST ? "l" : "r" + +/** + * Turns the arm on [side] to [angle] degrees up from hanging (0) over [time]. [reach] pixels of piston + * push the fist out along the arm (negative pulls it in, as an arm reaching toward the viewer looks + * shorter). The arm stretches or shortens from the shoulder, so its top never leaves the shoulder. + * + * BYOND blends the move part of a turned matrix in a straight line, which would carry the shoulder off + * its pivot halfway through a big swing, so a swing is cut into steps of a few degrees. + */ +/datum/chrome_rig/proc/swing_arm(side, angle, time, reach = 0, easing = SINE_EASING) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(!arm) + return + var/list/from = arm_poses[side] || list(0, 1) + var/to_turn = angle * raise_sign(side) + var/to_length = max(CHROME_RIG_MIN_ARM_LENGTH, 1 + reach / (GLOB.chrome_rig_arm_length["[facing]"] || 10)) + arm_poses[side] = list(to_turn, to_length) + var/steps = max(1, CEILING(abs(to_turn - from[1]) / CHROME_RIG_SWING_STEP, 1)) + var/step_time = time / steps + for(var/i in 1 to steps) + // Sine in and out across the whole swing; each step itself is straight + var/progress = (easing & SINE_EASING) ? (1 - cos(180 * i / steps)) / 2 : i / steps + var/matrix/pose = arm_matrix(side, from[1] + (to_turn - from[1]) * progress, from[2] + (to_length - from[2]) * progress) + if(i == 1) + animate(arm, transform = pose, time = step_time) + else + animate(transform = pose, time = step_time) + +/** + * The body crouches over [time]: squashed to [squash] of its height and dropped [drop] pixels, the arms + * coming down with their shoulders. 1 and 0 stand it back up. + */ +/datum/chrome_rig/proc/crouch(squash, drop, time, easing = SINE_EASING) + var/obj/effect/abstract/chrome_puppet/body_puppet = puppet("body") + if(body_puppet) + animate(body_puppet, transform = matrix(1, 0, 0, 0, squash, -drop), time = time, easing = easing) + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(!arm) + continue + // Where the squashed body puts this shoulder + var/list/pivot = shoulder(side) + animate(arm, pixel_z = round(pivot[2] * squash - drop - pivot[2]), time = time, easing = easing, flags = ANIMATION_PARALLEL) + +/// Every puppet (but those under the keys in [skip]: one drawn on another moves with it) trembles a pixel for [time], and ends where it began. +/datum/chrome_rig/proc/tremble(time = 0.4 SECONDS, list/skip) + for(var/key in puppets) + if(key in skip) + continue + var/obj/effect/abstract/chrome_puppet/puppet = puppets[key] + var/moved = 0 + for(var/i in 1 to max(1, round(time / (0.1 SECONDS)))) + var/step = (i % 2) ? 1 : -1 + if(i == 1) + animate(puppet, pixel_w = step, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + moved += step + else + animate(pixel_w = step * 2, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE) + moved += step * 2 + animate(pixel_w = -step, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE) + moved -= step + // An even count leaves it a pixel off, which would jump back when the rig comes down + if(moved) + animate(pixel_w = -moved, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE) + +/// Every puppet flashes white and fades back. +/datum/chrome_rig/proc/flash() + for(var/key in puppets) + cyberware_flash_fx(puppets[key], 0.2 SECONDS) + +#undef CHROME_RIG_HIDE_FILTER +#undef CHROME_RIG_SWAP_FILTER +#undef CHROME_RIG_BOX_STATE +#undef CHROME_RIG_SWING_STEP +#undef CHROME_RIG_SWAP +#undef CHROME_RIG_MIN_ARM_LENGTH diff --git a/voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm b/voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm new file mode 100644 index 00000000000..4b24244c20a --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm @@ -0,0 +1,25 @@ +/** + * # Use chrome, let go + * + * A hook for charged chrome on the chrome wheel (cyberware/chrome_wheel*.dm): when the Use chrome key comes + * back up, the picked ability hears it through chrome_use_released(). The Gorilla haymaker throws its + * charge there. Kept in its own file so other charged chrome can share it. + */ + +/** + * The bearer let go of the Use chrome key with this ability picked. A charged ability can throw what + * it built up. Returns TRUE if it did something. Every other ability ignores it. + */ +/datum/action/proc/chrome_use_released(mob/living/carbon/bearer) + return FALSE + +/datum/keybinding/living/chrome/use/up(client/user, turf/target) + . = ..() + if(.) + return + var/mob/living/carbon/bearer = user.mob + if(!iscarbon(bearer)) + return + var/datum/component/chrome_wheel/wheel = bearer.GetComponent(/datum/component/chrome_wheel) + var/datum/action/picked = wheel?.get_selected_ability() + picked?.chrome_use_released(bearer) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm new file mode 100644 index 00000000000..cb85f71c3c4 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm @@ -0,0 +1,2128 @@ +/** + * # Gorilla finishers (owner D39) + * + * A piston punch from a synced real pair, with both hands empty, on someone in crit or stunned, finishes + * them instead. The aimed zone picks which: + * - Head: the head clap. Hauled off the floor by the collar, a beat of kicking, then both fists meet on + * the head and it bursts (#509's burst; the brain is thrown out whole, so revival works). A helmet or + * plating crumples instead and the skull caves in. Needs gravity, or the brain would drift off. + * - Arm (the aimed one, else the other): the limb rip. A boot on the body, both fists on the arm, it tears + * off at the shoulder with the spray running back over the bearer, and one blow to the head with it. + * Armour on the arm wrenches the shoulder out instead. + * - Chest: the backbreaker. Pressed overhead, brought down across a knee, the back breaks and the body + * is shoved off it, still bent, onto the tile he faces (at his feet if that tile is blocked). Armour on + * the chest cracks the ribs instead. + * The Atlas frame beats all three: nothing bursts, tears or breaks. + * A synced pair of Brawler myomers has the limb rip only, the same rip on the same terms. + * + * The wind-up is the two bodies (owner D34: no mark): the puppet rig (chrome_puppet_rig.dm) poses the + * bearer's arms and the victim. It can be stopped like any #509 finisher: enough damage on the bearer, + * the bearer moving, stunned or cuffed, the victim pulled away or out of their stun, the arms falling + * out of sync. The haymaker and the finishers share the pistons' cooldown: longer after a kill than + * after being stopped. Starting one counts as aggression at a trader outpost. + */ + +/** + * Starts the Gorilla finisher [zone] picks (the bearer's aim when null), if one can start on [victim]. + * Returns TRUE if it did. The punch calls it with the aim; an AI can call it with a zone. + */ +/proc/gorilla_try_finisher(mob/living/carbon/human/source, mob/living/victim, zone) + if(!iscarbon(victim) || !ishuman(source) || !gorilla_may_use_heavy_moves(source)) + return FALSE + var/gorilla_pair = gorilla_synced(source) + if(!gorilla_pair && !brawler_synced(source)) + return FALSE + if(!TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_PISTONS) || source.chrome_finisher) + return FALSE + if(!chrome_finisher_valid_victim(source, victim)) + return FALSE + zone = check_zone(zone || source.zone_selected) + var/datum/hv_finisher/chrome/gorilla/finisher_type + switch(zone) + if(BODY_ZONE_HEAD) + finisher_type = /datum/hv_finisher/chrome/gorilla/clap + if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) + finisher_type = /datum/hv_finisher/chrome/gorilla/rip + if(BODY_ZONE_CHEST) + finisher_type = /datum/hv_finisher/chrome/gorilla/backbreaker + if(!finisher_type) + return FALSE + // Brawler myomers have the rip and nothing else + if(!gorilla_pair && !initial(finisher_type.brawler_ok)) + return FALSE + var/datum/hv_finisher/chrome/gorilla/finisher = new finisher_type(source, victim) + finisher.aimed_zone = zone + if(!finisher.start()) + qdel(finisher) + return FALSE + gorilla_register_aggression(victim, source) + return TRUE + +/datum/hv_finisher/chrome/gorilla + name = "gorilla finisher" + /// The zone the bearer aimed when it started + var/aimed_zone + /// The bearer's facing toward the victim, fixed at the start + var/facing + /// The bearer's puppets + var/datum/chrome_rig/bearer_rig + /// The victim's puppets + var/datum/chrome_rig/victim_rig + /// Whether a synced pair of Brawler myomers can do this one too, not only Gorilla Arms + var/brawler_ok = FALSE + /// Where the victim was drawn before the finisher's grab, list(x, y) of their pixel offsets: tg draws a grabbed body + /// pulled in at once, and the puppets ease that in instead + var/list/lying_offsets + +/datum/hv_finisher/chrome/gorilla/take_grip() + if(!gripped && isnull(lying_offsets) && !QDELETED(victim)) + lying_offsets = list(victim.pixel_x + victim.pixel_w, victim.pixel_y + victim.pixel_z) + return ..() + +/// Pixels from where the victim is drawn now back to where they lay before the grab: list(x, y). +/datum/hv_finisher/chrome/gorilla/proc/grab_pull_back() + if(isnull(lying_offsets) || QDELETED(victim)) + return list(0, 0) + return list(lying_offsets[1] - (victim.pixel_x + victim.pixel_w), lying_offsets[2] - (victim.pixel_y + victim.pixel_z)) + +/datum/hv_finisher/chrome/gorilla/can_start() + if(!..()) + return FALSE + if(!ishuman(user) || !pair_synced() || !TIMER_COOLDOWN_FINISHED(user, COOLDOWN_GORILLA_PISTONS)) + return FALSE + var/mob/living/carbon/human/bearer = user + for(var/obj/item/held in bearer.held_items) + return FALSE + var/list/arms = gorilla_synced_pair(bearer) + if(length(arms) < 2) + return FALSE + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) + if(!arm.gorilla_running()) + return FALSE + // Square on only: from a corner the punch is just a punch + if(!finisher_square_on(user, victim)) + return FALSE + return can_start_on(victim) + +/// Whether the bearer's arms are synced for this finisher: a Gorilla pair, or a Brawler pair where it allows one. +/datum/hv_finisher/chrome/gorilla/proc/pair_synced() + return gorilla_synced(user) || (brawler_ok && brawler_synced(user)) + +/// What this finisher needs of the victim besides being down. +/datum/hv_finisher/chrome/gorilla/proc/can_start_on(mob/living/carbon/target) + return TRUE + +/datum/hv_finisher/chrome/gorilla/safety_check() + . = ..() + if(!. && !completed && !pair_synced()) + return HV_FINISHER_ATTACKER_DOWN + if(!. && !completed && finisher_on_corner(user, victim)) + return HV_FINISHER_LOST_GRIP + +/datum/hv_finisher/chrome/gorilla/stop(reason) + if(!running) + return + var/finished = completed + // The parent deletes the finisher, which clears these + var/mob/living/bearer = user + var/mob/living/body = victim + . = ..() + cleanup_rigs() + if(!QDELETED(bearer)) + gorilla_spend_pistons(bearer, finished ? GORILLA_PISTONS_COOLDOWN : GORILLA_PISTONS_INTERRUPTED) + if(finished && !QDELETED(body)) + hv_execution_witnesses(bearer, body) + +/datum/hv_finisher/chrome/gorilla/Destroy() + cleanup_rigs() + return ..() + +/// Takes every puppet down and shows both bodies. +/datum/hv_finisher/chrome/gorilla/proc/cleanup_rigs() + QDEL_NULL(bearer_rig) + QDEL_NULL(victim_rig) + +/// The bearer's rig, made on first use: the body without arms and both arms on their own puppets. +/datum/hv_finisher/chrome/gorilla/proc/rig_bearer() + if(bearer_rig || QDELETED(user)) + return bearer_rig + user.face_atom(victim) + facing = user.dir + bearer_rig = new(user, facing) + if(!bearer_rig.rig_arms()) + QDEL_NULL(bearer_rig) + return bearer_rig + +/// Whether the bearer is seen from the side (facing east or west). +/datum/hv_finisher/chrome/gorilla/proc/side_view() + return facing == EAST || facing == WEST + +// The one gate on who may use the Gorilla's heavy moves (gorilla_punch.dm). +/datum/hv_finisher/chrome/gorilla/user_may_finish() + return gorilla_may_use_heavy_moves(user) + +/// Pixels from the victim's tile to the bearer's. +/datum/hv_finisher/chrome/gorilla/proc/to_bearer() + return list((user.x - victim.x) * ICON_SIZE_X, (user.y - victim.y) * ICON_SIZE_Y) + +/// Both of the bearer's arms to [angle], with [reach] pixels of piston. +/datum/hv_finisher/chrome/gorilla/proc/arms_to(angle, time, reach = 0) + bearer_rig?.swing_arm("l", angle, time, reach) + bearer_rig?.swing_arm("r", angle, time, reach) + +/// The bearer's punching hand, the attacking item for #509's hooks. +/datum/hv_finisher/chrome/gorilla/proc/fist() + var/mob/living/carbon/bearer = user + return bearer.get_active_hand() || bearer.get_bodypart(BODY_ZONE_R_ARM) + +/// The bearer's arm on [which] ("near" or "far" to the viewer in a side view) to [angle], with [reach]. +/datum/hv_finisher/chrome/gorilla/proc/arm_to(which, angle, time, reach = 0) + if(!bearer_rig) + return + var/near = bearer_rig.near_side() + var/side = which == "near" ? near : (near == "l" ? "r" : "l") + bearer_rig.swing_arm(side, angle, time, reach) + +/// The bearer's arm puppet on [which] ("near" or "far"), or null. +/datum/hv_finisher/chrome/gorilla/proc/arm_puppet(which) + if(!bearer_rig) + return null + var/near = bearer_rig.near_side() + return bearer_rig.puppet("arm_[which == "near" ? near : (near == "l" ? "r" : "l")]") + +/// The beat a stage begins with: poses and sounds. Lines come from enter_line(). +/datum/hv_finisher/chrome/gorilla/proc/pose(key) + return + +/// A stage's beat: damage, gore, poses. +/datum/hv_finisher/chrome/gorilla/proc/beat(key) + return + +/// The line the room sees as a stage begins, or null. +/datum/hv_finisher/chrome/gorilla/proc/enter_line(key) + return null + +/// The line the victim sees as a stage begins, or null for the room's. +/datum/hv_finisher/chrome/gorilla/proc/enter_self_line(key) + return null + +/// The flash, sparks, ring and shake of a killing blow on [where], the sparks [spark_w], [spark_z] pixels from its middle. Only the victim flashes; the arms keep their look. +/datum/hv_finisher/chrome/gorilla/proc/hit_stop(atom/movable/where, heavy = TRUE, spark_z = 0, spark_w = 0) + victim_rig?.flash() + var/obj/effect/abstract/cyberware_anim/sparks = gorilla_play_impact(user, where, "impact_gorilla") + if(sparks) + sparks.pixel_z += spark_z + sparks.pixel_w += spark_w + var/turf/spot = get_turf(where) + if(spot) + // On the floor: on the game plane the ring was drawn over bodies standing further up the screen + new /obj/effect/temp_visual/circle_wave/cyberware_slam/floor(spot, heavy ? 1 : 0.6) + for(var/mob/living/viewer in range(4, spot)) + if(viewer.client) + shake_camera(viewer, heavy ? 3 : 2, heavy ? 2 : 1) + +// ---- The stages: each one hands its key to the finisher ---- + +/datum/hv_finisher_stage/hold/gorilla + lunge = 0 + effect_severity = 0 + /// What the finisher does in this stage + var/key + +/datum/hv_finisher_stage/hold/gorilla/enter() + . = ..() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.pose(key) + +/datum/hv_finisher_stage/hold/gorilla/effect() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.beat(key) + +/datum/hv_finisher_stage/hold/gorilla/enter_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_line(key) + +/datum/hv_finisher_stage/hold/gorilla/enter_self_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_self_line(key) + +/datum/hv_finisher_stage/kill/gorilla + lunge = 0 + effect_delay = 0 + duration = 1 SECONDS + var/key + +/datum/hv_finisher_stage/kill/gorilla/enter() + . = ..() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.pose(key) + +/datum/hv_finisher_stage/kill/gorilla/effect() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.beat(key) + +/datum/hv_finisher_stage/kill/gorilla/enter_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_line(key) + +/datum/hv_finisher_stage/recover/gorilla + duration = 0.8 SECONDS + var/key + +/datum/hv_finisher_stage/recover/gorilla/enter() + . = ..() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.pose(key) + +/datum/hv_finisher_stage/recover/gorilla/effect() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.beat(key) + +/datum/hv_finisher_stage/recover/gorilla/enter_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_line(key) + +/** + * Keeps #509's heavy-hit spray and its lost-limb spray off [victim] for the blow being dealt now. A + * finisher lays its own blood; #509's full fan from a held body reads as blood out of nowhere. + */ +/proc/gorilla_hold_spray(mob/living/victim) + victim.hv_blood_spray_until = max(victim.hv_blood_spray_until, world.time + 1) + victim.hv_blood_spray_strength = INFINITY + +/// One short line of [victim]'s blood toward [direction], [tiles] long, and nothing more from #509 for this blow. +/proc/gorilla_short_spray(mob/living/victim, direction, tiles = 2) + victim.hv_blood_throw(direction, tiles) + gorilla_hold_spray(victim) + +// ========================================================================= +// (A) THE HEAD CLAP +// ========================================================================= + +/datum/hv_finisher/chrome/gorilla/clap + name = "gorilla head clap" + zone_preference = list(BODY_ZONE_HEAD) + stages = list( + /datum/hv_finisher_stage/hold/gorilla/clap_hoist, + /datum/hv_finisher_stage/hold/gorilla/clap_squeeze, + /datum/hv_finisher_stage/hold/gorilla/clap_toss, + /datum/hv_finisher_stage/kill/gorilla/clap, + /datum/hv_finisher_stage/recover/gorilla/clap, + ) + /// The held body's offsets at rest in the hold, list(pixel_x, pixel_y, pixel_z): the fists move it about from there + var/list/held_at + /// Where the held head is as the fists meet on it, list(x, y) in pixels from the middle of its tile: what comes out of it starts there + var/list/burst_at + +/datum/hv_finisher_stage/hold/gorilla/clap_hoist + key = "hoist" + duration = 0.65 SECONDS + +/datum/hv_finisher_stage/hold/gorilla/clap_squeeze + key = "squeeze" + duration = 1 SECONDS + +/datum/hv_finisher_stage/hold/gorilla/clap_toss + key = "toss" + duration = 0.3 SECONDS + +/datum/hv_finisher_stage/kill/gorilla/clap + key = "clap" + // The fists meet, a beat of hit-stop, then the head goes + effect_delay = 0.15 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/clap + key = "recover" + +// With nothing to hold the brain down it would drift off for good. +/datum/hv_finisher/chrome/gorilla/clap/can_start_on(mob/living/carbon/target) + var/turf/spot = get_turf(target) + return !isnull(target.get_bodypart(BODY_ZONE_HEAD)) && spot?.has_gravity() + +/datum/hv_finisher/chrome/gorilla/clap/pick_zone() + target_zone = BODY_ZONE_HEAD + return target_zone + +/datum/hv_finisher/chrome/gorilla/clap/enter_line(key) + switch(key) + if("hoist") + return "[user] hauls [victim] off the floor by the collar!" + if("squeeze") + return "[victim] chokes and kicks in [user]'s grip." + +/datum/hv_finisher/chrome/gorilla/clap/enter_self_line(key) + switch(key) + if("hoist") + return "[user] hauls you off the floor by the collar!" + if("squeeze") + return "You choke and kick in [user]'s grip." + +/// The middle of a human head above the neck line, half its width, and half a fist's width, in pixels (tg's human base sprite). +#define CLAP_HEAD_RISE 3.5 +#define CLAP_HEAD_HALF_WIDTH 4.5 +#define CLAP_FIST_HALF_WIDTH 1.5 +/// A chrome fist on an arm pointing up is drawn a couple of pixels short of its hand's middle: aim it that much higher. +#define CLAP_FIST_RAISE 2 + +/// The puppets of the body held in the clap that move it about. The head rides on the body (see the hoist), so it is not one. +/datum/hv_finisher/chrome/gorilla/clap/proc/held_parts() + . = list() + for(var/key_name in victim_rig?.puppets) + if(key_name != "head") + . += victim_rig.puppets[key_name] + +/** + * Where the bearer's fists go on the held head: side ("l" or "r") -> list(x, y), pixels from the middle of his + * puppets, as pose_reaching() takes them. Seen from the side both go on the temple, the near one over the head and the + * far one behind it; from the front or back, one on each side of it. [apart] pixels back off it (negative: pressed + * in), the head squashed to [width] and [height] about its neck, the held body moved [shift_x], [shift_y] pixels from + * where it rests in the hold. Null without both rigs. + */ +/datum/hv_finisher/chrome/gorilla/clap/proc/fists_on_head(apart = 0, width = 1, height = 1, shift_x = 0, shift_y = 0) + var/obj/effect/abstract/chrome_puppet/body_puppet = victim_rig?.puppet("body") + if(!bearer_rig || !body_puppet || !isturf(body_puppet.loc) || !held_at) + return null + // The held body's middle as drawn, from the middle of the bearer's puppets + var/held_x = (body_puppet.x - user.x) * ICON_SIZE_X + held_at[1] + shift_x - (user.pixel_x + user.pixel_w) + var/held_y = (body_puppet.y - user.y) * ICON_SIZE_Y + held_at[2] + held_at[3] + shift_y - (user.pixel_y + user.pixel_z) + // The picture faces the bearer, which puts the head's middle half a pixel off the picture's + var/head_x = held_x + (facing == WEST ? 0.5 : -0.5) * width + var/head_y = held_y + CHROME_RIG_NECK_PIVOT_Y + CLAP_HEAD_RISE * height + . = list() + if(side_view()) + var/back = facing == EAST ? -1 : 1 + for(var/side in list("l", "r")) + .[side] = list(head_x + back * apart, head_y - 1) + return + var/out_by = CLAP_HEAD_HALF_WIDTH * width + CLAP_FIST_HALF_WIDTH + apart + for(var/side in list("l", "r")) + var/list/pivot = bearer_rig.shoulder(side) + .[side] = list(head_x + (pivot[1] < 0 ? -out_by : out_by), head_y + CLAP_FIST_RAISE) + +/// Both fists to [fists] (fists_on_head()) over [time], in short steps. +/datum/hv_finisher/chrome/gorilla/clap/proc/fists_to(list/fists, time) + for(var/side in fists) + bearer_rig.reach_through(side, list(fists[side]), list(time), GORILLA_CLAP_MAX_REACH) + +/// The most either arm must stretch to put its fist on [fists] (fists_on_head()), as a share of its length. +/datum/hv_finisher/chrome/gorilla/clap/proc/fists_stretch(list/fists) + . = 0 + for(var/side in fists) + var/list/point = fists[side] + var/list/pose = bearer_rig.pose_reaching(side, point[1], point[2]) + . = max(., pose[2]) + +/** + * [shift_x], [shift_y] (pixels the fists would move the held head from where it rests in the hold), cut back as far as it + * takes for both fists to stay on the head there ([apart], [width] and [height] as fists_on_head() takes them): never further + * than his arms reach, or than they already stretch to hold it still. list(x, y), whole pixels. + */ +/datum/hv_finisher/chrome/gorilla/clap/proc/within_reach(shift_x, shift_y, apart = 0, width = 1, height = 1) + var/list/still = fists_on_head(apart, width, height) + if(!still) + return list(0, 0) + var/most = max(GORILLA_CLAP_MAX_REACH, fists_stretch(still)) + for(var/scale in list(1, 0.75, 0.5, 0.25)) + var/list/shift = list(round(shift_x * scale, 1), round(shift_y * scale, 1)) + if(fists_stretch(fists_on_head(apart, width, height, shift[1], shift[2])) <= most) + return shift + return list(0, 0) + +/** + * The held body hangs from its head: to each of [shifts] (list(x, y) pixels from where it rests in the hold) in turn, + * step_times[i] to the i-th, swinging about its neck behind the head as it is shaken aside. Returns the swing at each step, + * which the head takes back off its own turn so only its strain shows. + */ +/datum/hv_finisher/chrome/gorilla/clap/proc/move_held(list/shifts, list/step_times, easing = SINE_EASING) + . = list() + var/obj/effect/abstract/chrome_puppet/body_puppet = victim_rig?.puppet("body") + for(var/i in 1 to length(shifts)) + var/list/shift = shifts[i] + var/sway = clamp(shift[1] * GORILLA_CLAP_SWAY, -GORILLA_CLAP_MAX_SWAY, GORILLA_CLAP_MAX_SWAY) + . += sway + if(!body_puppet || !held_at) + continue + var/matrix/hung = chrome_rig_turn(sway, 0, CHROME_RIG_NECK_PIVOT_Y) + if(i == 1) + animate(body_puppet, transform = hung, pixel_x = held_at[1] + shift[1], pixel_y = held_at[2], pixel_z = held_at[3] + shift[2], time = step_times[i], easing = easing) + else + animate(transform = hung, pixel_x = held_at[1] + shift[1], pixel_y = held_at[2], pixel_z = held_at[3] + shift[2], time = step_times[i], easing = easing) + +/datum/hv_finisher/chrome/gorilla/clap/pose(key) + switch(key) + if("hoist") + rig_bearer() + victim_rig = new(victim, REVERSE_DIR(facing || user.dir)) + if(victim_rig.snapshot()) + // The head on its own puppet, so it can strain and crush while the body hangs + victim_rig.add_puppet("body", victim_rig.cut_at_neck(FALSE), TRUE) + var/obj/effect/abstract/chrome_puppet/head = victim_rig.add_puppet("head", victim_rig.cut_at_neck(TRUE)) + // The head rides on the body, drawn wherever the body is and turned with it, so it can never come + // off the neck: only its own squash and strain are its own + head.pixel_x = 0 + head.pixel_y = 0 + head.pixel_w = 0 + head.pixel_z = 0 + head.moveToNullspace() + var/obj/effect/abstract/chrome_puppet/body_puppet = victim_rig.puppet("body") + body_puppet.vis_contents += head + // Its puppets start where it lay before the grab (below): so does what stands in for them + var/list/lay_at = grab_pull_back() + victim_rig.conceal(lay_at[1], lay_at[2]) + var/list/toward = to_bearer() + // Held up in close, and kept there until the burst: seen from the side chest to chest, from the front + // up to his face, from the back just past his head, so its head is where his fists can close on it + var/share = side_view() ? GORILLA_CLAP_HOLD_SIDE : (facing == NORTH ? GORILLA_CLAP_HOLD_BACK : GORILLA_CLAP_HOLD_FRONT) + var/lift = side_view() ? GORILLA_CLAP_LIFT_SIDE : (facing == NORTH ? GORILLA_CLAP_LIFT_BACK : GORILLA_CLAP_LIFT_FRONT) + held_at = list(round(toward[1] * share), round(toward[2] * share), lift) + // It comes up from where it lay, not from where tg's grab draws it pulled in + var/list/pull_back = grab_pull_back() + for(var/obj/effect/abstract/chrome_puppet/held as anything in held_parts()) + held.pixel_x += pull_back[1] + held.pixel_y += pull_back[2] + // The held body is in front of the bearer, unless he faces away from the viewer + held.layer = facing == NORTH ? user.layer - 0.02 : user.layer + 0.02 + animate(held, transform = matrix(), pixel_x = held_at[1], pixel_y = held_at[2], pixel_w = 0, pixel_z = held_at[3], time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) + // The head's bottom row overlaps the body's top one: the head draws over it + head.layer = body_puppet.layer + 0.001 + else + QDEL_NULL(victim_rig) + if(side_view()) + // The near fist holds the shirt front, over the held body; the far one is behind the bearer. + var/obj/effect/abstract/chrome_puppet/near_arm = arm_puppet("near") + if(near_arm) + near_arm.layer = user.layer + 0.03 + arm_to("near", 125, 0.4 SECONDS, 2) + arm_to("far", 60, 0.4 SECONDS) + else + // Seen from the front or back: both hands on the shoulders of the body held in front. The arms + // are over the bearer's body and under the held one (which is behind him when he faces away). + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") + if(arm) + arm.layer = user.layer + 0.01 + arms_to(-25, 0.4 SECONDS) + if("squeeze") + // The fists close on the head and press, then wrench it side to side and lift it as they squeeze. The body + // hangs from the head and goes with them, swinging behind it; the head strains and gives between the fists in + // steps, about the neck so it stays on the shoulders, and the fists follow it in. + var/obj/effect/abstract/chrome_puppet/head = victim_rig?.puppet("head") + var/list/jerks = list(-5, 4, -4, 5, -3, 0) + // Shares of GORILLA_CLAP_SHAKE (aside) and GORILLA_CLAP_HEAVE (up), never lowered + var/list/shakes = list(0, 1, -1, 1, -0.5, 0) + var/list/heaves = list(0, 0.5, 0.5, 1, 1, 1) + var/list/step_times = list() + var/list/squashes = list() + var/list/shifts = list() + var/list/fist_points = list("l" = list(), "r" = list()) + for(var/i in 1 to length(jerks)) + var/give = 1 - 0.05 * i + var/width = side_view() ? 1 + (1 - give) * 0.5 : give + var/height = side_view() ? give : 1 + (1 - give) * 0.3 + step_times += i == 1 ? 0.2 SECONDS : 0.15 SECONDS + squashes += list(list(width, height)) + var/list/shift = within_reach(shakes[i] * GORILLA_CLAP_SHAKE, heaves[i] * GORILLA_CLAP_HEAVE, 0, width, height) + shifts += list(shift) + var/list/fists = fists_on_head(0, width, height, shift[1], shift[2]) + for(var/side in fists) + fist_points[side] += list(fists[side]) + // Each puppet's steps in one run, as animate() chains onto the last thing it was given + var/list/sways = move_held(shifts, step_times) + if(head) + for(var/i in 1 to length(jerks)) + var/list/squash = squashes[i] + // Its own strain and squash about the neck, on top of the body's swing + var/matrix/pressed = chrome_rig_turn(jerks[i] - sways[i], 0, CHROME_RIG_NECK_PIVOT_Y, 0, 0, squash[1], squash[2]) + if(i == 1) + animate(head, transform = pressed, time = step_times[i], easing = SINE_EASING) + else + animate(transform = pressed, time = step_times[i], easing = SINE_EASING) + for(var/side in fist_points) + if(length(fist_points[side])) + bearer_rig.reach_through(side, fist_points[side], step_times, GORILLA_CLAP_MAX_REACH) + // After the swings: the tremble runs alongside them, his fists and the body he holds shaking together + bearer_rig?.tremble(1 SECONDS) + victim_rig?.tremble(1 SECONDS, list("head")) + playsound(victim, 'sound/mobs/humanoids/human/gasp/gasp_male1.ogg', 30, TRUE) + if("toss") + // The fists heave the head up and come off it a little, for the clap; the body goes up with it + var/width = side_view() ? 1.15 : 0.7 + var/height = side_view() ? 0.7 : 1.09 + var/list/shift = within_reach(0, GORILLA_CLAP_HEAVE + GORILLA_CLAP_TOSS_LIFT, GORILLA_CLAP_WIND, width, height) + fists_to(fists_on_head(GORILLA_CLAP_WIND, width, height, shift[1], shift[2]), 0.15 SECONDS) + move_held(list(shift), list(0.15 SECONDS), SINE_EASING | EASE_OUT) + if("clap") + // It bursts and falls where it is held, on the bearer's tile. First, as it moves every puppet to his tile. + bring_in() + // ...and the fists slam back in and down, pressed into the head as it gives between them, still on the neck, + // and drive it down with them + var/width = side_view() ? 1.2 : 0.55 + var/height = side_view() ? 0.6 : 1.15 + var/list/shift = within_reach(0, GORILLA_CLAP_HEAVE + GORILLA_CLAP_TOSS_LIFT - GORILLA_CLAP_SLAM, -1, width, height) + fists_to(fists_on_head(-1, width, height, shift[1], shift[2]), 0.08 SECONDS) + move_held(list(shift), list(0.08 SECONDS), SINE_EASING | EASE_IN) + if(held_at) + burst_at = list(held_at[1] + shift[1], held_at[2] + held_at[3] + shift[2] + CHROME_RIG_NECK_PIVOT_Y + CLAP_HEAD_RISE * height) + var/obj/effect/abstract/chrome_puppet/head = victim_rig?.puppet("head") + if(head) + animate(head, transform = chrome_rig_turn(0, 0, CHROME_RIG_NECK_PIVOT_Y, 0, 0, width, height), time = 0.08 SECONDS, easing = SINE_EASING | EASE_IN) + // The sparks go where the fists meet, on the head + hit_stop(victim_rig?.puppet("body") || victim, TRUE, 9) + if("recover") + arms_to(0, 0.5 SECONDS) + +/datum/hv_finisher/chrome/gorilla/clap/beat(key) + if(key != "clap" || QDELETED(victim)) + return + playsound(victim, 'sound/effects/wounds/crack2.ogg', 80, TRUE) + // Blood and pieces go on out ahead of him + var/outcome = gorilla_head_clap(victim, user, fist(), facing, burst_at) + drop_held_body(outcome == "burst") + +/** + * Brings the held body onto the bearer's tile, drawn where it is held, so it bursts there and falls at his feet + * (a body on the floor shares his tile). + */ +/datum/hv_finisher/chrome/gorilla/clap/proc/bring_in() + var/turf/here = get_turf(user) + var/turf/from = get_turf(victim) + if(!isturf(here) || !isturf(from) || here == from) + return + // Off the grab first: tg turns a pulled body to face whoever pulls it, and on one tile there is no way to face + release_grip() + if(!victim.Move(here, get_dir(from, here))) + victim.forceMove(here) + if(get_turf(victim) != here) + return + for(var/obj/effect/abstract/chrome_puppet/part as anything in held_parts()) + part.forceMove(here) + part.pixel_x -= (here.x - from.x) * ICON_SIZE_X + part.pixel_y -= (here.y - from.y) * ICON_SIZE_Y + if(held_at) + held_at[1] -= (here.x - from.x) * ICON_SIZE_X + held_at[2] -= (here.y - from.y) * ICON_SIZE_Y + +/// The held body falls from where it is held, headless after a burst, to lie as the real one does; that one shows when it lands. +/datum/hv_finisher/chrome/gorilla/clap/proc/drop_held_body(burst) + var/obj/effect/abstract/chrome_puppet/body_puppet = victim_rig?.puppet("body") + if(!body_puppet || QDELETED(victim) || get_turf(body_puppet) != get_turf(victim)) + land() + return + if(burst) + var/obj/effect/abstract/chrome_puppet/head = victim_rig.puppet("head") + if(head) + body_puppet.vis_contents -= head + victim_rig.drop_puppet("head") + // The real body's turn and offsets are already its own, though it may still be drawn on its way to them + animate(body_puppet, transform = victim.transform, pixel_x = victim.pixel_x, pixel_y = victim.pixel_y, pixel_w = victim.pixel_w, pixel_z = victim.pixel_z, time = GORILLA_CLAP_DROP_TIME, easing = SINE_EASING | EASE_IN) + addtimer(CALLBACK(src, PROC_REF(land)), GORILLA_CLAP_DROP_TIME, TIMER_DELETE_ME) + +/// The held body is down: the real one shows in its place. +/datum/hv_finisher/chrome/gorilla/clap/proc/land() + QDEL_NULL(victim_rig) + if(!QDELETED(victim) && victim.stat == DEAD) + victim.hv_death_throes() + +#undef CLAP_HEAD_RISE +#undef CLAP_HEAD_HALF_WIDTH +#undef CLAP_FIST_HALF_WIDTH +#undef CLAP_FIST_RAISE + +/** + * Both fists meet on [victim]'s head. Bursts it (#509), or on a helmet or plating crumples the helmet and + * caves the skull in, or on the Atlas frame does heavy damage and nothing breaks. [head_at] is where a held + * head is drawn (list(x, y), pixels from the middle of its tile), for gorilla_clap_burst(). Returns what + * happened: "burst", "helmet", "frame" or "crit". + */ +/proc/gorilla_head_clap(mob/living/carbon/victim, mob/living/user, obj/item/fist, direction, list/head_at) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + if(isnull(head)) + return null + victim.hv_hold_attack_message("[user] claps both fists together on [victim]'s head!", "[user] claps both fists together on your head!", "You clap both fists together on [victim]'s head!", user, vision_range = DEFAULT_MESSAGE_RANGE) + . = "burst" + if(HAS_TRAIT(victim, TRAIT_NODISMEMBER) && HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) + // The Atlas frame: nothing bursts and nothing breaks + victim.apply_damage(35, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND) + victim.adjustOrganLoss(ORGAN_SLOT_BRAIN, 20) + victim.hv_add_hit_line("[victim]'s frame holds!", HV_LINE_BOLD, "Your frame holds!") + . = "frame" + else if(HAS_TRAIT(victim, TRAIT_HEAD_INJURY_BLOCKED) || hv_crit_cover_rating(victim, head, MELEE) >= GORILLA_CLAP_HELMET_ARMOUR) + // A helmet or plating: no burst. It crumples and the skull caves in under it. + // No attack direction: a sealed helmet throws no blood + victim.apply_damage(35, BRUTE, head, wound_bonus = CANT_WOUND) + var/obj/item/clothing/helmet + if(ishuman(victim)) + var/mob/living/carbon/human/human_victim = victim + helmet = human_victim.head + if(istype(helmet)) + helmet.take_damage(40, BRUTE, MELEE, FALSE) + if(!QDELETED(helmet)) + gorilla_helmet_dent(victim) + victim.hv_add_hit_line("[victim]'s helmet crumples!", HV_LINE_BOLD, "Your helmet crumples!", DEFAULT_MESSAGE_RANGE) + if(hv_crit_apply_wound(head, /datum/wound/blunt/bone/critical/hv_skull, direction, fist)) + victim.hv_add_hit_line("[victim]'s skull caves in!", HV_LINE_HUGE, "Your skull caves in!", DEFAULT_MESSAGE_RANGE) + . = "helmet" + else + // No attack direction: the burst lays its own blood (gorilla_clap_burst()) + victim.apply_damage(30, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND) + if(QDELETED(victim) || isnull(victim.get_bodypart(BODY_ZONE_HEAD))) + . = "burst" + else if(!gorilla_clap_burst(victim, direction, head_at)) + // A head that can't come off: the skull caves in instead + if(hv_crit_apply_wound(head, /datum/wound/blunt/bone/critical/hv_skull, direction, fist)) + victim.hv_add_hit_line("[victim]'s skull caves in!", HV_LINE_HUGE, "Your skull caves in!", DEFAULT_MESSAGE_RANGE) + . = "crit" + if(!QDELETED(victim)) + victim.hv_flush_hit() + playsound(victim, 'sound/effects/wounds/splatter.ogg', 60, TRUE) + log_combat(user, victim, "head-clapped", "gorilla arms", "([.])") + +/** + * #509's head burst (hv_burst_head_apart()) sized for two fists at arm's length: the gibs and splatter + * stay round the head, one short line of blood goes out along [burst_dir], the eyes, ears and tongue + * drop on the spot or a tile out and the brain lands a tile or two away, whole, for revival. They leave + * from [head_at] (list(x, y), pixels from the middle of the tile), where the head is held, or from where + * the body's own head is drawn if that isn't given. Returns TRUE if the head burst. + */ +/proc/gorilla_clap_burst(mob/living/carbon/victim, burst_dir, list/head_at) + if(HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + if(!head || !head.can_dismember()) + return FALSE + if(!burst_dir) + burst_dir = pick(GLOB.alldirs) + var/turf/here = get_turf(victim) + var/organic = IS_ORGANIC_LIMB(head) + var/bleeds = head.can_bleed() + var/list/blood_dna = bleeds ? victim.get_blood_dna_list() : null + var/blood_color = bleeds ? victim.get_bloodtype()?.get_color() : null + // Read before the head comes off + if(!head_at) + head_at = victim.hv_vis_part_offset(BODY_ZONE_HEAD) + var/list/obj/item/organ/loose = list() + for(var/obj/item/organ/organ in head) + if(!(organ.organ_flags & ORGAN_UNREMOVABLE)) + loose += organ + // One short line; the lost-limb spray the dismember would add is held off + gorilla_short_spray(victim, burst_dir, GORILLA_CLAP_SPRAY_TILES) + // Not dismember(): it throws the head a tile or two, and a head is heavy enough to knock whoever it hits + // a tile back. Thrown into the bearer it shoved him off the body and ended the finisher at the burst. + head = gorilla_tear_limb(victim, head, burst_dir, FALSE) + if(isnull(head)) + return FALSE + // A burst head comes apart where it was + if(isturf(here) && head.loc != here) + head.forceMove(here) + // No violent removal: the brain has to stay good for revival + head.drop_organs() + qdel(head) + if(isturf(here)) + if(!organic) + new /obj/effect/gibspawner/robot(here, victim) + else if(bleeds) + gorilla_clap_gibs(here, burst_dir, blood_dna) + hv_head_burst_splatter(here, burst_dir, blood_color) + playsound(here, 'sound/effects/wounds/splatter.ogg', 80, TRUE) + for(var/obj/item/organ/organ as anything in loose) + if(QDELETED(organ) || !isturf(organ.loc)) + continue + // Flown, not thrown (hv_fling()): nobody is hit by it, the bearer least of all. It starts at the head. + if(istype(organ, /obj/item/organ/brain)) + var/datum/callback/landed = blood_dna ? CALLBACK(organ, TYPE_PROC_REF(/obj/item/organ, hv_landed), blood_dna) : null + organ.hv_vis_fling_from = head_at + hv_fling_organ(organ, burst_dir, GORILLA_CLAP_BRAIN_MIN, GORILLA_CLAP_BRAIN_MAX, landed) + else if(prob(50)) + organ.hv_vis_fling_from = head_at + hv_fling_organ(organ, turn(burst_dir, pick(-45, 0, 45)), 1, 1) + organ.hv_vis_forget_fling() + victim.hv_add_hit_line(pick("[victim]'s head bursts apart!", "[victim]'s skull bursts open!"), HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + return TRUE + +/** + * The clap's gibs: piled round the head, one a tile out along [burst_dir]. #509's burst gibs streak up to + * three tiles across a cone, which from two fists at arm's length reads as a blast. + */ +/proc/gorilla_clap_gibs(turf/where, burst_dir, list/blood_dna) + for(var/i in 1 to 3) + var/obj/effect/decal/cleanable/blood/gibs/gib = new(where, null, blood_dna) + if(!QDELETED(gib)) + gib.pixel_x = rand(-8, 8) + gib.pixel_y = rand(-8, 8) + var/turf/ahead = get_step(where, burst_dir) + if(ahead && !isgroundlessturf(ahead) && !hv_blood_spray_blocked(ahead)) + new /obj/effect/decal/cleanable/blood/gibs(ahead, null, blood_dna) + new /obj/effect/decal/cleanable/blood/gibs/core(where, null, blood_dna) + +/// A crumpled dent drawn over the helmet for a while. +/proc/gorilla_helmet_dent(mob/living/carbon/victim) + var/mutable_appearance/dent = mutable_appearance(GORILLA_GORE_ICON, "helmet_dent", -(HEAD_LAYER - 0.1)) + victim.add_overlay(dent) + addtimer(CALLBACK(victim, TYPE_PROC_REF(/atom, cut_overlay), dent), 10 SECONDS) + +// ========================================================================= +// (B) THE LIMB RIP +// ========================================================================= + +/datum/hv_finisher/chrome/gorilla/rip + name = "gorilla limb rip" + brawler_ok = TRUE + stages = list( + /datum/hv_finisher_stage/hold/gorilla/rip_plant, + /datum/hv_finisher_stage/hold/gorilla/rip_strain, + /datum/hv_finisher_stage/kill/gorilla/rip, + /datum/hv_finisher_stage/recover/gorilla/rip_club, + /datum/hv_finisher_stage/recover/gorilla/rip, + ) + /// The torn-off arm, held until it is dropped on the victim's tile + var/obj/item/bodypart/held_limb + /// "l" or "r": the side of the victim's body the arm is on + var/side + /// The bearer's arm whose fist takes the wrist first ("l" or "r"); the other fist joins it + var/grip_side + /// Pixels the bearer's puppets lean onto the body now: further in as he takes the arm, back to lean_out as he hauls + var/list/lean = list(0, 0) + /// The lean he hauls back to and holds through the blow + var/list/lean_out = list(0, 0) + /// Pixels the body's puppets are drawn from the real body: where it lay before the grab, until he drags it in by the arm + var/list/body_shift = list(0, 0) + /// Degrees (clockwise) the body is turned up about its feet toward his fists by the haul + var/raised = 0 + /// The hauled arm's length when the fists take it, as a share of its own + var/grip_stretch = 1 + /// Where the fists hold the wrist: pixels from the middle of the bearer's tile + var/list/grip + /// The torn arm's hand and shoulder in its own picture, list(hand_x, hand_y, shoulder_x, shoulder_y): the bearer + /// holds it by the hand and swings the shoulder end + var/list/club_ends + +/datum/hv_finisher_stage/hold/gorilla/rip_plant + key = "plant" + duration = 0.5 SECONDS + enter_sound = 'sound/effects/gravhit.ogg' + enter_volume = 40 + +/datum/hv_finisher_stage/hold/gorilla/rip_strain + key = "strain" + duration = 1.35 SECONDS + +/datum/hv_finisher_stage/kill/gorilla/rip + key = "tear" + duration = 0.8 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/rip_club + key = "club" + duration = 0.45 SECONDS + // Still held through the blow, so the body doesn't slide back off the grab under it. The hit is timed with the + // swing (swing_club()), not this stage's beat, which only fires on the finisher's next tick. + pins = TRUE + +/datum/hv_finisher_stage/recover/gorilla/rip + key = "recover" + +/datum/hv_finisher/chrome/gorilla/rip/Destroy() + drop_held_limb() + return ..() + +/datum/hv_finisher/chrome/gorilla/rip/stop(reason) + if(!running) + return + . = ..() + drop_held_limb() + +/// The aimed arm, else the other one. +/datum/hv_finisher/chrome/gorilla/rip/pick_zone() + var/other = aimed_zone == BODY_ZONE_L_ARM ? BODY_ZONE_R_ARM : BODY_ZONE_L_ARM + target_zone = victim.get_bodypart(aimed_zone) ? aimed_zone : (victim.get_bodypart(other) ? other : null) + side = target_zone == BODY_ZONE_L_ARM ? "l" : "r" + return target_zone + +/datum/hv_finisher/chrome/gorilla/rip/can_start_on(mob/living/carbon/target) + return !isnull(target.get_bodypart(BODY_ZONE_L_ARM)) || !isnull(target.get_bodypart(BODY_ZONE_R_ARM)) + +/datum/hv_finisher/chrome/gorilla/rip/enter_line(key) + switch(key) + if("plant") + return "[user] plants a boot on [victim] and grabs [victim.p_their()] arm!" + if("strain") + return "[user] hauls back on [victim]'s arm!" + +/datum/hv_finisher/chrome/gorilla/rip/enter_self_line(key) + switch(key) + if("plant") + return "[user] plants a boot on you and grabs your arm!" + if("strain") + return "[user] hauls back on your arm!" + +/datum/hv_finisher/chrome/gorilla/rip/pose(key) + var/list/toward = to_bearer() + switch(key) + if("plant") + rig_bearer() + if(bearer_rig && facing == NORTH) + // Seen from behind, everything he does here is on his far side: his arms reach out past his own + // back and head, which hide them where they cross + for(var/arm_side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/own_arm = bearer_rig.puppet("arm_[arm_side]") + own_arm?.layer = user.layer - 0.01 + lean_out = list(-round(toward[1] / ICON_SIZE_X * GORILLA_RIP_LEAN), -round(toward[2] / ICON_SIZE_Y * GORILLA_RIP_LEAN)) + lean = lean_out.Copy() + victim_rig = new(victim, victim.dir) + if(bearer_rig && victim_rig.snapshot()) + // The arm turns with the lying body, about its own shoulder + victim_rig.base_transform = matrix(victim.transform) + var/obj/effect/abstract/chrome_puppet/laid_body = victim_rig.add_puppet("body", victim_rig.cut_without_arms(side), TRUE) + // Draw order, back to front, the same from the plant to the let-go: + // - Facing the viewer the body lies nearer than he stands: his body, the body on the floor, its + // hauled arm, his arms (they reach toward the viewer), the torn arm. + // - From the side: his far arm, a far hauled arm, the body, his body (a boot on it), a near hauled + // arm, his near arm, the torn arm. + // - Facing away the body is beyond him, and so is all he does to it: the body, its hauled arm, his + // arms, the torn arm in his fists, and over them all his own back and head. + if(laid_body && facing == SOUTH) + laid_body.layer = user.layer + 0.004 + var/obj/effect/abstract/chrome_puppet/hauled = victim_rig.add_puppet("arm_[side]", victim_rig.arm_picture(side), TRUE) + // The far arm seen from the side comes out from behind the body. Any other arm is hauled up in front of the + // bearer, over his body and under his near fist, unless he faces away and it is beyond him. + if(victim_rig.behind_body(side)) + hauled.layer = victim.layer - 0.01 + else + hauled.layer = facing == NORTH ? victim.layer + 0.01 : user.layer + 0.005 + // The body stays where it lay (tg's grab draws it pulled in at once): he steps in to it as far, and drags it + // in by the arm as he hauls + body_shift = grab_pull_back() + lean = list(lean_out[1] + body_shift[1], lean_out[2] + body_shift[2]) + for(var/key_name in victim_rig.puppets) + var/obj/effect/abstract/chrome_puppet/part = victim_rig.puppets[key_name] + part.pixel_x += body_shift[1] + part.pixel_y += body_shift[2] + // What stands in for it until its puppets can be drawn stands where they do + victim_rig.conceal(body_shift[1], body_shift[2]) + take_wrist(0.3 SECONDS, GORILLA_RIP_START_HOLD) + else + QDEL_NULL(victim_rig) + arms_to(side_view() ? 45 : 30, 0.3 SECONDS, 3) + // A boot on the body: the bearer leans over them, arms and all (after the swings, which would cut + // the lean short on the arms). Nothing moves through the hold, while the puppets' pictures reach the + // client: they come on as the bodies stood, and then he steps in. + for(var/key_name in bearer_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = bearer_rig.puppets[key_name] + if(victim_rig) + animate(part, pixel_x = part.pixel_x, time = GORILLA_RIP_START_HOLD, flags = ANIMATION_PARALLEL) + animate(pixel_x = part.pixel_x + lean[1], pixel_y = part.pixel_y + lean[2], time = 0.3 SECONDS, easing = SINE_EASING | EASE_OUT) + else + animate(part, pixel_x = part.pixel_x + lean[1], pixel_y = part.pixel_y + lean[2], time = 0.3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + if("strain") + if(victim_rig && grip) + haul(1.3 SECONDS) + else + arms_to(side_view() ? 25 : 15, 1.3 SECONDS, 1) + bearer_rig?.tremble(1.3 SECONDS) + if("tear") + // The arm gives: he yanks it back and comes upright + bearer_rig?.bend_and_swing(0, side_view() ? -20 : 0, 0.1 SECONDS) + if("club") + if(held_limb) + // Up over his head, then down on theirs, the hit landing as it comes down + swing_club(GORILLA_CLUB_RAISE, GORILLA_CLUB_STRIKE) + // A shade before the arm is all the way down: the server's timer runs a tick or two behind the client's swing + addtimer(CALLBACK(src, PROC_REF(club_hit)), GORILLA_CLUB_RAISE + GORILLA_CLUB_STRIKE - GORILLA_CLUB_HIT_LEAD, TIMER_DELETE_ME) + else + arms_to(0, 0.4 SECONDS) + if("recover") + bearer_rig?.drop_puppet("club") + // The arm goes down with the body as he lets it go + drop_held_limb() + // Down the way they point: not back up over his head the way they came + bearer_rig?.unwind_arms() + arms_to(0, 0.5 SECONDS) + // Let go, the body slides back to where tg lays it; its stand-ins go with it, and the real one shows there + if(victim_rig && !QDELETED(victim)) + for(var/key_name in victim_rig.puppets) + var/obj/effect/abstract/chrome_puppet/part = victim_rig.puppets[key_name] + animate(part, pixel_x = victim.pixel_x, pixel_y = victim.pixel_y, pixel_w = victim.pixel_w, pixel_z = victim.pixel_z, time = GORILLA_RIP_LET_GO, easing = EASE_IN | EASE_OUT, flags = ANIMATION_PARALLEL) + addtimer(CALLBACK(src, PROC_REF(fallen)), GORILLA_RIP_LET_GO + 0.05 SECONDS, TIMER_DELETE_ME) + // Straightens up off the body, so the real one shows where he stands (after the swing, as the lean was) + for(var/key_name in bearer_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = bearer_rig.puppets[key_name] + animate(part, pixel_x = user.pixel_x, pixel_y = user.pixel_y, time = 0.4 SECONDS, easing = SINE_EASING, flags = ANIMATION_PARALLEL) + +/// The middle of the bearer's puppets as drawn: pixels from the middle of his tile. +/datum/hv_finisher/chrome/gorilla/rip/proc/bearer_origin() + return list(user.pixel_x + user.pixel_w + lean[1], user.pixel_y + user.pixel_z + lean[2]) + +/// The middle of the body's puppets as drawn (body_shift from the real body): pixels from the middle of the bearer's tile. +/datum/hv_finisher/chrome/gorilla/rip/proc/body_origin() + return list((victim.x - user.x) * ICON_SIZE_X + victim.pixel_x + victim.pixel_w + body_shift[1], (victim.y - user.y) * ICON_SIZE_Y + victim.pixel_y + victim.pixel_z + body_shift[2]) + +/** + * The hauled arm's shoulder as drawn, the body turned as the victim rig's base_transform turns it (pixels from the middle + * of the bearer's tile), and the angle the arm comes up at: toward the bearer's shoulders (bent by [bearer_base]), and + * seen from the side turned [tilt] degrees up off the floor. list(x, y, angle). + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/haul_line(tilt, matrix/bearer_base) + var/list/shoulder_at = body_shoulder_at() + var/list/left = bearer_shoulder_at("l", bearer_base) + var/list/right = bearer_shoulder_at("r", bearer_base) + var/angle = arctan((left[1] + right[1]) / 2 - shoulder_at[1], (left[2] + right[2]) / 2 - shoulder_at[2]) + if(side_view()) + // Up toward the top of the screen, never past it + var/to_up = 90 - angle + while(to_up > 180) + to_up -= 360 + while(to_up < -180) + to_up += 360 + angle += clamp(to_up, -tilt, tilt) + return list(shoulder_at[1], shoulder_at[2], angle) + +/// Where the bearer's shoulder on [arm_side] is drawn with his body bent by [bearer_base]: pixels from the middle of his tile. +/datum/hv_finisher/chrome/gorilla/rip/proc/bearer_shoulder_at(arm_side, matrix/bearer_base) + var/list/origin = bearer_origin() + var/list/at = bearer_rig.shoulder_point(arm_side, bearer_base) + return list(origin[1] + at[1], origin[2] + at[2]) + +/// Where the hauled arm's shoulder is drawn, the body turned as the victim rig's base_transform turns it: pixels from the middle of the bearer's tile. +/datum/hv_finisher/chrome/gorilla/rip/proc/body_shoulder_at() + var/list/body_at = body_origin() + var/list/at = victim_rig.shoulder_point(side) + return list(body_at[1] + at[1], body_at[2] + at[2]) + +/// Where the wrist is when the arm is [stretch] times its length: pixels from the middle of the bearer's tile. +/datum/hv_finisher/chrome/gorilla/rip/proc/wrist_at(stretch, tilt, matrix/bearer_base) + var/list/line = haul_line(tilt, bearer_base) + var/length = stretch * victim_rig.arm_reach(side) + return list(line[1] + length * cos(line[3]), line[2] + length * sin(line[3])) + +/** + * The poses that put both of the bearer's fists (his body bent by [bearer_base]) and the hauled hand (the body + * turned as the victim rig's base_transform turns it) on [wrist], each turned the short way from [from] (key -> turn). + * Keys "bearer_l", "bearer_r" and "body"; every pose carries its body's turn. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/poses_on(list/wrist, list/from, matrix/bearer_base) + . = list() + var/list/origin = bearer_origin() + for(var/arm_side in list("l", "r")) + var/list/pose = bearer_rig.pose_reaching(arm_side, wrist[1] - origin[1], wrist[2] - origin[2], from["bearer_[arm_side]"], bearer_base) + .["bearer_[arm_side]"] = list(pose[1], pose[2], bearer_base) + var/list/body_at = body_origin() + var/list/body_pose = victim_rig.pose_reaching(side, wrist[1] - body_at[1], wrist[2] - body_at[2], from["body"]) + .["body"] = list(body_pose[1], body_pose[2], victim_rig.base_transform) + +/// Sends the bearer's arms and body and the hauled arm through [all_poses] (a list of poses_on() lists), [step_time] a step (or step_time[i] for the i-th, given a list). +/datum/hv_finisher/chrome/gorilla/rip/proc/pose_through(list/all_poses, step_time) + var/list/bends = list() + for(var/key_name in list("bearer_l", "bearer_r", "body")) + var/list/steps = list() + for(var/list/poses as anything in all_poses) + var/list/pose = poses[key_name] + steps += list(pose) + if(key_name == "bearer_l") + bends += list(pose[3]) + if(key_name == "body") + victim_rig.pose_arm_through(side, steps, step_time) + else + bearer_rig.pose_arm_through(key_name == "bearer_l" ? "l" : "r", steps, step_time) + // His body bends with his arms, step for step, so they stay on his shoulders + bearer_rig.body_through(bends, step_time) + +/// The turns each arm is in now, keyed as poses_on() keys them. +/datum/hv_finisher/chrome/gorilla/rip/proc/turns_now() + . = list() + for(var/arm_side in list("l", "r")) + var/list/pose = bearer_rig.arm_poses[arm_side] || list(0, 1) + .["bearer_[arm_side]"] = pose[1] + var/list/pose = victim_rig.arm_poses[side] || list(0, 1) + .["body"] = pose[1] + +/** + * The plant, over [time]: the bearer's piston arms push out, both fists close on the wrist and the arm comes + * up off the floor into them. Every part stays as it is for [hold] first. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/take_wrist(time, hold = 0) + var/bend = 0 + var/matrix/bent = null + var/list/line = haul_line(GORILLA_RIP_TILT, bent) + // The fist on the near side (seen from the side) or nearer the arm takes it; the other one joins it + var/best_distance + for(var/arm_side in list("l", "r")) + var/list/at = bearer_shoulder_at(arm_side, bent) + var/distance = sqrt((at[1] - line[1]) ** 2 + (at[2] - line[2]) ** 2) + if(isnull(best_distance) || distance < best_distance) + best_distance = distance + grip_side = arm_side + if(side_view()) + grip_side = bearer_rig.near_side() + // The arm comes up as far as it takes to meet that fist pushed out to its reach + var/list/from = bearer_shoulder_at(grip_side, bent) + var/along = gorilla_ray_meets(line[1], line[2], line[3], from[1], from[2], GORILLA_RIP_REACH * bearer_rig.arm_reach(grip_side)) + grip_stretch = clamp(isnull(along) ? GORILLA_RIP_MAX_STRETCH : along / victim_rig.arm_reach(side), 1, GORILLA_RIP_MAX_STRETCH) + grip = wrist_at(grip_stretch, GORILLA_RIP_TILT, bent) + // Every arm swings the same number of short steps, so they arrive together + var/list/now = turns_now() + var/list/there = poses_on(grip, now, bent) + var/list/start = list( + "bearer_l" = bearer_rig.arm_poses["l"] || list(0, 1), + "bearer_r" = bearer_rig.arm_poses["r"] || list(0, 1), + "body" = victim_rig.arm_poses[side] || list(0, 1), + ) + var/steps = bearer_rig.swing_steps(0, bend) + for(var/key_name in there) + var/list/pose = there[key_name] + steps = max(steps, bearer_rig.swing_steps(now[key_name], pose[1])) + var/list/all_poses = list() + var/list/step_times = list() + if(hold) + var/list/still = list() + for(var/key_name in start) + var/list/was = start[key_name] + still[key_name] = list(was[1], was[2], null) + all_poses += list(still) + step_times += hold + for(var/i in 1 to steps) + var/progress = (1 - cos(180 * i / steps)) / 2 + var/matrix/bent_now = bearer_rig.bend_matrix(bend * progress) + var/list/poses = list() + for(var/key_name in there) + var/list/pose = there[key_name] + var/list/was = start[key_name] + poses[key_name] = list(was[1] + (pose[1] - was[1]) * progress, was[2] + (pose[2] - was[2]) * progress, key_name == "body" ? null : bent_now) + all_poses += list(poses) + step_times += time / steps + pose_through(all_poses, step_times) + +/** + * The strain, over [time]: the bearer leans back from his feet (seen from the side) with his arms out and steps back to + * lean_out, hauling the arm up and out of its socket. The body is dragged in by it to where tg's grab draws it, and turns + * up about its feet toward his fists, the feet staying on the floor; both fists on the wrist the whole way. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/haul(time) + var/lean_back = side_view() ? -GORILLA_RIP_LEAN_BACK : 0 + var/list/start_fist = bearer_shoulder_at(grip_side) + var/grip_reach = sqrt((grip[1] - start_fist[1]) ** 2 + (grip[2] - start_fist[2]) ** 2) + var/body_length = victim_rig.arm_reach(side) + var/matrix/flat = matrix(victim.transform) + raised = raise_toward(flat, grip) + var/list/lean_from = lean.Copy() + var/list/shift_from = body_shift.Copy() + var/step_time = time / GORILLA_RIP_HAUL_STEPS + var/list/all_poses = list() + var/list/body_turns = list() + var/list/leans = list() + var/list/shifts = list() + var/list/from = turns_now() + for(var/i in 1 to GORILLA_RIP_HAUL_STEPS) + // Sine in and out, the same for every part + var/progress = (1 - cos(180 * i / GORILLA_RIP_HAUL_STEPS)) / 2 + lean = list(round(lean_from[1] + (lean_out[1] - lean_from[1]) * progress, 1), round(lean_from[2] + (lean_out[2] - lean_from[2]) * progress, 1)) + body_shift = list(round(shift_from[1] * (1 - progress), 1), round(shift_from[2] * (1 - progress), 1)) + victim_rig.base_transform = chrome_rig_turn_about_feet(flat, raised * progress) + leans += list(lean) + shifts += list(body_shift) + body_turns += list(victim_rig.base_transform) + var/matrix/bent = bearer_rig.bend_matrix(lean_back * progress) + var/list/fist_from = bearer_shoulder_at(grip_side, bent) + var/list/arm_from = body_shoulder_at() + var/apart = sqrt((arm_from[1] - fist_from[1]) ** 2 + (arm_from[2] - fist_from[2]) ** 2) + var/fist_reach = grip_reach + // The arm gives as far as it must to stay in his fists + var/arm_reach = clamp(max((grip_stretch + GORILLA_RIP_HAUL * progress) * body_length, apart - fist_reach + 1), body_length, GORILLA_RIP_MAX_HAUL * body_length) + fist_reach = max(fist_reach, apart - arm_reach + 1) + grip = gorilla_circles_meet(fist_from[1], fist_from[2], fist_reach, arm_from[1], arm_from[2], arm_reach, grip[1], grip[2]) + var/list/poses = poses_on(grip, from, bent) + for(var/key_name in poses) + var/list/pose = poses[key_name] + from[key_name] = pose[1] + all_poses += list(poses) + pose_through(all_poses, step_time) + victim_rig.body_through(body_turns, step_time) + // He stays leaning back until the arm comes away + bearer_rig.bend_angle = lean_back + bearer_rig.base_transform = bearer_rig.bend_matrix(lean_back) + // After the swings and turns, which would cut these short: he steps back and the body comes with him + gorilla_offsets_through(bearer_rig.puppets, leans, user.pixel_x, user.pixel_y, step_time) + gorilla_offsets_through(victim_rig.puppets, shifts, victim.pixel_x, victim.pixel_y, step_time) + // Both bodies shake together, so the fists stay on the wrist + bearer_rig.tremble(time) + victim_rig.tremble(time) + +/** + * Degrees (clockwise) to turn the body lying as [flat] turns it about its feet as the arm is hauled toward [point] (pixels + * from the middle of the bearer's tile). Seen from the side the upper body comes up off the floor, GORILLA_RIP_RAISE; from + * the front or back it turns toward the fists, at most that. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/raise_toward(matrix/flat, list/point) + var/list/feet = chrome_rig_apply(flat, 0, CHROME_RIG_FEET_Y) + var/list/shoulder_at = victim_rig.shoulder_point(side, flat) + if(side_view()) + // Clockwise lifts a shoulder that lies to the left of the feet + return shoulder_at[1] < feet[1] ? GORILLA_RIP_RAISE : -GORILLA_RIP_RAISE + var/list/body_at = body_origin() + var/turned = arctan(point[1] - body_at[1] - feet[1], point[2] - body_at[2] - feet[2]) - arctan(shoulder_at[1] - feet[1], shoulder_at[2] - feet[2]) + while(turned > 180) + turned -= 360 + while(turned <= -180) + turned += 360 + // Angles run anticlockwise, turns clockwise + return -clamp(turned, -GORILLA_RIP_RAISE, GORILLA_RIP_RAISE) + +/** + * The arm gives: the body drops back flat from its turn about the feet, faster and faster, the arm falling back to its + * side with it if it held. It stays on its stand-ins, in their draw order, until he lets go (the recover). + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/fall_back() + var/obj/effect/abstract/chrome_puppet/body_puppet = victim_rig?.puppet("body") + if(!body_puppet || QDELETED(victim)) + QDEL_NULL(victim_rig) + return + if(!raised) + return + var/matrix/flat = matrix(victim.transform) + var/list/arm_from = victim_rig.puppet("arm_[side]") ? (victim_rig.arm_poses[side] || list(0, 1)) : null + var/steps = 3 + if(arm_from) + steps = max(steps, victim_rig.swing_steps(arm_from[1], 0)) + var/list/body_turns = list() + var/list/arm_steps = list() + for(var/i in 1 to steps) + // Gravity: slow off the top, fast onto the floor + var/progress = 1 - cos(90 * i / steps) + var/matrix/turned = chrome_rig_turn_about_feet(flat, raised * (1 - progress)) + body_turns += list(turned) + if(arm_from) + arm_steps += list(list(arm_from[1] * (1 - progress), arm_from[2] + (1 - arm_from[2]) * progress, turned)) + victim_rig.body_through(body_turns, GORILLA_RIP_FALL_TIME / steps) + if(arm_from) + victim_rig.pose_arm_through(side, arm_steps, GORILLA_RIP_FALL_TIME / steps) + raised = 0 + victim_rig.base_transform = flat + +/// He has let the body go and it lies where tg lays it: the real one shows in its place. +/datum/hv_finisher/chrome/gorilla/rip/proc/fallen() + QDEL_NULL(victim_rig) + +/** + * The stand-ins take on how both bodies look now. With the arm [torn] off, the victim's whole picture is the body, + * stump and all; else the arm is still its own piece. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/redraw_bodies(torn) + bearer_rig?.redraw_arms() + if(QDELETED(victim) || !victim_rig?.snapshot()) + return + victim_rig.repaint("body", torn ? victim_rig.flat : victim_rig.cut_without_arms(side)) + if(!torn) + victim_rig.repaint("arm_[side]", victim_rig.arm_picture(side)) + +/// Blood bursts every way from the socket the arm just left, where the shoulder is drawn at that moment. +/datum/hv_finisher/chrome/gorilla/rip/proc/shoulder_burst() + var/turf/spot = get_turf(victim) + if(!isturf(spot)) + return + var/list/joint + if(victim_rig?.puppet("body")) + // The stand-in's shoulder, hauled up off the floor: from the middle of the bearer's tile, so back onto the victim's + var/list/at = body_shoulder_at() + var/list/toward = to_bearer() + joint = list(at[1] + toward[1], at[2] + toward[2]) + else + joint = gorilla_shoulder_at(victim, side) + gorilla_blood_burst(spot, round(joint[1]), round(joint[2]), victim, GORILLA_RIP_BURST_DROPS) + +/** + * Where the pool under the torn shoulder goes: pixels from the middle of the victim's tile. Half-way between where the + * shoulder lies as the arm comes off, pulled in by the grab, and where it lies once he lets go, so the stump is over it + * both times. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/pool_spot() + if(QDELETED(victim)) + return null + var/list/joint = gorilla_shoulder_at(victim, side, victim_rig?.facing) + // Off a body still on its feet it lands at them + if(victim.body_position != LYING_DOWN) + joint[2] = CHROME_RIG_FEET_Y + victim.pixel_y + victim.pixel_z + var/list/back = grab_pull_back() + return list(clamp(round(joint[1] + back[1] / 2), -GORILLA_RIP_POOL_REACH, GORILLA_RIP_POOL_REACH), clamp(round(joint[2] + back[2] / 2), -GORILLA_RIP_POOL_REACH, GORILLA_RIP_POOL_REACH)) + +/** + * Where the shoulder on [side] ("l" or "r") of [body] is drawn, lying or standing as it is now: pixels from the middle of + * its tile. [facing] is the way its picture faces, else its own. + */ +/proc/gorilla_shoulder_at(mob/living/body, side, facing) + var/list/pivots = GLOB.chrome_rig_shoulders["[facing || body.dir]"] || GLOB.chrome_rig_shoulders["[SOUTH]"] + var/list/at = chrome_rig_apply(body.transform, side == "l" ? pivots[1] : pivots[3], side == "l" ? pivots[2] : pivots[4]) + return list(at[1] + body.pixel_x + body.pixel_w, at[2] + body.pixel_y + body.pixel_z) + +/** + * Blood bursts from a wound at [from_x], [from_y] (pixels from the middle of [origin]) every way at once: [count] drops + * of [bleeder]'s blood thrown in a broken ring, no two in a row alike, each landing as a drip one of [reach] pixels out, + * give or take. Drops only: no line along the floor, as #509's sprays lay, and no heavy splats. + */ +/proc/gorilla_blood_burst(turf/origin, from_x, from_y, mob/living/bleeder, count = 10, list/reach = list(6, 9, 12, 15, 19)) + if(!isturf(origin) || QDELETED(bleeder) || bleeder.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/blood_color = bleeder.get_bloodtype()?.get_damage_color(bleeder) || BLOOD_COLOR_RED + var/list/blood_dna = bleeder.get_blood_dna_list() + var/list/viruses = bleeder.get_static_viruses() + var/start = rand(0, 359) + for(var/i in 1 to count) + var/angle = start + i * (360 / count) + rand(-14, 14) + var/distance = pick(reach) + rand(-2, 2) + var/land_x = from_x + round(sin(angle) * distance) + var/land_y = from_y + round(cos(angle) * distance) + // list(start x, start y, land x, land y, arc height, flight time, wait): the monowire's drop, which lands as a drip + new /obj/effect/temp_visual/wire_blood_drop(origin, list(from_x, from_y, land_x, land_y, 3 + rand(0, 5), round(1.5 + distance / 14, 0.5), 0), blood_color, blood_dna, viruses) + +/** + * Moves [pool] (blood just laid on the floor) so its middle is [x], [y] pixels from the middle of its tile, and keeps it + * there as it grows into a pool. + */ +/proc/gorilla_place_blood(obj/effect/decal/cleanable/blood/pool, x, y) + pool.pixel_x += x - pool.base_pixel_x + pool.pixel_y += y - pool.base_pixel_y + pool.base_pixel_x = x + pool.base_pixel_y = y + +/** + * [body] shudders up to [shift_x], [shift_y] pixels for [time] and ends where it began. Drawn only (pixel_w and pixel_z, + * each step relative), so whatever offsets tg has on the body stay as they are. + */ +/proc/gorilla_shudder(atom/movable/body, shift_x = 2, shift_y = 1, time = 0.2 SECONDS) + var/steps = max(2, round(time / (0.04 SECONDS))) + var/moved_w = 0 + var/moved_z = 0 + for(var/i in 1 to steps) + // The last step takes it back + var/to_w = i == steps ? 0 : rand(-shift_x, shift_x) + var/to_z = i == steps ? 0 : rand(-shift_y, shift_y) + if(i == 1) + animate(body, pixel_w = to_w - moved_w, pixel_z = to_z - moved_z, time = time / steps, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + else + animate(pixel_w = to_w - moved_w, pixel_z = to_z - moved_z, time = time / steps, flags = ANIMATION_RELATIVE) + moved_w = to_w + moved_z = to_z + +/// Sends every puppet in [parts] (key -> puppet) through [points] (list(x, y) pixels on from [base_x], [base_y]), [step_time] a step, alongside its turns. +/proc/gorilla_offsets_through(list/parts, list/points, base_x, base_y, step_time) + for(var/key_name in parts) + var/obj/effect/abstract/chrome_puppet/part = parts[key_name] + for(var/i in 1 to length(points)) + var/list/point = points[i] + if(i == 1) + animate(part, pixel_x = base_x + point[1], pixel_y = base_y + point[2], time = step_time, flags = ANIMATION_PARALLEL) + else + animate(pixel_x = base_x + point[1], pixel_y = base_y + point[2], time = step_time) + +/** + * The point [first_radius] from [first_x], [first_y] and [second_radius] from [second_x], [second_y] nearest + * [near_x], [near_y]. If the two never meet, the point [first_radius] along the line between them. + */ +/proc/gorilla_circles_meet(first_x, first_y, first_radius, second_x, second_y, second_radius, near_x, near_y) + var/apart_x = second_x - first_x + var/apart_y = second_y - first_y + var/apart = sqrt(apart_x * apart_x + apart_y * apart_y) + if(!apart) + return list(first_x, first_y) + apart_x /= apart + apart_y /= apart + if(first_radius + second_radius <= apart || abs(first_radius - second_radius) >= apart) + return list(first_x + apart_x * first_radius, first_y + apart_y * first_radius) + var/along = (apart * apart + first_radius * first_radius - second_radius * second_radius) / (2 * apart) + var/aside = sqrt(max(0, first_radius * first_radius - along * along)) + var/list/one = list(first_x + apart_x * along - apart_y * aside, first_y + apart_y * along + apart_x * aside) + var/list/two = list(first_x + apart_x * along + apart_y * aside, first_y + apart_y * along - apart_x * aside) + if((one[1] - near_x) ** 2 + (one[2] - near_y) ** 2 <= (two[1] - near_x) ** 2 + (two[2] - near_y) ** 2) + return one + return two + +/// How far along the line from [from_x], [from_y] heading [angle] degrees it first meets the circle of [radius] round [round_x], [round_y], or null. +/proc/gorilla_ray_meets(from_x, from_y, angle, round_x, round_y, radius) + var/off_x = from_x - round_x + var/off_y = from_y - round_y + var/half_b = off_x * cos(angle) + off_y * sin(angle) + var/c = off_x * off_x + off_y * off_y - radius * radius + var/disc = half_b * half_b - c + if(disc < 0) + return null + var/root = sqrt(disc) + if(-half_b - root >= 0) + return -half_b - root + if(-half_b + root >= 0) + return -half_b + root + return null + +/datum/hv_finisher/chrome/gorilla/rip/beat(key) + if(QDELETED(victim)) + return + switch(key) + if("tear") + // The sparks at the shoulder the arm leaves, on the body: on the arm they would go off with it in his fists + var/obj/effect/abstract/chrome_puppet/torn_from = victim_rig?.puppet("body") + if(torn_from) + var/list/socket = victim_rig.shoulder_point(side) + hit_stop(torn_from, TRUE, round(socket[2]), round(socket[1])) + else + hit_stop(victim) + var/outcome = gorilla_limb_rip(victim, target_zone, user, fist(), pool_spot()) + // Both bodies are hidden behind their stand-ins, and so is what this just did to them (the stump, the blood + // that ran back over him): the stand-ins take it on now, or it would only show when they come down + redraw_bodies(outcome == "torn") + if(outcome == "torn") + held_limb = gorilla_last_torn_limb(victim, target_zone) + if(held_limb?.can_bleed()) + shoulder_burst() + // The arm goes with the bearer's fists; the body drops back to the floor + var/obj/effect/abstract/chrome_puppet/arm = victim_rig?.puppet("arm_[side]") + if(arm && bearer_rig) + var/list/arm_shoulder = victim_rig.shoulder(side) + var/list/arm_hand = victim_rig.hand_offset(side) + club_ends = list(arm_shoulder[1] + arm_hand[1], arm_shoulder[2] + arm_hand[2], arm_shoulder[1], arm_shoulder[2]) + victim_rig.puppets -= "arm_[side]" + bearer_rig.puppets["club"] = arm + // Drawn where it was, from the bearer's tile + var/list/toward = to_bearer() + arm.pixel_x -= toward[1] + arm.pixel_y -= toward[2] + arm.forceMove(get_turf(user)) + // In his fists, over them. Seen from behind he holds it on his far side, so his body hides it + arm.layer = facing == NORTH ? user.layer - 0.005 : user.layer + 0.03 + if(grip && facing == NORTH) + // Yanked back lying as it was hauled, it would cross the back of his legs and reach past his feet. + // It comes down in his fist, back to its own length, and hangs from it beside him + var/list/origin = bearer_origin() + hang_club(arm, list(grip[1] - origin[1], grip[2] - origin[2]), bearer_rig.hand_point(grip_side)) + else if(grip) + // ...and yanked back with the fist that held it + var/list/fist_at = bearer_rig.hand_point(grip_side) + var/list/origin = bearer_origin() + animate(arm, pixel_x = arm.pixel_x + round(origin[1] + fist_at[1] - grip[1]), pixel_y = arm.pixel_y + round(origin[2] + fist_at[2] - grip[2]), time = 0.1 SECONDS, easing = SINE_EASING) + else + arm.pixel_x = 0 + arm.pixel_y = 0 + arm.pixel_z = 6 + arm.transform = matrix().Turn(100) + for(var/key_name in bearer_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = bearer_rig.puppets[key_name] + animate(part, pixel_w = -2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_w = 2, time = 0.3 SECONDS, flags = ANIMATION_RELATIVE) + // It drops back to the floor as the arm gives + fall_back() + +/** + * The torn arm ([club]) goes with the fist that holds its hand, from [from_fist] to [fist_at] (pixels from the middle of + * his puppets), swinging about it in short steps to hang straight down. The fist is at his side, and the arm back at + * its own length, by half way. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/hang_club(obj/effect/abstract/chrome_puppet/club, list/from_fist, list/fist_at) + var/held_length = club_length() + if(!held_length) + return + var/matrix/drawn = club.transform + var/list/hand_now = chrome_rig_apply(drawn, club_ends[1], club_ends[2]) + var/list/stump_now = chrome_rig_apply(drawn, club_ends[3], club_ends[4]) + var/angle_now = arctan(stump_now[1] - hand_now[1], stump_now[2] - hand_now[2]) + var/stretch_now = sqrt((stump_now[1] - hand_now[1]) ** 2 + (stump_now[2] - hand_now[2]) ** 2) / held_length + var/turn = -90 - angle_now + while(turn > 180) + turn -= 360 + while(turn <= -180) + turn += 360 + var/steps = max(4, bearer_rig.swing_steps(0, turn)) + var/obj/effect/abstract/chrome_puppet/own_arm = bearer_rig.puppet("arm_[grip_side]") + for(var/i in 1 to steps) + var/progress = i / steps + var/yanked = min(1, progress * 2) + var/matrix/hung = club_matrix(from_fist[1] + (fist_at[1] - from_fist[1]) * yanked, from_fist[2] + (fist_at[2] - from_fist[2]) * yanked, angle_now + turn * progress, stretch_now + (1 - stretch_now) * yanked) + if(i == 1) + // From here on its transform alone puts it in his fist, from where his own arm stands + animate(club, transform = hung, pixel_x = own_arm?.pixel_x || 0, pixel_y = own_arm?.pixel_y || 0, pixel_z = own_arm?.pixel_z || 0, time = GORILLA_RIP_HANG_TIME / steps) + else + animate(transform = hung, time = GORILLA_RIP_HANG_TIME / steps) + +/// The middle of the victim's head as drawn, lying or not: pixels from the middle of the bearer's tile. +/datum/hv_finisher/chrome/gorilla/rip/proc/victim_head_at() + var/list/head = chrome_rig_apply(victim.transform, 0, CHROME_RIG_NECK_PIVOT_Y + 3.5) + return list((victim.x - user.x) * ICON_SIZE_X + victim.pixel_x + victim.pixel_w + head[1], (victim.y - user.y) * ICON_SIZE_Y + victim.pixel_y + victim.pixel_z + head[2]) + +/// The torn arm's length in its own picture, hand to shoulder. +/datum/hv_finisher/chrome/gorilla/rip/proc/club_length() + return sqrt((club_ends[3] - club_ends[1]) ** 2 + (club_ends[4] - club_ends[2]) ** 2) + +/** + * The torn arm held by its hand at [fist_x], [fist_y] (pixels from the middle of the bearer's puppets), its shoulder + * end pointing [angle] degrees round from the right (anticlockwise), [stretch] times its length. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/club_matrix(fist_x, fist_y, angle, stretch = 1) + var/along = arctan(club_ends[3] - club_ends[1], club_ends[4] - club_ends[2]) + return chrome_rig_turn(along - angle, club_ends[1], club_ends[2], fist_x - club_ends[1], fist_y - club_ends[2], 1, stretch) + +/// The degrees from [from_angle] to [to_angle], turning the way [arm_turned] went (anticlockwise positive) when they are far apart. +/proc/gorilla_turn_with(from_angle, to_angle, arm_turned) + var/turned = to_angle - from_angle + while(turned > 180) + turned -= 360 + while(turned <= -180) + turned += 360 + if(abs(arm_turned) > 90 && turned * arm_turned < 0) + turned += arm_turned > 0 ? 360 : -360 + return turned + +/** + * The blow with the torn arm: both fists swing it up over his head in [raise], then bring it down on the victim's + * head in [strike], so it lands as the arms come down. The arm in his fists goes through the same short steps as + * his arms, held by its hand in the fist that tore it off. + */ +/datum/hv_finisher/chrome/gorilla/rip/proc/swing_club(raise, strike) + if(!bearer_rig) + return + var/obj/effect/abstract/chrome_puppet/club = club_ends ? bearer_rig.puppet("club") : null + var/holding = grip_side || bearer_rig.near_side() + var/list/origin = bearer_origin() + // The blow comes down from his shoulders onto the head; the fists go out as far along it as the arm in them doesn't + var/list/head = victim_head_at() + var/list/left = bearer_rig.shoulder("l") + var/list/right = bearer_rig.shoulder("r") + var/from_x = (left[1] + right[1]) / 2 + var/from_y = (left[2] + right[2]) / 2 + var/to_x = head[1] - origin[1] - from_x + var/to_y = head[2] - origin[2] - from_y + var/apart = sqrt(to_x * to_x + to_y * to_y) + var/blow_angle = arctan(to_x, to_y) + var/held_length = club ? club_length() : 0 + var/fist_along = clamp(apart - held_length, 0, GORILLA_CLUB_REACH * bearer_rig.arm_reach(holding)) + var/blow_stretch = held_length ? clamp((apart - fist_along) / held_length, 1, GORILLA_CLUB_MAX_STRETCH) : 1 + var/fist_x = from_x + fist_along * cos(blow_angle) + var/fist_y = from_y + fist_along * sin(blow_angle) + // Overhead: seen from the side up and back, the arm hanging back over his head; from the front or from behind + // straight up, where both his arms and the arm in them show over his head + var/club_up + var/list/poses_now = list() + var/list/poses_up = list() + var/list/poses_down = list() + for(var/arm_side in list("l", "r")) + poses_now[arm_side] = bearer_rig.arm_poses[arm_side] || list(0, 1) + var/list/up + if(side_view()) + up = list(200 * bearer_rig.raise_sign(arm_side), 1.2) + club_up = facing == EAST ? 145 : 35 + else + up = list(170 * bearer_rig.raise_sign(arm_side), 1.1) + club_up = 90 + poses_up[arm_side] = up + var/list/down = bearer_rig.pose_reaching(arm_side, fist_x, fist_y, up[1]) + down[2] = min(down[2], GORILLA_CLUB_REACH) + poses_down[arm_side] = down + // Every arm goes through the same steps: sine up, then faster and faster down + var/up_steps = 1 + var/down_steps = 1 + for(var/arm_side in list("l", "r")) + var/list/now = poses_now[arm_side] + var/list/up = poses_up[arm_side] + var/list/down = poses_down[arm_side] + up_steps = max(up_steps, bearer_rig.swing_steps(now[1], up[1])) + down_steps = max(down_steps, bearer_rig.swing_steps(up[1], down[1])) + var/list/progress = list() + var/list/step_times = list() + for(var/i in 1 to up_steps) + progress += (1 - cos(180 * i / up_steps)) / 2 + step_times += raise / up_steps + for(var/i in 1 to down_steps) + progress += 1 - cos(90 * i / down_steps) + step_times += strike / down_steps + var/list/arm_steps = list("l" = list(), "r" = list()) + for(var/i in 1 to length(progress)) + var/rising = i <= up_steps + for(var/arm_side in list("l", "r")) + var/list/was = rising ? poses_now[arm_side] : poses_up[arm_side] + var/list/will = rising ? poses_up[arm_side] : poses_down[arm_side] + arm_steps[arm_side] += list(list(was[1] + (will[1] - was[1]) * progress[i], was[2] + (will[2] - was[2]) * progress[i])) + for(var/arm_side in arm_steps) + bearer_rig.pose_arm_through(arm_side, arm_steps[arm_side], step_times) + if(!club) + return + // The torn arm turns the way the arm holding it does, from where it hangs in the fist now + var/matrix/drawn = club.transform + var/list/hand_now = chrome_rig_apply(drawn, club_ends[1], club_ends[2]) + var/list/stump_now = chrome_rig_apply(drawn, club_ends[3], club_ends[4]) + var/club_now = arctan(stump_now[1] - hand_now[1], stump_now[2] - hand_now[2]) + var/stretch_now = held_length ? sqrt((stump_now[1] - hand_now[1]) ** 2 + (stump_now[2] - hand_now[2]) ** 2) / held_length : 1 + var/list/hold_now = poses_now[holding] + var/list/hold_up = poses_up[holding] + var/list/hold_down = poses_down[holding] + // Arm turns are clockwise, the arm's angles anticlockwise + var/turn_up = gorilla_turn_with(club_now, club_up, hold_now[1] - hold_up[1]) + var/turn_down = gorilla_turn_with(club_now + turn_up, blow_angle, hold_up[1] - hold_down[1]) + var/obj/effect/abstract/chrome_puppet/holding_arm = bearer_rig.puppet("arm_[holding]") + var/list/held_steps = arm_steps[holding] + for(var/i in 1 to length(held_steps)) + var/rising = i <= up_steps + var/list/fist = bearer_rig.hand_at(holding, held_steps[i]) + var/angle = rising ? club_now + turn_up * progress[i] : club_now + turn_up + turn_down * progress[i] + var/stretch = rising ? stretch_now + (1 - stretch_now) * progress[i] : 1 + (blow_stretch - 1) * progress[i] + var/matrix/held = club_matrix(fist[1], fist[2], angle, stretch) + if(i == 1) + animate(club, transform = held, pixel_x = holding_arm?.pixel_x || 0, pixel_y = holding_arm?.pixel_y || 0, pixel_w = 0, pixel_z = holding_arm?.pixel_z || 0, time = step_times[i]) + else + animate(transform = held, time = step_times[i]) + +/// The torn arm lands on the victim's head. +/datum/hv_finisher/chrome/gorilla/rip/proc/club_hit() + if(!running || QDELETED(victim) || !held_limb) + return + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + victim.hv_hold_attack_message("[user] beats [victim] with [victim.p_their()] own arm!", "[user] beats you with your own arm!", "You beat [victim] with [victim.p_their()] own arm!", user, vision_range = DEFAULT_MESSAGE_RANGE) + // None of #509's heavy-hit spray for this blow: its fan lands a tile out past the body, nowhere near the head + gorilla_hold_spray(victim) + victim.apply_damage(15, BRUTE, head || BODY_ZONE_CHEST, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND, attack_direction = get_dir(user, victim), attacking_item = fist()) + victim.hv_flush_hit() + playsound(victim, 'sound/effects/wounds/splatter.ogg', 60, TRUE) + // The blow's wound is drawn on the hidden body: its stand-in takes it on, and takes the hit + var/obj/effect/abstract/chrome_puppet/body_puppet = victim_rig?.puppet("body") + if(body_puppet && victim_rig.snapshot()) + victim_rig.repaint("body", victim_rig.puppet("arm_[side]") ? victim_rig.cut_without_arms(side) : victim_rig.flat) + var/obj/effect/abstract/cyberware_anim/impact = gorilla_play_impact(user, body_puppet || victim, "impact_gorilla") + if(impact && body_puppet) + // On the head, where the arm lands. On a puppet it keeps the puppet's place but not its turn: the head as it lies + var/list/head_at = chrome_rig_apply(victim.transform, 0, CHROME_RIG_NECK_PIVOT_Y + 3.5) + impact.pixel_w += round(head_at[1]) + impact.pixel_z += round(head_at[2]) + else if(impact) + // It rides on the body, so its offset turns with a lying one: up is the head + impact.pixel_y += round(CHROME_RIG_NECK_PIVOT_Y + 3.5) + // Not tg's Shake(): its first step takes pixel_y from pixel_x, which throws a body pulled in by a grab a third of a + // tile aside for a frame + if(body_puppet) + for(var/key_name in victim_rig.puppets) + gorilla_shudder(victim_rig.puppets[key_name], 2, 1, 0.2 SECONDS) + else + gorilla_shudder(victim, 2, 1, 0.2 SECONDS) + if(held_limb.can_bleed()) + victim.add_splatter_floor(get_turf(victim), small_drip = TRUE) + +/// Puts the torn arm down on the victim's tile, wherever the finisher stopped. +/datum/hv_finisher/chrome/gorilla/rip/proc/drop_held_limb() + if(isnull(held_limb)) + return + var/obj/item/bodypart/limb = held_limb + held_limb = null + if(QDELETED(limb)) + return + var/turf/spot = get_turf(victim) || get_turf(user) + if(spot) + limb.forceMove(spot) + // What still runs out of it as it lands: a drip, into the pool from the tear where there is one. Not a fresh + // splash on the body's tile a second after the wound + if(limb.can_bleed()) + if(!QDELETED(victim) && get_turf(victim) == spot) + victim.add_splatter_floor(spot, TRUE) + else + new /obj/effect/decal/cleanable/blood/drip(spot, null, limb.blood_dna_info) + +/// The arm just torn from [victim] at [zone], waiting in nullspace, or null. +/proc/gorilla_last_torn_limb(mob/living/carbon/victim, zone) + var/obj/item/bodypart/limb = victim.gorilla_torn_limb?.resolve() + victim.gorilla_torn_limb = null + if(QDELETED(limb) || limb.body_zone != zone) + return null + return limb + +/mob/living/carbon + /// The last arm a Gorilla tore off this body, until the finisher takes it. + var/datum/weakref/gorilla_torn_limb + +/** + * Tears [victim]'s arm at [zone] off, or wrenches the shoulder out of its socket when it's armoured, or + * does nothing but hurt on the Atlas frame or a limb that can't come off. Returns "torn", + * "dislocated" or "held". [blood_at] (list(x, y) pixels from the middle of the victim's tile) is where + * the blood the torn shoulder puts on the floor goes; the middle of the tile without it. + */ +/proc/gorilla_limb_rip(mob/living/carbon/victim, zone, mob/living/user, obj/item/fist, list/blood_at) + var/obj/item/bodypart/limb = victim.get_bodypart(zone) + if(isnull(limb)) + return null + var/toward_bearer = get_dir(victim, user) + if(!limb.can_dismember() || HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) + victim.hv_hold_attack_message("[user] wrenches at [victim]'s [limb.plaintext_zone]!", "[user] wrenches at your [limb.plaintext_zone]!", null, user) + victim.apply_damage(25, BRUTE, limb, victim.run_armor_check(limb, MELEE), wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + victim.hv_add_hit_line("[victim]'s arm holds!", HV_LINE_BOLD, "Your arm holds!") + victim.hv_flush_hit() + log_combat(user, victim, "failed to tear off the arm of", "gorilla arms") + return "held" + if(hv_crit_cover_rating(victim, limb, MELEE) >= GORILLA_RIP_ARMOUR) + victim.hv_hold_attack_message("[user] wrenches at [victim]'s [limb.plaintext_zone]!", "[user] wrenches at your [limb.plaintext_zone]!", null, user) + victim.apply_damage(25, BRUTE, limb, victim.run_armor_check(limb, MELEE), wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + if(hv_crit_apply_wound(limb, /datum/wound/blunt/bone/moderate, toward_bearer, fist)) + victim.hv_add_hit_line("[victim]'s shoulder wrenches out of its socket!", HV_LINE_BOLD, "Your shoulder wrenches out of its socket!") + victim.hv_flush_hit() + do_sparks(2, TRUE, victim) + log_combat(user, victim, "dislocated the arm of", "gorilla arms") + return "dislocated" + // The spray runs back at the bearer: the last hit on the body came from them. + victim.hv_hold_attack_message("[user] tears [victim]'s [limb.plaintext_zone] off at the shoulder!", "[user] tears your [limb.plaintext_zone] off at the shoulder!", "You tear [victim]'s [limb.plaintext_zone] off at the shoulder!", user, vision_range = DEFAULT_MESSAGE_RANGE) + victim.apply_damage(10, BRUTE, limb, wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + // One straight line back at the bearer, who takes it; no fan past him + if(limb.can_bleed()) + gorilla_short_spray(victim, toward_bearer, 2) + var/obj/item/bodypart/torn = gorilla_tear_limb(victim, limb, toward_bearer, TRUE, blood_at) + victim.hv_flush_hit() + if(isnull(torn)) + return "held" + if(!torn.can_bleed()) + do_sparks(3, TRUE, victim) + playsound(victim, 'sound/effects/wounds/crackandbleed.ogg', 80, TRUE) + victim.gorilla_torn_limb = WEAKREF(torn) + log_combat(user, victim, "tore off the [torn.plaintext_zone] of", "gorilla arms") + return "torn" + +/** + * Takes [limb] off as tg's dismember() does (chest damage, scream, sound, mood, memory, bleeding, the + * floor splatter, the wet stump and the spray along the last blow), but never flings it: with [keep_out] + * it is kept out of the world for the finisher to hold, else it is left on the victim's tile. Fresh blood + * it puts on the floor goes [blood_at] (list(x, y) pixels from the middle of the tile), under the wound, if + * given; blood already on the tile stays where it is. Returns the limb or null. + */ +/proc/gorilla_tear_limb(mob/living/carbon/victim, obj/item/bodypart/limb, direction, keep_out = TRUE, list/blood_at) + if(isnull(limb) || limb.owner != victim || !limb.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return null + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + chest?.receive_damage(clamp(limb.brute_dam / 2 * chest.body_damage_coeff, 15, 50), wound_bonus = CANT_WOUND) + INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") + playsound(get_turf(victim), 'sound/effects/dismember.ogg', 80, TRUE) + victim.add_mood_event("dismembered_[limb.body_zone]", /datum/mood_event/dismembered, limb) + victim.add_mob_memory(/datum/memory/was_dismembered, lost_limb = limb) + LAZYSET(victim.body_zone_dismembered_by, limb.body_zone, WOUND_BLUNT) + var/bleeds = limb.can_bleed() + var/turf/spot = get_turf(victim) + var/list/old_blood = list() + if(blood_at && isturf(spot)) + for(var/obj/effect/decal/cleanable/blood/there in spot) + old_blood += there + if(bleeds) + victim.bleed(rand(20, 40)) + limb.drop_limb(FALSE, TRUE) + victim.update_equipment_speed_mods() + if(bleeds && isturf(spot)) + victim.add_splatter_floor(spot) + if(blood_at) + // tg lays it on the middle of the tile, which a body held in a grab is drawn well off + for(var/obj/effect/decal/cleanable/blood/pool in spot) + if(pool.type == /obj/effect/decal/cleanable/blood && !(pool in old_blood) && !QDELETED(pool)) + gorilla_place_blood(pool, blood_at[1], blood_at[2]) + if(QDELETED(limb)) + return null + if(keep_out) + limb.moveToNullspace() + return limb + +// ========================================================================= +// (C) THE BACKBREAKER +// ========================================================================= + +/datum/hv_finisher/chrome/gorilla/backbreaker + name = "gorilla backbreaker" + zone_preference = list(BODY_ZONE_CHEST) + stages = list( + /datum/hv_finisher_stage/hold/gorilla/back_hoist, + /datum/hv_finisher_stage/hold/gorilla/back_press, + /datum/hv_finisher_stage/kill/gorilla/backbreaker, + /datum/hv_finisher_stage/recover/gorilla/backbreaker, + ) + /// TRUE once the back broke: the body is rolled off still bent + var/broke = FALSE + /// Which way the body lies in his hands and on the floor after: 1 head east, -1 head west. Away from a bearer + /// facing east or west, so it goes off his knee head first; tg lays no body head north or south. + var/lay = 1 + +/datum/hv_finisher_stage/hold/gorilla/back_hoist + key = "hoist" + duration = 0.85 SECONDS + +/datum/hv_finisher_stage/hold/gorilla/back_press + key = "press" + duration = 1 SECONDS + +/datum/hv_finisher_stage/kill/gorilla/backbreaker + key = "slam" + effect_delay = 0.18 SECONDS + duration = 0.6 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/backbreaker + key = "roll" + duration = 0.8 SECONDS + +/datum/hv_finisher/chrome/gorilla/backbreaker/pick_zone() + target_zone = BODY_ZONE_CHEST + return target_zone + +/datum/hv_finisher/chrome/gorilla/backbreaker/can_start_on(mob/living/carbon/target) + return !isnull(target.get_bodypart(BODY_ZONE_CHEST)) + +/datum/hv_finisher/chrome/gorilla/backbreaker/enter_line(key) + switch(key) + if("hoist") + return "[user] hoists [victim] over [user.p_their()] head!" + if("press") + return "[victim] thrashes in [user]'s grip." + if("roll") + return "[user] drops [victim] off [user.p_their()] knee." + +/datum/hv_finisher/chrome/gorilla/backbreaker/enter_self_line(key) + switch(key) + if("hoist") + return "[user] hoists you over [user.p_their()] head!" + if("press") + return "You thrash in [user]'s grip." + +/// Where the belt sits, from the icon's centre: halves turn about it. +#define BACK_WAIST_PIVOT_Y -7 + +/datum/hv_finisher/chrome/gorilla/backbreaker/pose(key) + var/list/toward = to_bearer() + switch(key) + if("hoist") + rig_bearer() + lay = (facing || user.dir) == WEST ? -1 : 1 + victim_rig = new(victim, SOUTH) + if(victim_rig.snapshot()) + // A body lying with its head the other way from how he will hold it comes up head first: turned to that in one + // straight step it would shrink away to nothing half way and grow back + var/lying_turn = arctan(victim.transform.a, victim.transform.b) + var/turn_over = abs(lay * 90 - lying_turn) > 90 ? lay * 90 - lying_turn : 0 + // Draw order, the same from the floor to the floor, so nothing changes places mid-move: + // - Facing away he lifts the body on his far side, holds it there and breaks it over a knee that is + // beyond him too: the body, then his own back, head and arms over it wherever they cross. + // - Any other way it comes up on the viewer's side of him and stays there: he is under it. + var/halves_layer = user.layer + ((facing || user.dir) == NORTH ? -0.05 : 0.05) + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig.add_puppet(half, victim_rig.cut("rig_[half]"), TRUE, halves_layer) + // Overhead on his back, flat, held clear above the bearer's head + if(turn_over) + var/steps = bearer_rig?.swing_steps(0, turn_over) || 15 + for(var/i in 1 to steps) + // Fast off the floor, slow into his hands + var/progress = sin(90 * i / steps) + var/matrix/turned = chrome_rig_turn(lying_turn + turn_over * progress, 0, BACK_WAIST_PIVOT_Y * progress) + if(i == 1) + animate(part, transform = turned, time = 0.55 SECONDS / steps) + else + animate(transform = turned, time = 0.55 SECONDS / steps) + animate(part, pixel_x = toward[1], pixel_y = toward[2], pixel_w = 0, pixel_z = 29, time = 0.55 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + else + animate(part, pixel_x = toward[1], pixel_y = toward[2], pixel_w = 0, pixel_z = 29, transform = laid(), time = 0.55 SECONDS, easing = SINE_EASING | EASE_OUT) + victim_rig.conceal() + else + QDEL_NULL(victim_rig) + // Fists up under the body + if(side_view()) + arm_to("near", 166, 0.5 SECONDS, 6) + arm_to("far", 174, 0.5 SECONDS, 6) + else + arms_to(170, 0.5 SECONDS, 3) + if("press") + bearer_rig?.tremble(1 SECONDS) + var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") + var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") + for(var/i in 1 to 4) + var/kick = (i % 2) ? 1 : -1 + if(upper) + if(i == 1) + animate(upper, transform = laid(6 * kick), time = 0.12 SECONDS) + else + animate(transform = laid(6 * kick), time = 0.24 SECONDS) + if(lower) + if(i == 1) + animate(lower, transform = laid(-(12 * kick)), time = 0.12 SECONDS) + else + animate(transform = laid(-(12 * kick)), time = 0.24 SECONDS) + if(upper) + animate(transform = laid(), time = 0.1 SECONDS) + if(lower) + animate(transform = laid(), time = 0.1 SECONDS) + if("slam") + // Down to one knee, and the body comes down across it + arms_to(120, 0.15 SECONDS, 2) + bearer_rig?.crouch(0.8, 3, 0.15 SECONDS, SINE_EASING | EASE_IN) + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) + if(part) + animate(part, pixel_z = 2, time = 0.16 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + if("roll") + // Off the knee and forward, onto the tile he faces (owner). If something is in the way there, it comes + // down at his feet instead, on his own tile (a body on the floor shares it). + var/turf/knee = get_turf(user) + var/turf/from = get_turf(victim) + var/turf/ahead = get_step(user, facing || user.dir) + var/shoved = can_lay_on(ahead) + var/turf/lands = shoved ? ahead : knee + if(lands && from && lands != from && victim.body_position == LYING_DOWN) + // The real body is hidden under its halves, which stay drawn where they are as it moves under them + if(shoved) + victim.forceMove(lands) + else + victim.Move(lands, get_dir(from, lands)) + lands = get_turf(victim) + for(var/key_name in victim_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = victim_rig.puppets[key_name] + part.forceMove(lands) + part.pixel_x -= (lands.x - from.x) * ICON_SIZE_X + part.pixel_y -= (lands.y - from.y) * ICON_SIZE_Y + // At his feet it lies in front of him, toward the viewer, where he doesn't hide it: the real body too, for + // when the halves come down. Shoved clear of him it lies as tg lays any body. + var/at_feet = get_turf(victim) == knee + if(at_feet) + victim.AddComponent(/datum/component/gorilla_laid_in_front, lay) + // tg turns a lying body to the way it last crawled: lay it as the halves are, or it flips over when + // they come down. + if(victim.body_position == LYING_DOWN) + victim.set_lying_angle(lay > 0 ? LYING_ANGLE_EAST : LYING_ANGLE_WEST) + // Where the halves come to rest, from the middle of the tile they are on. They turn about the belt, seven + // pixels under the middle of the picture, so they sit that far back along the body and a pixel up to lie + // exactly where tg draws the lying body; at his feet the body is drawn out to them instead (the component). + var/rest_x = at_feet ? 0 : lay * BACK_WAIST_PIVOT_Y + var/rest_y = at_feet ? 1 - GORILLA_BACK_IN_FRONT : 1 + // Still bent if the back broke. A broken back hangs folded over the knee for a moment first. + var/bend = broke ? 33 : 0 + var/hold = broke ? GORILLA_BACK_FOLD_HOLD : 0 + if(at_feet) + // He lets it down and stands + arms_to(0, 0.5 SECONDS) + bearer_rig?.crouch(1, 0, 0.4 SECONDS, SINE_EASING | EASE_OUT) + else if(hold) + // He stays down on the knee under it until he shoves it off + addtimer(CALLBACK(src, PROC_REF(shove_off)), hold, TIMER_DELETE_ME) + else + shove_off() + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) + if(!part) + continue + var/way = half == "upper" ? 1 : -1 + animate(part, transform = laid(way * (broke ? 38 : 0)), time = hold) + if(at_feet) + animate(pixel_x = rest_x, pixel_y = rest_y, pixel_z = 0, transform = laid(way * bend), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) + else + // Shoved: up off the knee and over, then down onto the floor ahead + animate(pixel_x = (part.pixel_x + rest_x) / 2, pixel_y = (part.pixel_y + rest_y) / 2, pixel_z = GORILLA_BACK_SHOVE_RISE, transform = laid(way * bend), time = GORILLA_BACK_SHOVE_TIME * 0.45, easing = SINE_EASING | EASE_OUT) + animate(pixel_x = rest_x, pixel_y = rest_y, pixel_z = 0, time = GORILLA_BACK_SHOVE_TIME * 0.55, easing = SINE_EASING | EASE_IN) + // No blood of the move's own where he lands (owner): a broken back doesn't bleed, and a pool on the middle + // of the tile never lined up with the body. What the body really bleeds is tg's. + // At his feet the halves stay drawn as they were hoisted while they lie there: over him, or under him seen + // from behind, where tg draws the lying body too + +/// The bearer shoves the body off his knee: both arms push out after it, then he lets them down and stands. +/datum/hv_finisher/chrome/gorilla/backbreaker/proc/shove_off() + if(!running || !bearer_rig) + return + // Away from the viewer the body goes on past him, behind him as it has been since the hoist + arms_to(side_view() ? 70 : 60, GORILLA_BACK_SHOVE_PUSH, 4) + addtimer(CALLBACK(src, PROC_REF(stand_up)), GORILLA_BACK_SHOVE_PUSH + 0.05 SECONDS, TIMER_DELETE_ME) + +/// He lets his arms down and stands off the knee. +/datum/hv_finisher/chrome/gorilla/backbreaker/proc/stand_up() + if(!running || !bearer_rig) + return + arms_to(0, 0.3 SECONDS) + bearer_rig.crouch(1, 0, 0.3 SECONDS, SINE_EASING | EASE_OUT) + +/// A half of the body as it is held and laid: flat on its back, head the way [var/lay] says, bent [bend] degrees at the belt. +/datum/hv_finisher/chrome/gorilla/backbreaker/proc/laid(bend = 0) + return chrome_rig_turn(lay * (90 + bend), 0, BACK_WAIST_PIVOT_Y) + +/** + * Whether the body can be shoved off the knee onto [ahead], the tile the bearer faces: open floor he can reach, with + * nothing solid on it and nobody else on their feet there. The body's own tile is fine: it was lifted off it. + */ +/datum/hv_finisher/chrome/gorilla/backbreaker/proc/can_lay_on(turf/ahead) + var/turf/here = get_turf(user) + if(QDELETED(victim) || !isopenturf(ahead) || !here || !here.Adjacent(ahead)) + return FALSE + // Nowhere to lie, unless it lay there already + if(get_turf(victim) != ahead && (isgroundlessturf(ahead) || ischasm(ahead) || islava(ahead))) + return FALSE + // No source atom: a body lying on a crate or table tile may pass what it sits on, but it is not put back down on one + if(ahead.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + for(var/mob/living/other in ahead) + if(other != victim && other != user && other.density && other.body_position == STANDING_UP) + return FALSE + return TRUE + +/datum/hv_finisher/chrome/gorilla/backbreaker/beat(key) + if(key != "slam" || QDELETED(victim)) + return + hit_stop(victim_rig?.puppet("upper") || victim, TRUE) + gorilla_backbreaker_sound(victim) + playsound(victim, 'sound/effects/meteorimpact.ogg', 50, TRUE) + var/turf/knee = get_turf(user) + if(knee) + new /obj/effect/temp_visual/cyberware_impact_cracks(knee, "damaged1", 255) + cyberware_grey_dust(knee) + var/outcome = gorilla_backbreak(victim, user, fist()) + broke = outcome == "broken" + // What the blow did to the body (the snapped bone, tg's wounds) is drawn on the hidden one: the halves take it on + // now, or it would only show when they come down + if(!QDELETED(victim) && victim_rig?.snapshot()) + for(var/half in list("upper", "lower")) + victim_rig.repaint(half, victim_rig.cut("rig_[half]")) + if(broke) + // The body folds over the knee + var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") + var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") + if(upper) + animate(upper, transform = laid(38), time = 0.06 SECONDS) + if(lower) + animate(lower, transform = laid(-(38)), time = 0.06 SECONDS) + else + // Nothing gives: the body bounces off the knee + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) + if(part) + animate(part, pixel_z = 6, time = 0.12 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(pixel_z = 2, time = 0.12 SECONDS, easing = SINE_EASING | EASE_IN) + +/datum/hv_finisher/chrome/gorilla/backbreaker/cleanup_rigs() + // A broken back stays drawn bent for a while after + if(broke && victim_rig && !QDELETED(victim) && victim.get_bodypart(BODY_ZONE_CHEST)) + victim.AddComponent(/datum/component/gorilla_fold, victim_rig, lay) + victim_rig = null + return ..() + +/** + * Brings [victim] down across a knee: heavy brute to the chest, then a broken back, or cracked ribs under + * chest armour, or nothing on the Atlas frame. Returns "broken", "ribs", "frame" or "bruised". + */ +/proc/gorilla_backbreak(mob/living/carbon/victim, mob/living/user, obj/item/fist) + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + if(isnull(chest)) + return null + var/direction = get_dir(user, victim) + victim.hv_hold_attack_message("[user] drops to one knee and brings [victim] down across it!", "[user] drops to one knee and brings you down across it!", "You drop to one knee and bring [victim] down across it!", user, vision_range = DEFAULT_MESSAGE_RANGE) + // No attack direction: a body bent over a knee throws no blood across the room (#509's heavy-hit spray) + victim.apply_damage(45, BRUTE, chest, victim.run_armor_check(chest, MELEE), wound_bonus = CANT_WOUND) + . = "bruised" + if(HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) + victim.hv_add_hit_line("[victim]'s frame holds!", HV_LINE_BOLD, "Your frame holds!") + . = "frame" + else if(hv_crit_cover_rating(victim, chest, MELEE) >= GORILLA_BACK_ARMOUR) + if(hv_crit_apply_wound(chest, /datum/wound/blunt/bone/severe, direction, fist)) + victim.hv_add_hit_line("[victim]'s ribs crack!", HV_LINE_BOLD, "Your ribs crack!") + . = "ribs" + else if(hv_crit_apply_wound(chest, /datum/wound/blunt/bone/critical/gorilla_spine, direction, fist)) + victim.hv_add_hit_line("[victim]'s back breaks!", HV_LINE_HUGE, "Your back breaks!", DEFAULT_MESSAGE_RANGE) + . = "broken" + if(!QDELETED(victim)) + victim.hv_flush_hit() + log_combat(user, victim, "backbroke", "gorilla arms", "([.])") + +/** + * # Broken back + * + * tg's compound fracture on the chest, with the spine gone: while it lasts the body can crawl but not + * stand. Treated like any compound fracture (surgery, or bone gel and tape). + */ +/datum/wound/blunt/bone/critical/gorilla_spine + name = "Broken Back" + undiagnosed_name = "Broken Back" + desc = "Patient's spine has been snapped across, leaving the legs useless." + treat_text = "Repair surgically. Bone gel and surgical tape will hold it in an emergency." + treat_text_short = "Repair surgically, or apply bone gel and surgical tape." + simple_desc = "Patient's back is broken. They cannot stand." + simple_treat_text = "Surgery, or bone gel and surgical tape." + homemade_treat_text = "Don't move them." + examine_desc = "is bent at an angle no back should bend" + occur_text = "folds the wrong way with a crack" + +/datum/wound/blunt/bone/critical/gorilla_spine/wound_injury(datum/wound/old_wound = null, attack_direction = null) + . = ..() + if(victim) + ADD_TRAIT(victim, TRAIT_FLOORED, REF(src)) + +/datum/wound/blunt/bone/critical/gorilla_spine/remove_wound(ignore_limb, replaced = FALSE) + if(victim) + REMOVE_TRAIT(victim, TRAIT_FLOORED, REF(src)) + return ..() + +/datum/wound/blunt/bone/critical/gorilla_spine/set_victim(new_victim) + if(victim) + REMOVE_TRAIT(victim, TRAIT_FLOORED, REF(src)) + return ..() + +/datum/wound_pregen_data/bone/gorilla_spine + abstract = FALSE + wound_path_to_generate = /datum/wound/blunt/bone/critical/gorilla_spine + viable_zones = list(BODY_ZONE_CHEST) + can_be_randomly_generated = FALSE + threshold_minimum = 115 + +/** + * The fold a broken back leaves: the victim's puppets stay bent for a while after the backbreaker, until + * the body is moved, buckled or the back is set. Left alone, it eases out flat when its time is up, to lie as + * the real body does, which then shows in its place. + */ +/datum/component/gorilla_fold + /// The victim's rig, handed over by the finisher + var/datum/chrome_rig/rig + /// 1 if the body lies head east, -1 head west + var/lay = 1 + var/fold_timer + +/datum/component/gorilla_fold/Initialize(datum/chrome_rig/rig, lay = 1) + if(!iscarbon(parent) || !istype(rig)) + return COMPONENT_INCOMPATIBLE + src.rig = rig + src.lay = lay + fold_timer = addtimer(CALLBACK(src, PROC_REF(settle)), GORILLA_FOLD_TIME, TIMER_STOPPABLE) + +/// The fold's time is up: the halves ease out flat, where the real body lies, and it shows once they are. +/datum/component/gorilla_fold/proc/settle() + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = rig?.puppet(half) + if(part) + animate(part, transform = chrome_rig_turn(lay * 90, 0, BACK_WAIST_PIVOT_Y), time = GORILLA_FOLD_SETTLE, easing = SINE_EASING) + fold_timer = QDEL_IN_STOPPABLE(src, GORILLA_FOLD_SETTLE) + +/datum/component/gorilla_fold/RegisterWithParent() + RegisterSignals(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BUCKLED, COMSIG_CARBON_LOSE_WOUND, COMSIG_LIVING_REVIVE, COMSIG_LIVING_SET_BODY_POSITION), PROC_REF(unfold)) + +/datum/component/gorilla_fold/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BUCKLED, COMSIG_CARBON_LOSE_WOUND, COMSIG_LIVING_REVIVE, COMSIG_LIVING_SET_BODY_POSITION)) + +/datum/component/gorilla_fold/Destroy() + if(fold_timer) + deltimer(fold_timer) + fold_timer = null + QDEL_NULL(rig) + return ..() + +/datum/component/gorilla_fold/proc/unfold(datum/source) + SIGNAL_HANDLER + qdel(src) + +/** + * A body the backbreaker dropped on the bearer's own tile (the tile he faced was blocked), drawn [GORILLA_BACK_IN_FRONT] + * pixels toward the viewer so the one standing over it doesn't hide it. Comes off when it is moved, strapped in or gets up. + * + * It lies where its halves were laid. They turn about the belt, which is under the middle of the picture, so they lie + * that many pixels along toward the head from where tg draws a lying body (turned about its middle): the real body is + * drawn as far along, or it would jump back when the halves come down. + */ +/datum/component/gorilla_laid_in_front + +/// [lay] is 1 for a body lying head east, -1 head west. +/datum/component/gorilla_laid_in_front/Initialize(lay = 1) + if(!isliving(parent)) + return COMPONENT_INCOMPATIBLE + var/mob/living/body = parent + body.add_offsets(GORILLA_LAID_IN_FRONT_OFFSET, x_add = lay * -BACK_WAIST_PIVOT_Y, y_add = -GORILLA_BACK_IN_FRONT, animate = FALSE) + +/datum/component/gorilla_laid_in_front/RegisterWithParent() + RegisterSignals(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BUCKLED, COMSIG_LIVING_SET_BODY_POSITION), PROC_REF(moved_off)) + +/datum/component/gorilla_laid_in_front/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BUCKLED, COMSIG_LIVING_SET_BODY_POSITION)) + var/mob/living/body = parent + body.remove_offsets(GORILLA_LAID_IN_FRONT_OFFSET) + +/datum/component/gorilla_laid_in_front/proc/moved_off(datum/source) + SIGNAL_HANDLER + qdel(src) + +#undef BACK_WAIST_PIVOT_Y diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm new file mode 100644 index 00000000000..6ddd071aa9f --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm @@ -0,0 +1,623 @@ +/** + * # The charged haymaker + * + * A chrome wheel chip on each real Gorilla arm. Hold Use chrome to wind up and let go to throw it at + * whoever is in the tile you face; or tap Use chrome (or pick the chip) and click someone next to you. + * The wind-up slows the bearer, lets go of any grab and leans back, so the target has a + * beat to step out of line. The hit sends the body flying along the safe path, further for a full + * charge; into a wall it hits harder and cracks the chest. A swing at nobody staggers the bearer. + * Held too long, or called off, the charge is lost. + * + * The arm shows it. A bearer who stands their ground is drawn on the puppet rig (chrome_puppet_rig.dm, with the swing + * of gorilla_swing.dm): the punching arm draws back as the charge builds and is held there, the other comes up as a + * guard, and on the release the fist is thrown onto the part it hits with a step in behind it, stays there a beat, + * follows through and comes back to rest. The blow lands with the fist on it, a tick after the release. A bearer who + * walks or turns while winding up keeps the lean alone, and their blow is tg's lunge; so does one whose punching hand + * holds something. A guard hand that holds something stays down. + * + * The haymaker and every Gorilla finisher share one cooldown, kept on the bearer, so they never chain. + */ + +/datum/action/cooldown/cyberware/gorilla_haymaker + name = "Haymaker" + desc = "Wind up one fist and put everything behind it." + button_icon = GORILLA_GORE_ICON + button_icon_state = "chip_haymaker" + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_HANDS_BLOCKED + cooldown_time = GORILLA_PISTONS_COOLDOWN + click_to_activate = TRUE + unset_after_click = TRUE + /// world.time the wind-up began, 0 when not winding up + var/charge_started = 0 + /// world.time Use chrome went down for this charge, so a short press reads as a tap + var/pressed_at = 0 + /// Set while a release unsets the click ability, so that doesn't read as calling it off + var/releasing = FALSE + /// Timer that vents a charge held too long + var/vent_timer + /// Timer for a release made before the minimum wind-up: it lands when the wind-up is done + var/release_timer + /// The bearer's arm rig while the wind-up and the swing are drawn + var/datum/chrome_rig/rig + /// Timer that takes the rig down when the arms are back at rest + var/rig_timer + /// The guard hand held something when the rig came up, so that arm stays down + var/guard_still = FALSE + /// Timer that lands a thrown blow + var/land_timer + /// The part the blow must land on, for an AI that picks one. Else it is rolled from the part the bearer aims at + var/aimed_zone + +/datum/action/cooldown/cyberware/gorilla_haymaker/Destroy() + stop_charge() + if(land_timer) + deltimer(land_timer) + land_timer = null + return ..() + +/datum/action/cooldown/cyberware/gorilla_haymaker/Remove(mob/remove_from) + if(charge_started) + stop_charge() + return ..() + +/datum/action/cooldown/cyberware/gorilla_haymaker/IsAvailable(feedback = FALSE) + . = ..() + if(!.) + return + if(!TIMER_COOLDOWN_FINISHED(owner, COOLDOWN_GORILLA_PISTONS)) + if(feedback) + owner.balloon_alert(owner, "pistons still hot!") + return FALSE + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = organ + if(!istype(arm) || !arm.heavy_moves || !gorilla_may_use_heavy_moves(owner) || HAS_TRAIT(owner, TRAIT_PACIFISM)) + return FALSE + return TRUE + +/// Whether a wind-up is running. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/charging() + return charge_started > 0 + +// A second press while winding up throws it at the tile you face. +/datum/action/cooldown/cyberware/gorilla_haymaker/Trigger(mob/clicker, trigger_flags, atom/target) + if(charging() && !target && owner) + if(!IsAvailable(feedback = TRUE)) + return FALSE + release(get_step(owner, owner.dir)) + return TRUE + return ..() + +/datum/action/cooldown/cyberware/gorilla_haymaker/set_click_ability(mob/on_who) + . = ..() + start_charge() + +/datum/action/cooldown/cyberware/gorilla_haymaker/unset_click_ability(mob/on_who, refund_cooldown = TRUE) + . = ..() + // Called off by hand (the chip again, a stun, the arm going dark): the pistons vent. + if(charging() && !releasing && !release_timer) + vent() + +/datum/action/cooldown/cyberware/gorilla_haymaker/Activate(atom/target) + return release(target) + +/// Use chrome came back up: a held press throws it at the tile you face, a tap waits for a click. +/datum/action/cooldown/cyberware/gorilla_haymaker/chrome_use_released(mob/living/carbon/bearer) + if(!charging() || bearer != owner || release_timer) + return FALSE + if(world.time - pressed_at < GORILLA_HAYMAKER_TAP || !IsAvailable(feedback = TRUE)) + return FALSE + release(get_step(owner, owner.dir)) + return TRUE + +/** + * Winds up and lands the blow on [target] after [hold_time], with no click or key: the same wind-up anyone can see + * and dodge. On [zone], if one is given. For an AI. Returns TRUE if the wind-up began. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/wind_up_at(atom/target, hold_time = GORILLA_HAYMAKER_FULL_CHARGE, zone) + if(charging() || !IsAvailable()) + return FALSE + start_charge() + if(!charging()) + return FALSE + aimed_zone = zone + // Thrown a throw's length early, so the blow lands when the hold is up + release_timer = addtimer(CALLBACK(src, PROC_REF(queued_release), WEAKREF(target)), max(hold_time, GORILLA_HAYMAKER_MIN_CHARGE) - GORILLA_HAYMAKER_THROW, TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + +/// Begins the wind-up. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/start_charge() + var/mob/living/carbon/bearer = owner + if(!iscarbon(bearer) || charging()) + return + charge_started = world.time + pressed_at = world.time + aimed_zone = null + // Before the lean: the puppets come up where the body rests, and lean with it from there + raise_rig(bearer) + bearer.apply_status_effect(/datum/status_effect/gorilla_windup, src) + if(!bearer.has_status_effect(/datum/status_effect/gorilla_windup)) + drop_rig() + vent_timer = addtimer(CALLBACK(src, PROC_REF(vent)), GORILLA_HAYMAKER_VENT_AFTER, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Ends the wind-up without throwing anything. Clears every timer and the look; with [keep_rig] the arms stay up, for a swing or to come down. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/stop_charge(keep_rig = FALSE) + if(!keep_rig) + drop_rig() + charge_started = 0 + pressed_at = 0 + if(vent_timer) + deltimer(vent_timer) + vent_timer = null + if(release_timer) + deltimer(release_timer) + release_timer = null + var/mob/living/bearer = owner + if(!QDELETED(bearer)) + var/datum/status_effect/gorilla_windup/windup = bearer.has_status_effect(/datum/status_effect/gorilla_windup) + if(windup?.action == src) + qdel(windup) + +/// Held too long, or called off: a short cooldown, nothing thrown. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/vent() + vent_timer = null + if(!charging()) + return + var/mob/living/bearer = owner + stop_charge(keep_rig = TRUE) + lower_rig() + if(QDELETED(bearer)) + return + gorilla_spend_pistons(bearer, GORILLA_PISTONS_VENTED) + if(bearer.click_intercept == src) + releasing = TRUE + unset_click_ability(bearer, refund_cooldown = FALSE) + releasing = FALSE + +/// The wind-up's status effect went away under it (a stun, death): end quietly, no vent. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/windup_lost() + if(!charging()) + return + drop_rig() + charge_started = 0 + pressed_at = 0 + if(vent_timer) + deltimer(vent_timer) + vent_timer = null + if(release_timer) + deltimer(release_timer) + release_timer = null + if(owner?.click_intercept == src) + releasing = TRUE + unset_click_ability(owner, refund_cooldown = FALSE) + releasing = FALSE + +/** + * Lets it go at [target] (a mob, or a tile to swing into). Before the minimum wind-up the blow waits + * and lands when it is done. Returns TRUE if anything was thrown or queued. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/release(atom/target) + if(!charging() || release_timer) + return FALSE + var/held = world.time - charge_started + // Thrown a throw's length before the minimum, so the blow lands on it + if(held < GORILLA_HAYMAKER_MIN_CHARGE - GORILLA_HAYMAKER_THROW) + var/datum/weakref/target_ref = WEAKREF(target) + release_timer = addtimer(CALLBACK(src, PROC_REF(queued_release), target_ref), GORILLA_HAYMAKER_MIN_CHARGE - GORILLA_HAYMAKER_THROW - held, TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + throw_haymaker(target, max(held, GORILLA_HAYMAKER_MIN_CHARGE)) + return TRUE + +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/queued_release(datum/weakref/target_ref) + release_timer = null + if(!charging() || !IsAvailable()) + stop_charge() + return + // The charge it has when the blow lands + throw_haymaker(target_ref?.resolve(), world.time - charge_started + GORILLA_HAYMAKER_THROW) + +/** + * The swing itself, for a charge of [held] deciseconds. The fist is thrown now and the blow lands as it arrives + * (land()). Who it hits and where is settled here. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/throw_haymaker(atom/target, held) + var/mob/living/carbon/human/bearer = owner + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = organ + var/zone = aimed_zone + stop_charge(keep_rig = TRUE) + if(bearer?.click_intercept == src) + releasing = TRUE + unset_click_ability(bearer, refund_cooldown = FALSE) + releasing = FALSE + if(!ishuman(bearer) || !istype(arm) || arm.owner != bearer) + drop_rig() + return + gorilla_spend_pistons(bearer, GORILLA_PISTONS_COOLDOWN) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/both as anything in gorilla_heavy_arms(bearer)) + COOLDOWN_START(both, slam_cooldown, both.punch_cooldown_time) + bearer.changeNext_move(CLICK_CD_MELEE) + // Swinging from inside a camo field drops it, as any blow does + cyberware_camo_drop(bearer, "haymaker") + var/mob/living/victim = gorilla_haymaker_victim(bearer, target) + if(isnull(victim)) + var/turf/swing_at = isturf(target) ? target : get_step(bearer, bearer.dir) + if(swing_at) + // A turn takes the rig down (it is a picture of the old facing), and the swing is tg's lunge + bearer.face_atom(swing_at) + // The arm and the step in, or the lunge only: a fist that met nothing leaves no mark on the floor + if(!swing()) + bearer.do_attack_animation(swing_at) + else + drop_rig() + playsound(bearer, 'sound/items/weapons/punchmiss.ogg', 50, TRUE) + bearer.visible_message(span_danger("[bearer]'s fist hits nothing but air!"), span_userdanger("Your fist hits nothing but air!")) + bearer.adjust_staggered_up_to(1 SECONDS, 1 SECONDS) + return + var/charge = clamp((held - GORILLA_HAYMAKER_MIN_CHARGE) / (GORILLA_HAYMAKER_FULL_CHARGE - GORILLA_HAYMAKER_MIN_CHARGE), 0, 1) + zone ||= victim.get_random_valid_zone(bearer.zone_selected) + bearer.face_atom(victim) + // The lunge only, as with the piston punch, when the arm can't be drawn: the sparks are the blow's mark + if(!swing(victim, zone)) + bearer.do_attack_animation(victim) + if(land_timer) + deltimer(land_timer) + land_timer = addtimer(CALLBACK(src, PROC_REF(land), WEAKREF(victim), charge, zone), GORILLA_HAYMAKER_THROW, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// The thrown fist arrives on [zone] of the victim: the blow, as it was settled at the release. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/land(datum/weakref/victim_ref, charge, zone) + land_timer = null + var/mob/living/victim = victim_ref?.resolve() + var/mob/living/carbon/human/bearer = owner + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = organ + if(QDELETED(victim) || !ishuman(bearer) || !istype(arm) || arm.owner != bearer) + return + gorilla_haymaker_hit(bearer, victim, arm, charge, zone) + +// ========================================================================= +// THE ARM +// ========================================================================= + +/// The punching arm's side on the rig: the arm that carries this chip. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/punching_side() + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = organ + return (istype(arm) && arm.zone == BODY_ZONE_L_ARM) ? "l" : "r" + +/** + * The wind-up's poses for the way the rig faces: list(punching arm half drawn, punching arm drawn, guard arm half + * up, guard arm up, punching arm thrown at nobody, guard arm at the throw, degrees the punching arm carries on by, + * its piston then), each list(degrees up from hanging, pixels of piston) but the seventh. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/swing_poses() + var/static/list/side_on = list(list(-60, -1), list(-115, 0), list(45, -1), list(70, -1), list(92, 7), list(20, 0), 43, 2) + var/static/list/away = list(list(70, -3), list(115, -2), list(8, 0), list(12, 0), list(174, 6), list(5, 0), -14, 1) + var/static/list/toward = list(list(70, -3), list(120, -2), list(8, 0), list(12, 0), list(14, 6), list(5, 0), -22, 2) + if(rig.facing & (EAST|WEST)) + return side_on + return rig.facing == NORTH ? away : toward + +/** + * Draws the bearer on the rig and starts the wind-up: after a beat with the real body still on show, the punching + * arm draws back, half way by GORILLA_HAYMAKER_DRAW and all the way by GORILLA_HAYMAKER_DRAWN, and the other arm + * comes up as a guard if its hand is empty. Returns TRUE if the rig is up. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/raise_rig(mob/living/carbon/human/bearer) + drop_rig() + if(!ishuman(bearer) || bearer.body_position == LYING_DOWN || !isturf(bearer.loc) || !(bearer.dir in GLOB.cardinals)) + return FALSE + // Faded (a camo field, a cloak): puppets would stand there in plain sight. Taller or shorter than the rig's cuts: + // the picture is of a body of the usual height + if(bearer.alpha < 255 || bearer.invisibility || bearer.mob_height != HUMAN_HEIGHT_MEDIUM) + return FALSE + // An arm is cut from the picture at its own outline, so one that swings would take the thing in its hand apart and + // leave the rest of it behind. A punching hand that holds something gets no drawing, and a full guard hand stays down + var/side = punching_side() + var/guard = side == "l" ? "r" : "l" + if(bearer.get_item_for_held_index(side == "l" ? 1 : 2)) + return FALSE + guard_still = !!bearer.get_item_for_held_index(guard == "l" ? 1 : 2) + rig = new(bearer, bearer.dir) + if(!rig.rig_arms(GORILLA_HAYMAKER_RIG_HOLD)) + QDEL_NULL(rig) + return FALSE + // A step, a turn or a fall: the drawing comes down at once and the lean carries the rest of the wind-up + rig.watch_body(CALLBACK(src, PROC_REF(drop_rig)), turns = TRUE) + var/list/poses = swing_poses() + // Facing the viewer the arm is drawn back behind its own shoulder + var/back_layer = rig.facing == SOUTH ? bearer.layer - 0.01 : null + var/datum/chrome_swing_beat/hold = new(GORILLA_HAYMAKER_RIG_HOLD) + var/datum/chrome_swing_beat/half = new(GORILLA_HAYMAKER_DRAW - GORILLA_HAYMAKER_RIG_HOLD, CHROME_SWING_EASE_OUT) + half.arm(side, rig.pose_of(side, poses[1][1], poses[1][2]), back_layer) + var/datum/chrome_swing_beat/drawn = new(GORILLA_HAYMAKER_DRAWN - GORILLA_HAYMAKER_DRAW, CHROME_SWING_EASE_OUT) + drawn.arm(side, rig.pose_of(side, poses[2][1], poses[2][2])) + if(!guard_still) + half.arm(guard, rig.pose_of(guard, poses[3][1], poses[3][2])) + drawn.arm(guard, rig.pose_of(guard, poses[4][1], poses[4][2])) + play_rig(list(hold, half, drawn)) + return TRUE + +/// Plays [beats] on the rig from where its arms are, the body starting [shift_x], [shift_y] pixels from its rest. With [settle], every puppet eases onto the body's own offsets over the first frame. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/play_rig(list/beats, shift_x = 0, shift_y = 0, settle = FALSE) + var/list/arms_from = list() + var/list/arms = list() + for(var/arm_side in list("l", "r")) + arms_from[arm_side] = rig.arm_poses[arm_side] || list(0, 1) + arms[arm_side] = rig.puppet("arm_[arm_side]") + var/list/frames = chrome_swing_frames(beats, arms_from, shift_x, shift_y) + var/list/rest + if(settle) + rest = list() + var/list/offsets = list(rig.body.pixel_w, rig.body.pixel_x, rig.body.pixel_y, rig.body.pixel_z) + for(var/key in rig.puppets) + rest[rig.puppets[key]] = offsets + chrome_swing_play(rig, frames, list(rig.puppet("body")), arms, null, rest) + // Where the arms end up + for(var/datum/chrome_swing_beat/beat as anything in beats) + for(var/arm_side in beat.arms) + var/list/pose = beat.arms[arm_side] + rig.arm_poses[arm_side] = list(pose[1], pose[2]) + +/// How far the rig's puppets stand from where the hidden body is now: list(across, up). The lean and the tremble they still carry once the wind-up has let the body go. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/rig_leant() + var/obj/effect/abstract/chrome_puppet/puppet = rig.puppet("body") + var/mob/living/body = rig.body + if(isnull(puppet)) + return list(0, 0) + return list(puppet.pixel_x + puppet.pixel_w - body.pixel_x - body.pixel_w, puppet.pixel_y + puppet.pixel_z - body.pixel_y - body.pixel_z) + +/// The wind-up's lean or tremble moved the hidden body: its puppets go with it ([eased] as tg eases an offset, or at once). +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/rig_follow(eased = TRUE) + rig?.follow_offsets(eased) + +/** + * The swing on the rig, from where the wind-up left the arms: the fist thrown onto [zone] of [victim] (or through the + * empty tile ahead) with a step in behind it, the follow-through, and back to rest; then the rig comes down. Called + * once the wind-up has ended. Returns FALSE, with the rig down, if it can't be drawn: no rig, a bearer who has + * turned away from the way it faces, or a victim who isn't straight ahead. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/swing(mob/living/victim, zone) + var/mob/living/carbon/human/bearer = owner + if(isnull(rig) || rig.body != bearer || rig.facing != bearer.dir || !isturf(bearer.loc)) + drop_rig() + return FALSE + // The arm is thrown the way the rig faces. A body a corner away, or on the bearer's own tile, isn't there + if(victim && get_turf(victim) != get_step(bearer.loc, rig.facing)) + drop_rig() + return FALSE + var/list/leant = rig_leant() + var/side = punching_side() + var/guard = side == "l" ? "r" : "l" + var/list/poses = swing_poses() + var/list/way = chrome_swing_way(rig.facing) + // The throw: onto the part, the body stepping in as far as it takes + var/list/thrown = rig.pose_of(side, poses[5][1], poses[5][2]) + var/step = rig.facing & (EAST|WEST) ? GORILLA_HAYMAKER_STEP_MAX - 2 : GORILLA_HAYMAKER_STEP_MIN + var/list/point = victim && gorilla_fist_target(bearer, victim, zone) + if(point) + var/list/reach = chrome_swing_reach(rig, side, point[1], point[2], way, GORILLA_HAYMAKER_STEP_MIN, (rig.facing & (EAST|WEST)) ? GORILLA_HAYMAKER_STEP_MAX : GORILLA_SWING_STEP_UPRIGHT, 0, GORILLA_SWING_EASY_LENGTH, GORILLA_SWING_MAX_LENGTH, thrown[1]) + step = reach[1] + thrown = list(reach[2], reach[3]) + // The layers. Seen from the side the near arm is over everything and the far one under its own body; facing away + // the thrown arm passes behind its own head; facing the viewer it comes out from behind its own shoulder + var/over = bearer.layer + 0.01 + var/under = bearer.layer - 0.01 + var/throw_layer + switch(rig.facing) + if(NORTH) + throw_layer = under + if(SOUTH) + throw_layer = over + else + throw_layer = rig.behind_body(side) ? under : max(bearer.layer, victim?.layer) + 0.01 + var/datum/chrome_swing_beat/throw_beat = new(GORILLA_HAYMAKER_OUT, CHROME_SWING_LINEAR, way[1] * step, way[2] * step) + throw_beat.arm(side, thrown, throw_layer) + if(!guard_still) + throw_beat.arm(guard, rig.pose_of(guard, poses[6][1], poses[6][2])) + // The fist is there a moment before the blow is due, and stays as it lands + var/datum/chrome_swing_beat/dwell_beat = new(GORILLA_HAYMAKER_DWELL, CHROME_SWING_LINEAR, way[1] * step, way[2] * step) + // The follow-through carries the fist on the way it was going, and the body half way back + var/follow_step = round(step * 0.4) + var/datum/chrome_swing_beat/follow_beat = new(GORILLA_HAYMAKER_FOLLOW, CHROME_SWING_EASE_OUT, way[1] * follow_step, way[2] * follow_step) + follow_beat.arm(side, list(thrown[1] + poses[7] * rig.raise_sign(side), max(1, 1 + poses[8] / (GLOB.chrome_rig_arm_length["[rig.facing]"] || 10)))) + follow_beat.arm(guard, list(0, 1)) + var/datum/chrome_swing_beat/return_beat = new(GORILLA_HAYMAKER_RETURN, CHROME_SWING_EASE_IN_OUT) + return_beat.arm(side, list(0, 1)) + return_beat.arm(guard, list(0, 1)) + if(rig.facing == NORTH) + // Out from behind the head once it is clear of it + return_beat.layer_once_under = list() + return_beat.layer_once_under[side] = list(120, over) + play_rig(list(throw_beat, dwell_beat, follow_beat, return_beat), leant[1], leant[2], settle = TRUE) + if(rig_timer) + deltimer(rig_timer) + rig_timer = addtimer(CALLBACK(src, PROC_REF(drop_rig)), GORILLA_HAYMAKER_OUT + GORILLA_HAYMAKER_DWELL + GORILLA_HAYMAKER_FOLLOW + GORILLA_HAYMAKER_RETURN, TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + +/// A wind-up that ended with nothing thrown: the arms come down, then the rig does. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/lower_rig() + if(isnull(rig) || QDELETED(rig.body)) + drop_rig() + return + var/list/leant = rig_leant() + var/datum/chrome_swing_beat/lower = new(GORILLA_HAYMAKER_LOWER, CHROME_SWING_EASE_IN_OUT) + for(var/arm_side in list("l", "r")) + lower.arm(arm_side, list(0, 1), rig.body.layer + (rig.behind_body(arm_side) ? -0.01 : 0.01)) + play_rig(list(lower), leant[1], leant[2], settle = TRUE) + if(rig_timer) + deltimer(rig_timer) + rig_timer = addtimer(CALLBACK(src, PROC_REF(drop_rig)), GORILLA_HAYMAKER_LOWER, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Takes the rig down and shows the real body. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/drop_rig() + if(rig_timer) + deltimer(rig_timer) + rig_timer = null + QDEL_NULL(rig) + +/// Who a haymaker at [target] hits: the clicked body if it is next to the bearer, else whoever stands in that tile. +/proc/gorilla_haymaker_victim(mob/living/bearer, atom/target) + if(isliving(target) && target != bearer && bearer.Adjacent(target)) + return target + var/turf/swing_at = isturf(target) ? target : get_turf(target) + if(isnull(swing_at) || get_dist(bearer, swing_at) != 1) + swing_at = get_step(bearer, get_dir(bearer, target) || bearer.dir) + if(isnull(swing_at) || !bearer.Adjacent(swing_at)) + return null + var/mob/living/lying + for(var/mob/living/body in swing_at) + if(body == bearer || body.stat == DEAD) + continue + if(body.body_position != LYING_DOWN) + return body + lying ||= body + return lying + +/// A haymaker lands on [victim]: [charge] 0 to 1 sets the damage and the reach. On [zone], else a part rolled from the one the bearer aims at. Only the blow: the swing is the caller's. +/proc/gorilla_haymaker_hit(mob/living/carbon/human/bearer, mob/living/victim, obj/item/organ/cyberimp/cyberware/gorilla_arms/arm, charge, zone) + var/obj/item/bodypart/hand = bearer.get_bodypart(arm.zone) + var/damage = round(GORILLA_HAYMAKER_MIN_DAMAGE + (GORILLA_HAYMAKER_MAX_DAMAGE - GORILLA_HAYMAKER_MIN_DAMAGE) * charge) + var/reach = charge >= 1 ? GORILLA_HAYMAKER_MAX_RANGE : GORILLA_HAYMAKER_MIN_RANGE + var/direction = get_dir(bearer, victim) || bearer.dir + if(ishuman(victim)) + var/mob/living/carbon/human/human_victim = victim + if(human_victim.check_block(bearer, damage, "[bearer]'s haymaker")) + playsound(victim, 'sound/items/weapons/punchmiss.ogg', 40, TRUE) + log_combat(bearer, victim, "attempted a haymaker on", "gorilla arms") + return FALSE + var/list/plan = gorilla_plan_knockback(victim, direction, reach) + var/flies = plan[2] > 0 && victim.body_position != LYING_DOWN && !victim.buckled && !victim.anchored + if(flies) + victim.hv_hold_attack_message("[bearer] drives a fist into [victim] and sends [victim.p_them()] flying!", "[bearer] drives a fist into you and sends you flying!", "You drive a fist into [victim] and send [victim.p_them()] flying!", bearer, vision_range = DEFAULT_MESSAGE_RANGE) + else + victim.hv_hold_attack_message("[bearer] drives a fist into [victim]!", "[bearer] drives a fist into you!", "You drive a fist into [victim]!", bearer, vision_range = DEFAULT_MESSAGE_RANGE) + zone ||= victim.get_random_valid_zone(bearer.zone_selected) + var/armour = victim.run_armor_check(zone, MELEE, armour_penetration = hand?.unarmed_effectiveness) + var/list/wounds_before + if(iscarbon(victim)) + var/mob/living/carbon/carbon_victim = victim + wounds_before = carbon_victim.gorilla_wound_snapshot() + if(flies) + // The flight leaves its own trail and the wall its own marks; no #509 fan on top of them + gorilla_hold_spray(victim) + var/dealt = victim.apply_damage(damage, BRUTE, zone, armour, wound_bonus = GORILLA_PUNCH_WOUND_BONUS + 5, attack_direction = direction, attacking_item = hand) + var/wounded = FALSE + if(iscarbon(victim) && !QDELETED(victim)) + var/mob/living/carbon/carbon_victim = victim + if(dealt >= GORILLA_STRAIN_MIN_DAMAGE) + gorilla_add_strain(carbon_victim, check_zone(zone), bearer, arm.snap_strain, hand, direction) + wounded = gorilla_new_wound(carbon_victim, wounds_before) + victim.hv_flush_hit() + gorilla_register_aggression(victim, bearer) + gorilla_haymaker_impact_sound(victim) + arm.gorilla_punch_fx(bearer, victim, TRUE, zone) + cyberware_flash_fx(victim, 0.2 SECONDS) + shake_camera(victim, 3, 2) + if(flies && !QDELETED(victim)) + gorilla_launch(victim, bearer, direction, reach, GORILLA_HAYMAKER_SPEED, FALSE, TRUE, wounded) + log_combat(bearer, victim, "landed a haymaker on", "gorilla arms", "(charge [round(charge * 100)]%)") + return TRUE + +/// A haymaker from [bearer] at [target] with whichever arm is ready, wound up for [hold_time], onto [zone] if one is given. For an AI. Returns TRUE if it began. +/proc/gorilla_try_haymaker(mob/living/carbon/human/bearer, atom/target, hold_time = GORILLA_HAYMAKER_FULL_CHARGE, zone) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + for(var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker in arm.actions) + if(haymaker.owner == bearer && haymaker.wind_up_at(target, hold_time, zone)) + return TRUE + return FALSE + +/// Starts the pistons' shared cooldown on [bearer] and shows it on every haymaker chip. +/proc/gorilla_spend_pistons(mob/living/bearer, time) + gorilla_cooldown(bearer, COOLDOWN_GORILLA_PISTONS, time) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + for(var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker in arm.actions) + haymaker.StartCooldownSelf(time) + +// ========================================================================= +// THE WIND-UP +// ========================================================================= + +/// The bearer winding up a haymaker: slowed, empty-handed of grabs, leaning back, trembling at full charge. +/datum/status_effect/gorilla_windup + id = "gorilla_windup" + alert_type = null + duration = GORILLA_HAYMAKER_VENT_AFTER + 1 SECONDS + tick_interval = 0.2 SECONDS + status_type = STATUS_EFFECT_UNIQUE + /// The haymaker winding up + var/datum/action/cooldown/cyberware/gorilla_haymaker/action + /// Trembling at full charge + var/trembling = FALSE + /// Which way the tremble's next pixel goes + var/tremble_side = 1 + +/datum/status_effect/gorilla_windup/on_creation(mob/living/new_owner, datum/action/cooldown/cyberware/gorilla_haymaker/action) + src.action = action + return ..() + +/datum/status_effect/gorilla_windup/on_apply() + if(owner.stat != CONSCIOUS) + return FALSE + owner.add_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup) + if(owner.pulling) + owner.stop_pulling() + RegisterSignal(owner, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + RegisterSignal(owner, COMSIG_ATOM_POST_DIR_CHANGE, PROC_REF(on_turned)) + lean() + return TRUE + +/** + * Leans back from the way they face now, further as the charge builds, so the whole wind-up moves: away from the + * target seen from the side (a pixel at once, GORILLA_HAYMAKER_LEAN at full charge), half as far up the screen when + * they face the viewer and down it when they face away. A tracked offset tg eases to and removes cleanly. + */ +/datum/status_effect/gorilla_windup/proc/lean() + var/charged = action?.charging() ? clamp((world.time - action.charge_started) / GORILLA_HAYMAKER_FULL_CHARGE, 0, 1) : 0 + var/back_by = 1 + round(charged * (GORILLA_HAYMAKER_LEAN - 1)) + var/up_by = CEILING(back_by / 2, 1) + var/back = REVERSE_DIR(owner.dir) + var/dx = (back & EAST) ? back_by : ((back & WEST) ? -back_by : 0) + var/dz = (back & NORTH) ? up_by : ((back & SOUTH) ? -up_by : 0) + owner.add_offsets(id, x_add = dx, z_add = dz) + // Drawn on the rig, the puppets lean as the hidden body does + action?.rig_follow(TRUE) + +/// Turned mid wind-up: the lean turns with them. +/datum/status_effect/gorilla_windup/proc/on_turned(datum/source, old_dir, new_dir) + SIGNAL_HANDLER + if(old_dir != new_dir) + lean() + +/datum/status_effect/gorilla_windup/on_remove() + UnregisterSignal(owner, list(COMSIG_MOB_STATCHANGE, COMSIG_ATOM_POST_DIR_CHANGE)) + owner.remove_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup) + owner.remove_offsets("[id]_tremble", animate = FALSE) + owner.remove_offsets(id) + var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker = action + action = null + haymaker?.windup_lost() + +/datum/status_effect/gorilla_windup/Destroy() + action = null + return ..() + +/datum/status_effect/gorilla_windup/tick(seconds_between_ticks) + // Stunned, cuffed or knocked down mid wind-up: the swing is lost + if(HAS_TRAIT(owner, TRAIT_INCAPACITATED) || HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED) || owner.body_position == LYING_DOWN) + qdel(src) + return + if(!action?.charging()) + return + lean() + if(world.time - action.charge_started < GORILLA_HAYMAKER_FULL_CHARGE) + return + // Full: a tremble for as long as it is held, a pixel either way. A tracked offset, so it can't leave them + // off their tile, and never the pixel of nothing tg's random jitter can roll + trembling = TRUE + tremble_side = -tremble_side + owner.add_offsets("[id]_tremble", w_add = tremble_side, animate = FALSE) + action.rig_follow(FALSE) + +/datum/status_effect/gorilla_windup/proc/on_stat_change(datum/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != CONSCIOUS) + qdel(src) + +/datum/movespeed_modifier/gorilla_windup + multiplicative_slowdown = 1.5 diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm new file mode 100644 index 00000000000..af865681fbe --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm @@ -0,0 +1,173 @@ +/** + * # Gorilla Arms looks + * + * Pictures on top of moves that already happened; nothing here changes a number. + * The arms always look like their worn sprite: no tint, glow or steam over them (owner). + * - The deploy flex: as the pair syncs, after an EMP reboot, or on the Arm chrome key when the Gorilla + * has nothing to deploy. A squat, the knuckles flash in turn, and it settles. + * - Snapped bones: a compound fracture on an arm, a leg or the chest shows bone through the skin and + * blood, whatever broke it. Drawn like #509's stumps (bone plus greyscale blood tinted to the body's + * blood), on its own overlay list beside them. + */ + +/// Whether this arm's worn plate is on show (no suit over the arms). +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/plate_shows() + var/mob/living/carbon/human/bearer = owner + if(!ishuman(bearer)) + return FALSE + return !(bearer.wear_suit?.body_parts_covered & ARMS) + +// ========================================================================= +// THE DEPLOY FLEX +// ========================================================================= + +/// The flex, at most once per GORILLA_FLEX_COOLDOWN. Every step is relative and ends where it began. +/proc/gorilla_flex(mob/living/carbon/human/bearer) + if(!ishuman(bearer) || bearer.stat != CONSCIOUS || bearer.body_position == LYING_DOWN || !isturf(bearer.loc)) + return FALSE + var/list/arms = gorilla_heavy_arms(bearer) + if(!length(arms) || !TIMER_COOLDOWN_FINISHED(bearer, COOLDOWN_GORILLA_FLEX)) + return FALSE + gorilla_cooldown(bearer, COOLDOWN_GORILLA_FLEX, GORILLA_FLEX_COOLDOWN) + // The squat, about the feet, and back up + var/matrix/squat = matrix(1.05, 0, 0, 0, 0.93, -1) + var/matrix/rise = matrix(squat) + rise.Invert() + animate(bearer, transform = squat, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(transform = matrix(), time = 0.5 SECONDS, flags = ANIMATION_RELATIVE) + animate(transform = rise, time = 0.25 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT, flags = ANIMATION_RELATIVE) + var/delay = 0.15 SECONDS + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) + addtimer(CALLBACK(arm, TYPE_PROC_REF(/obj/item/organ/cyberimp/cyberware/gorilla_arms, flex_beat)), delay) + delay += 0.15 SECONDS + return TRUE + +/** + * Where each hand hangs on tg's human sprite, in pixels from the middle of the icon (x right, y up), by facing: + * arm zone -> list("[dir]" = list(x, y)). Measured on icons/mob/human/bodyparts_greyscale.dmi. Seen from the side + * the near hand hangs behind the middle and the far one shows in front of the belly. + */ +GLOBAL_LIST_INIT(gorilla_hand_points, list( + BODY_ZONE_L_ARM = list("[SOUTH]" = list(6, -4), "[NORTH]" = list(-7, -4), "[EAST]" = list(4, -4), "[WEST]" = list(2, -4)), + BODY_ZONE_R_ARM = list("[SOUTH]" = list(-7, -4), "[NORTH]" = list(6, -4), "[EAST]" = list(-2, -4), "[WEST]" = list(-4, -4)), +)) + +/// Where the hand of [bearer]'s arm on [arm_zone] is drawn for the way they face now: list(x, y) from the middle of the icon. +/proc/gorilla_hand_point(mob/living/bearer, arm_zone) + var/facing = bearer.dir + if(ISDIAGONALDIR(facing)) + facing = (facing & EAST) ? EAST : WEST + var/list/point = GLOB.gorilla_hand_points[arm_zone]?["[facing]"] + return point ? list(point[1], point[2]) : list(0, -4) + +/// One arm's part of the flex: the knuckles flashing. +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/flex_beat() + if(!owner) + return + var/obj/effect/abstract/cyberware_anim/spark = cyberware_anim_play(owner, GORILLA_GORE_ICON, "impact_brawler", GORILLA_ANIM_TOP_LAYER, 0.4 SECONDS) + if(spark) + spark.appearance_flags |= RESET_TRANSFORM + // On this arm's own hand, wherever the facing puts it + var/list/hand = gorilla_hand_point(owner, zone) + spark.pixel_w = hand[1] + spark.pixel_z = hand[2] + spark.transform = matrix().Scale(0.5) + +// The pair comes back from an EMP: once it is back in sync, the arms flex. +/datum/component/cyberware/finish_emp_reboot() + var/was_down = emp_down + . = ..() + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = parent + if(!was_down || emp_down || !istype(arm) || !arm.heavy_moves || !arm.owner) + return + if(gorilla_synced(arm.owner)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(gorilla_flex), arm.owner), 0.6 SECONDS) + +// The Arm chrome key with nothing to deploy but a synced pair of Gorilla Arms: they flex. +/datum/component/chrome_wheel/use_arms() + var/mob/living/carbon/bearer = parent + if((!arm_action || arm_action.owner != bearer) && gorilla_synced(bearer)) + gorilla_flex(bearer) + return TRUE + return ..() + +// ========================================================================= +// SNAPPED BONES +// ========================================================================= + +/mob/living/carbon + /// Snapped-bone images drawn on us now (hyperviolence_gorilla). + var/list/gorilla_snap_overlays + /// What gorilla_snap_overlays was built from. + var/gorilla_snap_key + +/mob/living/carbon/update_damage_overlays() + . = ..() + gorilla_update_snap_overlays() + +/mob/living/carbon/regenerate_icons() + gorilla_snap_key = null + return ..() + +/// The zones showing a snapped bone: an arm, a leg or the chest with a compound fracture (not the skull). +/mob/living/carbon/proc/gorilla_snapped_zones() + var/static/list/snap_zones = list(BODY_ZONE_CHEST, BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + . = list() + if(!ishuman(src)) + return + for(var/zone in snap_zones) + var/obj/item/bodypart/part = get_bodypart(zone) + if(isnull(part) || !part.hv_shows_gore()) + continue + for(var/datum/wound/blunt/bone/critical/fracture in part.wounds) + . += zone + break + +/// Rebuilds the snapped-bone overlays if what they show changed. +/mob/living/carbon/proc/gorilla_update_snap_overlays() + var/list/zones = gorilla_snapped_zones() + var/new_key + var/blood_color + if(length(zones)) + if(can_bleed()) + blood_color = get_bloodtype()?.get_damage_color(src) || BLOOD_COLOR_RED + var/height = HUMAN_HEIGHT_MEDIUM + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + height = as_human.mob_height + new_key = "[jointext(zones, ",")]|[islist(blood_color) ? jointext(blood_color, ",") : blood_color]|[height]" + if(new_key == gorilla_snap_key && (new_key || !gorilla_snap_overlays)) + return + gorilla_snap_key = new_key + if(gorilla_snap_overlays) + cut_overlay(gorilla_snap_overlays) + gorilla_snap_overlays = null + if(isnull(new_key)) + return + var/list/images = list() + for(var/zone in zones) + if(blood_color) + var/image/blood = image(GORILLA_GORE_ICON, "snap_[zone]_blood", -HV_STUMP_FRESH_LAYER) + blood.appearance_flags = RESET_COLOR + blood.color = blood_color + images += blood + images += image(GORILLA_GORE_ICON, "snap_[zone]_bone", -HV_STUMP_FRESH_LAYER) + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + if(as_human.mob_height != HUMAN_HEIGHT_MEDIUM) + for(var/image/snap_image as anything in images) + as_human.apply_height_filters(snap_image) + gorilla_snap_overlays = images + add_overlay(gorilla_snap_overlays) + +// A compound fracture comes and goes: the snapped bone with it. +/datum/wound/blunt/bone/critical/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) + . = ..() + if(. && iscarbon(victim)) + victim.gorilla_update_snap_overlays() + +/datum/wound/blunt/bone/critical/remove_wound(ignore_limb, replaced) + var/mob/living/carbon/was_on = victim + . = ..() + if(iscarbon(was_on) && !QDELETED(was_on)) + was_on.gorilla_update_snap_overlays() diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm new file mode 100644 index 00000000000..be2e7cfe0c4 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm @@ -0,0 +1,245 @@ +/** + * # Ground pound + * + * A chrome wheel chip the synced pair grants. The bearer crouches for a beat, then drives both fists into + * the floor: a ring and cracks, dust, a shake for anyone close. Everyone on the tiles around the bearer, + * bar the bearer's own crew, staggers, is dazed (the next shove knocks them down) and loses some wind; it + * does no brute. Loose things on the floor nearby hop a tile outward. They are moved, never thrown, so a + * ring of knives never becomes a ring of thrown knives, and they never hop into a hazard or through glass. + * + * Brawler myomers' synced pair gets a lighter one (the /brawler chip): a shorter stagger, less wind, no daze, + * nothing hops, a smaller ring and one crack, on a longer clock. Same crouch, so it reads the same. + */ + +/datum/action/cooldown/cyberware/gorilla_pound + name = "Ground pound" + desc = "Drive both fists into the floor." + button_icon = GORILLA_GORE_ICON + button_icon_state = "chip_pound" + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_HANDS_BLOCKED | AB_CHECK_IMMOBILE | AB_CHECK_LYING + cooldown_time = GORILLA_POUND_COOLDOWN + /// The crouch before the slam + var/crouch_timer + /// The bearer's arm rig while the pound is drawn (chrome_puppet_rig.dm) + var/datum/chrome_rig/rig + /// The longest stagger it leaves + var/stagger = GORILLA_POUND_STAGGER + /// Daze on everyone it hits; 0 for none + var/daze = GORILLA_POUND_DAZE + /// Stamina it takes from everyone it hits + var/stamina = GORILLA_POUND_STAMINA + /// Whether loose things nearby hop outward, and the full crash, ring and cracks + var/full_pound = TRUE + +/// Brawler myomers' pound: a shove-off, no daze, nothing hops. +/datum/action/cooldown/cyberware/gorilla_pound/brawler + cooldown_time = BRAWLER_POUND_COOLDOWN + stagger = BRAWLER_POUND_STAGGER + daze = 0 + stamina = BRAWLER_POUND_STAMINA + full_pound = FALSE + +/// Whether the pair this chip belongs to is synced. +/datum/action/cooldown/cyberware/gorilla_pound/proc/pair_synced() + return gorilla_synced(owner) + +/datum/action/cooldown/cyberware/gorilla_pound/brawler/pair_synced() + return brawler_synced(owner) + +/datum/action/cooldown/cyberware/gorilla_pound/Destroy() + if(crouch_timer) + deltimer(crouch_timer) + crouch_timer = null + drop_rig() + return ..() + +/// Draws the bearer crouching with both arms up. Returns TRUE if the rig is up. +/datum/action/cooldown/cyberware/gorilla_pound/proc/raise_rig(mob/living/carbon/human/bearer) + drop_rig() + rig = new(bearer, bearer.dir) + if(!rig.rig_arms()) + QDEL_NULL(rig) + return FALSE + // Moved or knocked down: the drawing comes down at once (a deleted body takes its rig with it). The rig does the + // listening: this chip already hears the bearer lie down and be deleted, and a second listener here would + // replace those and then remove them with the rig. + rig.watch_body(CALLBACK(src, PROC_REF(drop_rig))) + // Arms first: a crouch runs alongside, and a swing started after it would cut it short + for(var/side in list("l", "r")) + rig.swing_arm(side, 150, GORILLA_POUND_CROUCH, 0, SINE_EASING) + rig.crouch(0.88, 2, GORILLA_POUND_CROUCH, SINE_EASING | EASE_OUT) + return TRUE + +/// Takes the rig down and shows the real body. +/datum/action/cooldown/cyberware/gorilla_pound/proc/drop_rig() + QDEL_NULL(rig) + +/datum/action/cooldown/cyberware/gorilla_pound/IsAvailable(feedback = FALSE) + . = ..() + if(!.) + return + if(!pair_synced() || !gorilla_may_use_heavy_moves(owner)) + if(feedback) + owner.balloon_alert(owner, "arms out of sync!") + return FALSE + if(HAS_TRAIT(owner, TRAIT_PACIFISM)) + return FALSE + if(!TIMER_COOLDOWN_FINISHED(owner, COOLDOWN_GORILLA_POUND)) + return FALSE + var/turf/floor = get_turf(owner) + if(!isfloorturf(floor) || !floor.has_gravity()) + if(feedback) + owner.balloon_alert(owner, "nothing to hit!") + return FALSE + return TRUE + +/datum/action/cooldown/cyberware/gorilla_pound/Activate(atom/target) + var/mob/living/carbon/human/bearer = owner + if(!ishuman(bearer) || crouch_timer) + return FALSE + StartCooldown() + gorilla_cooldown(bearer, COOLDOWN_GORILLA_POUND, cooldown_time) + // The crouch: down, both fists up. Without a rig (something huge worn) the body only dips. + if(!raise_rig(bearer)) + animate(bearer, pixel_z = -2, time = GORILLA_POUND_CROUCH, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_z = 2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) + crouch_timer = addtimer(CALLBACK(src, PROC_REF(slam), get_turf(bearer)), GORILLA_POUND_CROUCH, TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + +/// A ground pound from [bearer], as the chip would. For an AI. Returns TRUE if it began. +/proc/gorilla_try_pound(mob/living/carbon/human/bearer) + if(!iscarbon(bearer)) + return FALSE + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in bearer.organs) + for(var/datum/action/cooldown/cyberware/gorilla_pound/pound in arm.actions) + if(pound.owner == bearer) + return !!pound.Trigger() + return FALSE + +/// The fists hit the floor, if the bearer is still crouched where they began. +/datum/action/cooldown/cyberware/gorilla_pound/proc/slam(turf/where) + crouch_timer = null + var/mob/living/carbon/human/bearer = owner + if(!ishuman(bearer) || bearer.loc != where || bearer.stat != CONSCIOUS || HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || HAS_TRAIT(bearer, TRAIT_HANDS_BLOCKED)) + drop_rig() + return + if(rig) + // Down hard, fists on the floor, then back up + for(var/side in list("l", "r")) + rig.swing_arm(side, 0, GORILLA_POUND_DRIVE, 3, NONE) + rig.crouch(0.72, 6, GORILLA_POUND_DRIVE, SINE_EASING | EASE_IN) + addtimer(CALLBACK(src, PROC_REF(rise)), GORILLA_POUND_DRIVE + 0.1 SECONDS, TIMER_DELETE_ME) + gorilla_ground_pound(bearer, where, stagger, daze, stamina, full_pound) + +/// The bearer stands back up after the pound. +/datum/action/cooldown/cyberware/gorilla_pound/proc/rise() + if(!rig) + return + for(var/side in list("l", "r")) + rig.swing_arm(side, 0, GORILLA_POUND_RISE, 0) + rig.crouch(1, 0, GORILLA_POUND_RISE, SINE_EASING | EASE_OUT) + addtimer(CALLBACK(src, PROC_REF(drop_rig)), GORILLA_POUND_RISE, TIMER_DELETE_ME) + +/** + * Both fists into the floor at [where]: the shock, the stagger and the hop. Returns the mobs it hit. + * [full] is the Gorilla's: the big crash and ring, the cracks round it, and loose things hopping. Without it + * (Brawler myomers) a smaller ring, one crack and nothing moved. + */ +/proc/gorilla_ground_pound(mob/living/carbon/human/bearer, turf/where, stagger = GORILLA_POUND_STAGGER, daze = GORILLA_POUND_DAZE, stamina = GORILLA_POUND_STAMINA, full = TRUE) + . = list() + cyberware_camo_drop(bearer, "ground pound") + // No attack ring from tg: it is drawn over everything on the tile, the bearer's head included, and the pound has + // its own ring, cracks and dust on the floor under him + bearer.do_attack_animation(where) + bearer.visible_message(span_danger("[bearer] drives both fists into the floor!"), span_danger("You drive both fists into the floor!"), vision_distance = DEFAULT_MESSAGE_RANGE) + playsound(where, 'sound/effects/meteorimpact.ogg', full ? 80 : 50, TRUE, full ? 8 : 2) + gorilla_pound_impact_fx(where, full ? 1.5 : 1, full) + cyberware_grey_dust(where) + for(var/mob/living/shaken in range(full ? 3 : 2, where)) + if(shaken.client && shaken != bearer) + shake_camera(shaken, 3, get_dist(shaken, where) <= 1 ? 2 : 1) + shake_camera(bearer, 2, 1) + for(var/mob/living/hit in orange(1, where)) + // Not through glass or a door edge, and never the bearer's own crew + if(hit == bearer || hit.stat == DEAD || !hit.Adjacent(bearer) || cyberware_is_ally(bearer, hit)) + continue + hit.adjust_staggered_up_to(STAGGERED_SLOWDOWN_LENGTH, stagger) + if(daze > 0) + hit.apply_status_effect(/datum/status_effect/dazed, daze) + hit.apply_damage(stamina, STAMINA) + gorilla_register_aggression(hit, bearer) + log_combat(bearer, hit, "ground-pounded", "gorilla arms") + . += hit + if(full) + gorilla_pound_scatter(where) + +/// The ring and the cracks under a ground pound: the Piledriver's cross, gone sooner. Without [cross], the centre crack only. +/proc/gorilla_pound_impact_fx(turf/where, reach, cross = TRUE) + if(isnull(where)) + return + // On the floor: a ring on the game plane is drawn over anyone standing further up the screen than the bearer + new /obj/effect/temp_visual/circle_wave/cyberware_slam/floor(where, reach) + new /obj/effect/temp_visual/cyberware_impact_cracks/gorilla_pound(where, "damaged1", cross ? 255 : 180) + if(!cross) + return + for(var/direction in GLOB.cardinals) + var/turf/next = get_step(where, direction) + if(next) + new /obj/effect/temp_visual/cyberware_impact_cracks/gorilla_pound(next, "damaged5", 140) + +/obj/effect/temp_visual/cyberware_impact_cracks/gorilla_pound + duration = 4 SECONDS + +/// Loose things on the floor near [where] hop a tile outward, never into a hazard, glass or a door. +/proc/gorilla_pound_scatter(turf/where) + var/moved = 0 + for(var/obj/item/loose in range(GORILLA_POUND_ITEM_REACH, where)) + if(moved >= 30) + return + if(loose.anchored || !isturf(loose.loc) || (loose.item_flags & ABSTRACT) || loose.invisibility) + continue + var/turf/here = loose.loc + if(locate(/obj/structure/table) in here) + continue + var/out = get_dir(where, here) || pick(GLOB.cardinals) + var/turf/next = get_step(here, out) + if(isnull(next) || isclosedturf(next) || !isfloorturf(next) || gorilla_throw_stops_short(here, next, out)) + continue + if(!loose.Move(next, out)) + continue + moved++ + animate(loose, pixel_z = 5, time = 0.08 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_z = -5, time = 0.1 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_RELATIVE) + +// The set bonus grants the chip to the right arm of its pair, or whichever arm there is. + +/datum/status_effect/organ_set_bonus/cyberware_gorilla + /// The ground pound chip, while the pair is synced + var/datum/action/cooldown/cyberware/gorilla_pound/pound + +/datum/status_effect/organ_set_bonus/cyberware_gorilla/proc/grant_pound() + if(pound && !QDELETED(pound)) + return + var/list/arms = gorilla_pair_arms(owner, type) + if(!length(arms)) + return + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/carrier = arms[1] + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) + if(arm.zone == BODY_ZONE_R_ARM) + carrier = arm + pound = carrier.add_item_action(pound_type) + if(carrier.owner) + pound.Grant(carrier.owner) + +/datum/status_effect/organ_set_bonus/cyberware_gorilla/proc/take_pound() + if(!pound) + return + var/datum/action/cooldown/cyberware/gorilla_pound/old = pound + pound = null + if(QDELETED(old)) + return + var/obj/item/organ/carrier = old.organ + if(istype(carrier)) + carrier.remove_item_action(old) + else + qdel(old) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm new file mode 100644 index 00000000000..024f6f0ce23 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm @@ -0,0 +1,173 @@ +/** + * # Gorilla door pry + * + * The synced pair's bare-handed pry (Gorilla Arms, and slower from Brawler myomers) is tg's door_pryer (instant on an unpowered door, a slow pry on a + * powered one, refused through bolts, welds and seals), with two changes: + * - Trader outpost doors never give: the sanctuary airlocks and any door on an outpost's protected + * ground. Trying counts as aggression against the outpost. A combat-mode punch at one is refused the + * same way (ware_military_arms.dm on_punch()). + * - A powered pry strains: every half second the bearer trembles, the door shakes, the seam opens a + * little wider and throws sparks. The sparks are for show (gorilla_pry_sparks()): they heat nothing and set + * nothing alight. + */ +/datum/element/door_pryer/gorilla + +/datum/element/door_pryer/gorilla/on_attack(mob/living/attacker, atom/target, proximity_flag) + SIGNAL_HANDLER + if(proximity_flag && istype(target, /obj/machinery/door/airlock) && !attacker.combat_mode) + var/obj/machinery/door/airlock/airlock_target = target + if(airlock_target.density && gorilla_pry_refused(airlock_target)) + airlock_target.balloon_alert(attacker, "it won't budge!") + gorilla_pry_aggression(airlock_target, attacker) + attacker.log_message("tried to pry open [airlock_target] at a trader outpost, located at [loc_name(airlock_target)].", LOG_GAME) + return COMPONENT_CANCEL_ATTACK_CHAIN + // Browned out or EMP'd, the arms are only arms: an ordinary click on the door + if(airlock_target.density && iscarbon(attacker)) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_synced_pair(attacker)) + if(!arm.gorilla_running()) + return NONE + return ..() + +/// Whether a trader outpost keeps this door shut against Gorilla Arms or Brawler myomers, pried or punched. +/proc/gorilla_pry_refused(obj/machinery/door/door) + return istype(door, /obj/machinery/door/airlock/outpost) || is_trader_outpost_protected(door) + +/// Trying to force a trader outpost's door counts against the one who tried. +/proc/gorilla_pry_aggression(obj/machinery/door/door, mob/living/offender) + var/obj/machinery/door/airlock/outpost/outpost_door = door + if(istype(outpost_door) && outpost_door.outpost) + outpost_door.outpost.register_aggression(offender) + return + var/obj/structure/overmap/trader_outpost/guarding = get_trader_outpost_for_turf(get_turf(door)) + guarding?.register_aggression(offender) + +/datum/element/door_pryer/gorilla/open_door(mob/living/attacker, obj/machinery/door/airlock/airlock_target) + // Unpowered or opened for them: nothing to strain against. + if(!airlock_target.hasPower() || airlock_target.allowed(attacker)) + return ..() + var/datum/gorilla_pry_strain/strain = new(attacker, airlock_target, pry_time) + . = ..() + qdel(strain) + +/// The strain of a powered pry, beat by beat, while the do_after runs. +/datum/gorilla_pry_strain + var/mob/living/carbon/bearer + var/obj/machinery/door/airlock/door + /// The seam opening over the door + var/obj/effect/abstract/gorilla_pry_seam/seam + /// Beats so far + var/beats = 0 + var/beat_timer + /// world.time the pry began + var/started + /// How long the pry takes + var/pry_time + +/datum/gorilla_pry_strain/New(mob/living/carbon/bearer, obj/machinery/door/airlock/door, pry_time = GORILLA_PRY_TIME) + . = ..() + src.bearer = bearer + src.door = door + src.pry_time = pry_time + started = world.time + seam = new(null) + seam.stepped = gorilla_door_seam_stepped(door) + seam.set_width(0) + door.vis_contents += seam + RegisterSignal(door, COMSIG_QDELETING, PROC_REF(on_deleted)) + RegisterSignal(door, COMSIG_AIRLOCK_OPEN, PROC_REF(on_opening)) + RegisterSignal(bearer, COMSIG_QDELETING, PROC_REF(on_deleted)) + // The first beat waits a tick. Every beat asks whether the pry is still going, and the pry's do_after only + // starts once this strain is made: asked here the answer was always no, and no beat ever ran. + beat_timer = addtimer(CALLBACK(src, PROC_REF(beat)), 1, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/gorilla_pry_strain/Destroy() + if(beat_timer) + deltimer(beat_timer) + beat_timer = null + if(door) + UnregisterSignal(door, list(COMSIG_QDELETING, COMSIG_AIRLOCK_OPEN)) + door.vis_contents -= seam + if(bearer) + UnregisterSignal(bearer, COMSIG_QDELETING) + QDEL_NULL(seam) + bearer = null + door = null + return ..() + +/datum/gorilla_pry_strain/proc/on_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// The door gives: from here the gap is the door's own, sliding open. The seam goes before the leaves move. +/datum/gorilla_pry_strain/proc/on_opening(datum/source) + SIGNAL_HANDLER + if(door && seam) + door.vis_contents -= seam + +/datum/gorilla_pry_strain/proc/beat() + beat_timer = null + if(QDELETED(bearer) || QDELETED(door) || !DOING_INTERACTION_WITH_TARGET(bearer, door)) + return + beats++ + // A tremble that is over before the next beat: a pixel each way, twice. tg's jitter runs on for more than two + // seconds, so the bearer would still be shaking long after the door had opened, and it can roll no movement at all. + var/side = pick(-1, 1) + animate(bearer, pixel_w = side, time = 0.1 SECONDS, loop = 2, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + animate(pixel_w = -side, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) + door.Shake(1, 0, 0.3 SECONDS) + seam.set_width((world.time - started) / max(pry_time, 1)) + gorilla_pry_sparks(2, TRUE, door) + beat_timer = addtimer(CALLBACK(src, PROC_REF(beat)), 0.5 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/** + * Sparks off a door being forced, thrown and spread as do_sparks() throws tg's. They look and sound like tg's sparks + * and do nothing else: tg's are hot (they light fuel gas, fuel pools and plasma floors, and heat what they touch), + * and a pry is not meant to set a room alight. + */ +/proc/gorilla_pry_sparks(number, cardinal_only, datum/source) + var/datum/effect_system/gorilla_pry_sparks/sparks = new + sparks.set_up(number, cardinal_only, source) + sparks.autocleanup = TRUE + sparks.start() + +/datum/effect_system/gorilla_pry_sparks + effect_type = /obj/effect/particle_effect/gorilla_pry_sparks + +/// A cold spark. Not a subtype of tg's sparks on purpose: a fuel pool lights when anything of that type enters it. +/obj/effect/particle_effect/gorilla_pry_sparks + name = "sparks" + icon_state = "sparks" + // The glow of tg's sparks. It is this effect's own and goes with it. + light_system = OVERLAY_LIGHT + light_range = 1.5 + light_power = 0.8 + light_color = LIGHT_COLOR_FIRE + +/obj/effect/particle_effect/gorilla_pry_sparks/Initialize(mapload) + . = ..() + flick(icon_state, src) + playsound(src, SFX_SPARKS, 100, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + QDEL_IN(src, 2 SECONDS) + +/// The crack forced open down an airlock's middle seam: a dark gap with lit edges. +/obj/effect/abstract/gorilla_pry_seam + name = "seam" + icon = GORILLA_GORE_ICON + icon_state = "pry_seam_1" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_PLANE | VIS_INHERIT_ID + layer = ABOVE_OBJ_LAYER + /// The door's leaves meet on the stepped seam of tg's station airlocks, not a straight one + var/stepped = FALSE + +/// Widens the seam as the pry goes on: three steps, a third of the pry each. [progress] runs 0 to 1. +/obj/effect/abstract/gorilla_pry_seam/proc/set_width(progress) + icon_state = "pry_seam_[stepped ? "step_" : ""][clamp(1 + round(progress * 3), 1, 3)]" + +/** + * Whether [door]'s two leaves meet on the stepped seam tg's station airlock sprites share (public, command, + * maintenance and the rest of icons/obj/doors/airlocks/station), so the gap is drawn along it. Any other door + * gets the straight gap. + */ +/proc/gorilla_door_seam_stepped(obj/machinery/door/airlock/door) + return !!findtext("[door.icon]", "/airlocks/station/") diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm new file mode 100644 index 00000000000..4d7c1673a2e --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm @@ -0,0 +1,501 @@ +/** + * # The Gorilla punch, through #509 + * + * The piston punch of Gorilla Arms and Brawler myomers (ware_military_arms.dm on_punch()) lands here: + * - The arm throws it: the fist draws back and drives out onto the part it hits (gorilla_thrust.dm), and the blow + * lands when the fist gets there, a tenth of a second after the click. What it hits was settled at the click. + * - The blow goes through #509 like any weapon: the punching hand is the attacking item and the blow + * has a direction, so aimed crits roll, blood sprays the way it went, and every wound line joins + * the attack line in one message. + * - Bone breakers: piston hits on one limb strain it. The third Gorilla hit inside the window (the + * fourth from Brawler myomers) snaps an arm or a leg, or caves in the ribs. Heavy armour on the limb, + * robotic limbs and the Atlas frame stop the snap, and the head never snaps. + * - The knockback rides the safe path (gorilla_throw.dm). A grabbed body is hurled, once per grab-move + * cooldown shared with the slam, so a held body can't be thrown into a wall over and over. + * - Grab and slam: with someone in an aggressive grab, a click on the floor under them, an open floor + * beside you or a table lifts them and brings them down (gorilla_slam.dm). Brawler myomers slam lighter, + * with a lower lift and a body-fall thud. A table click while grabbing slams instead of caving the table in. + * - Every Gorilla blow on a person counts as PvP aggression at a trader outpost: the punch cancels the + * attack chain before the outpost's own unarmed-attack listener ever hears it. + */ + +/obj/item/organ/cyberimp/cyberware/gorilla_arms + /// The hurl, the haymaker, the flex, and the Gorilla pair's pound and three finishers. The Brawler has none + /// of these; its pair has its own pound and the limb rip (cyberware_brawler, ware_line_models_a.dm). + var/heavy_moves = TRUE + /// Whether a grabbed body can be slammed onto the floor or a table. + var/grab_slam = TRUE + /// Brute a floor slam deals. + var/slam_floor_damage = GORILLA_SLAM_FLOOR_DAMAGE + /// Stamina a floor slam takes. + var/slam_floor_stamina = GORILLA_SLAM_FLOOR_STAMINA + /// Brute a table slam deals. + var/slam_table_damage = GORILLA_SLAM_TABLE_DAMAGE + /// Stamina a table slam takes. + var/slam_table_stamina = GORILLA_SLAM_TABLE_STAMINA + /// How long a slam floors them. + var/slam_knockdown = GORILLA_SLAM_KNOCKDOWN + /// Damage a table slam does to the table. + var/slam_table_break = GORILLA_SLAM_TABLE_BREAK + /// The bearer-wide wait after a slam or a hurl before the next one. + var/grab_move_cooldown = GORILLA_GRAB_MOVE_COOLDOWN + /// Whether a slammed body is pressed overhead first. Off: brought to the shoulder. + var/slam_overhead = TRUE + /// The Gorilla's floor slam: the meteor crash, the big ring and the cracks. Off: a body-fall thud, a small ring. + var/slam_full_fx = TRUE + /// What drives the fist, in the attack line. + var/fist_word = "piston-driven" + /// Strain on one limb that snaps it. + var/snap_strain = 3 + /// Wound bonus on the punch. + var/punch_wound_bonus = GORILLA_PUNCH_WOUND_BONUS + /// The sparks a landed punch throws (gorilla_gore.dmi). + var/impact_state = "impact_gorilla" + +/// Starts (or restarts) a bearer-wide cooldown, replacing any one already running under [index]. +/proc/gorilla_cooldown(mob/living/bearer, index, time) + var/old_timer = TIMER_COOLDOWN_RUNNING(bearer, index) + if(old_timer) + deltimer(old_timer) + TIMER_COOLDOWN_END(bearer, index) + S_TIMER_COOLDOWN_START(bearer, index, time) + +/// Counts a Gorilla blow on [victim] as PvP aggression at a trader outpost. +/proc/gorilla_register_aggression(mob/living/victim, mob/living/offender) + if(!isliving(victim) || !isliving(offender) || victim == offender) + return + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(victim, offender) + +/// The bearer's installed Gorilla Arms of the real (heavy) kind, both sides. +/proc/gorilla_heavy_arms(mob/living/carbon/bearer) + RETURN_TYPE(/list) + . = list() + if(!iscarbon(bearer)) + return + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in bearer.organs) + if(arm.heavy_moves && arm.owner == bearer) + . += arm + +/// Whether [bearer] has both real Gorilla arms synced (the set bonus): the finishers and the ground pound. +/proc/gorilla_synced(mob/living/carbon/bearer) + var/datum/status_effect/organ_set_bonus/cyberware_gorilla/bonus = bearer?.has_status_effect(/datum/status_effect/organ_set_bonus/cyberware_gorilla) + return !!bonus?.bonus_active + +/// Whether [bearer] has both Brawler myomers synced: the Brawler pound, the forced door and the limb rip. +/proc/brawler_synced(mob/living/carbon/bearer) + var/datum/status_effect/organ_set_bonus/cyberware_gorilla/bonus = bearer?.has_status_effect(/datum/status_effect/organ_set_bonus/cyberware_brawler) + return !!bonus?.bonus_active + +/// [bearer]'s installed arms that sync into [bonus_type] (an arm's pair_bonus). +/proc/gorilla_pair_arms(mob/living/carbon/bearer, bonus_type) + RETURN_TYPE(/list) + . = list() + if(!iscarbon(bearer) || !bonus_type) + return + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in bearer.organs) + if(arm.pair_bonus == bonus_type && arm.owner == bearer) + . += arm + +/// The arms of [bearer]'s synced pair, Gorilla or Brawler; empty when neither is synced. +/proc/gorilla_synced_pair(mob/living/carbon/bearer) + RETURN_TYPE(/list) + if(gorilla_synced(bearer)) + return gorilla_pair_arms(bearer, /datum/status_effect/organ_set_bonus/cyberware_gorilla) + if(brawler_synced(bearer)) + return gorilla_pair_arms(bearer, /datum/status_effect/organ_set_bonus/cyberware_brawler) + return list() + +/** + * Who may use the Gorilla's heavy moves: the slam, the haymaker, the ground pound and the finishers. + * Players, and NPCs of boss rank, as with gun executions (hv_can_execute()). This is the one gate for all + * of them, so letting other NPCs use the moves later is a change here and nowhere else. + */ +/proc/gorilla_may_use_heavy_moves(mob/living/bearer) + return isliving(bearer) && bearer.hv_can_execute() + +/// Whether this arm's chrome is running (not EMP'd, browned out or failing). +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/gorilla_running() + if(organ_flags & ORGAN_FAILING) + return FALSE + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + return !chrome || chrome.is_running() + +// ========================================================================= +// THE PISTON PUNCH +// ========================================================================= + +/** + * A piston punch on [living_target] that can no longer miss: the arm throws it (gorilla_thrust()), and it lands when + * the fist gets there (piston_land()). It lands on [aimed_zone] if one is given, else on a part rolled from the one + * [source] aims at, settled now so the fist goes to it. Returns TRUE. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/piston_hit(mob/living/carbon/human/source, mob/living/living_target, obj/item/bodypart/active_hand, aimed_zone) + var/target_zone = aimed_zone || living_target.get_random_valid_zone(source.zone_selected) + // The arm and the lunge. No attack ring from tg: it is drawn over the middle of the target and rides it wherever + // it is knocked; the knuckle sparks (gorilla_punch_fx()) are this blow's mark, on the part it landed on + var/lands_in = gorilla_thrust(source, living_target, zone, target_zone) + addtimer(CALLBACK(src, PROC_REF(piston_land), source, living_target, active_hand, target_zone), lands_in) + return TRUE + +/** + * The fist of a piston punch arrives on [target_zone] of [living_target]: the damage through #509 as one line, strain, + * the knockback or the hurl, aggression, and the look. Returns the brute it dealt. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/piston_land(mob/living/carbon/human/source, mob/living/living_target, obj/item/bodypart/active_hand, target_zone) + if(QDELETED(source) || QDELETED(living_target) || QDELETED(active_hand)) + return 0 + var/grabbed = living_target.pulledby == source && source.grab_state >= GRAB_AGGRESSIVE + var/standing = source.body_position != LYING_DOWN + var/hurl = heavy_moves && hurl_range > 0 && grabbed && standing && TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_GRAB_MOVE) + var/direction = get_dir(source, living_target) || source.dir + gorilla_punch_impact_sound(living_target) + var/armor_block = living_target.run_armor_check(target_zone, MELEE, armour_penetration = active_hand.unarmed_effectiveness) + if(hurl) + living_target.hv_hold_attack_message("[source] hurls [living_target] across the room!", "[source] hurls you across the room!", "You hurl [living_target] across the room!", source) + else + living_target.hv_hold_attack_message("[source] slams [living_target] with a [fist_word] fist!", "[source] slams you with a [fist_word] fist!", "You slam [living_target]!", source) + var/list/wounds_before + if(iscarbon(living_target)) + var/mob/living/carbon/carbon_before = living_target + wounds_before = carbon_before.gorilla_wound_snapshot() + var/dealt = living_target.apply_damage(punch_damage, BRUTE, target_zone, armor_block, wound_bonus = punch_wound_bonus, attack_direction = direction, attacking_item = active_hand) + var/wounded = FALSE + if(iscarbon(living_target) && !QDELETED(living_target)) + var/mob/living/carbon/carbon_target = living_target + if(dealt >= GORILLA_STRAIN_MIN_DAMAGE) + gorilla_add_strain(carbon_target, check_zone(target_zone), source, snap_strain, active_hand, direction) + wounded = gorilla_new_wound(carbon_target, wounds_before) + living_target.hv_flush_hit() + gorilla_register_aggression(living_target, source) + // Before the launch: a throw's first step is taken at once, and the sparks belong where the blow fell + gorilla_punch_fx(source, living_target, wounded, target_zone) + if(standing && !QDELETED(living_target)) + if(hurl) + gorilla_cooldown(source, COOLDOWN_GORILLA_GRAB_MOVE, grab_move_cooldown) + gorilla_launch(living_target, source, direction, hurl_range, 2, FALSE, FALSE, wounded) + else if(!grabbed) + gorilla_launch(living_target, source, direction, 1, rand(1, 4), TRUE, FALSE, wounded) + log_combat(source, living_target, hurl ? "piston-hurled" : "piston-punched", "gorilla arms") + return dealt + +/// Whether [victim] picked up a wound (or a worse one) since [before] (gorilla_wound_snapshot()). +/proc/gorilla_new_wound(mob/living/carbon/victim, list/before) + for(var/datum/wound/wound as anything in victim.all_wounds) + if(!(wound in before) || before[wound] < wound.severity) + return TRUE + return FALSE + +/** + * What a piston punch looks like: knuckle sparks on the part of the target it landed on ([zone]), and on a heavy + * hit a small ring. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/gorilla_punch_fx(mob/living/carbon/human/source, mob/living/target, heavy = FALSE, zone) + gorilla_punch_sparks(source, target, impact_state, zone, gorilla_far_fist(source, src.zone)) + if(heavy) + gorilla_floor_ring(target, 0.4) + +/** + * A shockwave ring on the floor under [target], out to [reach]. On the floor plane, so it passes under whoever stands + * in it or beyond it up the screen. Laid where the body is drawn: one hit mid-stride is short of its turf, as the + * sparks are (gorilla_glide_lag()). + */ +/proc/gorilla_floor_ring(atom/movable/target, reach) + var/turf/spot = get_turf(target) + if(isnull(spot)) + return null + var/obj/effect/temp_visual/circle_wave/cyberware_slam/floor/ring = new(spot, reach) + var/list/lag = gorilla_glide_lag(target) + ring.pixel_w += round(lag[1]) + ring.pixel_z += round(lag[2]) + return ring + +/// The sound of a landed piston punch on [target]. The one place to swap it. +/proc/gorilla_punch_impact_sound(atom/target) + playsound(get_turf(target), GORILLA_PUNCH_SOUND, 55, TRUE, -1) + +/// The landed piston punch: one of three at random. +/datum/sound_effect/gorilla_piston_punch + key = GORILLA_PUNCH_SOUND + file_paths = list( + 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg', + 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch2.ogg', + 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg', + ) + +/// The sound of a landed haymaker on [target]. The one place to swap it. +/proc/gorilla_haymaker_impact_sound(atom/target) + playsound(target, GORILLA_HAYMAKER_SOUND, 70, TRUE) + +/// The sound of a back breaking across a Gorilla's knee on [target]. The one place to swap it. +/proc/gorilla_backbreaker_sound(atom/target) + playsound(target, GORILLA_BACKBREAKER_SOUND, 70, TRUE) + +/** + * Knuckle sparks over [target]: a short one-shot drawn upright and turned to come from the + * attacker's side, like cyberware_play_impact() but from this module's sheet. + */ +/proc/gorilla_play_impact(atom/movable/attacker, atom/movable/target, impact_state) + if(!impact_state || !ismovable(target)) + return null + var/obj/effect/abstract/cyberware_anim/impact = cyberware_anim_play(target, GORILLA_GORE_ICON, impact_state, GORILLA_ANIM_TOP_LAYER, 0.4 SECONDS, glow = impact_state == "impact_gorilla") + if(!impact) + return null + impact.appearance_flags |= RESET_TRANSFORM + var/direction = get_dir(attacker, target) + if((direction & WEST) || (!(direction & EAST) && prob(50))) + impact.transform = matrix(-1, 0, 0, 0, 1, 0) + return impact + +/** + * Where each body zone sits on tg's human sprite, in pixels from the middle of the icon (x right, y up), by the way + * the body faces: zone -> list("[dir]" = list(x, y)). Measured on icons/mob/human/bodyparts_greyscale.dmi. Left and + * right swap sides on screen as the body turns, and the far arm of a body seen from the side is behind its chest. + */ +GLOBAL_LIST_INIT(gorilla_zone_points, list( + BODY_ZONE_HEAD = list("[SOUTH]" = list(0, 9), "[NORTH]" = list(0, 9), "[EAST]" = list(1, 9), "[WEST]" = list(-1, 9)), + BODY_ZONE_CHEST = list("[SOUTH]" = list(0, 2), "[NORTH]" = list(0, 2), "[EAST]" = list(0, 2), "[WEST]" = list(0, 2)), + BODY_ZONE_L_ARM = list("[SOUTH]" = list(6, 0), "[NORTH]" = list(-7, 0), "[EAST]" = list(0, 0), "[WEST]" = list(3, 0)), + BODY_ZONE_R_ARM = list("[SOUTH]" = list(-7, 0), "[NORTH]" = list(6, 0), "[EAST]" = list(-3, 0), "[WEST]" = list(0, 0)), + BODY_ZONE_L_LEG = list("[SOUTH]" = list(3, -10), "[NORTH]" = list(-4, -10), "[EAST]" = list(1, -10), "[WEST]" = list(0, -10)), + BODY_ZONE_R_LEG = list("[SOUTH]" = list(-4, -10), "[NORTH]" = list(3, -10), "[EAST]" = list(0, -10), "[WEST]" = list(-1, -10)), +)) + +/** + * Where [zone] of [target] is drawn right now, in pixels from the middle of its icon: list(x, y). The part's place for + * the way the body faces, turned with the body when it lies. The middle of the icon for anything that isn't a human. + */ +/proc/gorilla_zone_point(atom/movable/target, zone) + var/mob/living/carbon/human/body = target + if(!ishuman(body) || !zone) + return list(0, 0) + var/facing = body.dir + if(ISDIAGONALDIR(facing)) + facing = (facing & EAST) ? EAST : WEST + var/list/point = GLOB.gorilla_zone_points[check_zone(zone)]?["[facing]"] + if(!point) + return list(0, 0) + return chrome_rig_apply(body.transform, point[1], point[2]) + +/** + * How far short of its tile [target] is still drawn while its last step glides: list(x, y), pixels from the middle + * of its turf. A client eases each step over the time the step takes, so a body hit mid-stride is drawn behind the + * turf it already stands on, and a mark left on the turf would miss it. Worked out from when its next step is due: + * a player's move delay, or an AI's move loop. Nothing when neither says (a body standing still, or dragged). + */ +/proc/gorilla_glide_lag(atom/movable/target) + . = list(0, 0) + if(!ismovable(target) || !target.last_move || !isturf(target.loc)) + return + var/due_in = 0 + var/mob/as_mob = target + if(ismob(as_mob) && as_mob.client) + due_in = as_mob.client.move_delay - world.time + else if(target.move_packet?.running_loop) + due_in = target.move_packet.running_loop.timer - world.time + if(due_in <= 0) + return + var/short_by = target.glide_size * due_in / world.tick_lag + // Longer than a step's glide: something else is holding its next move, and it has long since arrived + if(short_by > ICON_SIZE_ALL) + return + var/came_from = REVERSE_DIR(target.last_move) + .[1] = (came_from & EAST) ? short_by : ((came_from & WEST) ? -short_by : 0) + .[2] = (came_from & NORTH) ? short_by : ((came_from & SOUTH) ? -short_by : 0) + +/// Knuckle sparks where a piston punch landed (gorilla_gore.dmi). They stay where the blow fell. +/obj/effect/temp_visual/gorilla_punch_sparks + name = "sparks" + icon = GORILLA_GORE_ICON + icon_state = "impact_gorilla" + plane = GAME_PLANE + layer = ABOVE_MOB_LAYER + appearance_flags = PIXEL_SCALE | TILE_BOUND | LONG_GLIDE + randomdir = FALSE + duration = 0.4 SECONDS + +/obj/effect/temp_visual/gorilla_punch_sparks/Initialize(mapload, state, glow = FALSE) + if(state) + icon_state = state + . = ..() + if(glow) + add_overlay(emissive_appearance(icon, icon_state, src)) + +/// Whether the fist of [bearer]'s arm on [arm_zone] is the far one for the way they face: seen from the side, the arm behind their own body. +/proc/gorilla_far_fist(mob/living/bearer, arm_zone) + return ((bearer.dir & EAST) && arm_zone == BODY_ZONE_L_ARM) || ((bearer.dir & WEST) && arm_zone == BODY_ZONE_R_ARM) + +/** + * Knuckle sparks where a piston punch from [attacker] lands on [target]: on the part that was hit ([zone], else the + * middle of the body), for the way the body faces and lies, where the body is drawn (mid-stride it is short of its + * turf). They are left where the blow fell, so a body knocked away doesn't carry them off. They are drawn where the + * fist is: over the body, but + * - thrown from beyond a standing body (the attacker further from the viewer), or by the far fist of an attacker beside + * it ([behind]), they are behind that body and show round it; + * - thrown from nearer the viewer, they are in front of the body with the fist, and so under the attacker's own body, + * which that fist passes behind. + */ +/proc/gorilla_punch_sparks(atom/movable/attacker, atom/movable/target, impact_state, zone, behind = FALSE) + var/turf/spot = get_turf(target) + if(!impact_state || isnull(spot) || !ismovable(target)) + return null + var/list/point = gorilla_zone_point(target, zone) + var/direction = get_dir(attacker, target) + // From nearer the viewer: on the attacker's own screen row (rows sort before layers), reaching a tile up it + var/turf/from = get_turf(attacker) + var/from_below = (direction & NORTH) && ismob(attacker) && from && from.z == spot.z + var/turf/row = from_below ? from : spot + var/obj/effect/temp_visual/gorilla_punch_sparks/sparks = new(row, impact_state, impact_state == "impact_gorilla") + // On the side of the part the fist came in on + var/near_side = (direction & EAST) ? -2 : ((direction & WEST) ? 2 : 0) + // On the body's own screen row (pixel_y sorts before layer), then moved over the part without leaving it + sparks.pixel_x = target.pixel_x + sparks.pixel_y = from_below ? attacker.pixel_y : target.pixel_y + // ...and back along its last step if it was hit mid-stride, to where it is drawn + var/list/lag = gorilla_glide_lag(target) + sparks.pixel_w = target.pixel_w + round(point[1] + lag[1]) + near_side + (spot.x - row.x) * ICON_SIZE_X + sparks.pixel_z = target.pixel_z + round(point[2] + lag[2]) + (spot.y - row.y) * ICON_SIZE_Y + (target.pixel_y - sparks.pixel_y) + var/mob/living/struck = target + var/standing = !isliving(struck) || struck.body_position != LYING_DOWN + if(from_below) + sparks.plane = attacker.plane + sparks.layer = attacker.layer - 0.005 + else + sparks.plane = target.plane + sparks.layer = target.layer + 0.02 + if(standing && (direction & SOUTH)) + sparks.layer = target.layer - 0.02 + else if(standing && behind && (direction & (EAST|WEST))) + // Behind the body, and still in front of the far arm that threw it (a hair under its own body) + sparks.layer = target.layer - 0.005 + if((direction & WEST) || (!(direction & EAST) && prob(50))) + sparks.transform = matrix(-1, 0, 0, 0, 1, 0) + return sparks + +// ========================================================================= +// BONE BREAKERS +// ========================================================================= + +/** + * Strain on a body from piston hits: how many hard hits each limb has taken, and when the last one + * landed. A limb that goes [GORILLA_STRAIN_MEMORY] without a hit forgets its count. Expires on its own. + */ +/datum/status_effect/gorilla_strain + id = "gorilla_strain" + alert_type = null + duration = GORILLA_STRAIN_MEMORY + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_REFRESH + /// Body zone -> hard hits counted + var/list/counts = list() + /// Body zone -> world.time of its last hard hit + var/list/last_hit = list() + +/// Counts one more hard hit on [zone]. Returns the count. +/datum/status_effect/gorilla_strain/proc/add(zone) + if(world.time - (last_hit[zone] || -INFINITY) > GORILLA_STRAIN_MEMORY) + counts[zone] = 0 + counts[zone] = (counts[zone] || 0) + 1 + last_hit[zone] = world.time + return counts[zone] + +/// Forgets [zone]'s count after it snapped. +/datum/status_effect/gorilla_strain/proc/clear(zone) + counts -= zone + last_hit -= zone + +/// A hard piston hit on [zone]: strains it, and snaps it when it has taken [snap_at] in the window. +/proc/gorilla_add_strain(mob/living/carbon/victim, zone, mob/living/attacker, snap_at, obj/item/source_item, direction) + if(zone == BODY_ZONE_HEAD) + return FALSE + var/datum/status_effect/gorilla_strain/strain = victim.has_status_effect(/datum/status_effect/gorilla_strain) + if(strain) + strain.refresh() + else + strain = victim.apply_status_effect(/datum/status_effect/gorilla_strain) + if(!strain) + return FALSE + if(strain.add(zone) < snap_at) + return FALSE + strain.clear(zone) + return gorilla_snap_limb(victim, victim.get_bodypart(zone), attacker, direction, source_item) + +/** + * Snaps [limb]: tg's compound fracture forced on an arm or a leg, or the ribs caved in on the chest. + * Heavy armour on the limb, a limb without bone (robotic) and the Atlas frame stop it. Returns TRUE + * if it snapped. + */ +/proc/gorilla_snap_limb(mob/living/carbon/victim, obj/item/bodypart/limb, mob/living/attacker, direction, obj/item/source_item) + if(isnull(limb) || limb.owner != victim || limb.body_zone == BODY_ZONE_HEAD) + return FALSE + if(hv_crit_cover_rating(victim, limb, MELEE) >= GORILLA_SNAP_ARMOUR) + return FALSE + if(!hv_crit_apply_wound(limb, /datum/wound/blunt/bone/critical, direction, source_item)) + return FALSE + if(limb.body_zone == BODY_ZONE_CHEST) + victim.hv_add_hit_line("[victim]'s ribs cave in!", HV_LINE_HUGE, "Your ribs cave in!", DEFAULT_MESSAGE_RANGE) + else + victim.hv_add_hit_line("The bone in [victim]'s [limb.plaintext_zone] snaps!", HV_LINE_HUGE, "The bone in your [limb.plaintext_zone] snaps!", DEFAULT_MESSAGE_RANGE) + playsound(victim, 'sound/effects/wounds/crack2.ogg', 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) + cyberware_flash_fx(victim, 0.2 SECONDS) + gorilla_floor_ring(victim, 0.5) + log_combat(attacker, victim, "snapped the [limb.plaintext_zone] of", source_item, "(gorilla arms)") + return TRUE + +// ========================================================================= +// GRAB AND SLAM +// ========================================================================= + +/** + * With someone in an aggressive grab, a click on the floor under them, an open floor beside the bearer + * or a table starts the slam that brings them down onto it (gorilla_slam.dm). Returns GORILLA_SLAM_*. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/try_slam(mob/living/carbon/human/source, atom/target, obj/item/bodypart/active_hand) + var/mob/living/victim = source.pulling + if(!isliving(victim) || source.grab_state < GRAB_AGGRESSIVE || victim == source) + return GORILLA_SLAM_NO + var/obj/structure/table/table = istype(target, /obj/structure/table) ? target : null + if(!table && !isturf(target)) + return GORILLA_SLAM_NO + if(!grab_slam || !gorilla_running() || !gorilla_may_use_heavy_moves(source) || HAS_TRAIT(source, TRAIT_PACIFISM) || victim.buckled || source.body_position == LYING_DOWN) + return table ? GORILLA_SLAM_PASS : GORILLA_SLAM_NO + if(!TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_GRAB_MOVE)) + return table ? GORILLA_SLAM_PASS : GORILLA_SLAM_NO + var/turf/spot = table ? get_turf(table) : target + if(!source.Adjacent(spot)) + return GORILLA_SLAM_NO + if(!table && !gorilla_slam_floor_ok(spot)) + return GORILLA_SLAM_NO + var/datum/gorilla_slam/slam = new(source, victim, spot, table, src, active_hand) + if(!slam.start()) + qdel(slam) + return table ? GORILLA_SLAM_PASS : GORILLA_SLAM_NO + gorilla_cooldown(source, COOLDOWN_GORILLA_GRAB_MOVE, grab_move_cooldown) + source.changeNext_move(CLICK_CD_MELEE) + return GORILLA_SLAM_DONE + +/** + * Slams whoever [bearer] holds in an aggressive grab onto [onto] (their own tile, an open floor beside the + * bearer, or a table) with the active arm, as a click there would. Returns TRUE if it slammed. For an AI. + * Brawler arms slam too; gorilla_may_use_heavy_moves() still keeps it to players and boss-rank NPCs. + */ +/proc/gorilla_try_slam(mob/living/carbon/human/bearer, atom/onto) + var/obj/item/bodypart/hand = bearer?.get_active_hand() + if(!iscarbon(bearer)) + return FALSE + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in bearer.organs) + if(arm.owner != bearer || !arm.grab_slam || arm.zone != hand?.body_zone || !arm.gorilla_running() || !COOLDOWN_FINISHED(arm, slam_cooldown)) + continue + if(arm.try_slam(bearer, onto, hand) != GORILLA_SLAM_DONE) + return FALSE + COOLDOWN_START(arm, slam_cooldown, arm.punch_cooldown_time) + return TRUE + return FALSE + +/// Whether a body can be slammed down on [spot]: solid floor with gravity and nothing standing in the way. +/proc/gorilla_slam_floor_ok(turf/spot) + if(!isfloorturf(spot) || isgroundlessturf(spot) || !spot.has_gravity()) + return FALSE + for(var/obj/thing in spot) + if(thing.density && !(thing.flags_1 & ON_BORDER_1)) + return FALSE + return TRUE diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_slam.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_slam.dm new file mode 100644 index 00000000000..6abe224fd6d --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_slam.dm @@ -0,0 +1,1054 @@ +/** + * # The grab-slam + * + * With someone in an aggressive grab, a click on the floor under them, an open floor beside the bearer or a table + * presses them up and drives them down onto it (gorilla_arms/proc/try_slam(), gorilla_punch.dm). The lift is drawn + * with the puppet rig (chrome_puppet_rig.dm), so neither real body is moved or turned by it. + * + * - Grip: he leans in and both fists take the body, by belt and collar seen from the side. + * - Hoist: up and over, turning flat. Gorilla Arms press it overhead; Brawler myomers bring it to the shoulder. + * - Top: one held beat. A body taken from behind or beside him is turned to the table or tile here. + * - Slam: driven down with his fists still on it. Seen from the side he bends at the belt; from the front or back he + * drops and lunges. + * - Impact: the damage, the knockdown and the grab letting go all land here, and the real body is put on the tile. + * A ring, and on a floor cracks (and dust and the heavy crash, for Gorilla Arms); both cameras shake and the body + * flashes white. Blood only if the blow made them bleed (a wound through the skin, or tg's cracked ribs bleeding + * inside), as a few drops thrown out round the body. + * - Rebound: the body bounces once and he comes up, hands off. The puppets go once both are still, with the real body + * lying in the same pixels. + * + * Until the impact nothing has happened to anyone. The bearer can't step, the grab is held, and the slam is called off + * if he is stunned or knocked down, loses the grab, or either of them is moved: the body comes down on its feet, unhurt. + * + * The poses are keys (the grip, the top, the impact, the rebound) played as short straight steps. While his fists are on + * the body they are solved onto it at every step, so they never leave it. Every puppet stands on the bearer's tile and is + * moved by its transform alone, which keeps its place in the draw order: the client sorts by row before layer, so a puppet + * that must draw over a table or a ring on the row nearer the viewer is put on that row. + */ + +/mob/living + /// The grab-slam this mob is doing or is held in, if any (gorilla_slam.dm). Cleared when it ends. + var/datum/gorilla_slam/gorilla_slam + +// What an arm does in a key pose. +/// On the body: list(kind, x, y) in the body's own pixels, from the middle of its picture. +#define SLAM_ARM_GRIP 1 +/// On the body, at a point of the scene: list(kind, x, y) pixels from where the bearer stands. +#define SLAM_ARM_POINT 2 +/// On the body, wherever a swing puts the hand: list(kind, angle, reach) as swing_arm() takes them. +#define SLAM_ARM_HELD 3 +/// Off the body: list(kind, angle, reach). +#define SLAM_ARM_SWING 4 + +// How a key pose is eased into. +#define SLAM_EASE_LINEAR 0 +#define SLAM_EASE_OUT 1 +#define SLAM_EASE_IN 2 +#define SLAM_EASE_SINE 3 + +/// Where the belt sits, from the middle of the picture: seen from the side the bearer bends about it. +#define SLAM_WAIST_Y -7 + +/// [progress] (0 to 1) eased as [kind] (SLAM_EASE_*). +/proc/gorilla_slam_ease(kind, progress) + progress = clamp(progress, 0, 1) + switch(kind) + if(SLAM_EASE_OUT) + return 1 - (1 - progress) ** 2 + if(SLAM_EASE_IN) + return progress ** 2 + if(SLAM_EASE_SINE) + return (1 - cos(180 * progress)) / 2 + return progress + +/// One key pose of the bearer. +/datum/gorilla_slam_pose + /// Deciseconds from the click + var/time = 0 + /// The facing of the rig that shows it + var/facing + /// How the move eases into it from the pose before (SLAM_EASE_*) + var/ease = SLAM_EASE_LINEAR + /// Pixels the whole bearer is moved + var/shift_x = 0 + var/shift_y = 0 + /// Degrees he leans forward from the feet, seen from the side + var/lean = 0 + /// Degrees he bends forward at the belt, seen from the side + var/waist = 0 + /// The crouch seen from the front or back: his height as a share, and pixels dropped + var/squash = 1 + var/drop = 0 + /// Side -> list(SLAM_ARM_*, a, b) + var/list/arms + /// Side -> list(x, y): where the fist is on the body, in the body's own pixels. No entry for an arm that is off it. + var/list/grips = list() + /// Side -> list(turn, length): the arm as it is in this pose + var/list/reached = list() + +/datum/gorilla_slam_pose/New(time, facing, ease = SLAM_EASE_LINEAR) + src.time = time + src.facing = facing + src.ease = ease + +/// One grab-slam, from the click to the puppets coming down. +/datum/gorilla_slam + /// Who slams + var/mob/living/carbon/human/bearer + /// Who is slammed + var/mob/living/victim + /// The arm that does it, and its hand: the attacking item + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm + var/obj/item/bodypart/fist + /// The tile the body is brought down on, and the table there if it is slammed onto one + var/turf/spot + var/datum/weakref/table_ref + /// Where the two stood at the click + var/turf/bearer_spot + var/turf/victim_spot + /// The way the bearer faces to take the body, and the way he faces to bring it down + var/taking + var/facing + /// The zone he aimed at the click + var/aimed_zone + /// TRUE for a head-aimed table slam: the body lands face down + var/face_down = FALSE + /// world.time at the click + var/started_at = 0 + /// TRUE once the impact has landed + var/landed = FALSE + /// TRUE once it is over, or called off + var/ended = FALSE + /// TRUE while the slam itself lets go of the grab and moves the body + var/placing = FALSE + /// FALSE when a body couldn't be rigged: the slam happens without the poses + var/posed = FALSE + // The beats, in deciseconds from the click + var/t_grip = GORILLA_SLAM_GRIP_TIME + var/t_top + var/t_drive + var/t_hit + var/t_mid + var/t_up + var/t_end + // The body's middle, in pixels from where the bearer is drawn, and its turn (degrees clockwise): held at the click, + // at the top, and where it lands + var/held_x = 0 + var/held_y = 0 + var/held_angle = 0 + var/top_x = 0 + var/top_y = 0 + var/top_angle = 0 + var/land_x = 0 + var/land_y = 0 + var/land_angle = 0 + /// The middle of the tile it lands on + var/tile_x = 0 + var/tile_y = 0 + /// Where the bearer is drawn on his own tile: every puppet stands there + var/origin_x = 0 + var/origin_y = 0 + /// Where the real body lies after the impact: the puppet settles there + var/final_x = 0 + var/final_y = 0 + var/final_angle = 0 + /// Facing ("[dir]") -> the bearer's rig in that facing + var/list/rigs + /// The body's puppets, one for each of the bearer's rigs ("body_[dir]") + var/datum/chrome_rig/victim_rig + /// The key poses, in order (/datum/gorilla_slam_pose) + var/list/keys + var/hit_timer + var/end_timer + +/datum/gorilla_slam/New(mob/living/carbon/human/bearer, mob/living/victim, turf/spot, obj/structure/table/table, obj/item/organ/cyberimp/cyberware/gorilla_arms/arm, obj/item/bodypart/fist) + . = ..() + src.bearer = bearer + src.victim = victim + src.spot = spot + src.arm = arm + src.fist = fist + if(table) + table_ref = WEAKREF(table) + +/datum/gorilla_slam/Destroy() + ended = TRUE + if(hit_timer) + deltimer(hit_timer) + hit_timer = null + if(end_timer) + deltimer(end_timer) + end_timer = null + take_down() + if(bearer) + stop_listening() + REMOVE_TRAIT(bearer, TRAIT_IMMOBILIZED, GORILLA_SLAM_TRAIT) + if(bearer.gorilla_slam == src) + bearer.gorilla_slam = null + if(victim?.gorilla_slam == src) + victim.gorilla_slam = null + bearer = null + victim = null + arm = null + fist = null + spot = null + bearer_spot = null + victim_spot = null + keys = null + return ..() + +/// Takes every puppet down and shows both bodies. +/datum/gorilla_slam/proc/take_down() + QDEL_NULL(victim_rig) + if(rigs) + QDEL_LIST_ASSOC_VAL(rigs) + rigs = null + +/// The facing toward [target] from where the bearer stands, as face_atom() picks it. Null for his own tile. +/datum/gorilla_slam/proc/toward(turf/target) + var/dx = target.x - bearer_spot.x + var/dy = target.y - bearer_spot.y + if(!dx && !dy) + return null + if(abs(dx) < abs(dy)) + return dy > 0 ? NORTH : SOUTH + return dx > 0 ? EAST : WEST + +/** + * Starts the slam: the rigs go up, the bearer is held where he stands and the impact is set for t_hit. Returns FALSE if + * it can't start; nothing has changed then. + */ +/datum/gorilla_slam/proc/start() + if(QDELETED(bearer) || QDELETED(victim) || QDELETED(arm) || bearer.gorilla_slam || victim.gorilla_slam) + return FALSE + bearer_spot = bearer.loc + victim_spot = victim.loc + if(!isturf(bearer_spot) || !isturf(victim_spot) || !isturf(spot)) + return FALSE + // A body on its feet can't be put down where he stands himself + if(spot == bearer_spot && victim.density) + return FALSE + for(var/mob/living/in_the_way in spot) + if(in_the_way != victim && in_the_way != bearer && in_the_way.density) + return FALSE + bearer.gorilla_slam = src + victim.gorilla_slam = src + started_at = world.time + aimed_zone = check_zone(bearer.zone_selected) + face_down = !isnull(table_ref) && aimed_zone == BODY_ZONE_HEAD + var/now_facing = (bearer.dir in GLOB.cardinals) ? bearer.dir : ((bearer.dir & (EAST|WEST)) || SOUTH) + taking = toward(victim_spot) || now_facing + facing = toward(spot) || taking + bearer.setDir(taking) + t_top = t_grip + (arm.slam_overhead ? GORILLA_SLAM_HOIST_TIME : BRAWLER_SLAM_HOIST_TIME) + t_drive = t_top + GORILLA_SLAM_TOP_TIME + t_hit = t_drive + GORILLA_SLAM_DRIVE_TIME + t_mid = t_hit + GORILLA_SLAM_REBOUND_TIME / 2 + t_up = t_hit + GORILLA_SLAM_RISE_TIME + t_end = t_hit + GORILLA_SLAM_SETTLE_TIME + measure() + posed = build_rigs() + if(posed) + build_keys() + set_stage() + play(0, t_hit) + else + take_down() + ADD_TRAIT(bearer, TRAIT_IMMOBILIZED, GORILLA_SLAM_TRAIT) + listen() + hit_timer = addtimer(CALLBACK(src, PROC_REF(hit)), t_hit, TIMER_STOPPABLE | TIMER_DELETE_ME) + return TRUE + +/// Where [body] is drawn: list(x, y) pixels from where the bearer was drawn at the click. +/datum/gorilla_slam/proc/drawn_at(mob/living/body) + var/turf/here = get_turf(body) + return list((here.x - bearer_spot.x) * ICON_SIZE_X + body.pixel_x + body.pixel_w - origin_x, (here.y - bearer_spot.y) * ICON_SIZE_Y + body.pixel_y + body.pixel_z - origin_y) + +/// [body]'s lying turn in degrees clockwise, the short way round: 0 on its feet, 90 or -90 lying. +/datum/gorilla_slam/proc/turn_of(mob/living/body) + return body.lying_angle > 180 ? body.lying_angle - 360 : body.lying_angle + +/// Works out where the body is held, where the top of the lift is and where it lands, all from where the bearer is drawn. +/datum/gorilla_slam/proc/measure() + origin_x = bearer.pixel_x + bearer.pixel_w + origin_y = bearer.pixel_y + bearer.pixel_z + var/list/victim_at = drawn_at(victim) + held_x = victim_at[1] + held_y = victim_at[2] + held_angle = turn_of(victim) + tile_x = (spot.x - bearer_spot.x) * ICON_SIZE_X + tile_y = (spot.y - bearer_spot.y) * ICON_SIZE_Y + var/side_on = facing & (EAST|WEST) + var/ahead = facing == WEST ? -1 : 1 + // Seen from the side the head ends at his feet, under his fists; from the front or back the body lies head east + var/turned = facing == EAST ? -1 : 1 + if(arm.slam_overhead) + top_x = side_on ? GORILLA_SLAM_TOP_SIDE_X * ahead : GORILLA_SLAM_TOP_FRONT_X + top_y = side_on ? GORILLA_SLAM_TOP_SIDE_Y : (facing == NORTH ? GORILLA_SLAM_TOP_BACK_Y : GORILLA_SLAM_TOP_FRONT_Y) + top_angle = 90 * turned + else + top_x = side_on ? BRAWLER_SLAM_TOP_SIDE_X * ahead : BRAWLER_SLAM_TOP_FRONT_X + top_y = side_on ? BRAWLER_SLAM_TOP_SIDE_Y : BRAWLER_SLAM_TOP_FRONT_Y + top_angle = BRAWLER_SLAM_TOP_TURN * turned + land_angle = 90 * turned + land_x = tile_x + // Where tg draws a body lying there: a little down the tile, and up on a table top + land_y = tile_y + PIXEL_Y_OFFSET_LYING + (table_ref ? GORILLA_SLAM_TABLE_TOP : 0) + final_x = land_x + final_y = land_y + final_angle = land_angle + +// ========================================================================= +// THE RIGS +// ========================================================================= + +/// A flat picture of [body] facing [way], into [rig]. getFlatIcon() draws a body in its own facing unless it faces south, so it is turned for the picture. +/datum/gorilla_slam/proc/picture(datum/chrome_rig/rig, mob/living/body, way) + var/real_dir = body.dir + body.dir = way + . = rig.snapshot() + body.dir = real_dir + +/** + * Stands a puppet of [rig] under [key] where the bearer stands, [row] tiles up the map from him (-1: the row nearer the + * viewer), on [puppet_layer]. It is moved by its transform from there. + */ +/datum/gorilla_slam/proc/stand(datum/chrome_rig/rig, key, icon/picture, row, puppet_layer) + var/obj/effect/abstract/chrome_puppet/puppet = new(bearer_spot) + puppet.name = rig.body.name + puppet.icon = picture + puppet.dir = SOUTH + puppet.plane = bearer.plane + puppet.layer = puppet_layer + puppet.pixel_x = bearer.pixel_x + puppet.pixel_w = bearer.pixel_w + puppet.pixel_y = bearer.pixel_y + row * ICON_SIZE_Y + puppet.pixel_z = bearer.pixel_z - row * ICON_SIZE_Y + rig.puppets[key] = puppet + return puppet + +/// The two arms of [rig] in the order key poses name them: the near one then the far one seen from the side, else the one on the viewer's left then the other. +/datum/gorilla_slam/proc/arm_pair(datum/chrome_rig/rig) + if(rig.facing & (EAST|WEST)) + var/near = rig.near_side() + return list(near, near == "l" ? "r" : "l") + return rig.facing == NORTH ? list("l", "r") : list("r", "l") + +/** + * Builds the bearer's rig for each way he faces in the move, and a puppet of the body for each. Back to front: + * seen from the side, far arm, legs and torso, the body, near arm; from behind, the body, torso, arms; from the front, + * torso, the body, arms. Returns FALSE if either couldn't be rigged. + */ +/datum/gorilla_slam/proc/build_rigs() + rigs = list() + var/list/ways = taking == facing ? list(taking) : list(taking, facing) + for(var/way in ways) + var/datum/chrome_rig/rig = new(bearer, way) + rigs["[way]"] = rig + if(!picture(rig, bearer, way)) + return FALSE + victim_rig = new(victim, face_down ? NORTH : SOUTH) + if(!picture(victim_rig, victim, victim_rig.facing)) + return FALSE + var/base = bearer.layer + for(var/way in ways) + var/datum/chrome_rig/rig = rigs["[way]"] + // The row of the tile the body is on while this rig shows: the one it is taken from, then the one it lands on + var/turf/from = way == taking ? victim_spot : spot + var/turf/onto = way == facing ? spot : victim_spot + var/body_row = min(from.y, onto.y) < bearer_spot.y ? -1 : 0 + var/icon/whole = rig.cut_without_arms("both") + if(way & (EAST|WEST)) + // Split at the belt, the halves sharing three rows so no gap opens behind the bend + var/icon/legs = icon(whole) + legs.DrawBox(null, 1, 13, ICON_SIZE_X, ICON_SIZE_Y) + var/icon/torso = icon(whole) + torso.DrawBox(null, 1, 1, ICON_SIZE_X, 9) + stand(rig, "legs", legs, 0, base) + stand(rig, "body", torso, 0, base + 0.001) + var/near = rig.near_side() + stand(rig, "arm_[near]", rig.arm_picture(near), body_row, base + 0.03) + var/far = near == "l" ? "r" : "l" + stand(rig, "arm_[far]", rig.arm_picture(far), 0, base - 0.02) + stand(victim_rig, "body_[way]", victim_rig.flat, body_row, base + 0.02) + else if(way == NORTH) + stand(rig, "body", whole, 0, base) + for(var/side in list("l", "r")) + stand(rig, "arm_[side]", rig.arm_picture(side), 0, base + 0.02) + stand(victim_rig, "body_[way]", victim_rig.flat, 0, base - 0.02) + else + stand(rig, "body", whole, 0, base) + for(var/side in list("l", "r")) + stand(rig, "arm_[side]", rig.arm_picture(side), body_row, base + 0.03) + stand(victim_rig, "body_[way]", victim_rig.flat, body_row, base + 0.02) + var/datum/chrome_rig/first = rigs["[taking]"] + first.conceal() + victim_rig.conceal() + // The rig he turns into hides no body of its own: its puppets still go in the same file as the rest + if(taking != facing) + var/datum/chrome_rig/turned = rigs["[facing]"] + turned.pack() + return TRUE + +// ========================================================================= +// THE POSES +// ========================================================================= + +/// The body at [time]: list(x, y, turn), its middle in pixels from where the bearer is drawn and its turn in degrees clockwise. +/datum/gorilla_slam/proc/body_at(time) + if(time <= t_grip) + return list(held_x, held_y, held_angle) + if(time <= t_top) + // Fast out of the floor, easing into the top + var/up = gorilla_slam_ease(SLAM_EASE_OUT, (time - t_grip) / (t_top - t_grip)) + return list(held_x + (top_x - held_x) * up, held_y + (top_y - held_y) * up, held_angle + (top_angle - held_angle) * up) + if(time <= t_drive) + return list(top_x, top_y, top_angle) + if(time <= t_hit) + // Straight down, faster all the way + var/down = gorilla_slam_ease(SLAM_EASE_IN, (time - t_drive) / (t_hit - t_drive)) + return list(top_x + (land_x - top_x) * down, top_y + (land_y - top_y) * down, top_angle + (land_angle - top_angle) * down) + // It bounces once, tipping, and drops back onto where the real body lies + var/settled = clamp((time - t_hit) / GORILLA_SLAM_REBOUND_TIME, 0, 1) + var/hop = sin(180 * settled) + var/tip = (land_angle < 0 ? GORILLA_SLAM_BOUNCE_TIP : -GORILLA_SLAM_BOUNCE_TIP) * hop + return list( + land_x + (final_x - land_x) * settled, + land_y + (final_y - land_y) * settled + (arm?.slam_overhead ? GORILLA_SLAM_BOUNCE : BRAWLER_SLAM_BOUNCE) * hop, + land_angle + (final_angle - land_angle) * settled + tip, + ) + +/// The body puppet's transform at [time]. +/datum/gorilla_slam/proc/body_matrix(time) + var/list/at = body_at(time) + var/matrix/placed + if(landed && time >= t_hit + GORILLA_SLAM_REBOUND_TIME && !QDELETED(victim)) + // The real body's own turn, so the puppet ends in its pixels + placed = matrix(victim.transform) + else + placed = matrix() + placed.Turn(at[3]) + placed.Translate(at[1], at[2]) + return placed + +/// The point [x], [y] of the scene (pixels from where the bearer is drawn) in the body's own pixels at [time]. +/datum/gorilla_slam/proc/on_body(time, x, y) + var/list/at = body_at(time) + var/dx = x - at[1] + var/dy = y - at[2] + return list(dx * cos(at[3]) - dy * sin(at[3]), dx * sin(at[3]) + dy * cos(at[3])) + +/** + * The bearer's transforms in a pose of the rig facing [way]: list(legs, torso, arms' base). Seen from the side the legs lean + * from the feet and the torso bends on at the belt; from the front or back the whole body is squashed and the legs are null. + */ +/datum/gorilla_slam/proc/bases(way, shift_x, shift_y, lean, waist, squash, drop) + if(way & (EAST|WEST)) + var/forward = way == EAST ? 1 : -1 + var/matrix/legs = chrome_rig_turn(forward * lean, 0, CHROME_RIG_FEET_Y) + var/matrix/torso = chrome_rig_turn(forward * waist, 0, SLAM_WAIST_Y) + torso.Multiply(legs) + torso.Translate(shift_x, shift_y) + legs.Translate(shift_x, shift_y) + return list(legs, torso, torso) + var/datum/chrome_rig/rig = rigs["[way]"] + var/shoulder_y = rig.shoulder("l")[2] + // The arms come down with the squashed shoulders + return list(null, matrix(1, 0, shift_x, 0, squash, shift_y - drop), matrix(1, 0, shift_x, 0, 1, shift_y + shoulder_y * squash - drop - shoulder_y)) + +/// The bases of key pose [pose]. +/datum/gorilla_slam/proc/bases_of(datum/gorilla_slam_pose/pose) + return bases(pose.facing, pose.shift_x, pose.shift_y, pose.lean, pose.waist, pose.squash, pose.drop) + +/// The arm of [rig] on [side] as swing_arm() would leave it: list(turn, length). +/datum/gorilla_slam/proc/swung(datum/chrome_rig/rig, side, angle, reach) + return list(angle * rig.raise_sign(side), max(GORILLA_SLAM_MIN_REACH, 1 + reach / (GLOB.chrome_rig_arm_length["[rig.facing]"] || 10))) + +/// The arm of [rig] on [side] with its hand at [x], [y] of the scene, the body under it turned by [base]: list(turn, length). It stops short past its longest. +/datum/gorilla_slam/proc/reaching(datum/chrome_rig/rig, side, x, y, matrix/base) + var/list/pose = rig.pose_reaching(side, x, y, 0, base) + pose[2] = min(pose[2], GORILLA_SLAM_MAX_REACH) + return pose + +/** + * Poses [pose] with both fists on a body lying on the tile at [at_x], [at_y] (pixels from where the bearer stands), its + * middle [rise] pixels above the tile's: bent at the belt and leaning in, seen from the side; dropped and lunging, from the + * front or back. [on_table] is the shallower reach onto a table top. + */ +/datum/gorilla_slam/proc/press(datum/gorilla_slam_pose/pose, at_x, at_y, rise, on_table) + var/datum/chrome_rig/rig = rigs["[pose.facing]"] + var/way_x = (pose.facing == EAST) - (pose.facing == WEST) + var/way_y = (pose.facing == NORTH) - (pose.facing == SOUTH) + // How far in front of him the tile is: a whole tile, or none for his own + var/ahead = at_x * way_x + at_y * way_y + var/body_y = at_y + rise + pose.arms = list() + if(way_x) + pose.waist = on_table ? 30 : 50 + pose.shift_x = way_x * ((on_table ? 10 : 12) + (ahead - ICON_SIZE_X) * 18 / ICON_SIZE_X) + // A tile up or down the map as well: he goes most of the way with it + pose.shift_y = at_y * 22 / ICON_SIZE_Y + var/list/pair = arm_pair(rig) + pose.arms[pair[1]] = list(SLAM_ARM_POINT, pose.shift_x + way_x * (on_table ? 11 : 15), body_y + (on_table ? 2 : 3)) + pose.arms[pair[2]] = list(SLAM_ARM_POINT, pose.shift_x + way_x * (on_table ? 9 : 12), body_y + (on_table ? 2 : 3)) + return + var/share = ahead / ICON_SIZE_Y + var/edge + if(way_y > 0) + pose.squash = 0.8 + pose.drop = 3 + pose.shift_y = (on_table ? 13 : 10) * share + edge = body_y - (on_table ? 7 : 4) + else + pose.squash = on_table ? 0.85 : 0.8 + pose.drop = on_table ? 2.25 : 3 + pose.shift_y = (on_table ? -4 : -14) * share + edge = body_y + (on_table ? 6 : 7) * share + 2 * (1 - share) + for(var/side in list("l", "r")) + pose.arms[side] = list(SLAM_ARM_POINT, rig.shoulder(side)[1], edge) + +/// Works out [pose]'s arms: where each fist is on the body (grips) and the arm's own turn and length there (reached). +/datum/gorilla_slam/proc/settle_arms(datum/gorilla_slam_pose/pose) + var/datum/chrome_rig/rig = rigs["[pose.facing]"] + var/matrix/base = bases_of(pose)[3] + for(var/side in list("l", "r")) + var/list/does = pose.arms[side] + switch(does[1]) + if(SLAM_ARM_SWING) + pose.reached[side] = swung(rig, side, does[2], does[3]) + continue + if(SLAM_ARM_GRIP) + pose.grips[side] = list(does[2], does[3]) + if(SLAM_ARM_POINT) + pose.grips[side] = on_body(pose.time, does[2], does[3]) + if(SLAM_ARM_HELD) + var/list/swing = swung(rig, side, does[2], does[3]) + var/list/hand = rig.hand_at(side, list(swing[1], swing[2], base)) + pose.grips[side] = on_body(pose.time, hand[1], hand[2]) + var/list/grip = pose.grips[side] + var/list/point = chrome_rig_apply(body_matrix(pose.time), grip[1], grip[2]) + pose.reached[side] = reaching(rig, side, point[1], point[2], base) + +/// Both arms hanging. +/datum/gorilla_slam/proc/arms_down() + return list("l" = list(SLAM_ARM_SWING, 0, 0), "r" = list(SLAM_ARM_SWING, 0, 0)) + +/// Builds the key poses: standing, the grip, the top (the end of the hoist in the rig that takes the body, then held in the rig that brings it down), the impact, half way up, and upright. +/datum/gorilla_slam/proc/build_keys() + var/datum/chrome_rig/take_rig = rigs["[taking]"] + var/datum/chrome_rig/slam_rig = rigs["[facing]"] + var/take_x = (taking == EAST) - (taking == WEST) + var/slam_x = (facing == EAST) - (facing == WEST) + + var/datum/gorilla_slam_pose/stood = new(0, taking) + stood.arms = arms_down() + + // The grip. A body on its feet: he leans in and takes its near side by belt and collar, or its sides seen from the + // front or back. A body on the floor: he goes down to it as he will at the impact. + var/datum/gorilla_slam_pose/grip = new(t_grip, taking, SLAM_EASE_SINE) + var/list/pair = arm_pair(take_rig) + if(held_angle) + press(grip, held_x, held_y - PIXEL_Y_OFFSET_LYING, PIXEL_Y_OFFSET_LYING, FALSE) + else if(take_x) + grip.waist = 18 + grip.shift_x = 4 * take_x + grip.shift_y = held_y * 22 / ICON_SIZE_Y + grip.arms = list() + grip.arms[pair[1]] = list(SLAM_ARM_GRIP, -5 * take_x, -6) + grip.arms[pair[2]] = list(SLAM_ARM_GRIP, -4 * take_x, 3) + else if(taking == NORTH) + grip.arms = list() + grip.arms[pair[1]] = list(SLAM_ARM_GRIP, -5, -3) + grip.arms[pair[2]] = list(SLAM_ARM_GRIP, 5, -3) + else + grip.squash = 0.9 + grip.drop = 1.5 + grip.shift_y = -4 + grip.arms = list() + grip.arms[pair[1]] = list(SLAM_ARM_GRIP, -6, 2) + grip.arms[pair[2]] = list(SLAM_ARM_GRIP, 6, 2) + + // The top. Gorilla Arms: pressed overhead at full reach, leaning back under it. Brawler myomers: at the shoulder. + var/datum/gorilla_slam_pose/top = new(t_top, facing) + pair = arm_pair(slam_rig) + top.arms = list() + if(slam_x) + if(arm.slam_overhead) + top.lean = -6 + top.arms[pair[1]] = list(SLAM_ARM_HELD, 166, 6) + top.arms[pair[2]] = list(SLAM_ARM_HELD, 174, 6) + else + top.arms[pair[1]] = list(SLAM_ARM_GRIP, -7 * slam_x, 0) + top.arms[pair[2]] = list(SLAM_ARM_GRIP, -5 * slam_x, 4.5) + else + if(arm.slam_overhead && facing == NORTH) + top.shift_y = GORILLA_SLAM_TOP_BACK_RISE + for(var/side in list("l", "r")) + top.arms[side] = list(SLAM_ARM_POINT, slam_rig.shoulder(side)[1], top_y - (arm.slam_overhead ? 8 : 7)) + settle_arms(top) + var/datum/gorilla_slam_pose/top_held = new(t_drive, facing) + top_held.lean = top.lean + top_held.shift_y = top.shift_y + top_held.arms = list() + // The hoist ends with the fists where the top has them, in the rig that took the body + var/datum/gorilla_slam_pose/hoisted = new(t_top, taking, SLAM_EASE_OUT) + hoisted.arms = list() + if(taking == facing) + hoisted.lean = top.lean + hoisted.shift_y = top.shift_y + for(var/side in list("l", "r")) + var/list/grip_at = top.grips[side] + top_held.arms[side] = list(SLAM_ARM_GRIP, grip_at[1], grip_at[2]) + hoisted.arms[side] = list(SLAM_ARM_GRIP, grip_at[1], grip_at[2]) + + // The impact, and half way back up with his hands off it + var/datum/gorilla_slam_pose/impact = new(t_hit, facing, SLAM_EASE_IN) + press(impact, tile_x, tile_y, land_y - tile_y, !isnull(table_ref)) + var/datum/gorilla_slam_pose/rising = new(t_mid, facing, SLAM_EASE_OUT) + rising.shift_x = impact.shift_x / 2 + rising.shift_y = impact.shift_y / 2 + rising.arms = list() + if(slam_x) + rising.waist = 24 + rising.arms[pair[1]] = list(SLAM_ARM_SWING, 38, 2) + rising.arms[pair[2]] = list(SLAM_ARM_SWING, 46, 2) + else + rising.squash = 0.9 + rising.drop = 1.5 + for(var/side in list("l", "r")) + rising.arms[side] = list(SLAM_ARM_SWING, 24, 0) + var/datum/gorilla_slam_pose/upright = new(t_up, facing, SLAM_EASE_SINE) + upright.arms = arms_down() + var/datum/gorilla_slam_pose/still = new(t_end, facing) + still.arms = arms_down() + + keys = list(stood, grip, hoisted, top, top_held, impact, rising, upright, still) + for(var/datum/gorilla_slam_pose/pose as anything in keys) + if(pose != top) + settle_arms(pose) + +/** + * The bearer's puppets in the rig facing [way] at [time]: key ("legs", "body", "arm_l", "arm_r") -> transform. The body + * pose is eased between the keys either side; an arm on the body is solved onto its grip, which slides between the keys' + * grips, and the hand of an arm coming off it or going onto it is carried straight between where the two keys have it. + */ +/datum/gorilla_slam/proc/pose_at(time, way) + var/datum/gorilla_slam_pose/from_key + var/datum/gorilla_slam_pose/to_key + for(var/i in 1 to length(keys) - 1) + var/datum/gorilla_slam_pose/first = keys[i] + var/datum/gorilla_slam_pose/second = keys[i + 1] + if(first.facing != way || second.facing != way || second.time <= first.time) + continue + from_key = first + to_key = second + if(time <= second.time) + break + if(!from_key) + return null + var/datum/chrome_rig/rig = rigs["[way]"] + var/progress = gorilla_slam_ease(to_key.ease, (time - from_key.time) / (to_key.time - from_key.time)) + var/list/base = bases(way, + from_key.shift_x + (to_key.shift_x - from_key.shift_x) * progress, + from_key.shift_y + (to_key.shift_y - from_key.shift_y) * progress, + from_key.lean + (to_key.lean - from_key.lean) * progress, + from_key.waist + (to_key.waist - from_key.waist) * progress, + from_key.squash + (to_key.squash - from_key.squash) * progress, + from_key.drop + (to_key.drop - from_key.drop) * progress) + . = list("body" = base[2]) + if(base[1]) + .["legs"] = base[1] + var/matrix/held + for(var/side in list("l", "r")) + var/list/grip_from = from_key.grips[side] + var/list/grip_to = to_key.grips[side] + var/list/arm_pose + if(grip_from && grip_to) + held ||= body_matrix(time) + var/list/point = chrome_rig_apply(held, grip_from[1] + (grip_to[1] - grip_from[1]) * progress, grip_from[2] + (grip_to[2] - grip_from[2]) * progress) + arm_pose = reaching(rig, side, point[1], point[2], base[3]) + else + // Coming off the body or going onto it: the hand goes straight from where one key has it to where the other + // does, in his own pixels, so the arm folds in past the shoulder and never swings out wide + var/list/hand_from = rig.hand_at(side, from_key.reached[side]) + var/list/hand_to = rig.hand_at(side, to_key.reached[side]) + arm_pose = rig.pose_reaching(side, hand_from[1] + (hand_to[1] - hand_from[1]) * progress, hand_from[2] + (hand_to[2] - hand_from[2]) * progress) + arm_pose[2] = min(arm_pose[2], GORILLA_SLAM_MAX_REACH) + .["arm_[side]"] = rig.arm_matrix(side, arm_pose[1], arm_pose[2], base[3]) + +/// Every puppet takes its first pose: the rig that takes the body as he stands, the rig that brings it down at the top and unseen until then. +/datum/gorilla_slam/proc/set_stage() + for(var/way_text in rigs) + var/datum/chrome_rig/rig = rigs[way_text] + var/way = rig.facing + var/first_time = way == taking ? 0 : t_top + var/list/poses = pose_at(first_time, way) + var/obj/effect/abstract/chrome_puppet/held = victim_rig.puppet("body_[way]") + if(way == taking) + // Exactly as the real body is drawn, whatever its size + var/matrix/as_held = matrix(victim.transform) + as_held.Translate(held_x, held_y) + held.transform = as_held + else + held.transform = body_matrix(first_time) + held.alpha = 0 + for(var/key in poses) + var/obj/effect/abstract/chrome_puppet/puppet = rig.puppet(key) + puppet.transform = poses[key] + if(way != taking) + puppet.alpha = 0 + +/** + * Plays the move from [from_time] to [to_time] (deciseconds from the click, now being [from_time]): every puppet goes + * through its poses in straight steps of GORILLA_SLAM_STEP. The rig that takes the body shows until the top and the rig + * that brings it down from then on. + */ +/datum/gorilla_slam/proc/play(from_time, to_time) + for(var/way_text in rigs) + var/datum/chrome_rig/rig = rigs[way_text] + var/way = rig.facing + var/on_from = max(from_time, way == taking ? 0 : t_top) + var/on_to = min(to_time, way == facing ? t_end : t_top) + if(on_to <= on_from) + continue + var/steps = max(1, round((on_to - on_from) / GORILLA_SLAM_STEP)) + var/step_time = (on_to - on_from) / steps + var/list/chains = list() + for(var/i in 1 to steps) + var/time = on_from + step_time * i + var/list/poses = pose_at(time, way) + poses["body_[way]"] = body_matrix(time) + for(var/key in poses) + if(!chains[key]) + chains[key] = list() + chains[key] += poses[key] + for(var/key in chains) + var/obj/effect/abstract/chrome_puppet/puppet = key == "body_[way]" ? victim_rig.puppet(key) : rig.puppet(key) + if(puppet) + send_through(puppet, chains[key], step_time, on_from - from_time, on_to < to_time) + +/** + * Sends [puppet] through [poses] (transforms), [step_time] each. With a [wait] it stays unseen that long first; with + * [leave] it goes unseen at the end. The swap takes a hundredth of a second, so two rigs never show at once. + */ +/datum/gorilla_slam/proc/send_through(obj/effect/abstract/chrome_puppet/puppet, list/poses, step_time, wait = 0, leave = FALSE) + var/begun = FALSE + var/first_step = step_time + if(wait > 0) + animate(puppet, alpha = 0, time = wait) + animate(alpha = 255, time = GORILLA_SLAM_SWAP) + first_step = max(0, step_time - GORILLA_SLAM_SWAP) + begun = TRUE + for(var/i in 1 to length(poses)) + if(begun) + animate(transform = poses[i], time = i == 1 ? first_step : step_time) + else + animate(puppet, transform = poses[i], time = step_time) + begun = TRUE + if(leave) + animate(alpha = 0, time = GORILLA_SLAM_SWAP) + +// ========================================================================= +// CALLING IT OFF +// ========================================================================= + +/datum/gorilla_slam/proc/listen() + RegisterSignals(bearer, list( + COMSIG_MOVABLE_MOVED, + COMSIG_ATOM_NO_LONGER_PULLING, + COMSIG_LIVING_SET_BODY_POSITION, + COMSIG_MOB_STATCHANGE, + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + ), PROC_REF(on_change)) + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_change)) + RegisterSignal(bearer, COMSIG_QDELETING, PROC_REF(on_deleted)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_deleted)) + +/datum/gorilla_slam/proc/stop_listening() + if(bearer) + UnregisterSignal(bearer, list( + COMSIG_MOVABLE_MOVED, + COMSIG_ATOM_NO_LONGER_PULLING, + COMSIG_LIVING_SET_BODY_POSITION, + COMSIG_MOB_STATCHANGE, + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + COMSIG_QDELETING, + )) + if(victim) + UnregisterSignal(victim, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + +/// Whether the slam can still land: the bearer up and able, the grab on, both where they stood, the arm running. +/datum/gorilla_slam/proc/still_held() + if(QDELETED(bearer) || QDELETED(victim) || QDELETED(arm) || arm.owner != bearer || !arm.gorilla_running()) + return FALSE + if(bearer.stat != CONSCIOUS || HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || bearer.body_position == LYING_DOWN) + return FALSE + if(bearer.pulling != victim || bearer.grab_state < GRAB_AGGRESSIVE || victim.buckled) + return FALSE + return bearer.loc == bearer_spot && victim.loc == victim_spot + +/// Something changed for one of them: before the impact the slam is called off if it can't land any more; after it, a body that is moved shows as it is. +/datum/gorilla_slam/proc/on_change(datum/source) + SIGNAL_HANDLER + if(ended || placing) + return + if(!landed) + if(!still_held()) + call_off() + return + if(bearer.loc != bearer_spot || victim.loc != spot) + finish() + +/datum/gorilla_slam/proc/on_deleted(datum/source) + SIGNAL_HANDLER + finish() + +/** + * Calls the slam off before the impact. The bearer shows as he is now, free to move, and the body comes down on its feet + * where it stands, unhurt. + */ +/datum/gorilla_slam/proc/call_off() + if(landed || ended) + return + ended = TRUE + if(hit_timer) + deltimer(hit_timer) + hit_timer = null + stop_listening() + if(QDELETED(bearer) || QDELETED(victim)) + finish() + return + REMOVE_TRAIT(bearer, TRAIT_IMMOBILIZED, GORILLA_SLAM_TRAIT) + victim.visible_message(span_danger("[bearer] loses [bearer.p_their()] hold on [victim]!"), span_userdanger("[bearer] loses [bearer.p_their()] hold on you!"), ignored_mobs = bearer) + to_chat(bearer, span_danger("You lose your hold on [victim]!")) + var/showing = world.time - started_at < t_top ? taking : facing + var/obj/effect/abstract/chrome_puppet/held = posed ? victim_rig?.puppet("body_[showing]") : null + // A body taken off its tile shows where it is at once: its puppet would set down on the tile it left. + if(!held || !isturf(victim.loc) || victim.loc != victim_spot) + finish() + return + // His own puppets go at once; the body's puppet comes down onto the real body, and the one not on yet never shows + QDEL_LIST_ASSOC_VAL(rigs) + rigs = null + for(var/key in victim_rig.puppets) + if(victim_rig.puppets[key] != held) + victim_rig.drop_puppet(key) + var/list/victim_at = drawn_at(victim) + var/matrix/down = matrix(victim.transform) + down.Translate(victim_at[1], victim_at[2]) + animate(held, transform = down, alpha = 255, time = GORILLA_SLAM_SET_DOWN_TIME, easing = SINE_EASING | EASE_IN) + end_timer = addtimer(CALLBACK(src, PROC_REF(finish)), GORILLA_SLAM_SET_DOWN_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME) + // Lost grip first, then the body taken off its tile (a pull breaks before the body moves): it shows where it is at + // once, not hidden under a puppet setting down on the tile it left. Destroy()'s stop_listening() lets go of this. + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_deleted)) + +/// The slam is over: the puppets go and both bodies show. +/datum/gorilla_slam/proc/finish() + end_timer = null + if(!QDELETED(src)) + qdel(src) + +// ========================================================================= +// THE IMPACT +// ========================================================================= + +/// The body comes down, if the slam can still land: the grab lets go, the body is put on the tile, and the blow lands. +/datum/gorilla_slam/proc/hit() + hit_timer = null + if(landed || ended) + return + if(!still_held()) + call_off() + return + var/obj/structure/table/table = table_ref?.resolve() + if(table && (QDELETED(table) || table.loc != spot)) + table = null + if(!table && !gorilla_slam_floor_ok(spot)) + call_off() + return + placing = TRUE + bearer.stop_pulling() + var/turf/stood = victim.loc + if(victim.loc != spot) + var/passtable_key = REF(src) + if(table) + passtable_on(victim, passtable_key) + victim.Move(spot, get_dir(victim, spot)) + if(table) + passtable_off(victim, passtable_key) + if(victim.loc != spot && victim.loc != stood && isturf(stood)) + // A diagonal move goes a step at a time: one that got half way was never there, it never left the hoist + victim.forceMove(stood) + placing = FALSE + if(victim.loc != spot) + // Something stopped them getting there + call_off() + return + landed = TRUE + bearer.setDir(facing) + // On its back, or face down for a head-aimed table slam: tg's fall keeps a body facing north or south as it is + if(victim.rotate_on_lying) + victim.setDir(face_down ? NORTH : SOUTH) + var/on_table = !isnull(table) + var/bled = strike(table) + if(!QDELETED(src) && !QDELETED(victim) && !QDELETED(bearer)) + impact_fx(on_table, bled) + if(QDELETED(src) || QDELETED(victim) || QDELETED(bearer)) + if(!QDELETED(src)) + finish() + return + if(victim.rotate_on_lying && victim.body_position == LYING_DOWN) + victim.set_lying_angle(facing == EAST ? LYING_ANGLE_WEST : LYING_ANGLE_EAST) + if(!posed) + finish() + return + // The real body is under the puppets: whatever tg is still easing on it (the fall, the grab's pull, a table's lift) ends now + animate(victim, transform = victim.transform, pixel_x = victim.pixel_x, pixel_y = victim.pixel_y, pixel_w = victim.pixel_w, pixel_z = victim.pixel_z, time = 0, flags = ANIMATION_END_NOW) + var/list/victim_at = drawn_at(victim) + final_x = victim_at[1] + final_y = victim_at[2] + final_angle = turn_of(victim) + play(t_hit, t_end) + end_timer = addtimer(CALLBACK(src, PROC_REF(finish)), t_end - t_hit, TIMER_STOPPABLE | TIMER_DELETE_ME) + +/** + * The blow itself on the body, now on the tile: the damage through #509 as one line, the stamina, the knockdown, and what + * the table takes. Returns TRUE if it opened a wound that bleeds. + */ +/datum/gorilla_slam/proc/strike(obj/structure/table/table) + var/obj/item/bodypart/limb = victim.get_bodypart(aimed_zone) || victim.get_bodypart(BODY_ZONE_CHEST) + var/others + var/self + if(table) + others = face_down ? "[bearer] slams [victim] face first into [table]!" : "[bearer] slams [victim] onto [table]!" + self = face_down ? "[bearer] slams you face first into [table]!" : "[bearer] slams you onto [table]!" + else + others = "[bearer] slams [victim] into the floor!" + self = "[bearer] slams you into the floor!" + victim.hv_hold_attack_message(others, self, null, bearer, vision_range = DEFAULT_MESSAGE_RANGE) + var/armour = victim.run_armor_check(limb, MELEE) + // No attack direction: a body driven into the floor throws no blood across the room (#509's heavy-hit spray) + gorilla_hold_spray(victim) + var/mob/living/carbon/carbon_victim = iscarbon(victim) ? victim : null + var/list/wounds_before = carbon_victim?.gorilla_wound_snapshot() + // What tg lays itself for a chest that bleeds inside from the blow (below): where, and what is there already + var/list/coughed_on = list(spot) + var/turf/faced = get_step(spot, victim.dir) + if(faced) + coughed_on += faced + var/list/blood_before = list() + for(var/turf/where as anything in coughed_on) + for(var/obj/effect/blood in where) + if(istype(blood, /obj/effect/decal/cleanable/blood) || istype(blood, /obj/effect/temp_visual/dir_setting/bloodsplatter)) + blood_before += blood + victim.apply_damage(table ? arm.slam_table_damage : arm.slam_floor_damage, BRUTE, limb || BODY_ZONE_CHEST, armour, wound_bonus = arm.punch_wound_bonus, attacking_item = fist) + victim.hv_flush_hit() + if(QDELETED(victim)) + return FALSE + . = !isnull(carbon_victim) && gorilla_slam_opened_wound(carbon_victim, wounds_before) + // A chest that bleeds inside from the blow coughs it up (tg's cracked ribs): real blood, which tg bursts one way over + // the top of the body and splashes on the tile it faces. A body on its back throws the slam's drops instead, so what + // tg has just laid is taken up and the drops (the same blood) go down in its place. + for(var/turf/where as anything in coughed_on) + for(var/obj/effect/blood in where) + if(blood in blood_before) + continue + if(istype(blood, /obj/effect/decal/cleanable/blood) || istype(blood, /obj/effect/temp_visual/dir_setting/bloodsplatter)) + qdel(blood) + . = TRUE + victim.apply_damage(table ? arm.slam_table_stamina : arm.slam_floor_stamina, STAMINA) + victim.Knockdown(arm.slam_knockdown) + gorilla_register_aggression(victim, bearer) + if(table) + var/datum/component/table_smash/smash = table.GetComponent(/datum/component/table_smash) + table.take_damage(arm.slam_table_break) + // Glass tables break as they do under any slammed body + if(!QDELETED(table)) + smash?.after_smash?.Invoke(victim) + log_combat(bearer, victim, table ? "piston-slammed onto a table" : "piston-slammed into the floor", "gorilla arms") + +/** + * What the impact looks and sounds like. Both cameras shake and the body flashes white. A ring spreads from under it: on + * the table top for a table slam, with the table's own crash; on a floor with cracks, and for Gorilla Arms dust and the + * heavy crash. With [bled], a few drops of blood are thrown out round it. + */ +/datum/gorilla_slam/proc/impact_fx(on_table, bled) + var/heavy = arm.slam_full_fx + for(var/mob/living/shaken in list(bearer, victim)) + shake_camera(shaken, heavy ? 3 : 2, heavy ? 2 : 1) + cyberware_flash_fx((posed && victim_rig?.puppet("body_[facing]")) || victim, 0.2 SECONDS) + // Level with the middle of the body: a table top is half way up to where tg draws a body lying on it + new /obj/effect/temp_visual/circle_wave/cyberware_slam/under_body(spot, heavy ? 0.8 : 0.5, on_table ? GORILLA_SLAM_TABLE_TOP / 2 : 0) + if(on_table) + playsound(victim, 'sound/effects/tableslam.ogg', 90, TRUE) + else if(heavy) + playsound(spot, 'sound/effects/meteorimpact.ogg', 40, TRUE) + new /obj/effect/temp_visual/cyberware_impact_cracks(spot, "damaged1", 220) + var/obj/effect/temp_visual/mook_dust/dust = cyberware_grey_dust(spot) + if(dust) + // On the tile's own row, as the ring is, so the body lying there draws over it + dust.pixel_x = 0 + dust.pixel_y = 0 + dust.pixel_w = -ICON_SIZE_X / 2 + dust.pixel_z = -ICON_SIZE_Y / 2 + else + playsound(spot, SFX_BODYFALL, 70, TRUE) + new /obj/effect/temp_visual/cyberware_impact_cracks(spot, "damaged1", 70) + if(bled && iscarbon(victim)) + gorilla_slam_blood(victim, spot) + +/** + * The slam's ring. tg's ring is drawn from half a tile toward the viewer, which puts it on the row in front of its own + * tile and over anybody lying there; this one stays on its tile's row and is centred by eye only, so the body draws over it. + */ +/obj/effect/temp_visual/circle_wave/cyberware_slam/under_body + pixel_x = 0 + pixel_y = 0 + pixel_w = -16 + pixel_z = -16 + +/obj/effect/temp_visual/circle_wave/cyberware_slam/under_body/Initialize(mapload, reach = 2, rise = 0) + pixel_z += rise + return ..() + +/// Whether [victim] has a wound that bleeds which it didn't have, or had milder, at [before] (gorilla_wound_snapshot()): a cut, a stab, or bone through the skin. +/proc/gorilla_slam_opened_wound(mob/living/carbon/victim, list/before) + for(var/datum/wound/wound as anything in victim.all_wounds) + if((wound in before) && before[wound] >= wound.severity) + continue + if(wound.blood_flow > 0 || (istype(wound, /datum/wound/blunt/bone) && wound.severity >= WOUND_SEVERITY_CRITICAL)) + return TRUE + return FALSE + +/// A few drops of [victim]'s blood thrown out round where it lies on [floor], in a ragged ring: no two the same size or the same way up. +/proc/gorilla_slam_blood(mob/living/carbon/victim, turf/floor) + if(!isturf(floor) || isgroundlessturf(floor) || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/list/blood_dna = victim.get_blood_dna_list() + var/list/viruses = victim.get_static_viruses() + var/drops = rand(GORILLA_SLAM_DROPS_MIN, GORILLA_SLAM_DROPS_MAX) + var/first = rand(0, 359) + for(var/i in 1 to drops) + var/obj/effect/decal/cleanable/blood/splatter/gorilla_skid/drop = new(floor, viruses, blood_dna) + if(QDELETED(drop)) + continue + var/way = first + i * 360 / drops + rand(-18, 18) + var/far = rand(GORILLA_SLAM_DROPS_NEAR, GORILLA_SLAM_DROPS_FAR) + var/matrix/shape = matrix() + shape.Scale(pick(0.45, 0.55, 0.7)) + shape.Turn(pick(0, 90, 180, 270)) + drop.transform = shape + // From under the body outward. The floor is seen from above and a little from the front, so the ring is flattened. + drop.pixel_x = 0 + drop.pixel_y = PIXEL_Y_OFFSET_LYING + animate(drop, pixel_x = round(cos(way) * far), pixel_y = round(sin(way) * far * 0.75) + PIXEL_Y_OFFSET_LYING, time = 0.12 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + +#undef SLAM_ARM_GRIP +#undef SLAM_ARM_POINT +#undef SLAM_ARM_HELD +#undef SLAM_ARM_SWING +#undef SLAM_EASE_LINEAR +#undef SLAM_EASE_OUT +#undef SLAM_EASE_IN +#undef SLAM_EASE_SINE +#undef SLAM_WAIST_Y diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_swing.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_swing.dm new file mode 100644 index 00000000000..3473f7dcffe --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_swing.dm @@ -0,0 +1,251 @@ +/** + * # Arm swings + * + * A Gorilla fist is thrown by an arm that moves: the piston punch draws its fist back and drives it out + * (gorilla_thrust.dm), and the haymaker winds its arm up, throws it and follows through (gorilla_haymaker.dm). Both + * are drawn with the puppet rig's pictures and maths (chrome_puppet_rig.dm), and both land their blow when the fist + * arrives, not at the click. What they share is here: + * + * - A swing is a list of beats (where the body and each arm are when the beat ends), cut into frames of a few degrees + * so a shoulder never leaves its body, and played on every piece at once. + * - The fist is sent to the part of the body the blow lands on, where that part is drawn. The body steps in behind it + * as far as it takes; an arm is only drawn so long. + */ + +/// Most degrees an arm or a body turns in one frame of a swing. +#define SWING_FRAME_TURN 12 +/// The longest one frame of an eased beat lasts. +#define SWING_FRAME_TIME (0.03 SECONDS) + +// A frame of a swing: list(time, the body's matrix, side -> list(turn, length), side -> a layer the arm takes) +#define SWING_FRAME_TIME_INDEX 1 +#define SWING_FRAME_BASE 2 +#define SWING_FRAME_ARMS 3 +#define SWING_FRAME_LAYERS 4 + +// ========================================================================= +// BEATS AND FRAMES +// ========================================================================= + +/// One beat of a swing: where the body and its arms are when it ends. +/datum/chrome_swing_beat + /// How long the beat lasts + var/time = 0 + /// Its pace (CHROME_SWING_*) + var/easing = CHROME_SWING_LINEAR + /// Pixels the body has moved from its rest, across and up the screen + var/shift_x = 0 + var/shift_y = 0 + /// Degrees the body is tipped, clockwise + var/tip = 0 + /// Side -> list(turn, length): the arms that move in this beat. One left out stays as it is + var/list/arms + /// Side -> the layer that arm takes as the beat starts + var/list/layers + /// Side -> list(degrees, layer): the layer that arm takes once it is turned less than that from hanging + var/list/layer_once_under + +/datum/chrome_swing_beat/New(time = 0, easing = CHROME_SWING_LINEAR, shift_x = 0, shift_y = 0, tip = 0) + src.time = time + src.easing = easing + src.shift_x = shift_x + src.shift_y = shift_y + src.tip = tip + +/// Sends the arm on [side] to [pose] (list(turn, length)) in this beat, on [layer] from its start if one is given. +/datum/chrome_swing_beat/proc/arm(side, list/pose, layer) + LAZYSET(arms, side, pose) + if(!isnull(layer)) + LAZYSET(layers, side, layer) + return src + +/// The body's matrix [tip] degrees over and moved by [shift_x], [shift_y]: tipped about its middle, then moved. +/proc/chrome_swing_base(shift_x = 0, shift_y = 0, tip = 0) + var/matrix/base = matrix() + if(tip) + base.Turn(tip) + if(shift_x || shift_y) + base.Translate(shift_x, shift_y) + return base + +/** + * Cuts [beats] into frames. [arms_from] (side -> list(turn, length)) is where each arm starts, and [shift_x], [shift_y] + * and [tip] where the body does. Every arm in [arms_from] is in every frame, moving or not. + */ +/proc/chrome_swing_frames(list/beats, list/arms_from, shift_x = 0, shift_y = 0, tip = 0) + var/list/frames = list() + var/list/arms_now = list() + for(var/side in arms_from) + var/list/pose = arms_from[side] + arms_now[side] = list(pose[1], pose[2]) + for(var/datum/chrome_swing_beat/beat as anything in beats) + var/steps = 1 + if(beat.time > 0) + for(var/side in beat.arms) + var/list/from_pose = arms_now[side] + var/list/to_pose = beat.arms[side] + if(from_pose) + steps = max(steps, CEILING(abs(to_pose[1] - from_pose[1]) / SWING_FRAME_TURN, 1)) + steps = max(steps, CEILING(abs(beat.tip - tip) / SWING_FRAME_TURN, 1)) + if(beat.easing != CHROME_SWING_LINEAR) + steps = max(steps, min(CEILING(beat.time / SWING_FRAME_TIME, 1), 12)) + var/list/switched = list() + for(var/i in 1 to steps) + var/progress = i / steps + switch(beat.easing) + if(CHROME_SWING_EASE_OUT) + progress = sin(90 * progress) + if(CHROME_SWING_EASE_IN_OUT) + progress = (1 - cos(180 * progress)) / 2 + var/matrix/base = chrome_swing_base(shift_x + (beat.shift_x - shift_x) * progress, shift_y + (beat.shift_y - shift_y) * progress, tip + (beat.tip - tip) * progress) + var/list/frame_arms = list() + var/list/frame_layers + for(var/side in arms_now) + var/list/from_pose = arms_now[side] + var/list/to_pose = beat.arms?[side] + var/list/pose = to_pose ? list(from_pose[1] + (to_pose[1] - from_pose[1]) * progress, from_pose[2] + (to_pose[2] - from_pose[2]) * progress) : from_pose + frame_arms[side] = pose + if(i == 1 && !isnull(beat.layers?[side])) + LAZYSET(frame_layers, side, beat.layers[side]) + var/list/under = beat.layer_once_under?[side] + if(under && !switched[side] && abs(pose[1]) < under[1]) + switched[side] = TRUE + LAZYSET(frame_layers, side, under[2]) + frames += list(list(beat.time / steps, base, frame_arms, frame_layers)) + for(var/side in beat.arms) + if(arms_now[side]) + var/list/to_pose = beat.arms[side] + arms_now[side] = list(to_pose[1], to_pose[2]) + shift_x = beat.shift_x + shift_y = beat.shift_y + tip = beat.tip + return frames + +/** + * Plays [frames] on the pieces of one body, with [rig]'s maths. Everything in [bodies] (a puppet standing for the body, + * anything that rides on it) takes each frame's matrix; each arm in [arms] (side -> its puppet) takes its pose on that + * matrix, and any layer the frame gives it. [real_body] is the body itself when it is the one on show: it takes the + * matrices alongside whatever else is moving it, as tg's own lunge does. [settle] (piece -> list(pixel_w, pixel_x, + * pixel_y, pixel_z)) are offsets a piece eases to over its first frame. + */ +/proc/chrome_swing_play(datum/chrome_rig/rig, list/frames, list/bodies, list/arms, atom/movable/real_body, list/settle) + if(!length(frames)) + return + for(var/atom/movable/piece as anything in bodies) + var/list/rest = settle?[piece] + var/first = TRUE + for(var/list/frame as anything in frames) + if(!first) + animate(transform = frame[SWING_FRAME_BASE], time = frame[SWING_FRAME_TIME_INDEX]) + continue + first = FALSE + if(rest) + animate(piece, transform = frame[SWING_FRAME_BASE], pixel_w = rest[1], pixel_x = rest[2], pixel_y = rest[3], pixel_z = rest[4], time = frame[SWING_FRAME_TIME_INDEX]) + else + animate(piece, transform = frame[SWING_FRAME_BASE], time = frame[SWING_FRAME_TIME_INDEX]) + if(real_body) + var/first = TRUE + for(var/list/frame as anything in frames) + if(first) + animate(real_body, transform = frame[SWING_FRAME_BASE], time = frame[SWING_FRAME_TIME_INDEX], flags = ANIMATION_PARALLEL) + first = FALSE + else + animate(transform = frame[SWING_FRAME_BASE], time = frame[SWING_FRAME_TIME_INDEX]) + for(var/side in arms) + var/atom/movable/arm = arms[side] + if(isnull(arm)) + continue + var/list/rest = settle?[arm] + var/first = TRUE + for(var/list/frame as anything in frames) + var/list/pose = frame[SWING_FRAME_ARMS][side] + if(isnull(pose)) + continue + var/matrix/posed = rig.arm_matrix(side, pose[1], pose[2], frame[SWING_FRAME_BASE]) + var/new_layer = frame[SWING_FRAME_LAYERS]?[side] + if(!first) + if(!isnull(new_layer)) + animate(layer = new_layer, time = 0) + animate(transform = posed, time = frame[SWING_FRAME_TIME_INDEX]) + continue + first = FALSE + if(!isnull(new_layer)) + arm.layer = new_layer + if(rest) + animate(arm, transform = posed, pixel_w = rest[1], pixel_x = rest[2], pixel_y = rest[3], pixel_z = rest[4], time = frame[SWING_FRAME_TIME_INDEX]) + else + animate(arm, transform = posed, time = frame[SWING_FRAME_TIME_INDEX]) + +// ========================================================================= +// WHERE A FIST LANDS +// ========================================================================= + +/// Which way [facing] steps: list(across, up). +/proc/chrome_swing_way(facing) + switch(facing) + if(EAST) + return list(1, 0) + if(WEST) + return list(-1, 0) + if(NORTH) + return list(0, 1) + return list(0, -1) + +/** + * Where the middle of a fist from [bearer] stops for a blow on [zone] of [target]: list(x, y), pixels from where the + * middle of [bearer] is drawn. The body is taken where it is drawn now: for the way it faces and lies, its offsets, and + * short of its tile when it is hit mid-stride. A standing body beside the bearer is met at its edge, at the height of + * the part, the knuckles just on it; anything else, on the part itself. Null when they aren't on one level. + */ +/proc/gorilla_fist_target(mob/living/bearer, atom/movable/target, zone) + var/turf/from = get_turf(bearer) + var/turf/onto = get_turf(target) + if(isnull(from) || isnull(onto) || from.z != onto.z) + return null + var/list/part = gorilla_zone_point(target, zone) + var/list/lag = gorilla_glide_lag(target) + var/body_x = (onto.x - from.x) * ICON_SIZE_X + target.pixel_x + target.pixel_w + lag[1] - bearer.pixel_x - bearer.pixel_w + var/body_y = (onto.y - from.y) * ICON_SIZE_Y + target.pixel_y + target.pixel_z + lag[2] - bearer.pixel_y - bearer.pixel_z + var/mob/living/struck = target + if(ishuman(target) && struck.body_position != LYING_DOWN && body_x && abs(body_x) >= abs(body_y)) + // Beside the bearer: half the width of tg's human sprite at that height, seen side on or from the front or back + var/side_on = struck.dir & (EAST|WEST) + var/half = 4 + switch(check_zone(zone)) + if(BODY_ZONE_CHEST) + half = side_on ? 4 : 7 + if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) + half = side_on ? 4 : 8 + if(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + half = side_on ? 2 : 5 + return list(body_x + (body_x > 0 ? -1 : 1) * (half + GORILLA_FIST_KNUCKLES), body_y + part[2]) + // Up or down the screen, or lying: the fist is drawn on the part, a little to the bearer's side of it + var/to_x = body_x + part[1] + var/to_y = body_y + part[2] + var/far = sqrt(to_x * to_x + to_y * to_y) + if(far < 3) + return list(to_x, to_y) + return list(to_x - to_x / far * 2, to_y - to_y / far * 2) + +/** + * The swing that lands the fist of [rig]'s arm on [side] at [x], [y] (gorilla_fist_target()): list(step, turn, length). + * The body steps [way] (chrome_swing_way()) by at least [least] pixels and at most [most], tipped [tip] degrees: as few + * as bring the fist there with the arm no longer than [easy] times its length. The arm is never longer than [cap]: the + * fist stops short instead. [near_turn] picks which way round the arm comes. + */ +/proc/chrome_swing_reach(datum/chrome_rig/rig, side, x, y, list/way, least, most, tip, easy, cap, near_turn) + var/list/found + for(var/step in least to most) + var/list/pose = rig.pose_reaching(side, x, y, near_turn, chrome_swing_base(way[1] * step, way[2] * step, tip)) + found = list(step, pose[1], pose[2]) + if(pose[2] <= easy) + break + found[3] = min(found[3], cap) + return found + +#undef SWING_FRAME_TURN +#undef SWING_FRAME_TIME +#undef SWING_FRAME_TIME_INDEX +#undef SWING_FRAME_BASE +#undef SWING_FRAME_ARMS +#undef SWING_FRAME_LAYERS diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm new file mode 100644 index 00000000000..d2b4a5d58a9 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm @@ -0,0 +1,513 @@ +/** + * # Gorilla throws and wall impacts + * + * Every body the Gorilla Arms send flying (the one-tile shove, the hurl, the haymaker) takes one + * safe path: gorilla_plan_knockback() walks the tiles first and stops the body on the last good one + * before space, open air, a chasm, lava, a tile with no gravity, a window, a door, a railing or a + * grille. It never launches at all from a tile with no gravity. A wall or a standing body in the way + * is hit, as tg would. + * + * Bodies fly flat, without spinning, and skid. Along the way the body leaves a scuff and a little dust + * on each tile, or a ragged smear of blood drops if it bleeds, on at most GORILLA_TRAIL_MAX tiles. + * + * A Gorilla throw that meets a wall hurts through tg's damage and wound rolls (owner D48): a blunt hit + * on the part that meets it and split skin on the wall's edge, harder for a haymaker than a hurl, and + * likelier to wound a part that is already beaten. It replaces tg's own random-limb wall crash for that + * throw. A shove only dents the wall. Any hard throw into a wall (a Gorilla's or anyone else's) then + * leaves a mark chosen by the injuries the body carries after the hit (owner D38): no broken skin dents + * the wall and knocks chips off it; a bleeding cut or a compound fracture splashes a half-moon on the + * floor and smears the wall's edge; heavy bleeding or a torn artery sprays up the wall and runs. The + * body drops at the foot of the wall with tg's knockdown, no slide (D44). A haymaker always dents too. + */ + +/mob/living + /// The Gorilla throw this mob is flying in, if any (hyperviolence_gorilla). Cleared when it lands. + var/datum/gorilla_flight/gorilla_flight + +/// One Gorilla throw in flight: who threw it, what kind, and what it has left on the floor. +/datum/gorilla_flight + /// The body in the air + var/mob/living/victim + /// Who threw it + var/mob/living/thrower + /// TRUE for a haymaker: it always dents a wall + var/haymaker = FALSE + /// GORILLA_IMPACT_*: how hard it meets a wall + var/impact = GORILLA_IMPACT_HURL + /// Decals left so far + var/trail_left = 0 + /// The punch that launched it broke the skin: the trail is blood even if nothing bled before + var/wounded = FALSE + /// Safety timer that ends the flight if the landing signal never comes + var/expiry_timer + +/datum/gorilla_flight/New(mob/living/victim, mob/living/thrower, haymaker = FALSE, wounded = FALSE, impact = GORILLA_IMPACT_HURL) + . = ..() + src.victim = victim + src.thrower = thrower + src.haymaker = haymaker + src.wounded = wounded + src.impact = haymaker ? GORILLA_IMPACT_HAYMAKER : impact + victim.gorilla_flight = src + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(victim, COMSIG_MOVABLE_THROW_LANDED, PROC_REF(on_landed)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + expiry_timer = addtimer(CALLBACK(src, PROC_REF(end)), 3 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/gorilla_flight/Destroy() + if(expiry_timer) + deltimer(expiry_timer) + expiry_timer = null + if(victim) + UnregisterSignal(victim, list(COMSIG_MOVABLE_MOVED, COMSIG_MOVABLE_THROW_LANDED, COMSIG_QDELETING)) + if(victim.gorilla_flight == src) + victim.gorilla_flight = null + victim = null + thrower = null + return ..() + +/// Ends the flight. Safe to call more than once. +/datum/gorilla_flight/proc/end() + expiry_timer = null + if(!QDELETED(src)) + qdel(src) + +/datum/gorilla_flight/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + end() + +/// Whether the body leaves blood rather than a scuff. +/datum/gorilla_flight/proc/bleeds() + if(victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return FALSE + if(wounded) + return TRUE + if(!iscarbon(victim)) + return FALSE + var/mob/living/carbon/body = victim + return body.get_bleed_rate() > 0 + +/// Each tile left behind gets a scuff and dust, or a streak of blood. +/datum/gorilla_flight/proc/on_moved(atom/movable/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + if(trail_left >= GORILLA_TRAIL_MAX || !isturf(old_loc) || !victim.throwing) + return + var/turf/left = old_loc + if(!isfloorturf(left) || !left.has_gravity()) + return + trail_left++ + var/travel = get_dir(old_loc, victim) || dir + if(bleeds()) + gorilla_lay_skid_blood(left, travel, victim.get_blood_dna_list(), victim.get_static_viruses()) + return + new /obj/effect/temp_visual/gorilla_scuff(left, travel) + var/obj/effect/temp_visual/mook_dust/dust = cyberware_grey_dust(left) + if(dust) + dust.transform = matrix().Scale(0.5) + +/// Where it skids to a stop: a splash if it bleeds. +/datum/gorilla_flight/proc/on_landed(datum/source, datum/thrownthing/throwing_datum) + SIGNAL_HANDLER + var/turf/stop = get_turf(victim) + if(isfloorturf(stop) && stop.has_gravity() && bleeds() && trail_left < GORILLA_TRAIL_MAX) + hv_blood_lay_spray(stop, /obj/effect/decal/cleanable/blood/hv_spray, throwing_datum?.init_dir || victim.dir, victim.get_blood_dna_list(), victim.get_static_viruses()) + end() + +/** + * A bleeding body's skid over [floor] along [travel]: two to GORILLA_SKID_SPLATS drops and small splats + * of tg's blood, each a different shape, size and place along the line, so tiles don't repeat. + */ +/proc/gorilla_lay_skid_blood(turf/floor, travel, list/blood_dna, list/viruses) + var/along_x = (travel & EAST) ? 1 : ((travel & WEST) ? -1 : 0) + var/along_y = (travel & NORTH) ? 1 : ((travel & SOUTH) ? -1 : 0) + for(var/i in 1 to rand(2, GORILLA_SKID_SPLATS)) + var/obj/effect/decal/cleanable/blood/splatter/gorilla_skid/splat = new(floor, viruses, blood_dna) + if(QDELETED(splat)) + continue + // Spread along the line of travel, a little either side of it + var/along = rand(-13, 13) + var/across = rand(-3, 3) + splat.pixel_x = along * along_x + across * along_y + splat.pixel_y = along * along_y + across * along_x + var/matrix/shape = matrix() + var/size = pick(0.6, 0.75, 0.9, 1.1) + shape.Scale(size * pick(1, -1), size) + shape.Turn(pick(0, 90, 180, 270)) + splat.transform = shape + +/// One drop or splat of a bleeding body's skid. Several share a tile. +/obj/effect/decal/cleanable/blood/splatter/gorilla_skid + random_icon_states = list("gibbl1", "gibbl2", "gibbl3", "gibbl4", "gibbl5", "drip2", "drip4") + mergeable_decal = FALSE + // Thin enough that walking through it does not leave footprints + bloodiness = 5 + +/// The grey drag mark a launched body leaves on the floor. It fades on its own. +/obj/effect/temp_visual/gorilla_scuff + icon = GORILLA_GORE_ICON + icon_state = "scuff" + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + randomdir = FALSE + duration = 8 SECONDS + +/obj/effect/temp_visual/gorilla_scuff/Initialize(mapload, travel_dir) + . = ..() + setDir(travel_dir || SOUTH) + animate(src, alpha = 255, time = duration - 3 SECONDS) + animate(alpha = 0, time = 3 SECONDS) + +// ========================================================================= +// THE SAFE PATH +// ========================================================================= + +/** + * Whether a body flying from [here] into [next] must stop short on [here] without hitting anything: + * the edge of the map, a hole, lava, open air, space, no gravity, or glass, a door, a railing or a + * grille in the way (the body would break the glass or the door would take the hit). + */ +/proc/gorilla_throw_stops_short(turf/here, turf/next, direction) + if(isnull(next)) + return TRUE + if(isspaceturf(next) || isopenspaceturf(next) || ischasm(next) || islava(next) || isgroundlessturf(next)) + return TRUE + if(!isclosedturf(next) && !next.has_gravity()) + return TRUE + // Something on the edge of our own tile, facing the way we fly + for(var/obj/border in here) + if(!border.density || !(border.flags_1 & ON_BORDER_1) || !(border.dir & direction)) + continue + if(gorilla_throw_is_barrier(border)) + return TRUE + for(var/obj/thing in next) + if(!thing.density) + continue + if((thing.flags_1 & ON_BORDER_1) && !(thing.dir & REVERSE_DIR(direction))) + continue + if(gorilla_throw_is_barrier(thing)) + return TRUE + return FALSE + +/// Glass, doors, railings and grilles: a flying body stops before them rather than going through or breaking them. +/proc/gorilla_throw_is_barrier(obj/thing) + return istype(thing, /obj/structure/window) || istype(thing, /obj/machinery/door) || istype(thing, /obj/structure/railing) || istype(thing, /obj/structure/grille) || istype(thing, /obj/structure/falsewall) + +/** + * Plans a knockback of [victim] up to [range] tiles along [direction]. Returns a list: + * (the turf to throw at, how many tiles it travels, GORILLA_THROW_* why it stops, what it hits or null). + * A distance of 0 means no throw at all. + */ +/proc/gorilla_plan_knockback(mob/living/victim, direction, range) + var/turf/here = get_turf(victim) + if(isnull(here) || !direction || range <= 0) + return list(here, 0, GORILLA_THROW_SHORT, null) + if(!here.has_gravity()) + return list(here, 0, GORILLA_THROW_SHORT, null) + var/turf/current = here + for(var/step in 1 to range) + var/turf/next = get_step(current, direction) + if(isnull(next)) + return list(current, step - 1, GORILLA_THROW_SHORT, null) + if(isclosedturf(next)) + // The wall is hit: throw at it and the body stops against it + return list(next, step, GORILLA_THROW_WALL, next) + if(gorilla_throw_stops_short(current, next, direction)) + return list(current, step - 1, GORILLA_THROW_SHORT, null) + for(var/mob/living/standing in next) + if(standing.density && standing != victim) + return list(next, step, GORILLA_THROW_MOB, standing) + current = next + return list(current, range, GORILLA_THROW_CLEAR, null) + +/** + * Throws [victim] along [direction] on the safe path. [haymaker] marks a haymaker's flight; [wounded] + * makes the trail blood. Returns the distance it will travel, 0 if it doesn't move. + */ +/proc/gorilla_launch(mob/living/victim, mob/living/thrower, direction, range, speed = 2, gentle = FALSE, haymaker = FALSE, wounded = FALSE) + if(QDELETED(victim) || victim.anchored || victim.buckled || victim.move_resist == INFINITY) + return 0 + var/list/plan = gorilla_plan_knockback(victim, direction, range) + var/turf/target = plan[1] + var/distance = plan[2] + if(distance <= 0 || isnull(target)) + return 0 + // Megafauna and anything bolted down shrug it off, as safe_throw_at() decides + if(MOVE_FORCE_STRONG < victim.move_resist * MOVE_FORCE_THROW_RATIO) + return 0 + if(victim.gorilla_flight) + qdel(victim.gorilla_flight) + var/datum/gorilla_flight/flight = new(victim, thrower, haymaker, wounded, gentle ? GORILLA_IMPACT_SHOVE : GORILLA_IMPACT_HURL) + // Flat, no spin: the body skids to a stop + if(!victim.throw_at(target, distance, speed, thrower, spin = FALSE, gentle = gentle)) + flight.end() + return 0 + return distance + +// ========================================================================= +// WALL IMPACTS (owner D38) +// ========================================================================= + +/// The wounds on a body right now, with their severities, to tell what a hit added. +/mob/living/carbon/proc/gorilla_wound_snapshot() + . = list() + for(var/datum/wound/wound as anything in all_wounds) + .[wound] = wound.severity + +/** + * Which picture a wall impact leaves, from the injuries the body carries after the hit (owner D38, D48): + * GORILLA_SPLAT_SPRAY for a heavy cut or stab or a torn artery, GORILLA_SPLAT_EDGE for a bleeding + * cut or stab or a compound fracture, else GORILLA_SPLAT_DENT. A body that can't bleed only dents. + */ +/mob/living/carbon/proc/gorilla_splat_kind() + if(can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return GORILLA_SPLAT_DENT + var/kind = GORILLA_SPLAT_DENT + for(var/datum/wound/wound as anything in all_wounds) + var/series = wound.get_pregen_data()?.wound_series + // A torn aorta or throat (#509), or a limb artery torn by a blade + if(series == WOUND_SERIES_HV_AORTA || series == WOUND_SERIES_HV_CAROTID || series == "wound_series_hv_limb_artery") + return GORILLA_SPLAT_SPRAY + if(istype(wound, /datum/wound/slash) || istype(wound, /datum/wound/pierce)) + if(wound.severity >= WOUND_SEVERITY_SEVERE) + return GORILLA_SPLAT_SPRAY + if(wound.severity >= WOUND_SEVERITY_MODERATE) + kind = GORILLA_SPLAT_EDGE + else if(istype(wound, /datum/wound/blunt/bone) && wound.severity >= WOUND_SEVERITY_CRITICAL) + // A compound fracture breaks the skin + kind = GORILLA_SPLAT_EDGE + return kind + +/mob/living/carbon/throw_impact(atom/hit_atom, datum/thrownthing/throwingdatum) + var/datum/gorilla_flight/flight = gorilla_flight + if(!isclosedturf(hit_atom) || !hit_atom.density || !istype(throwingdatum)) + return ..() + var/was_gentle = throwingdatum.gentle + // A Gorilla throw's crash is its own (gorilla_wall_crash()): tg's random-limb crash is held off by + // passing the throw to the parent as gentle, then put back. + var/own_crash = !isnull(flight) && flight.victim == src + if(own_crash) + throwingdatum.gentle = TRUE + . = ..() + throwingdatum.gentle = was_gentle + if(QDELETED(src) || (was_gentle && !own_crash)) + return + gorilla_wall_impact(hit_atom, own_crash ? flight : null) + +/** + * A landing against [wall]: the Gorilla throw's crash, if it was one, then the marks by the wounds after it. + */ +/mob/living/carbon/proc/gorilla_wall_impact(turf/wall, datum/gorilla_flight/flight) + var/turf/floor = get_turf(src) + var/toward = get_dir(floor, wall) + if(!toward) + return + if(flight) + gorilla_wall_crash(wall, flight, toward) + if(QDELETED(src)) + return + // A shove only bumps the wall, whatever the body carries + var/kind = flight?.impact == GORILLA_IMPACT_SHOVE ? GORILLA_SPLAT_DENT : gorilla_splat_kind() + // Only a straight hit on a wall face gets the pictures; a glancing corner hit gets the dent. + if(!(toward in GLOB.cardinals)) + gorilla_wall_dent(wall, toward) + return + switch(kind) + if(GORILLA_SPLAT_SPRAY) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray, toward) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs, toward) + if(GORILLA_SPLAT_EDGE) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash, toward) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/edge, toward) + else + gorilla_wall_chips(floor, toward) + if(kind == GORILLA_SPLAT_DENT || flight?.haymaker) + gorilla_wall_dent(wall, toward) + +/** + * A Gorilla throw meets [wall] going [toward] (owner D48). A shove does nothing more than tg's gentle + * throw. A hurl or a haymaker knocks the body down as tg's crash does and lands two real hits on the + * part that meets the wall: a blunt one (tg's bone wounds) and the skin split on the wall's edge (tg's + * cuts), each through armour and tg's wound roll. A part that is already beaten gets a higher wound + * bonus, so a hurt body is likelier to break open. + */ +/mob/living/carbon/proc/gorilla_wall_crash(turf/wall, datum/gorilla_flight/flight, toward) + var/blunt + var/blunt_wound + var/edge + var/edge_wound + switch(flight.impact) + if(GORILLA_IMPACT_HAYMAKER) + blunt = GORILLA_WALL_HAYMAKER_BLUNT + blunt_wound = GORILLA_WALL_HAYMAKER_BLUNT_WOUND + edge = GORILLA_WALL_HAYMAKER_EDGE + edge_wound = GORILLA_WALL_HAYMAKER_EDGE_WOUND + if(GORILLA_IMPACT_HURL) + blunt = GORILLA_WALL_HURL_BLUNT + blunt_wound = GORILLA_WALL_HURL_BLUNT_WOUND + edge = GORILLA_WALL_HURL_EDGE + edge_wound = GORILLA_WALL_HURL_EDGE_WOUND + else + return + Paralyze(GORILLA_WALL_KNOCKDOWN) + visible_message(span_danger("[src] slams into [wall]!"), span_userdanger("You slam into [wall]!")) + playsound(src, 'sound/items/weapons/punch1.ogg', 50, TRUE) + log_combat(flight.thrower || wall, src, "slammed into a wall", null, "(gorilla throw, impact [flight.impact])") + var/obj/item/bodypart/part = get_bodypart(gorilla_wall_zone(toward)) || get_bodypart(BODY_ZONE_CHEST) + if(isnull(part)) + return + var/hurt_bonus = min(GORILLA_WALL_HURT_MAX, round(part.brute_dam / GORILLA_WALL_HURT_PER_POINT)) + var/armour = run_armor_check(part, MELEE, silent = TRUE) + // The wall's marks are the picture; no #509 blood fan on top of them + gorilla_hold_spray(src) + apply_damage(blunt, BRUTE, part, armour, wound_bonus = blunt_wound + hurt_bonus, attack_direction = toward) + if(QDELETED(src) || part.owner != src) + return + apply_damage(edge, BRUTE, part, armour, wound_bonus = edge_wound + hurt_bonus, sharpness = SHARP_EDGED, attack_direction = toward) + +/** + * The part that meets a wall at [toward]: a body side-on to it takes it on the shoulder half the time, + * otherwise the chest, sometimes the head. + */ +/mob/living/carbon/proc/gorilla_wall_zone(toward) + if(turn(dir, -90) == toward && prob(50)) + return BODY_ZONE_R_ARM + if(turn(dir, 90) == toward && prob(50)) + return BODY_ZONE_L_ARM + return prob(75) ? BODY_ZONE_CHEST : BODY_ZONE_HEAD + +/// Lays (or refreshes) one wall-impact decal on the floor tile in front of the wall. +/mob/living/carbon/proc/gorilla_lay_wall_decal(turf/floor, decal_type, toward) + if(!isturf(floor) || isclosedturf(floor) || isgroundlessturf(floor)) + return null + var/list/blood_dna = get_blood_dna_list() + var/list/viruses = get_static_viruses() + for(var/obj/effect/decal/cleanable/blood/hv_spray/existing in floor) + if(existing.type != decal_type || existing.dir != toward || existing.dried || QDELETED(existing)) + continue + existing.add_blood_DNA(blood_dna, viruses) + return existing + var/obj/effect/decal/cleanable/blood/hv_spray/fresh = new decal_type(floor, viruses, blood_dna) + if(QDELETED(fresh)) + return null + fresh.hv_aim(toward) + return fresh + +/// Chips knocked off the wall where the body hit it. +/mob/living/carbon/proc/gorilla_wall_chips(turf/floor, toward) + var/obj/effect/abstract/gorilla_chips/chips = new(floor) + chips.aim(toward) + +/// A body-sized dent in a wall, kept with the wall's bullet holes and dents so a welder repairs it. +/proc/gorilla_wall_dent(turf/closed/wall/wall, toward) + if(!istype(wall) || LAZYLEN(wall.dent_decals) >= MAX_DENT_DECALS) + return FALSE + if(!(toward in GLOB.cardinals)) + toward = (toward & (EAST|WEST)) || (toward & (NORTH|SOUTH)) + var/mutable_appearance/dent = mutable_appearance(GORILLA_GORE_ICON, "wall_dent_[dir2text(toward)]", BULLET_HOLE_LAYER) + if(LAZYLEN(wall.dent_decals)) + wall.cut_overlay(wall.dent_decals) + wall.dent_decals += dent + else + wall.dent_decals = list(dent) + wall.add_overlay(wall.dent_decals) + return TRUE + +/// A short burst of grey chips off the contact point. +/obj/effect/abstract/gorilla_chips + name = "chips" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + layer = ABOVE_MOB_LAYER + +/obj/effect/abstract/gorilla_chips/Initialize(mapload) + . = ..() + particles = new /particles/gorilla_chips + addtimer(CALLBACK(src, PROC_REF(stop)), 0.2 SECONDS) + QDEL_IN(src, 1 SECONDS) + +/// Throws the chips back off the wall at [toward]. +/obj/effect/abstract/gorilla_chips/proc/aim(toward) + var/dx = (toward & EAST) ? 1 : ((toward & WEST) ? -1 : 0) + var/dy = (toward & NORTH) ? 1 : ((toward & SOUTH) ? -1 : 0) + pixel_x = dx * 14 + pixel_y = dy * 14 + var/particles/gorilla_chips/burst = particles + burst.velocity = generator(GEN_BOX, list(-dx * 3 - 1.5, -dy * 3 - 1.5, 0), list(-dx * 1 + 1.5, -dy * 1 + 1.5, 0), UNIFORM_RAND) + +/obj/effect/abstract/gorilla_chips/proc/stop() + if(particles) + particles.spawning = 0 + +/particles/gorilla_chips + icon = 'icons/effects/particles/generic.dmi' + icon_state = list("dot" = 3, "cross" = 1) + color = "#8a8e96" + count = 12 + spawning = 6 + lifespan = 0.5 SECONDS + fade = 0.25 SECONDS + position = generator(GEN_BOX, list(-4, -4, 0), list(4, 4, 0), UNIFORM_RAND) + velocity = generator(GEN_BOX, list(-2, -2, 0), list(2, 2, 0), UNIFORM_RAND) + gravity = list(0, -0.4) + friction = 0.1 + scale = 0.5 + +// ---- The decals. Greyscale art tinted to the victim's blood, on the floor tile in front of the wall. ---- + +/// Option C: a half-moon splash on the floor at the foot of the wall. Mops like floor blood. +/obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash + name = "blood splatter" + icon = GORILLA_GORE_LARGE_ICON + icon_state = "impact_splash" + random_icon_states = null + bloodiness = 20 + +/obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash/hv_aim(spray_dir) + setDir(spray_dir) + // Centred, the half-moon's flat side lies on this tile's edge toward the wall in every facing, so the + // splash meets the wall's foot. Its ragged 2 px fringe past that sits under the wall, which draws over + // floor decals. Pulled back any further, floor shows between the blood and the wall. + pixel_x = -16 + pixel_y = -16 + +/// Wall pictures: 64x64 on the floor tile at -16,-16, drawn up onto the wall's near edge. +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray + name = "blood spray" + icon = GORILLA_GORE_LARGE_ICON + icon_state = "impact_spray" + random_icon_states = null + alpha = 230 + +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/hv_aim(spray_dir) + setDir(spray_dir) + pixel_x = -16 + pixel_y = -16 + +/// Option A's runs down the wall face. They crawl down as they appear. +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs + name = "blood runs" + icon_state = "impact_runs" + alpha = 220 + +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs/hv_aim(spray_dir) + . = ..() + // The runs grow downward from their top on the wall face, so they never show past it. Parallel: tg's + // drying animation keeps running. + var/top = 0 + switch(spray_dir) + if(SOUTH) + top = -17 + if(NORTH) + top = 25 + else + top = -1 + var/matrix/start = matrix() + start.Scale(1, 0.25) + start.Translate(0, round(top * 0.75)) + transform = start + animate(src, transform = matrix(), time = 3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + +/// Option C's thin band on the wall's edge where the body slid down it. +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/edge + name = "blood smear" + icon_state = "impact_edge" + alpha = 220 diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_thrust.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_thrust.dm new file mode 100644 index 00000000000..567afa6aae9 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_thrust.dm @@ -0,0 +1,306 @@ +/** + * # The piston punch's arm + * + * A piston punch is thrown by its arm: the fist draws back, drives out onto the part it hits with the body lunging + * behind it, stays there as the blow lands, and comes back to rest, in three tenths of a second (the swing is + * gorilla_swing.dm's). + * + * The punch comes about once a second, so it does not take the whole body's picture each time and stand puppets for + * all of it, as a finisher does. The punching arm alone is cut from one picture of the bearer, kept until their look + * changes, and drawn by a puppet over the real body, whose own arm is masked out for as long as the punch lasts. The + * real body does its own lunging. A bearer who can't be drawn that way lunges as tg has them lunge. + */ + +/// The mask on a body whose punching arm is drawn by a puppet. +#define GORILLA_THRUST_FILTER "gorilla_thrust" + +/mob/living/carbon/human + /// The pictures a piston punch's arm is drawn from, by facing: "[dir]" -> /datum/gorilla_arm_look (gorilla_swing.dm) + var/list/gorilla_arm_looks + /// The piston punch being drawn on us now + var/datum/gorilla_thrust/gorilla_thrust + +/// One picture of a bearer in one facing, and the arms cut from it. Good until their look changes. +/datum/gorilla_arm_look + /// What the bearer looked like when it was taken (gorilla_look_key()) + var/key + /// Holds the picture and does the maths. Never hides anyone and stands no puppets + var/datum/chrome_rig/rig + /// Cuts made from the picture so far, by name + var/list/pictures = list() + /// Side -> TRUE once that arm's picture has been shown + var/list/shown = list() + +/datum/gorilla_arm_look/Destroy() + QDEL_NULL(rig) + pictures = null + return ..() + +/// The picture of the arm on [side]. +/datum/gorilla_arm_look/proc/arm_picture(side) + . = pictures["arm_[side]"] + if(isnull(.)) + . = (rig.behind_body(side) && far_arm_picture(side)) || rig.arm_picture(side) + pictures["arm_[side]"] = . + +/** + * The picture of the arm on [side] when it is the far one, hidden behind the body in this facing: that arm as it shows + * from the bearer's other side, turned round, a pixel further back and in shadow. The rig's own far arm is a copy of + * the near one, which would bring along whatever the near hand holds; the far hand is the one punching, and empty. + * Null if the other side can't be pictured. + */ +/datum/gorilla_arm_look/proc/far_arm_picture(side) + var/mob/living/body = rig.body + if(QDELETED(body)) + return null + var/datum/chrome_rig/other_side = new(body, REVERSE_DIR(rig.facing)) + // getFlatIcon() draws a body in its own facing, so it is turned for the picture + var/real_dir = body.dir + body.dir = other_side.facing + var/taken = other_side.snapshot() + body.dir = real_dir + var/icon/picture = taken ? other_side.cut("rig_arm_[side]") : null + qdel(other_side) + if(isnull(picture)) + return null + picture.Flip(EAST) + picture.Shift(rig.facing == EAST ? WEST : EAST, 1) + picture.Blend(rgb(170, 170, 178), ICON_MULTIPLY) + return picture + +/// The torso under the arm on [side], or null where there is none. +/datum/gorilla_arm_look/proc/fill_picture(side) + if("fill_[side]" in pictures) + return pictures["fill_[side]"] + . = rig.fill_picture(side) + pictures["fill_[side]"] = . + +/// What [bearer] looks like, as text: everything drawn on them and the way they face. Not where or how they stand. +/proc/gorilla_look_key(mob/living/carbon/human/bearer) + var/list/parts = list("[bearer.dir]", "[bearer.icon]", "[bearer.icon_state]") + for(var/overlay in bearer.overlays) + parts += ref(overlay) + for(var/underlay in bearer.underlays) + parts += ref(underlay) + return parts.Join("|") + +/// The picture a piston punch's arm is cut from for the way [bearer] faces and looks now, taken if there is none. Null if they can't be drawn. +/proc/gorilla_arm_look(mob/living/carbon/human/bearer) + var/key = gorilla_look_key(bearer) + var/datum/gorilla_arm_look/look = bearer.gorilla_arm_looks?["[bearer.dir]"] + if(look?.key == key && !QDELETED(look.rig)) + return look + if(look) + qdel(look) + LAZYREMOVE(bearer.gorilla_arm_looks, "[bearer.dir]") + var/datum/chrome_rig/rig = new(bearer, bearer.dir) + if(!rig.snapshot()) + qdel(rig) + return null + look = new + look.key = key + look.rig = rig + LAZYSET(bearer.gorilla_arm_looks, "[bearer.dir]", look) + return look + +/// The mask that cuts the hanging arm on [side] out of a body facing [facing]. Each is made once. +/proc/gorilla_arm_mask(facing, side) + var/static/list/masks = list() + var/key = "[facing]-[side]" + . = masks[key] + if(isnull(.)) + . = icon(GORILLA_GORE_ICON, "rig_arm_[side]", facing, 1) + masks[key] = . + +/// One piston punch's arm on its bearer: the puppets, and the mask on the real body. Deleting it puts the real arm back. +/datum/gorilla_thrust + /// Who is punching + var/mob/living/carbon/human/bearer + /// The puppets: the arm, and the torso under it where there is any + var/list/pieces = list() + /// Takes it down when the arm is back at rest + var/end_timer + +/datum/gorilla_thrust/New(mob/living/carbon/human/bearer) + src.bearer = bearer + bearer.gorilla_thrust = src + RegisterSignal(bearer, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(bearer, COMSIG_ATOM_POST_DIR_CHANGE, PROC_REF(on_turned)) + RegisterSignals(bearer, list(COMSIG_LIVING_SET_BODY_POSITION, COMSIG_QDELETING), PROC_REF(on_lost)) + +/datum/gorilla_thrust/Destroy() + if(end_timer) + deltimer(end_timer) + end_timer = null + QDEL_LIST(pieces) + if(bearer) + UnregisterSignal(bearer, list(COMSIG_MOVABLE_MOVED, COMSIG_ATOM_POST_DIR_CHANGE, COMSIG_LIVING_SET_BODY_POSITION, COMSIG_QDELETING)) + if(!QDELETED(bearer)) + bearer.remove_filter(GORILLA_THRUST_FILTER) + if(bearer.gorilla_thrust == src) + bearer.gorilla_thrust = null + bearer = null + return ..() + +/// Stands a puppet showing [picture] where the bearer is drawn, on [puppet_layer]. +/datum/gorilla_thrust/proc/stand(icon/picture, puppet_layer) + var/obj/effect/abstract/chrome_puppet/puppet = new(bearer.loc) + puppet.name = bearer.name + puppet.icon = picture + puppet.dir = SOUTH + puppet.pixel_x = bearer.pixel_x + puppet.pixel_y = bearer.pixel_y + puppet.pixel_w = bearer.pixel_w + puppet.pixel_z = bearer.pixel_z + puppet.plane = bearer.plane + puppet.layer = puppet_layer + // It walks with the bearer (on_moved()), gliding as they glide + puppet.animate_movement = SLIDE_STEPS + pieces += puppet + return puppet + +/// The bearer stepped mid-punch: the arm steps with them, at their pace. +/datum/gorilla_thrust/proc/on_moved(atom/movable/source, atom/old_loc) + SIGNAL_HANDLER + var/turf/now = source.loc + if(!isturf(now) || !isturf(old_loc) || get_dist(old_loc, now) > 1) + qdel(src) + return + for(var/obj/effect/abstract/chrome_puppet/puppet as anything in pieces) + puppet.set_glide_size(source.glide_size) + puppet.forceMove(now) + +/// The bearer turned to face another way: the arm is a picture of the old facing. +/datum/gorilla_thrust/proc/on_turned(datum/source, old_dir, new_dir) + SIGNAL_HANDLER + if(old_dir != new_dir) + qdel(src) + +/// Knocked down, or gone. +/datum/gorilla_thrust/proc/on_lost(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/gorilla_thrust/proc/finish() + end_timer = null + qdel(src) + +/** + * [bearer] throws a piston punch at [zone] of [target] with the arm on [arm_zone]: the fist draws back and drives out + * onto the part, the body lunging behind it, and comes back to rest. Only the picture. Returns how long the fist takes + * to land, for the blow to wait on: the same for every punch. A bearer who can't be drawn that way (lying, turned or + * resized, something huge worn, a target that isn't straight ahead) lunges as tg has them lunge. + */ +/proc/gorilla_thrust(mob/living/carbon/human/bearer, atom/movable/target, arm_zone, zone) + if(!gorilla_thrust_arm(bearer, target, arm_zone, zone)) + bearer.do_attack_animation(target) + return GORILLA_THRUST_LANDS + +/// Draws gorilla_thrust()'s arm and lunge. Returns TRUE, or FALSE if it can't be drawn. +/proc/gorilla_thrust_arm(mob/living/carbon/human/bearer, atom/movable/target, arm_zone, zone) + if(!ishuman(bearer) || !ismovable(target) || QDELETED(target) || !isturf(bearer.loc)) + return FALSE + if(bearer.body_position == LYING_DOWN || !(bearer.dir in GLOB.cardinals) || bearer.mob_height != HUMAN_HEIGHT_MEDIUM) + return FALSE + // The arm is thrown the way the bearer faces. A target a corner away, or on the bearer's own tile, isn't there + if(get_turf(target) != get_step(bearer.loc, bearer.dir)) + return FALSE + // Faded (a camo field, a cloak): a puppet would be an arm in plain sight + if(bearer.alpha < 255 || bearer.invisibility) + return FALSE + var/matrix/own = bearer.transform + if(own && (own.a != 1 || own.b || own.c || own.d || own.e != 1 || own.f)) + return FALSE + // One punch drawn at a time + if(bearer.gorilla_thrust) + qdel(bearer.gorilla_thrust) + var/list/point = gorilla_fist_target(bearer, target, zone) + var/datum/gorilla_arm_look/look = point && gorilla_arm_look(bearer) + var/datum/chrome_rig/rig = look?.rig + if(isnull(rig)) + return FALSE + var/facing = rig.facing + var/side = arm_zone == BODY_ZONE_L_ARM ? "l" : "r" + // A picture no client has drawn yet may reach one late. The real arm stays on show under its puppet for the beat the + // fist would have drawn back in, and the fist is driven straight out in what is left: it lands when it always does + var/hold = look.shown[side] ? 0 : GORILLA_THRUST_HOLD + look.shown[side] = TRUE + + // The poses: drawn back, then out onto the part + var/list/way = chrome_swing_way(facing) + var/list/back + var/out_angle + var/tip = GORILLA_THRUST_TIP + switch(facing) + if(EAST, WEST) + back = rig.pose_of(side, -45, -2) + out_angle = 90 + // Into the punch, as tg's lunge tips them + tip *= facing == EAST ? 1 : -1 + if(NORTH) + back = rig.pose_of(side, 60, -3) + out_angle = 174 + // The punching shoulder goes forward + tip *= side == "r" ? -1 : 1 + else + back = rig.pose_of(side, 60, -3) + out_angle = 12 + tip *= side == "r" ? -1 : 1 + var/list/reach = chrome_swing_reach(rig, side, point[1], point[2], way, GORILLA_THRUST_LUNGE_MIN, (facing & (EAST|WEST)) ? GORILLA_THRUST_LUNGE_MAX : GORILLA_SWING_STEP_UPRIGHT, tip, GORILLA_SWING_EASY_LENGTH, GORILLA_SWING_MAX_LENGTH, out_angle * rig.raise_sign(side)) + var/lunge = reach[1] + var/list/out = list(reach[2], reach[3]) + + // The layers. Seen from the side the near arm is over everything and the far one under its own body. Facing away + // the thrown arm passes behind its own head; facing the viewer it is drawn back behind its own shoulder + var/over = bearer.layer + 0.01 + var/under = bearer.layer - 0.01 + var/datum/gorilla_thrust/thrust = new(bearer) + var/obj/effect/abstract/chrome_puppet/arm + // The body leans back a pixel as the fist draws back (or waits, the first time). The fist is out a moment before the + // blow is due and stays there a moment after it + var/datum/chrome_swing_beat/back_beat = new(hold || GORILLA_THRUST_BACK, CHROME_SWING_LINEAR, -way[1], -way[2]) + var/datum/chrome_swing_beat/out_beat = new(GORILLA_THRUST_BACK + GORILLA_THRUST_OUT - back_beat.time, CHROME_SWING_LINEAR, way[1] * lunge, way[2] * lunge, tip) + var/datum/chrome_swing_beat/dwell_beat = new(GORILLA_THRUST_DWELL, CHROME_SWING_LINEAR, way[1] * lunge, way[2] * lunge, tip) + var/datum/chrome_swing_beat/rest_beat = new(GORILLA_THRUST_REST, CHROME_SWING_EASE_IN_OUT) + if(!hold) + back_beat.arm(side, back) + switch(facing) + if(EAST, WEST) + arm = thrust.stand(look.arm_picture(side), rig.behind_body(side) ? under : max(bearer.layer, target.layer) + 0.01) + out_beat.arm(side, out) + rest_beat.arm(side, list(0, 1)) + if(NORTH) + arm = thrust.stand(look.arm_picture(side), over) + out_beat.arm(side, out, under) + rest_beat.arm(side, list(0, 1)) + rest_beat.layer_once_under = list() + rest_beat.layer_once_under[side] = list(120, over) + else + arm = thrust.stand(look.arm_picture(side), under) + out_beat.arm(side, out, over) + rest_beat.arm(side, list(0, 1)) + var/icon/fill_picture = look.fill_picture(side) + var/obj/effect/abstract/chrome_puppet/fill = fill_picture ? thrust.stand(fill_picture, bearer.layer - 0.005) : null + + var/list/beats = list(back_beat, out_beat, dwell_beat, rest_beat) + // The real arm goes: at once, or when the hold is over by the client's own clock, in step with the puppet. The + // mask starts off to one side of the body, where it cuts nothing. Before the lunge, and alongside whatever else is + // moving the bearer: an animation of a filter is one of its owner's, and a new one would end the rest + bearer.add_filter(GORILLA_THRUST_FILTER, 1, alpha_mask_filter(x = hold ? ICON_SIZE_X * 2 : 0, icon = gorilla_arm_mask(facing, side), flags = MASK_INVERSE)) + if(hold) + var/mask = bearer.get_filter(GORILLA_THRUST_FILTER) + if(mask) + animate(mask, x = ICON_SIZE_X * 2, time = hold, flags = ANIMATION_PARALLEL) + animate(x = 0, time = 0) + // A rebuild of the bearer's filters lands on the cut, not the wait + bearer.filter_data[GORILLA_THRUST_FILTER]["x"] = 0 + var/list/arms_from = list() + arms_from[side] = list(0, 1) + var/list/arms = list() + arms[side] = arm + chrome_swing_play(rig, chrome_swing_frames(beats, arms_from), fill ? list(fill) : null, arms, bearer) + play_fov_effect(target, 5, "attack") + thrust.end_timer = addtimer(CALLBACK(thrust, TYPE_PROC_REF(/datum/gorilla_thrust, finish)), GORILLA_THRUST_BACK + GORILLA_THRUST_OUT + GORILLA_THRUST_DWELL + GORILLA_THRUST_REST, TIMER_STOPPABLE | TIMER_DELETE_ME) + return TRUE + +#undef GORILLA_THRUST_FILTER diff --git a/voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm b/voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm new file mode 100644 index 00000000000..3783cdc7fdd --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm @@ -0,0 +1,142 @@ +/** + * # Chrome finishers: #509's finisher engine for a player's bare hands + * + * #509's /datum/hv_finisher was built for bosses with a weapon in hand and NPC factions. A chrome + * finisher (a player's or a boss's own arms) changes four things, and nothing else: + * - No weapon needed: the start and the safety check never ask for one. + * - Anyone can be finished, as with a gun execution: two players share a faction, so the faction test + * only stops an NPC finishing its own side. Players and boss-rank NPCs only (hv_can_execute()). + * - Down means in crit or stunned, never just lying there. A victim whose stun wears off before the + * kill twists free. + * - No red mark over the victim (owner D34): the wind-up is the two bodies. + * - The one doing it can't walk away mid-way: any step lets go. + * + * Kept in its own file so the other chrome finishers (Mantis, monowire) can share it. + */ + +/mob/living + /// The chrome finisher this mob is doing now, if any. + var/datum/hv_finisher/chrome/chrome_finisher + +/datum/hv_finisher/chrome + name = "chrome finisher" + interrupt_damage = GORILLA_FINISHER_INTERRUPT + /// Set when the victim came out of their stun before the kill + var/twisted_free = FALSE + +/datum/hv_finisher/chrome/Destroy() + if(user?.chrome_finisher == src) + user.chrome_finisher = null + return ..() + +/datum/hv_finisher/chrome/start() + . = ..() + if(. && user) + user.chrome_finisher = src + +/datum/hv_finisher/chrome/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + if(HAS_TRAIT(user, TRAIT_PACIFISM) || !user_may_finish()) + return FALSE + if(user.chrome_finisher && user.chrome_finisher != src) + return FALSE + if(!chrome_finisher_valid_victim(user, victim)) + return FALSE + return get_dist(user, victim) <= 1 && user.Adjacent(victim) + +/// Who may do this finisher at all. Players and boss-rank NPCs, as with gun executions; a chrome line can route +/// this through its own gate. +/datum/hv_finisher/chrome/proc/user_may_finish() + return user.hv_can_execute() + +/datum/hv_finisher/chrome/safety_check() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) + return HV_FINISHER_ATTACKER_DOWN + if(QDELETED(victim)) + return HV_FINISHER_VICTIM_GONE + if(!completed && victim.stat == DEAD) + return HV_FINISHER_VICTIM_GONE + if(!isturf(user.loc) || !isturf(victim.loc) || user.z != victim.z || get_dist(user, victim) > 1) + return HV_FINISHER_LOST_GRIP + if(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return HV_FINISHER_LOST_GRIP + if(completed) + return 0 + if(HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return HV_FINISHER_ATTACKER_DOWN + if(gripped && (user.pulling != victim || user.grab_state < GRAB_AGGRESSIVE)) + return HV_FINISHER_LOST_GRIP + if(!chrome_finisher_is_down(victim)) + twisted_free = TRUE + return HV_FINISHER_LOST_GRIP + return 0 + +// The one doing it takes a step, is shoved or is thrown: it lets go. The victim moving is #509's rule. +/datum/hv_finisher/chrome/on_moved(datum/source) + SIGNAL_HANDLER + if(source == user && running) + end_early(HV_FINISHER_LOST_GRIP) + return + return ..() + +/datum/hv_finisher/chrome/interrupt(reason) + if(running && !completed && reason == HV_FINISHER_LOST_GRIP && twisted_free && !QDELETED(victim) && !QDELETED(user)) + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_interrupt() + victim.visible_message(span_danger("[victim] twists free of [user]'s grip!"), span_userdanger("You twist free of [user]'s grip!")) + stop(reason) + return + return ..() + +/datum/hv_finisher/chrome/stop(reason) + if(!running) + return + . = ..() + if(user?.chrome_finisher == src) + user.chrome_finisher = null + // Nothing owns a chrome finisher but its own timers, and its stages point back at it: it has to delete + // itself once it is over, or the pair would hold both bodies forever. + if(!QDELETED(src)) + qdel(src) + +/** + * Whether [user] may start a chrome finisher on [victim]: a humanoid body that is alive, in crit or + * stunned, on the floor beside them, not strapped to anything and not already being finished. A player + * can finish anyone, as with a gun execution; an NPC never finishes its own side. + */ +/proc/chrome_finisher_valid_victim(mob/living/user, mob/living/carbon/victim) + if(!iscarbon(victim) || !isliving(user) || QDELETED(victim) || QDELETED(user) || victim == user) + return FALSE + if(!ishuman(victim) && !(victim.mob_biotypes & MOB_HUMANOID)) + return FALSE + if(victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!chrome_finisher_is_down(victim)) + return FALSE + if(victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + // An NPC (anything run by an AI) never finishes its own side + if(user.ai_controller && user.faction_check_atom(victim)) + return FALSE + return TRUE + +/// Down, for a chrome finisher: in crit, or stunned. Lying there isn't enough. +/proc/chrome_finisher_is_down(mob/living/victim) + return victim.stat >= SOFT_CRIT || HAS_TRAIT(victim, TRAIT_INCAPACITATED) + +/** + * Whether [victim] is square on to [user]: straight north, south, east or west, never on a corner. The + * Gorilla finishers and the Mantis scissor draw both bodies along one straight line, so they only start + * square on; from a corner the click is an ordinary blow and the bearer steps round to finish. + */ +/proc/finisher_square_on(atom/user, atom/victim) + return (get_dir(user, victim) in GLOB.cardinals) + +/// Whether [victim] has ended up on a corner from [user] mid-move: dragged there or the like. The rigs above +/// can't draw that, so it lets go. The same tile is fine (the clap brings the body in). +/proc/finisher_on_corner(atom/user, atom/victim) + return (get_dir(user, victim) in GLOB.diagonals) diff --git a/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi new file mode 100644 index 00000000000..e1886ca097d Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi differ diff --git a/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi new file mode 100644 index 00000000000..9da1856f484 Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi differ diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg new file mode 100644 index 00000000000..cb6e3ddfb17 Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg differ diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_haymaker.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_haymaker.ogg new file mode 100644 index 00000000000..cae44544ff7 Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_haymaker.ogg differ diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg new file mode 100644 index 00000000000..7bd87514966 Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg differ diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch2.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch2.ogg new file mode 100644 index 00000000000..8f721e72461 Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch2.ogg differ diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg new file mode 100644 index 00000000000..ba7bd00f79d Binary files /dev/null and b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg differ diff --git a/voidcrew/modules/lich/lich_spells.dm b/voidcrew/modules/lich/lich_spells.dm index 096c68d95f2..0a4d2d0761b 100644 --- a/voidcrew/modules/lich/lich_spells.dm +++ b/voidcrew/modules/lich/lich_spells.dm @@ -332,7 +332,7 @@ mend_the_dead(victim) return - victim.adjustToxLoss(VERDIGRIS_BOLT_ROT, forced = TRUE) + victim.adjustToxLoss(VERDIGRIS_BOLT_ROT) victim.visible_message( span_danger("The green soaks into [victim] and starts to rot [victim.p_them()]."), span_userdanger("Something green gets under your skin and starts rotting you."), @@ -636,7 +636,8 @@ return stung = TRUE - attacker.adjustBruteLoss(npc_zone_damage(VERDIGRIS_MIRAGE_STING, src)) + // Through the arm that struck it, as the message says (a body without arms takes it anywhere). + attacker.apply_damage(npc_zone_damage(VERDIGRIS_MIRAGE_STING, src), BRUTE, attacker.get_active_hand() || null) to_chat(attacker, span_userdanger("The mirage bursts, spraying green light through your arm!")) new /obj/effect/temp_visual/small_smoke/halfsecond(get_turf(src)) QDEL_IN(src, 0) @@ -701,7 +702,7 @@ /obj/item/book/granter/action/spell/verdigris_bolt/recoil(mob/living/user) . = ..() user.visible_message(span_warning("[src] coughs out a wash of green, and the air around [user] starts to smell like wet soil.")) - user.adjustToxLoss(15, forced = TRUE) + user.adjustToxLoss(15) /obj/item/book/granter/action/spell/grave_mirage name = "codex of the fourth corpse" diff --git a/voidcrew/modules/lich/lich_thrall.dm b/voidcrew/modules/lich/lich_thrall.dm index 00d8117386b..ba1e9aec77e 100644 --- a/voidcrew/modules/lich/lich_thrall.dm +++ b/voidcrew/modules/lich/lich_thrall.dm @@ -370,6 +370,8 @@ continue // Ilthuun and his garrison if(candidate.has_status_effect(/datum/status_effect/lich_thrall)) continue + if(cyberware_camo_hides(candidate, owner)) // optical camo (cyberware_stealth.dm) + continue var/score = get_dist(owner, candidate) if(candidate.client) diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index da7a7b2fced..caba26f11c7 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -47,11 +47,16 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_GUN_POOL_ARMORY = list( LOOT_WEAPON_GREEN = list( /obj/item/gun/ballistic/rifle/boltaction/surplus = 10, + /obj/item/gun/ballistic/rifle/nt_m96 = 10, + /obj/item/gun/ballistic/automatic/battle_rifle = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/energy/laser = 10, /obj/item/gun/energy/e_gun = 10, /obj/item/gun/ballistic/automatic/wt550 = 10, + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = 10, + /obj/item/gun/ballistic/automatic/resolution = 10, + /obj/item/gun/ballistic/automatic/woodsman = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/energy/laser/carbine = 10, @@ -64,29 +69,42 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_GUN_POOL_SYNDICATE = list( LOOT_WEAPON_GREEN = list( /obj/item/gun/ballistic/automatic/pistol = 10, + /obj/item/gun/ballistic/automatic/rapier = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/automatic/c20r/unrestricted = 10, /obj/item/gun/ballistic/automatic/mini_uzi = 10, + /obj/item/gun/ballistic/automatic/pistol/clandestine = 10, + /obj/item/gun/ballistic/shotgun/slammer = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = 10, /obj/item/gun/energy/xray = 10, /obj/item/gun/energy/e_gun/nuclear = 10, + /obj/item/gun/ballistic/automatic/pistol/rattlesnake = 10, ), ), LOOT_GUN_POOL_PLUNDER = list( LOOT_WEAPON_GREEN = list( /obj/item/gun/ballistic/revolver/c38/detective = 10, /obj/item/gun/ballistic/shotgun/doublebarrel = 10, + /obj/item/gun/ballistic/automatic/pistol/zashch = 10, + /obj/item/gun/ballistic/shotgun/brimstone = 10, + /obj/item/gun/ballistic/shotgun/flaming_arrow = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/revolver = 10, + /obj/item/gun/ballistic/rifle/boltaction/harpoon = 10, + /obj/item/gun/ballistic/automatic/heater = 10, + /obj/item/gun/ballistic/automatic/skm = 10, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = 10, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = 10, /obj/item/gun/energy/laser/hellgun = 10, /obj/item/gun/ballistic/automatic/tommygun = 10, + /obj/item/gun/ballistic/automatic/heater/scorcher = 10, ), ), LOOT_GUN_POOL_FREIGHT = list( diff --git a/voidcrew/modules/loot/themes/armory.dm b/voidcrew/modules/loot/themes/armory.dm index c8c73e17723..4e76fe20567 100644 --- a/voidcrew/modules/loot/themes/armory.dm +++ b/voidcrew/modules/loot/themes/armory.dm @@ -8,6 +8,8 @@ // Cyberware: chrome a garrison would have issued rather than bought, light // weave and knuckle plate on the line, and in the back room the ceramic // plating, the targeting optics and the frame that stops a limb coming off. +// The governor and rage-core lines ride here too: the taped-over street +// models on the line, the surplus service models in the back room. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. Tune against the outfitter armor/gun prices // in theme_skus. @@ -62,6 +64,7 @@ ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/automatic/m90/unrestricted = 1, + /obj/item/gun/ballistic/rocketlauncher/unrestricted = 1, /obj/item/gun/energy/e_gun/hos = 1, /obj/item/gun/energy/e_gun/nuclear = 1, /obj/item/gun/energy/laser/carbine/cybersun/unrestricted = 1, @@ -139,6 +142,15 @@ /datum/shop_sku/ripperdoc/slabskin, /datum/shop_sku/ripperdoc/deadeye, /datum/shop_sku/ripperdoc/atlas, + /datum/shop_sku/ripperdoc/governor_street, + /datum/shop_sku/ripperdoc/governor_milspec, + /datum/shop_sku/ripperdoc/redline_street, + /datum/shop_sku/ripperdoc/redline_milspec, + /datum/shop_sku/ripperdoc/cuffer_wrists, + /datum/shop_sku/ripperdoc/muffler_ear, + /datum/shop_sku/ripperdoc/hardweave, + /datum/shop_sku/ripperdoc/deadeye_milspec, + /datum/shop_sku/ripperdoc/lookout, ) loot_common = list( /obj/item/clothing/suit/armor/vest = 10, @@ -151,6 +163,10 @@ /obj/item/clothing/mask/balaclava = 4, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/strength = 3, + // guard-post chrome: faster hands with the cuffs, and an ear that + // shuts out a flashbang + /obj/item/organ/cyberimp/cyberware/cuffer_wrists = 4, + /obj/item/organ/cyberimp/cyberware/muffler_ear = 4, ) loot_uncommon = list( /obj/item/clothing/suit/armor/bulletproof = 10, @@ -162,6 +178,11 @@ /obj/item/ship_parts/combat = 7, // Issue chrome: subdermal impact weave. /obj/item/organ/cyberimp/cyberware/dermal_mesh = 4, + // a patched limiter and a cheap rage core, both off a deserter + /obj/item/organ/cyberimp/cyberware/governor_delete/street = 2, + /obj/item/organ/cyberimp/cyberware/redline/street = 2, + // the heavier issue weave + /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave = 3, ) loot_prime = list( /obj/item/clothing/suit/armor/vest/marine = 9, @@ -177,6 +198,12 @@ /obj/item/organ/cyberimp/cyberware/atlas = 2, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/quick_recovery = 1, + // surplus service chrome with the unit numbers still on it + /obj/item/organ/cyberimp/cyberware/governor_delete/milspec = 2, + /obj/item/organ/cyberimp/cyberware/redline/milspec = 2, + // the service optics + /obj/item/organ/eyes/robotic/cyberware/deadeye/milspec = 2, + /obj/item/organ/eyes/robotic/cyberware/lookout = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/expedition.dm b/voidcrew/modules/loot/themes/expedition.dm index 9bd98aee396..24b2223ffa5 100644 --- a/voidcrew/modules/loot/themes/expedition.dm +++ b/voidcrew/modules/loot/themes/expedition.dm @@ -42,6 +42,13 @@ /datum/shop_sku/ripperdoc/angler, /datum/shop_sku/ripperdoc/prospector, /datum/shop_sku/ripperdoc/hopper, + /datum/shop_sku/ripperdoc/frostline, + /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_sku/ripperdoc/hopper_street, + /datum/shop_sku/ripperdoc/rockjaw_deepcore, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/hopper_milspec, ) loot_common = list( /obj/item/clothing/suit/hooded/explorer = 10, @@ -57,6 +64,10 @@ /obj/item/organ/cyberimp/cyberware/shock_coils = 4, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/geladikinesis = 3, + // a cold lining for the ice worlds, and cheap eyes that see a long way + /obj/item/organ/cyberimp/cyberware/frostline = 3, + /obj/item/organ/eyes/robotic/cyberware/spyglass = 3, + /obj/item/organ/eyes/robotic/cyberware/prospector/street = 3, ) loot_uncommon = list( /obj/item/gun/energy/recharge/kinetic_accelerator = 10, @@ -76,6 +87,11 @@ /obj/item/dnainjector/loot/olfaction = 3, /obj/item/dnainjector/loot/farsight = 2, /obj/item/dnainjector/loot/cold_adaptation = 2, + // claim chrome a step up: calf springs, the heavy drill fist and long + // optics + /obj/item/organ/cyberimp/cyberware/hopper/street = 3, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore = 3, + /obj/item/organ/eyes/robotic/cyberware/longsight = 2, ) loot_prime = list( /obj/item/borg/upgrade/modkit/range = 9, @@ -91,6 +107,8 @@ /obj/item/organ/cyberimp/cyberware/hopper = 2, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/pressure_adaptation = 1, + // the survey team's jump pistons + /obj/item/organ/cyberimp/cyberware/hopper/milspec = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -117,6 +135,7 @@ /// Planet fauna: territorial wildlife around and inside surface ruins. /obj/effect/zone_mobs/wildlife name = "zone mob spawner (wildlife)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/mining/bileworm = 8, /mob/living/basic/mining/lobstrosity = 6, @@ -160,6 +179,7 @@ /// asteroid ruin, everything in these tables survives space. /obj/effect/zone_mobs/asteroid name = "zone mob spawner (asteroid)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/mining/goldgrub = 8, /mob/living/basic/mining/hivelord = 6, diff --git a/voidcrew/modules/loot/themes/industrial.dm b/voidcrew/modules/loot/themes/industrial.dm index 6fae89d3e02..d860708cfad 100644 --- a/voidcrew/modules/loot/themes/industrial.dm +++ b/voidcrew/modules/loot/themes/industrial.dm @@ -8,7 +8,8 @@ // (see cave_entrance.dm): industrial plant equipment, uncommon-tier. // - cyberware: shop-floor chrome, the kind a plant fits to keep a line // running, omnitool fingers, then the coolant lattice for hot work and -// the myomer arms that pull a dead airlock open. +// the myomer arms that pull a dead airlock open. The cheap myomer pair +// is a yellow melee extra, the real Gorilla Arms a red one. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. Tune against outpost shop material prices and // the Boffin stall parts SKUs in theme_skus. @@ -26,6 +27,9 @@ ), ) melee_extras = list( + LOOT_WEAPON_YELLOW = list( + /obj/item/cyberware_pair_case/gorilla_arms/street = 1, + ), LOOT_WEAPON_RED = list( /obj/item/cyberware_pair_case/gorilla_arms = 1, ), @@ -41,6 +45,10 @@ /datum/shop_sku/ripperdoc/fixers, /datum/shop_sku/ripperdoc/coolant, /datum/shop_sku/ripperdoc/gorilla, + /datum/shop_sku/ripperdoc/coolant_street, + /datum/shop_sku/ripperdoc/fixers_street, + /datum/shop_sku/ripperdoc/atlas_street, + /datum/shop_sku/ripperdoc/gorilla_street, ) loot_common = list( /obj/item/stack/sheet/iron/fifty = 10, @@ -57,6 +65,9 @@ /obj/item/stack/sheet/plasteel/twenty = 4, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/rock_eater = 3, + // line-worker chrome: a heat-sink lining and loose fingertip tools + /obj/item/organ/cyberimp/cyberware/coolant/street = 3, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street = 4, ) loot_uncommon = list( /obj/item/stack/sheet/mineral/titanium/fifty = 8, @@ -79,6 +90,8 @@ // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/heat_adaptation = 2, /obj/item/dnainjector/loot/insulated = 3, + // a loader's strut frame + /obj/item/organ/cyberimp/cyberware/atlas/street = 3, ) loot_prime = list( /obj/item/stack/sheet/plasteel/fifty = 9, diff --git a/voidcrew/modules/loot/themes/medical.dm b/voidcrew/modules/loot/themes/medical.dm index 3d152ec9ea7..7b9bd79fd7e 100644 --- a/voidcrew/modules/loot/themes/medical.dm +++ b/voidcrew/modules/loot/themes/medical.dm @@ -34,6 +34,9 @@ /datum/shop_sku/ripperdoc/hemoglass, /datum/shop_sku/ripperdoc/dead_channel, /datum/shop_sku/ripperdoc/lazarus, + /datum/shop_sku/ripperdoc/clotter_node, + /datum/shop_sku/ripperdoc/medic_palms, + /datum/shop_sku/ripperdoc/second_wind_deep, ) loot_common = list( /obj/item/storage/medkit/regular = 10, @@ -53,6 +56,9 @@ /obj/item/organ/cyberimp/cyberware/gastro = 4, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/stimmed = 3, + // clinic chrome: a clotting node and a medic's sensor palms + /obj/item/organ/cyberimp/cyberware/clotter_node = 4, + /obj/item/organ/cyberimp/cyberware/medic_palms = 3, ) loot_uncommon = list( /obj/item/storage/medkit/advanced = 10, @@ -79,6 +85,8 @@ // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/hypermetabolic = 3, /obj/item/dnainjector/loot/plasmocile = 2, + // the long-shift air bladder + /obj/item/organ/cyberimp/cyberware/second_wind/deep = 3, ) loot_prime = list( /obj/item/reagent_containers/hypospray/medipen/atropine = 7, diff --git a/voidcrew/modules/loot/themes/occult.dm b/voidcrew/modules/loot/themes/occult.dm index e9e98726474..2bbfab11449 100644 --- a/voidcrew/modules/loot/themes/occult.dm +++ b/voidcrew/modules/loot/themes/occult.dm @@ -118,6 +118,7 @@ /// Cult remnants: constructs still bound to a dead master's wards. /obj/effect/zone_mobs/cult name = "zone mob spawner (cult)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/construct/proteon/hostile = 10, ) @@ -146,6 +147,7 @@ /// The restless dead: graves, derelicts, plague sites. /obj/effect/zone_mobs/undead name = "zone mob spawner (undead)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/skeleton = 10, /mob/living/basic/zombie = 6, diff --git a/voidcrew/modules/loot/themes/plunder.dm b/voidcrew/modules/loot/themes/plunder.dm index b142f73c2ae..313898b55d4 100644 --- a/voidcrew/modules/loot/themes/plunder.dm +++ b/voidcrew/modules/loot/themes/plunder.dm @@ -6,7 +6,9 @@ // crews. // Cyberware: boarding chrome, and it reads as taken off somebody rather than // bought, grip pads at uncommon, and in the treasure the blade pair, the -// folding machine-pistol and the rocket pod. +// folding machine-pistol and the rocket pod. The cheap shiv pair is a yellow +// melee extra, the heat-sunk blade pair a red one beside the Mantis, and the +// holdout revolver arm a yellow gun extra. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. // ========================================================================= @@ -22,6 +24,7 @@ /obj/item/gun/ballistic/revolver/mateba = 1, /obj/item/gun/ballistic/shotgun/musket/no_quarter = 1, /obj/item/gun/magic/hook/marlinspike = 1, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout = 1, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/automatic/combat = 1, @@ -30,9 +33,13 @@ ), ) melee_extras = list( + LOOT_WEAPON_YELLOW = list( + /obj/item/cyberware_pair_case/mantis_street = 1, + ), LOOT_WEAPON_RED = list( /obj/item/claymore/cutlass/parley = 1, /obj/item/cyberware_pair_case/mantis_blades = 1, + /obj/item/cyberware_pair_case/mantis_scorch = 1, /obj/item/heave_ho = 1, ), ) @@ -41,8 +48,11 @@ /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/ripperdoc/gecko, /datum/shop_sku/ripperdoc/mantis, + /datum/shop_sku/ripperdoc/mantis_street, + /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/ronin, /datum/shop_sku/ripperdoc/bunker_buster, + /datum/shop_sku/ripperdoc/ronin_holdout, ) loot_common = list( /obj/item/stack/spacecash/c100 = 10, @@ -97,40 +107,40 @@ /obj/effect/zone_mobs/pirate name = "zone mob spawner (pirate)" mobs_green = list( - /mob/living/basic/trooper/pirate/faction/grey/melee = 10, - /mob/living/basic/trooper/pirate/faction/skeleton/melee = 8, - /mob/living/basic/trooper/pirate/faction/silverscale/melee = 6, + /mob/living/carbon/human/npc/faction_pirate/grey/melee = 10, + /mob/living/carbon/human/npc/faction_pirate/skeleton/melee = 8, + /mob/living/carbon/human/npc/faction_pirate/silverscale/melee = 6, ) mobs_yellow = list( - /mob/living/basic/trooper/pirate/faction/grey/ranged = 8, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged = 8, - /mob/living/basic/trooper/pirate/faction/silverscale/melee = 6, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged = 5, + /mob/living/carbon/human/npc/faction_pirate/grey/ranged = 8, + /mob/living/carbon/human/npc/faction_pirate/skeleton/ranged = 8, + /mob/living/carbon/human/npc/faction_pirate/silverscale/melee = 6, + /mob/living/carbon/human/npc/faction_pirate/lustrous/ranged = 5, ) mobs_red = list( - /mob/living/basic/trooper/pirate/faction/silverscale/ranged = 8, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged = 8, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged = 6, - /mob/living/basic/trooper/pirate/faction/skeleton/captain = 4, - /mob/living/basic/trooper/pirate/faction/grey/captain = 4, + /mob/living/carbon/human/npc/faction_pirate/silverscale/ranged = 8, + /mob/living/carbon/human/npc/faction_pirate/lustrous/ranged = 8, + /mob/living/carbon/human/npc/faction_pirate/interdyne/ranged = 6, + /mob/living/carbon/human/npc/faction_pirate/skeleton/captain = 4, + /mob/living/carbon/human/npc/faction_pirate/grey/captain = 4, ) /// The pirate setpiece: a captain holding the vault, a proper boss in the red. /obj/effect/zone_mobs/pirate/boss name = "zone mob spawner (pirate boss)" mobs_green = list( - /mob/living/basic/trooper/pirate/faction/grey/captain = 1, - /mob/living/basic/trooper/pirate/faction/skeleton/captain = 1, + /mob/living/carbon/human/npc/faction_pirate/grey/captain = 1, + /mob/living/carbon/human/npc/faction_pirate/skeleton/captain = 1, ) mobs_yellow = list( - /mob/living/basic/trooper/pirate/faction/silverscale/captain = 1, - /mob/living/basic/trooper/pirate/faction/lustrous/captain = 1, - /mob/living/basic/trooper/pirate/faction/interdyne/captain = 1, + /mob/living/carbon/human/npc/faction_pirate/silverscale/captain = 1, + /mob/living/carbon/human/npc/faction_pirate/lustrous/captain = 1, + /mob/living/carbon/human/npc/faction_pirate/interdyne/captain = 1, ) mobs_red = list( - /mob/living/basic/trooper/pirate/faction/boss/silverscale = 1, - /mob/living/basic/trooper/pirate/faction/boss/skeleton = 1, - /mob/living/basic/trooper/pirate/faction/boss/grey = 1, - /mob/living/basic/trooper/pirate/faction/boss/lustrous = 1, - /mob/living/basic/trooper/pirate/faction/boss/interdyne = 1, + /mob/living/carbon/human/npc/faction_pirate/silverscale/boss = 1, + /mob/living/carbon/human/npc/faction_pirate/skeleton/boss = 1, + /mob/living/carbon/human/npc/faction_pirate/grey/boss = 1, + /mob/living/carbon/human/npc/faction_pirate/lustrous/boss = 1, + /mob/living/carbon/human/npc/faction_pirate/interdyne/boss = 1, ) diff --git a/voidcrew/modules/loot/themes/research.dm b/voidcrew/modules/loot/themes/research.dm index 0d1b04cd1cb..ad8cc8e3a2b 100644 --- a/voidcrew/modules/loot/themes/research.dm +++ b/voidcrew/modules/loot/themes/research.dm @@ -36,6 +36,9 @@ theme_skus = list( /datum/shop_sku/ripperdoc/doppler, /datum/shop_sku/ripperdoc/rigger, + /datum/shop_sku/ripperdoc/doppler_street, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/lookout, ) loot_common = list( /obj/item/storage/box/beakers = 10, @@ -64,6 +67,9 @@ // lab hardware worn instead of carried: a sonar transceiver socketed // into the bone behind the ear, reading movement through walls /obj/item/organ/cyberimp/cyberware/doppler = 3, + // a cheaper sonar ear, and survey optics off the long-range bench + /obj/item/organ/cyberimp/cyberware/doppler/street = 3, + /obj/item/organ/eyes/robotic/cyberware/longsight = 2, ) loot_prime = list( /obj/item/stack/ore/bluespace_crystal/refined = 10, @@ -87,6 +93,8 @@ // the annex's own interface work: a skull jack that flies a ship from // anywhere aboard it, as long as the hull still has a helm console /obj/item/organ/cyberimp/cyberware/rigger = 2, + // the observation post's eyes + /obj/item/organ/eyes/robotic/cyberware/lookout = 2, // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/thermal_vision = 3, /obj/item/dnainjector/loot/cryokinesis = 2, @@ -120,6 +128,7 @@ /// Infested nests: giant spiders. Webs sold separately. /obj/effect/zone_mobs/bug name = "zone mob spawner (bug)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/spider/giant/nurse = 8, /mob/living/basic/spider/giant = 6, diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 2de7274da42..f271f7b6cc5 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -9,6 +9,11 @@ // Cyberware rides the same two channels: the parlor's smuggling cavity and // door spike sit at uncommon, and the three voucher-only pieces an operative // would carry (cloak weave, wire, reflex shunt) sit at the bottom of prime. +// The camo and reflex lines follow the same split: the street weave and the +// taped reflex rig at uncommon, the service lattice in prime, and the live +// wire beside the plain one in the red melee extras. The chrome added with the +// cyberware lines sits at about half the weight of the older entries, so the +// syndicate tables stay mostly guns, gear and contraband. // ========================================================================= /datum/loot_theme/syndicate @@ -17,12 +22,14 @@ gun_pool = LOOT_GUN_POOL_SYNDICATE gun_extras = list( LOOT_WEAPON_YELLOW = list( + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher = 1, /obj/item/gun/ballistic/automatic/pistol/deagle = 1, /obj/item/gun/ballistic/revolver = 1, /obj/item/gun/ballistic/revolver/c38/housecall = 1, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/rifle/sniper_rifle = 1, + /obj/item/gun/ballistic/rocketlauncher/unrestricted = 1, /obj/item/gun/ballistic/shotgun/automatic/combat = 1, ), ) @@ -30,6 +37,7 @@ LOOT_WEAPON_RED = list( /obj/item/knife/understudy = 1, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire = 1, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live = 1, ), ) guard_themes = list(/obj/effect/zone_mobs/syndicate, /obj/effect/zone_mobs/syndicate/boss) @@ -45,6 +53,14 @@ /datum/shop_sku/ripperdoc/slipwire, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/ghostskin, + /datum/shop_sku/ripperdoc/ghostskin_street, + /datum/shop_sku/ripperdoc/cascade_street, + /datum/shop_sku/ripperdoc/cascade_milspec, + /datum/shop_sku/ripperdoc/monowire_live, + /datum/shop_sku/ripperdoc/subvocal_mic, + /datum/shop_sku/ripperdoc/slipwire_street, + /datum/shop_sku/ripperdoc/adrenal_pump, + /datum/shop_sku/ripperdoc/slipwire_milspec, ) loot_common = list( /obj/item/soap/syndie = 10, @@ -54,6 +70,8 @@ /obj/item/encryptionkey/syndicate = 6, /obj/item/grenade/empgrenade = 5, /obj/item/storage/medkit/tactical = 4, + // a throat mic nobody nearby hears + /obj/item/organ/cyberimp/cyberware/subvocal_mic = 2, ) loot_uncommon = list( /obj/item/grenade/c4 = 6, @@ -69,6 +87,12 @@ // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/mind_reader = 2, /obj/item/dnainjector/loot/anti_glow = 2, + // knockoff operative chrome: a patchy camo weave and a taped reflex rig + /obj/item/organ/cyberimp/cyberware/ghostskin/street = 2, + /obj/item/organ/cyberimp/cyberware/cascade/street = 1, + // street operative chrome: a twitchy reflex shunt and an adrenal pump + /obj/item/organ/cyberimp/cyberware/slipwire/street = 2, + /obj/item/organ/cyberimp/cyberware/adrenal_pump = 2, ) loot_prime = list( /obj/item/pen/sleepy = 4, @@ -77,6 +101,9 @@ // Operative chrome: the reflex shunt and refraction weave. /obj/item/organ/cyberimp/cyberware/slipwire = 3, /obj/item/organ/cyberimp/cyberware/ghostskin = 2, + /obj/item/organ/cyberimp/cyberware/cascade/milspec = 1, + // the boarding team's shunt + /obj/item/organ/cyberimp/cyberware/slipwire/milspec = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -101,29 +128,30 @@ /// Syndicate holdouts: operatives left guarding whatever the base was for. /obj/effect/zone_mobs/syndicate name = "zone mob spawner (syndicate)" + wave_pod_style = /datum/pod_style/syndicate mobs_green = list( - /mob/living/basic/trooper/syndicate/melee = 14, + /mob/living/carbon/human/npc/ruin/trooper/syndicate = 14, ) mobs_yellow = list( - /mob/living/basic/trooper/syndicate/melee/sword = 8, - /mob/living/basic/trooper/syndicate/ranged = 12, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/sword = 8, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner = 12, ) mobs_red = list( - /mob/living/basic/trooper/syndicate/ranged/smg = 8, - /mob/living/basic/trooper/syndicate/ranged/shotgun = 6, - /mob/living/basic/trooper/syndicate/melee/sword = 5, - /mob/living/basic/trooper/syndicate/ranged/smg/space/stormtrooper = 2, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg = 8, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun = 6, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/sword = 5, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper = 2, ) /obj/effect/zone_mobs/syndicate/boss name = "zone mob spawner (syndicate boss)" mobs_green = list( - /mob/living/basic/trooper/syndicate/melee = 1, + /mob/living/carbon/human/npc/ruin/trooper/syndicate = 1, ) mobs_yellow = list( - /mob/living/basic/trooper/syndicate/ranged = 2, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner = 2, ) mobs_red = list( - /mob/living/basic/trooper/syndicate/ranged/smg/space/stormtrooper = 1, - /mob/living/basic/trooper/syndicate/ranged/shotgun/space/stormtrooper = 1, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper = 1, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/shotgun = 1, ) diff --git a/voidcrew/modules/loot/themes/wardrobe.dm b/voidcrew/modules/loot/themes/wardrobe.dm index 35dc5bede0e..abdec557e70 100644 --- a/voidcrew/modules/loot/themes/wardrobe.dm +++ b/voidcrew/modules/loot/themes/wardrobe.dm @@ -32,6 +32,7 @@ theme_skus = list( /datum/shop_sku/ripperdoc/chromatic_dermis, /datum/shop_sku/ripperdoc/nightshade, + /datum/shop_sku/ripperdoc/spyglass, ) loot_common = list( /obj/item/clothing/suit/jacket/leather = 10, @@ -51,6 +52,8 @@ // gene injector loot (gene_injectors.dm) /obj/item/dnainjector/loot/glowy = 3, /obj/item/dnainjector/loot/gigantism = 2, + // cheap zoom eyes, bought for the look as much as the view + /obj/item/organ/eyes/robotic/cyberware/spyglass = 3, ) loot_uncommon = list( /obj/item/clothing/suit/jacket/leather/biker = 10, diff --git a/voidcrew/modules/loot/zone_loot.dm b/voidcrew/modules/loot/zone_loot.dm index 9fcf87af226..e1327158e87 100644 --- a/voidcrew/modules/loot/zone_loot.dm +++ b/voidcrew/modules/loot/zone_loot.dm @@ -58,29 +58,48 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( // detachable magazines /obj/item/gun/ballistic/automatic/ar = /obj/item/ammo_box/magazine/m223, + /obj/item/gun/ballistic/automatic/battle_rifle = /obj/item/ammo_box/magazine/m38, /obj/item/gun/ballistic/automatic/c20r/unrestricted = /obj/item/ammo_box/magazine/smgm45, /obj/item/gun/ballistic/automatic/gyropistol = /obj/item/ammo_box/magazine/m75, + /obj/item/gun/ballistic/automatic/heater = /obj/item/ammo_box/magazine/heater, + /obj/item/gun/ballistic/automatic/heater/scorcher = /obj/item/ammo_box/magazine/heater, /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = /obj/item/ammo_box/magazine/m7mm, /obj/item/gun/ballistic/automatic/m90 = /obj/item/ammo_box/magazine/m223, /obj/item/gun/ballistic/automatic/mini_uzi = /obj/item/ammo_box/magazine/uzim9mm, /obj/item/gun/ballistic/automatic/pistol = /obj/item/ammo_box/magazine/m9mm, + /obj/item/gun/ballistic/automatic/pistol/clandestine = /obj/item/ammo_box/magazine/m10mm, + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher = /obj/item/ammo_box/magazine/m10mm, /obj/item/gun/ballistic/automatic/pistol/deagle = /obj/item/ammo_box/magazine/m50, /obj/item/gun/ballistic/automatic/pistol/m1911 = /obj/item/ammo_box/magazine/m45, + /obj/item/gun/ballistic/automatic/pistol/rattlesnake = /obj/item/ammo_box/magazine/m9mm_rattlesnake, + /obj/item/gun/ballistic/automatic/pistol/zashch = /obj/item/ammo_box/magazine/zashch, /obj/item/gun/ballistic/automatic/proto/unrestricted = /obj/item/ammo_box/magazine/smgm9mm, + /obj/item/gun/ballistic/automatic/rapier = /obj/item/ammo_box/magazine/rapier, + /obj/item/gun/ballistic/automatic/resolution = /obj/item/ammo_box/magazine/resolution, + /obj/item/gun/ballistic/automatic/skm = /obj/item/ammo_box/magazine/skm, /obj/item/gun/ballistic/automatic/tommygun = /obj/item/ammo_box/magazine/tommygunm45, /obj/item/gun/ballistic/automatic/wt550 = /obj/item/ammo_box/magazine/wt550m9, + /obj/item/gun/ballistic/automatic/woodsman = /obj/item/ammo_box/magazine/m23, /obj/item/gun/ballistic/rifle/sniper_rifle = /obj/item/ammo_box/magazine/sniper_rounds, + /obj/item/gun/ballistic/shotgun/slammer = /obj/item/ammo_box/magazine/m12g_slammer, /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = /obj/item/ammo_box/magazine/m12g, // internal magazines: loose rounds instead /obj/item/gun/ballistic/revolver = /obj/item/ammo_box/a357, /obj/item/gun/ballistic/revolver/c38/detective = /obj/item/ammo_box/c38, /obj/item/gun/ballistic/revolver/mateba = /obj/item/ammo_box/a357, /obj/item/gun/ballistic/rifle/boltaction = /obj/item/ammo_box/strilka310, + /obj/item/gun/ballistic/rifle/boltaction/harpoon = /obj/item/storage/bag/harpoon_quiver, /obj/item/gun/ballistic/rifle/boltaction/prime = /obj/item/ammo_box/strilka310, /obj/item/gun/ballistic/rifle/boltaction/surplus = /obj/item/ammo_box/strilka310, + /obj/item/gun/ballistic/shotgun/flaming_arrow = /obj/item/ammo_box/c38, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = /obj/item/ammo_box/a357, + /obj/item/gun/ballistic/rocketlauncher/unrestricted = /obj/item/ammo_casing/rocket, // break/pump actions: a box of shells /obj/item/gun/ballistic/shotgun/automatic/combat = /obj/item/storage/box/lethalshot, + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = /obj/item/storage/box/lethalshot, + /obj/item/gun/ballistic/shotgun/brimstone = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/doublebarrel = /obj/item/storage/box/lethalshot, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/riot = /obj/item/storage/box/lethalshot, )) /obj/structure/closet/crate/zone_loot diff --git a/voidcrew/modules/loot/zone_mobs.dm b/voidcrew/modules/loot/zone_mobs.dm index 284115aa4f7..2be63404698 100644 --- a/voidcrew/modules/loot/zone_mobs.dm +++ b/voidcrew/modules/loot/zone_mobs.dm @@ -51,13 +51,24 @@ var/npc_role = "auto" /// How far spawns scatter from the marker var/scatter_radius = 2 + /// How a runtime wave gets on scene: ZONE_MOBS_ARRIVE_POD or ZONE_MOBS_ARRIVE_EMERGE + var/wave_arrival = ZONE_MOBS_ARRIVE_POD + /// Pod look for ZONE_MOBS_ARRIVE_POD waves + var/datum/pod_style/wave_pod_style = /datum/pod_style + /// Set for runtime waves: the pack shows up on screen instead of appearing in place + var/wave = FALSE /** * Runtime spawns (mission waves) can pass a count override: * new /obj/effect/zone_mobs/pirate(T, list(1, 2)) guarantees 1-2 spawns in * every band, regardless of the theme's defaults. + * Pass wave = TRUE when players may be watching: the pack drops in by pod or + * digs out of the ground (wave_arrival) instead of appearing next to them. */ -/obj/effect/zone_mobs/Initialize(mapload, list/count_override) +/obj/effect/zone_mobs/Initialize(mapload, list/count_override, wave = FALSE) + src.wave = wave + if(wave) + scatter_radius = max(scatter_radius, ZONE_MOBS_WAVE_SCATTER) if(npc_role == "auto" && npc_zone_boss_type(type)) npc_role = "boss" if(islist(count_override) && length(count_override) >= 2) @@ -107,7 +118,12 @@ continue var/turf/spot = length(open_turfs) ? pick_n_take(open_turfs) : get_turf(src) var/datum/npc_spawn_context/context = new(src, zone_type, npc_role) - spawn_npc_in_zone(mob_path, spot, context) + if(!wave) + spawn_npc_in_zone_or_queue(mob_path, spot, context) + else if(wave_arrival == ZONE_MOBS_ARRIVE_EMERGE) + zone_mobs_emerge(mob_path, spot, context) + else + zone_mobs_pod_drop(mob_path, spot, context, wave_pod_style) /** * Open, unblocked turfs around the marker the pack can scatter onto. @@ -115,7 +131,11 @@ /obj/effect/zone_mobs/proc/get_scatter_turfs() var/list/result = list() for(var/turf/open/tile in RANGE_TURFS(scatter_radius, src)) - if(tile.is_blocked_turf(exclude_mobs = TRUE)) + // A wave never lands a pod or digs up under someone's feet + if(tile.is_blocked_turf(exclude_mobs = !wave)) + continue + // Nor drops one into lava, water or a pit + if(wave && (islava(tile) || ischasm(tile) || istype(tile, /turf/open/water))) continue result += tile return result diff --git a/voidcrew/modules/loot/zone_mobs_arrival.dm b/voidcrew/modules/loot/zone_mobs_arrival.dm new file mode 100644 index 00000000000..689d9694b36 --- /dev/null +++ b/voidcrew/modules/loot/zone_mobs_arrival.dm @@ -0,0 +1,84 @@ +/** + * # Zone Mob Wave Arrivals + * + * Runtime waves (new /obj/effect/zone_mobs/(T, count, TRUE)) are called + * in while players are standing right there, so the pack has to visibly get + * there instead of appearing beside them. Crews drop in by pod; beasts, bugs + * and the dead dig up out of the ground after a short rumble. + */ + +/// How long the ground shakes before something digs out of it +#define ZONE_MOBS_EMERGE_TIME (2.5 SECONDS) +/// How far below the tile an emerging mob starts +#define ZONE_MOBS_EMERGE_DEPTH 16 + +/** + * Loads one mob into a pod and drops it on the spot. The pod shows its usual + * landing marker, does no damage or explosion, opens, then leaves, so it never + * stays behind as free furniture. + */ +/proc/zone_mobs_pod_drop(mob_path, turf/spot, datum/npc_spawn_context/context, datum/pod_style/style = /datum/pod_style) + // A human NPC is built by the spawn queue (human_npc_spawning.dm), then goes in its pod the same tick + if(ispath(mob_path, /mob/living/carbon/human/npc)) + spawn_human_npc(mob_path, spot, CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(zone_mobs_pod_send), spot, style), context = context) + return + var/obj/structure/closet/supplypod/podspawn/pod = new(null, style) + spawn_npc_in_zone(mob_path, pod, context) + new /obj/effect/pod_landingzone(spot, pod) + +/// Puts a human NPC the spawn queue has just built into a pod and drops it on the spot. +/proc/zone_mobs_pod_send(turf/spot, datum/pod_style/style, mob/living/body) + if(QDELETED(body)) + return + var/obj/structure/closet/supplypod/podspawn/pod = new(null, style) + body.forceMove(pod) + new /obj/effect/pod_landingzone(spot, pod) + +/** + * The ground rumbles, then the mob climbs out of it and leaves rubble behind. + */ +/proc/zone_mobs_emerge(mob_path, turf/spot, datum/npc_spawn_context/context) + new /obj/effect/temp_visual/zone_mobs_emerge(spot) + playsound(spot, 'sound/effects/break_stone.ogg', 50, TRUE) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(zone_mobs_emerge_finish), mob_path, spot, context), ZONE_MOBS_EMERGE_TIME) + +/proc/zone_mobs_emerge_finish(mob_path, turf/spot, datum/npc_spawn_context/context) + if(!spot) + return + playsound(spot, 'sound/effects/shovel_dig.ogg', 60, TRUE) + new /obj/effect/decal/cleanable/rubble(spot) + // A human NPC is built by the spawn queue (human_npc_spawning.dm) and climbs out as it appears + if(ispath(mob_path, /mob/living/carbon/human/npc)) + spawn_human_npc(mob_path, spot, CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(zone_mobs_emerge_rise)), context = context) + return + zone_mobs_emerge_rise(spawn_npc_in_zone(mob_path, spot, context)) + +/// The mob rises out of the ground it was just made on. +/proc/zone_mobs_emerge_rise(mob/living/body) + if(!isliving(body) || QDELETED(body)) + return + // Parallel and relative so a mob's own idle animation (floating, bobbing) survives + var/final_alpha = body.alpha + body.alpha = 0 + body.pixel_z -= ZONE_MOBS_EMERGE_DEPTH + animate(body, pixel_z = ZONE_MOBS_EMERGE_DEPTH, time = 0.6 SECONDS, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(body, alpha = final_alpha, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) + +/// The shaking dirt that gives away something digging up +/obj/effect/temp_visual/zone_mobs_emerge + name = "shifting ground" + icon = 'icons/obj/debris.dmi' + icon_state = "rubble_bounce" + layer = ABOVE_OPEN_TURF_LAYER + plane = FLOOR_PLANE + randomdir = FALSE + duration = ZONE_MOBS_EMERGE_TIME + +/obj/effect/temp_visual/zone_mobs_emerge/Initialize(mapload) + . = ..() + animate(src, pixel_x = 1, time = 0.5, loop = -1) + animate(pixel_x = -1, time = 1) + animate(pixel_x = 0, time = 0.5) + +#undef ZONE_MOBS_EMERGE_TIME +#undef ZONE_MOBS_EMERGE_DEPTH diff --git a/voidcrew/modules/mantis_gore/icons/mantis_artery.dmi b/voidcrew/modules/mantis_gore/icons/mantis_artery.dmi new file mode 100644 index 00000000000..8732265b3a0 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_artery.dmi differ diff --git a/voidcrew/modules/mantis_gore/icons/mantis_gore.dmi b/voidcrew/modules/mantis_gore/icons/mantis_gore.dmi new file mode 100644 index 00000000000..abb47b51618 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_gore.dmi differ diff --git a/voidcrew/modules/mantis_gore/icons/mantis_gore_large.dmi b/voidcrew/modules/mantis_gore/icons/mantis_gore_large.dmi new file mode 100644 index 00000000000..39c504a9b18 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_gore_large.dmi differ diff --git a/voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi b/voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi new file mode 100644 index 00000000000..2b66dba06d8 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi differ diff --git a/voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi b/voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi new file mode 100644 index 00000000000..263155493f3 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi differ diff --git a/voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi b/voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi new file mode 100644 index 00000000000..6d528bd0a56 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi differ diff --git a/voidcrew/modules/mantis_gore/icons/mantis_swing.dmi b/voidcrew/modules/mantis_gore/icons/mantis_swing.dmi new file mode 100644 index 00000000000..6ea7bf69677 Binary files /dev/null and b/voidcrew/modules/mantis_gore/icons/mantis_swing.dmi differ diff --git a/voidcrew/modules/mantis_gore/mantis_blade.dm b/voidcrew/modules/mantis_gore/mantis_blade.dm new file mode 100644 index 00000000000..d625a5a3fbf --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_blade.dm @@ -0,0 +1,382 @@ +// The blade side of the Mantis line's gore: what a landed cut does to the body (arterial gashes, the +// X-slash), the held poses, the glint, and blood on the blade. See mantis_core.dm for the overview. + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis + // tg's energy weapons refuse blood. This one takes it, and shows it in the hand. + item_flags = NONE + /// What this blade can do beyond cutting: MANTIS_MOVE_* flags. + var/gore_moves = MANTIS_MOVES_ALL + /// The wound a torn arm or leg artery is. + var/artery_wound_type = /datum/wound/slash/flesh/critical/hv_carotid/limb + /// The held pose: MANTIS_POSE_*. The held sprite is "[base_icon_state]_[mantis_pose]". + var/mantis_pose = MANTIS_POSE_GUARD + /// The housing that made this blade. + var/datum/weakref/housing_ref + /// The wielder of the swing being resolved, while it resolves. + var/datum/weakref/cut_wielder + /// Until this world.time the blade drips. + var/drenched_until = 0 + /// Until this world.time the blade is drawn soaked, not just bloodied. + var/soaked_until = 0 + /// Drops it can still leave from this drench. + var/drips_left = 0 + /// Timer of the next drop. + var/drip_timer + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/Destroy() + if(drip_timer) + deltimer(drip_timer) + drip_timer = null + housing_ref = null + cut_wielder = null + return ..() + +// ---- The models ---------------------------------------------------------- + +// The Shiv only bleeds people: no torn arteries, no X-slash, no scissor (owner D34). It still takes +// blood and holds the guard. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street + gore_moves = NONE + +// The Scorch's cuts burn and half-seal what they open, and the heat keeps the blade clean. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch + item_flags = NO_BLOOD_ON_ITEM + artery_wound_type = /datum/wound/slash/flesh/critical/hv_carotid/limb/seared + +// ========================================================================= +// THE CUT +// ========================================================================= + +/// Whether this swing is not a cut at all: the bearer is busy finishing someone, or it starts the scissor. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_swing_taken(mob/living/target, mob/living/user) + if(!isliving(user) || !isliving(target)) + return FALSE + // The blades are already at someone's neck. + if(user.has_status_effect(/datum/status_effect/hv_finishing)) + return TRUE + return mantis_try_scissor(target, user) + +/// Listens to [target] while this blade's swing resolves on them. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_watch_cut(mob/living/target, mob/living/user) + if(!isliving(target) || !isliving(user) || target == user) + return + cut_wielder = WEAKREF(user) + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_cut_damage_pre), override = TRUE) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_cut_damage), override = TRUE) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_unwatch_cut(mob/living/target) + cut_wielder = null + if(isliving(target)) + UnregisterSignal(target, list(COMSIG_MOB_APPLY_DAMAGE, COMSIG_MOB_AFTER_APPLY_DAMAGE)) + +/// Before the cut lands: a death from it counts as the wielder's kill, and only this blade runs red for it. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/on_cut_damage_pre(mob/living/source, damage_amount, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + if(attacking_item != src) + return + var/mob/living/user = cut_wielder?.resolve() + if(user) + mantis_note_hit(source, user, list(src)) + +/// The cut has landed, while its attack line is still held: what it does goes into the same message. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/on_cut_damage(mob/living/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + // The X-slash's follow-through comes from the other blade, so it never counts here. + if(attacking_item != src || damage_dealt <= 0 || damagetype != BRUTE) + return + var/mob/living/user = cut_wielder?.resolve() + if(isnull(user) || QDELETED(source)) + return + mantis_cut_landed(source, user, isbodypart(def_zone) ? def_zone : null, damage_dealt, attack_direction || get_dir(user, source)) + +/// A cut that did damage: the artery count, the scorch seam and the X-slash count. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_cut_landed(mob/living/victim, mob/living/user, obj/item/bodypart/limb, damage, attack_direction) + if(limb && iscarbon(victim)) + // The swing's slash goes on the part it cut (mantis_swing.dm). + mantis_swing_impact_onto(victim, limb.body_zone) + cut_fx(victim, limb) + if(gore_moves & MANTIS_MOVE_ARTERY) + mantis_gash(victim, user, limb, damage, attack_direction) + if(gore_moves & MANTIS_MOVE_XSLASH) + mantis_xslash_count(victim, user, limb, attack_direction) + +/// What a landed cut looks like on the limb, beyond the impact. Nothing for a plain blade. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/cut_fx(mob/living/carbon/victim, obj/item/bodypart/limb) + return + +// The cut sears: a glowing seam across the limb that cools to char, and a hiss. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/cut_fx(mob/living/carbon/victim, obj/item/bodypart/limb) + mantis_scorch_seam_fx(victim, limb) + playsound(victim, pick('sound/effects/wounds/sizzle1.ogg', 'sound/effects/wounds/sizzle2.ogg'), 40, TRUE) + +// ---- Arterial gashes ---------------------------------------------------- + +/// How many cuts tear the artery of [limb], or 0 if the cover on it never lets them. +/proc/mantis_cuts_to_tear(mob/living/carbon/victim, obj/item/bodypart/limb, armour_penetration) + var/cover = hv_crit_cover_rating(victim, limb, MELEE, armour_penetration) + if(cover >= MANTIS_COVER_STOP) + return 0 + if(cover >= MANTIS_COVER_LIGHT) + return MANTIS_TEAR_CUTS_COVERED + return MANTIS_TEAR_CUTS_BARE + +/// Counts a cut on [limb], and tears its artery when enough have landed. Returns TRUE if it tore. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_gash(mob/living/carbon/victim, mob/living/user, obj/item/bodypart/limb, damage, attack_direction) + if(damage < MANTIS_CUT_MIN_DAMAGE || victim.stat == DEAD || limb.owner != victim || !limb.can_bleed()) + return FALSE + if(!user.hv_can_land_aimed_crits()) + return FALSE + var/needed = mantis_cuts_to_tear(victim, limb, armour_penetration) + if(!needed) + return FALSE + var/datum/status_effect/mantis_cuts/cuts = victim.has_status_effect(/datum/status_effect/mantis_cuts) + if(isnull(cuts) || cuts.add_cut(limb.body_zone) < needed) + return FALSE + cuts.clear_cuts(limb.body_zone) + return mantis_tear_artery(victim, user, limb, attack_direction) + +/// What the torn artery of [limb] is: the throat and the aorta are #509's own wounds. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/artery_wound_for(obj/item/bodypart/limb) + switch(limb.body_zone) + if(BODY_ZONE_HEAD) + return /datum/wound/slash/flesh/critical/hv_carotid + if(BODY_ZONE_CHEST) + return /datum/wound/pierce/bleed/critical/hv_aorta + return artery_wound_type + +/// Where the artery of [limb] is, as the line names it. +/proc/mantis_artery_place(obj/item/bodypart/limb) + return limb.body_zone == BODY_ZONE_HEAD ? "neck" : limb.plaintext_zone + +/// Tears the artery of [limb]. Returns TRUE if it tore. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_tear_artery(mob/living/carbon/victim, mob/living/user, obj/item/bodypart/limb, attack_direction) + // A sealed helmet or an armoured collar still saves the throat, as it does from #509's slit throat. + if(limb.body_zone == BODY_ZONE_HEAD) + var/datum/hv_crit/throat/throat = locate() in GLOB.hv_crits + if(throat?.blocked_on_carbon(victim, limb, src)) + return FALSE + var/datum/wound/wound_type = artery_wound_for(limb) + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[wound_type] + if(!pregen?.can_be_applied_to(limb, list(WOUND_SLASH))) + return FALSE + // The throat's and the aorta's wounds are #509's and tg's: given the cut's direction they throw tg's wound + // spray along it, a line of blood three tiles long. The tear throws its own drops instead, so they get none. + var/own_wound = ispath(wound_type, /datum/wound/slash/flesh/critical/hv_carotid/limb) + var/datum/wound/torn = hv_crit_apply_wound(limb, wound_type, own_wound ? attack_direction : null, src) + if(!torn) + return FALSE + var/seared = ispath(artery_wound_type, /datum/wound/slash/flesh/critical/hv_carotid/limb/seared) + var/place = mantis_artery_place(limb) + if(seared) + // The throat and aorta wounds are #509's; the heat closes part of them. + if(!istype(torn, /datum/wound/slash/flesh/critical/hv_carotid/limb)) + torn.adjust_blood_flow(-torn.blood_flow * 0.4) + victim.hv_add_hit_line("[victim]'s [place] is seared open to the artery!", HV_LINE_BOLD, "Your [place] is seared open to the artery!", DEFAULT_MESSAGE_RANGE) + else + victim.hv_add_hit_line("The artery in [victim]'s [place] is cut!", HV_LINE_BOLD, "The artery in your [place] is cut!", DEFAULT_MESSAGE_RANGE) + drench(victim) + // What it looks like: one spurt of drops the way the cut went, then the blood runs down the body. + mantis_spurt_fx(victim, limb.body_zone, attack_direction, seared) + mantis_artery_run_fx(victim, limb.body_zone, torn) + log_combat(user, victim, "tore an artery in", src, "([limb.plaintext_zone])") + return TRUE + +// ---- X-slash ----------------------------------------------------------- + +/// Counts a landed cut toward the X-slash. Every third on one target, with both blades out, is one. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_xslash_count(mob/living/victim, mob/living/user, obj/item/bodypart/limb, attack_direction) + var/datum/mantis_bearer/state = user.get_mantis_state() + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/off_blade + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/held as anything in mantis_held_blades(user, MANTIS_MOVE_XSLASH)) + if(held != src) + off_blade = held + break + if(isnull(off_blade) || !user.hv_can_land_aimed_crits() || victim.stat == DEAD) + state.xslash_count = 0 + return FALSE + if(state.xslash_target?.resolve() != victim || world.time - state.xslash_last > MANTIS_XSLASH_WINDOW) + state.xslash_count = 0 + state.xslash_target = WEAKREF(victim) + state.xslash_last = world.time + state.xslash_count++ + if(state.xslash_count < MANTIS_XSLASH_EVERY) + return FALSE + state.xslash_count = 0 + mantis_xslash(victim, user, off_blade, limb, attack_direction) + return TRUE + +/// The off blade follows through across the first cut, and the target reels. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_xslash(mob/living/victim, mob/living/user, obj/item/melee/energy/blade/hardlight/cyberware_mantis/off_blade, obj/item/bodypart/limb, attack_direction) + var/zone = limb ? limb.body_zone : BODY_ZONE_CHEST + var/place = limb ? limb.plaintext_zone : "body" + // The attack line being held for this hit becomes the crossed slash. + var/datum/hv_hit/hit = victim.hv_hit + if(hit?.others_text && hit.attacker == user) + hit.others_text = "[user] crosses both blades through [victim]'s [place]!" + hit.victim_text = "[user] crosses both blades through your [place]!" + if(hit.attacker_text) + hit.attacker_text = "You cross both blades through [victim]'s [place]!" + victim.hv_add_hit_line("[victim] reels!", HV_LINE_BOLD, "You reel!") + var/armour = victim.run_armor_check(zone, MELEE, armour_penetration = off_blade.armour_penetration, silent = TRUE) + victim.apply_damage( + off_blade.force * 0.5, + BRUTE, + limb || zone, + armour, + wound_bonus = 10, + sharpness = SHARP_EDGED, + attack_direction = attack_direction, + attacking_item = off_blade, + ) + if(!QDELETED(victim)) + victim.set_staggered_if_lower(MANTIS_XSLASH_STAGGER) + mantis_xslash_cross(user, victim, off_blade, zone) + log_combat(user, victim, "x-slashed", src) + +// ========================================================================= +// POSES AND THE GLINT +// ========================================================================= + +/// Puts the held blade in [pose] (MANTIS_POSE_*), or back in the guard if this model has no such pose. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/set_mantis_pose(pose = MANTIS_POSE_GUARD) + var/state = "[base_icon_state]_[pose]" + if(pose != MANTIS_POSE_GUARD && !icon_exists(righthand_file, state)) + pose = MANTIS_POSE_GUARD + state = "[base_icon_state]_[pose]" + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = housing_ref?.resolve() + housing?.set_stance_raise(mantis_stance_raise(pose)) + if(mantis_pose == pose && inhand_icon_state == state) + return + housing?.unfold_overtaken(src) + mantis_pose = pose + inhand_icon_state = state + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + +/// How far the fists ride up in [pose], in pixels. Matches the held sprites. +/proc/mantis_stance_raise(pose) + switch(pose) + if(MANTIS_POSE_RAISE) + return 3 + if(MANTIS_POSE_COIL) + return 1 + return 2 + +/// Puts every blade of [housings] in [pose]. +/proc/mantis_pose_blades(list/housings, pose) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_housing_blades(housings)) + blade.set_mantis_pose(pose) + +/// The hand this blade sits in, as the art names it. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/hand_side() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = housing_ref?.resolve() + return housing?.zone == BODY_ZONE_L_ARM ? "left" : "right" + +/// A light runs along the edge, if this model has an edge that catches the light in [pose]. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/play_glint(pose = mantis_pose) + var/mob/living/holder = loc + if(!isliving(holder) || !holder.is_holding(src)) + return null + var/state = "[base_icon_state]_[pose]_glint_[hand_side()]" + if(!icon_exists(MANTIS_GORE_ICON, state)) + return null + var/obj/effect/abstract/cyberware_anim/glint = cyberware_anim_play(holder, MANTIS_GORE_ICON, state, MANTIS_ANIM_BLADE_LAYER, 0.3 SECONDS, glow = TRUE) + // Side on, the far blade's light stays behind the near blade. + glint?.sort_as_arm(hand_side()) + return glint + +// ========================================================================= +// BLOOD +// ========================================================================= + +/// Whether there is blood on the blade to show. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/is_bloody() + return !(item_flags & NO_BLOOD_ON_ITEM) && GET_ATOM_BLOOD_DECAL_LENGTH(src) + +/// A kill, a torn artery, a rip, the scissor or the X-slash: the blade runs red with [source]'s blood and drips +/// for a while. Only the blade that did it: the other keeps whatever it had. +/// [light]: the steel that went through is bloodied (the lighter coat), not soaked: the impale and the scissor, +/// so the held blade matches the one the finisher drew. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/drench(mob/living/source, light = FALSE) + if(item_flags & NO_BLOOD_ON_ITEM) + return FALSE + if(isliving(source) && source.can_bleed(BLOOD_COVER_ITEMS) == BLEED_SPLATTER) + add_mob_blood(source) + if(!is_bloody()) + return FALSE + drenched_until = world.time + MANTIS_DRENCH_TIME + if(!light) + soaked_until = drenched_until + drips_left = MANTIS_DRIP_MAX + if(!drip_timer) + drip_timer = addtimer(CALLBACK(src, PROC_REF(drip)), MANTIS_DRIP_INTERVAL, TIMER_STOPPABLE|TIMER_DELETE_ME) + refresh_held() + return TRUE + +/// One drop off the blade onto the floor under its wielder, while it is still drenched. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/drip() + drip_timer = null + if(world.time >= drenched_until || drips_left <= 0 || !is_bloody()) + stop_dripping() + return + var/mob/living/holder = loc + if(isliving(holder) && holder.is_holding(src) && isturf(holder.loc)) + var/turf/floor = holder.loc + if(!isgroundlessturf(floor) && floor.has_gravity()) + var/list/blood_dna = blood_dna_copy() + var/obj/effect/decal/cleanable/blood/drip/drop = locate() in floor + if(drop) + drop.add_blood_DNA(blood_dna) + else + new /obj/effect/decal/cleanable/blood/drip(floor, null, blood_dna) + drips_left-- + drip_timer = addtimer(CALLBACK(src, PROC_REF(drip)), MANTIS_DRIP_INTERVAL, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// The blade stops dripping. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/stop_dripping() + if(drip_timer) + deltimer(drip_timer) + drip_timer = null + var/was_drenched = drenched_until + drenched_until = 0 + soaked_until = 0 + drips_left = 0 + if(was_drenched) + refresh_held() + +/// The blood on the blade, as a list of its own (a decal keeps the list it is given). +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/blood_dna_copy() + var/list/blood_dna = GET_ATOM_BLOOD_DNA(src) + return blood_dna ? blood_dna.Copy() : list() + +/// The held sprite redraws. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/refresh_held() + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + +/// The blade is back in its housing: the guard, and no more drops. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_stowed() + stop_dripping() + mantis_pose = MANTIS_POSE_GUARD + inhand_icon_state = "[base_icon_state]_[MANTIS_POSE_GUARD]" + +// tg's item blood is drawn on the item only. In the hand, a coat of blood drawn for the pose, heavier and +// dripping while drenched. Drawn as its own state: an inset overlay inside a whole body masks against the +// body, not the blade. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/worn_overlays(mutable_appearance/standing, isinhands = FALSE, icon_file) + . = ..() + if(!isinhands || !is_bloody()) + return + var/blood_color = get_blood_dna_color() + var/drenched = world.time < drenched_until + var/coat_state = "[standing.icon_state]_[world.time < soaked_until ? "soak" : "blood"]" + if(icon_exists(icon_file, coat_state)) + var/mutable_appearance/coat = mutable_appearance(icon_file, coat_state, appearance_flags = RESET_COLOR) + coat.color = blood_color + . += coat + if(drenched && icon_exists(icon_file, "[standing.icon_state]_drip")) + var/mutable_appearance/drops = mutable_appearance(icon_file, "[standing.icon_state]_drip", appearance_flags = RESET_COLOR) + drops.color = blood_color + . += drops diff --git a/voidcrew/modules/mantis_gore/mantis_charge.dm b/voidcrew/modules/mantis_gore/mantis_charge.dm new file mode 100644 index 00000000000..548799fb2a3 --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_charge.dm @@ -0,0 +1,274 @@ +// The charged leap. With the lunge armed, press and hold on a target: past a short tap the bearer sinks +// into a crouch, rooted, blades swept back and glowing brighter the longer they hold. Release to pounce, +// further for a longer hold. A tap is still the ordinary lunge. One solid hit or a stun knocks them out +// of the crouch and costs the whole cooldown; holding too long they straighten up on their own. +// +// The input is tg's hold pattern (full-auto, the Justice mech): the armed button listens to its +// bearer's client for the press and the release. AI bearers, with no client, only ever tap. + +/datum/action/cooldown/cyberware/mantis_lunge + /// The client the armed button listens to. + var/client/charge_client + /// world.time the press began, or 0. + var/charge_started = 0 + /// Who the press was on. + var/datum/weakref/charge_target + /// TRUE once the crouch shows. + var/crouched = FALSE + /// The body crouched, so it is stood up again even if the button has moved on. + var/datum/weakref/crouch_body + /// The glow on each blade while crouched. + var/list/datum/weakref/crouch_glows + /// Timer of the crouch showing. + var/charge_timer + /// Timer of the next crouch check. + var/crouch_tick_timer + +/datum/action/cooldown/cyberware/mantis_lunge/set_click_ability(mob/on_who) + . = ..() + listen_for_charge(on_who) + +/datum/action/cooldown/cyberware/mantis_lunge/unset_click_ability(mob/on_who, refund_cooldown = TRUE) + stop_listening_for_charge() + cancel_charge() + return ..() + +/datum/action/cooldown/cyberware/mantis_lunge/Remove(mob/removed_from) + stop_listening_for_charge() + cancel_charge() + impale?.finish() + return ..() + +/datum/action/cooldown/cyberware/mantis_lunge/Destroy() + stop_listening_for_charge() + cancel_charge() + impale?.finish() + impale = null + return ..() + +/datum/action/cooldown/cyberware/mantis_lunge/proc/listen_for_charge(mob/on_who) + stop_listening_for_charge() + if(isnull(on_who?.client)) + return + charge_client = on_who.client + RegisterSignal(charge_client, COMSIG_CLIENT_MOUSEDOWN, PROC_REF(on_mouse_down)) + +/datum/action/cooldown/cyberware/mantis_lunge/proc/stop_listening_for_charge() + if(isnull(charge_client)) + return + UnregisterSignal(charge_client, list(COMSIG_CLIENT_MOUSEDOWN, COMSIG_CLIENT_MOUSEUP)) + charge_client = null + +/datum/action/cooldown/cyberware/mantis_lunge/proc/on_mouse_down(client/source, atom/object, turf/location, control, params) + SIGNAL_HANDLER + var/list/modifiers = params2list(params) + if(!LAZYACCESS(modifiers, LEFT_CLICK) || LAZYACCESS(modifiers, SHIFT_CLICK) || LAZYACCESS(modifiers, CTRL_CLICK) || LAZYACCESS(modifiers, ALT_CLICK)) + return + if(owner?.click_intercept != src || !isliving(object)) + return + INVOKE_ASYNC(src, PROC_REF(begin_charge), object) + +/datum/action/cooldown/cyberware/mantis_lunge/proc/on_mouse_up(client/source, atom/object, turf/location, control, params) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_CLIENT_MOUSEUP) + if(!charge_started) + return + if(!crouched) + // A tap: the click that follows is the ordinary lunge. + cancel_charge(rearm = FALSE) + return + INVOKE_ASYNC(src, PROC_REF(release_charge)) + // The crouch was the press: the click that follows it is spent. + return COMPONENT_CLIENT_MOUSEUP_INTERCEPT + +/// How far a leap held for [held] reaches, from a lunge of [base_range]. +/proc/mantis_charge_range(held, base_range) + var/progress = clamp((held - MANTIS_CHARGE_TAP) / (MANTIS_CHARGE_FULL - MANTIS_CHARGE_TAP), 0, 1) + return base_range + round(MANTIS_CHARGE_EXTRA_RANGE * progress, 1) + +/// The press on [target]. Returns TRUE if a charge began. +/datum/action/cooldown/cyberware/mantis_lunge/proc/begin_charge(mob/living/target) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ + var/mob/living/carbon/bearer = owner + if(charge_started || !istype(housing) || !iscarbon(bearer) || !isliving(target)) + return FALSE + if(!IsAvailable() || !housing.blade_extended() || HAS_TRAIT(bearer, TRAIT_PACIFISM)) + return FALSE + if(target == bearer || target.stat == DEAD || bearer.buckled || !isturf(bearer.loc)) + return FALSE + var/datum/mantis_bearer/state = bearer.get_mantis_state() + if(world.time < state.next_charge) + return FALSE + var/reach = housing.lunge_range + MANTIS_CHARGE_EXTRA_RANGE + if(get_dist(bearer, target) > reach || !can_see(bearer, target, reach)) + return FALSE + charge_started = world.time + charge_target = WEAKREF(target) + // The field drops the moment they set themselves, not when they leave the floor. + cyberware_camo_drop(bearer) + charge_timer = addtimer(CALLBACK(src, PROC_REF(enter_crouch)), MANTIS_CHARGE_TAP, TIMER_STOPPABLE|TIMER_DELETE_ME) + if(charge_client) + RegisterSignal(charge_client, COMSIG_CLIENT_MOUSEUP, PROC_REF(on_mouse_up), override = TRUE) + return TRUE + +/// Past the tap: the crouch shows. +/datum/action/cooldown/cyberware/mantis_lunge/proc/enter_crouch() + if(charge_timer) + deltimer(charge_timer) + charge_timer = null + var/mob/living/carbon/bearer = owner + if(!charge_started || !iscarbon(bearer) || crouched) + return + if(!can_hold_crouch()) + cancel_charge() + return + crouched = TRUE + crouch_body = WEAKREF(bearer) + ADD_TRAIT(bearer, TRAIT_IMMOBILIZED, MANTIS_CHARGE_TRAIT) + bearer.add_offsets(MANTIS_CHARGE_TRAIT, z_add = -MANTIS_CHARGE_CROUCH_DROP) + var/list/housings = get_extended_housings() + mantis_pose_blades(housings, MANTIS_POSE_COIL) + crouch_glows = list() + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_housing_blades(housings)) + var/obj/effect/abstract/cyberware_anim/glow = mantis_coil_glow_fx(bearer, blade) + if(glow) + crouch_glows += WEAKREF(glow) + var/mob/living/target = charge_target?.resolve() + if(target) + bearer.face_atom(target) + bearer.visible_message( + span_boldwarning("[bearer] sinks into a crouch, blades drawn back!"), + span_warning("You coil to spring."), + ) + playsound(bearer, 'sound/items/weapons/sear.ogg', 35, TRUE) + RegisterSignal(bearer, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_crouch_damaged)) + crouch_tick_timer = addtimer(CALLBACK(src, PROC_REF(crouch_tick)), 0.2 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Whether the bearer can keep crouching: on their feet, unhurt enough, blade out, still meaning harm. +/datum/action/cooldown/cyberware/mantis_lunge/proc/can_hold_crouch() + var/mob/living/carbon/bearer = owner + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ + if(!iscarbon(bearer) || !istype(housing) || !housing.blade_extended()) + return FALSE + if(bearer.stat != CONSCIOUS || bearer.buckled || !isturf(bearer.loc) || HAS_TRAIT(bearer, TRAIT_PACIFISM)) + return FALSE + return TRUE + +/// Whether something has put the crouched bearer down: a stun, a knockdown, a fall. +/datum/action/cooldown/cyberware/mantis_lunge/proc/crouch_knocked_out() + var/mob/living/carbon/bearer = owner + return HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || HAS_TRAIT(bearer, TRAIT_FLOORED) || bearer.body_position == LYING_DOWN || HAS_TRAIT(bearer, TRAIT_HANDS_BLOCKED) + +/datum/action/cooldown/cyberware/mantis_lunge/proc/crouch_tick() + crouch_tick_timer = null + if(!crouched) + return + if(!iscarbon(owner) || owner.stat != CONSCIOUS) + cancel_charge() + return + if(crouch_knocked_out()) + break_crouch() + return + if(!can_hold_crouch()) + cancel_charge() + return + if(world.time - charge_started >= MANTIS_CHARGE_MAX_HOLD) + // Held too long: they straighten up. No cooldown, a moment before they can set again. + var/mob/living/bearer = owner + cancel_charge() + bearer.balloon_alert(bearer, "straightened up") + return + crouch_tick_timer = addtimer(CALLBACK(src, PROC_REF(crouch_tick)), 0.2 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/action/cooldown/cyberware/mantis_lunge/proc/on_crouch_damaged(datum/source, damage_dealt, damagetype) + SIGNAL_HANDLER + if(!crouched || damage_dealt < MANTIS_CHARGE_BREAK_DAMAGE) + return + if(!(damagetype in list(BRUTE, BURN, STAMINA))) + return + INVOKE_ASYNC(src, PROC_REF(break_crouch)) + +/// Knocked out of the crouch: no pounce, and the whole cooldown. +/datum/action/cooldown/cyberware/mantis_lunge/proc/break_crouch() + if(!crouched) + return + var/mob/living/carbon/bearer = owner + cancel_charge() + if(!iscarbon(bearer)) + return + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge as anything in mantis_lunge_actions(bearer)) + lunge.StartCooldown() + bearer.do_stagger_animation() + bearer.visible_message( + span_danger("[bearer] is knocked out of [bearer.p_their()] crouch!"), + span_userdanger("You're knocked out of your crouch!"), + ) + if(bearer.click_intercept == src) + unset_click_ability(bearer, refund_cooldown = FALSE) + +/// Let go after the crouch: the leap. Returns TRUE if they sprang. +/datum/action/cooldown/cyberware/mantis_lunge/proc/release_charge() + var/held = world.time - charge_started + var/mob/living/target = charge_target?.resolve() + var/mob/living/carbon/bearer = owner + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ + cancel_charge(rearm = FALSE) + if(!iscarbon(bearer) || !istype(housing)) + return FALSE + if(QDELETED(target) || target.stat == DEAD) + bearer.balloon_alert(bearer, "no target!") + return FALSE + if(!IsAvailable(feedback = TRUE) || !housing.blade_extended() || HAS_TRAIT(bearer, TRAIT_PACIFISM)) + return FALSE + var/range = mantis_charge_range(held, housing.lunge_range) + if(get_dist(bearer, target) > range || !can_see(bearer, target, range)) + bearer.balloon_alert(bearer, "out of reach!") + return FALSE + cyberware_camo_drop(bearer) + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge as anything in mantis_lunge_actions(bearer)) + lunge.StartCooldown() + if(bearer.click_intercept == src) + unset_click_ability(bearer, refund_cooldown = FALSE) + bearer.face_atom(target) + mantis_pose_blades(get_extended_housings(), MANTIS_POSE_LUNGE) + playsound(bearer, 'sound/items/weapons/sear.ogg', 50, TRUE) + // The crouch was the telegraph: no second wind-up. + do_lunge(WEAKREF(target), range, TRUE) + return TRUE + +/** + * Ends any charge: stands the bearer back up, takes the glow off and puts the blades back in the guard. + * [rearm] keeps them from setting again for a moment, when the crouch had shown. + */ +/datum/action/cooldown/cyberware/mantis_lunge/proc/cancel_charge(rearm = TRUE) + if(charge_timer) + deltimer(charge_timer) + charge_timer = null + if(crouch_tick_timer) + deltimer(crouch_tick_timer) + crouch_tick_timer = null + if(charge_client) + UnregisterSignal(charge_client, COMSIG_CLIENT_MOUSEUP) + charge_started = 0 + charge_target = null + if(!crouched) + return + crouched = FALSE + for(var/datum/weakref/glow_ref as anything in crouch_glows) + var/obj/effect/abstract/cyberware_anim/glow = glow_ref.resolve() + if(glow) + qdel(glow) + crouch_glows = null + var/mob/living/carbon/bearer = crouch_body?.resolve() + crouch_body = null + if(isnull(bearer)) + return + UnregisterSignal(bearer, COMSIG_MOB_AFTER_APPLY_DAMAGE) + REMOVE_TRAIT(bearer, TRAIT_IMMOBILIZED, MANTIS_CHARGE_TRAIT) + bearer.remove_offsets(MANTIS_CHARGE_TRAIT, animate = FALSE) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + if(blade.mantis_pose == MANTIS_POSE_COIL) + blade.set_mantis_pose(MANTIS_POSE_GUARD) + if(rearm) + var/datum/mantis_bearer/state = bearer.get_mantis_state() + state.next_charge = world.time + MANTIS_CHARGE_REARM diff --git a/voidcrew/modules/mantis_gore/mantis_core.dm b/voidcrew/modules/mantis_gore/mantis_core.dm new file mode 100644 index 00000000000..0f13f8c6dcb --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_core.dm @@ -0,0 +1,253 @@ +/** + * # Mantis Blades x hyperviolence + * + * The Mantis line (voidcrew/modules/cyberware/ware_military_arms.dm, ware_line_mantis.dm) on top of the + * gore layer (voidcrew/modules/hyperviolence/). The Mantis is the predator: it wins by bleed-out, + * pinning and mobility. It never cuts a limb clean off mid-fight; its one dismemberment is the scissor, + * on someone already dying. + * + * - Arterial gashes: cuts on one limb add up and tear its artery (mantis_blade.dm, mantis_wounds.dm). + * - Impaling lunge: both blades into someone hurt hold them up, or pin them to a wall (mantis_impale.dm). + * - Scissor: a head swing at someone dying, a #509 finisher (mantis_scissor.dm). + * - Blood on the blades, dripping after a kill, flicked off on the way in (mantis_blade.dm, mantis_housing.dm). + * - Lunge trail, afterimages and the arrival (mantis_lunge_fx.dm). + * - X-slash: every third cut with both blades out (mantis_blade.dm). + * - Combat stance: the guard pose and the raised fists while the blades are out (mantis_poses.dm). + * - Charged leap and the kill reset (mantis_charge.dm, and the kill credit below). + * - Scorch burns and half-seals; the Shiv only bleeds (the model tuning in mantis_blade.dm). + * + * Human NPCs only get the gore moves at #509's ranks: aimed crits (hv_can_land_aimed_crits()) for the + * arteries, the impale, the X-slash and the reset; executions (hv_can_execute()) for the scissor. An + * ordinary NPC with Mantis blades cuts like a knife. + * + * Every number is in voidcrew/_DEFINES/mantis_gore.dm. + */ + +// ========================================================================= +// PER-BEARER BOOKKEEPING +// ========================================================================= + +/// What one bearer of the line is in the middle of: the X-slash count and the clocks of the +/// scissor, the kill reset and the crouch. Holds no hard references. +/datum/mantis_bearer + /// Who the X-slash count is on + var/datum/weakref/xslash_target + /// Landed cuts on them so far + var/xslash_count = 0 + /// world.time of the last of those cuts + var/xslash_last = 0 + /// world.time the scissor can be tried again + var/next_scissor = 0 + /// world.time a kill can reset the lunge again + var/next_kill_reset = 0 + /// world.time the bearer can crouch to charge again + var/next_charge = 0 + /// The scissor running now, if any + var/datum/hv_finisher/mantis_scissor/scissor + /// The blade rig drawing this bearer now, if any (mantis_rig.dm) + var/datum/mantis_rig/rig + +/datum/mantis_bearer/Destroy() + xslash_target = null + scissor = null + rig = null + return ..() + +/mob/living + /// The Mantis line's bookkeeping for this mob as a bearer (voidcrew/modules/mantis_gore/). Made on first use. + var/datum/mantis_bearer/mantis_state + /// world.time this mob, killed, last reset someone's lunge. Outlives death on purpose (revive loops). + var/mantis_reset_given_at = 0 + +/mob/living/proc/get_mantis_state() + RETURN_TYPE(/datum/mantis_bearer) + if(!mantis_state) + mantis_state = new + return mantis_state + +// ========================================================================= +// HELPERS +// ========================================================================= + +/// The Mantis-line blades [bearer] has out in a hand, optionally only those that can do [moves] (MANTIS_MOVE_*). +/proc/mantis_held_blades(mob/living/bearer, moves = NONE) + RETURN_TYPE(/list) + . = list() + if(!isliving(bearer)) + return + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade in bearer.held_items) + if(moves && (blade.gore_moves & moves) != moves) + continue + . += blade + +/// Every Mantis lunge button [bearer] has. Chrome abilities never join mob.actions (chrome_hud.dm). +/proc/mantis_lunge_actions(mob/living/bearer) + RETURN_TYPE(/list) + . = list() + if(!iscarbon(bearer)) + return + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge in get_chrome_wheel_actions(bearer)) + . += lunge + +/// The held blade of each of [housings], skipping any that isn't out. +/proc/mantis_housing_blades(list/housings) + RETURN_TYPE(/list) + . = list() + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing as anything in housings) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = housing.active_item + if(istype(blade) && housing.blade_extended()) + . += blade + +/// Whether [victim] is dying: in crit, or out cold at crit health. Lying down or stunned is not enough. +/proc/mantis_in_crit(mob/living/victim) + if(!isliving(victim) || victim.stat == DEAD) + return FALSE + if(victim.stat == SOFT_CRIT || victim.stat == HARD_CRIT) + return TRUE + return victim.stat == UNCONSCIOUS && victim.health <= victim.crit_threshold + +// ========================================================================= +// KILL CREDIT +// ========================================================================= + +/** + * [bearer] has just cut [victim] with the line: a death in the next few seconds is their kill. + * Called before the damage lands, so the cut that kills counts. [blades] are the blades that cut, the ones + * a kill drenches; null when every blade out struck (the lunge, the impale, the scissor). + */ +/proc/mantis_note_hit(mob/living/victim, mob/living/bearer, list/blades) + if(!isliving(victim) || !isliving(bearer) || victim == bearer || QDELETED(victim) || victim.stat == DEAD) + return null + var/datum/status_effect/mantis_cuts/cuts = victim.has_status_effect(/datum/status_effect/mantis_cuts) + if(cuts) + cuts.refresh() + else + cuts = victim.apply_status_effect(/datum/status_effect/mantis_cuts) + if(!cuts) + return null + cuts.last_bearer = WEAKREF(bearer) + cuts.last_hit = world.time + cuts.last_blades = null + for(var/obj/item/blade as anything in blades) + LAZYADD(cuts.last_blades, WEAKREF(blade)) + return cuts + +/** + * On someone the line has cut lately. Counts the cuts on each limb (the arterial gashes) and remembers + * who cut them last, so their death can be credited. Runs out on its own a while after the last cut. + */ +/datum/status_effect/mantis_cuts + id = "mantis_cuts" + alert_type = null + duration = MANTIS_CUT_WINDOW + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_UNIQUE + /// Body zone -> cuts on it that still add up + var/list/limb_cuts + /// Body zone -> world.time of its last cut + var/list/limb_cut_times + /// Who cut them last + var/datum/weakref/last_bearer + /// world.time of that cut + var/last_hit = 0 + /// Weakrefs to the blades of that cut, or null if every blade out struck + var/list/last_blades + +/datum/status_effect/mantis_cuts/on_apply() + RegisterSignal(owner, COMSIG_LIVING_DEATH, PROC_REF(on_owner_death)) + return TRUE + +/datum/status_effect/mantis_cuts/on_remove() + UnregisterSignal(owner, COMSIG_LIVING_DEATH) + +/datum/status_effect/mantis_cuts/Destroy() + last_bearer = null + last_blades = null + return ..() + +/// One more cut on [zone]. Returns how many now add up there. +/datum/status_effect/mantis_cuts/proc/add_cut(zone) + LAZYINITLIST(limb_cuts) + LAZYINITLIST(limb_cut_times) + if(world.time - (limb_cut_times[zone] || 0) > MANTIS_CUT_WINDOW) + limb_cuts[zone] = 0 + limb_cuts[zone] = (limb_cuts[zone] || 0) + 1 + limb_cut_times[zone] = world.time + return limb_cuts[zone] + +/// The count on [zone] starts over. +/datum/status_effect/mantis_cuts/proc/clear_cuts(zone) + LAZYREMOVE(limb_cuts, zone) + LAZYREMOVE(limb_cut_times, zone) + +/datum/status_effect/mantis_cuts/proc/on_owner_death(datum/source, gibbed) + SIGNAL_HANDLER + var/mob/living/bearer = last_bearer?.resolve() + if(isnull(bearer) || world.time - last_hit > MANTIS_KILL_CREDIT_WINDOW) + return + var/list/killing_blades + for(var/datum/weakref/blade_ref as anything in last_blades) + var/obj/item/blade = blade_ref.resolve() + if(blade) + LAZYADD(killing_blades, blade) + mantis_kill_credit(bearer, owner, last_blades ? (killing_blades || list()) : null) + +/** + * [bearer] killed [victim]: the blades that did it are drenched, and the lunge may come back early. + * [blades] are those blades; null means every blade [bearer] has out. A kill in the impale or the scissor + * only stains them: the blade rig draws the blood they leave on the steel, and the held art keeps that coat + * after. + */ +/proc/mantis_kill_credit(mob/living/bearer, mob/living/victim, list/blades) + if(QDELETED(bearer) || bearer.stat == DEAD) + return FALSE + var/light = !isnull(bearer.mantis_state?.rig) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + if(isnull(blades) || (blade in blades)) + blade.drench(victim, light) + return mantis_try_kill_reset(bearer, victim) + +/// Whether killing [victim] can reset a lunge at all: a person (not a monkey), or something big. +/proc/mantis_kill_reset_counts(mob/living/victim) + if(ishuman(victim)) + return !ismonkey(victim) + return victim.maxHealth >= MANTIS_KILL_RESET_MIN_HEALTH + +/** + * The kill reset. [bearer]'s lunge cooldown comes down to MANTIS_KILL_RESET_FLOOR, never lower, at most + * once per MANTIS_KILL_RESET_COOLDOWN per bearer, and a body that gave one gives no other for + * MANTIS_KILL_RESET_VICTIM_LOCKOUT. Prisoners (cuffed or strapped down at death) and small animals never + * count. Returns TRUE if it reset. + */ +/proc/mantis_try_kill_reset(mob/living/bearer, mob/living/victim) + if(!isliving(victim) || !iscarbon(bearer) || !bearer.hv_can_land_aimed_crits()) + return FALSE + if(!mantis_kill_reset_counts(victim)) + return FALSE + if(HAS_TRAIT(victim, TRAIT_RESTRAINED) || victim.buckled) + return FALSE + if(victim.mantis_reset_given_at && world.time < victim.mantis_reset_given_at + MANTIS_KILL_RESET_VICTIM_LOCKOUT) + return FALSE + var/datum/mantis_bearer/state = bearer.get_mantis_state() + if(world.time < state.next_kill_reset) + return FALSE + var/reset = FALSE + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge as anything in mantis_lunge_actions(bearer)) + if(lunge.next_use_time > world.time + MANTIS_KILL_RESET_FLOOR) + lunge.StartCooldown(MANTIS_KILL_RESET_FLOOR) + reset = TRUE + if(!reset) + return FALSE + state.next_kill_reset = world.time + MANTIS_KILL_RESET_COOLDOWN + victim.mantis_reset_given_at = world.time + mantis_reset_tell(bearer) + log_combat(bearer, victim, "had the mantis lunge come back early from killing") + return TRUE + +/// The only sign of a reset: both blades flare once, with a soft ring of steel. +/proc/mantis_reset_tell(mob/living/bearer) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + blade.play_glint(MANTIS_POSE_GUARD) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = blade.housing_ref?.resolve() + housing?.blade_flash(1, 0.3 SECONDS) + playsound(bearer, 'sound/items/unsheath.ogg', 25, TRUE, SILENCED_SOUND_EXTRARANGE) diff --git a/voidcrew/modules/mantis_gore/mantis_housing.dm b/voidcrew/modules/mantis_gore/mantis_housing.dm new file mode 100644 index 00000000000..2d682512cd6 --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_housing.dm @@ -0,0 +1,236 @@ +// The housing side of the Mantis line's gore: the combat stance while a blade is out (the guard pose, +// the fists raised, a glint now and then), the flick of blood on the way back in, and the stain the +// housing keeps until it is washed. See mantis_core.dm for the overview. + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis + /// Raises the fist on the arm while the blade is out. + var/datum/bodypart_overlay/mantis_stance/stance_overlay + /// The arm it is on. + var/datum/weakref/stance_limb + /// Timer of the next glint. + var/glint_timer + /// Blood flicked off the blade has left the housing stained. + var/stained = FALSE + /// Colour of that stain. + var/stain_color + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/Destroy() + stance_off() + stop_glint() + return ..() + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/on_mob_insert(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + RegisterSignal(arm_owner, COMSIG_COMPONENT_CLEAN_ACT, PROC_REF(on_bearer_washed)) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(arm_owner, COMSIG_COMPONENT_CLEAN_ACT) + stance_off() + stop_glint() + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/Extend(obj/item/augment) + . = ..() + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = active_item + if(!istype(blade) || (blade in src) || !owner?.is_holding(blade)) + return + blade.housing_ref = WEAKREF(src) + stance_on() + blade.set_mantis_pose(MANTIS_POSE_GUARD) + schedule_glint() + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/Retract() + var/mob/living/carbon/stowing_owner = owner + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/stowing = active_item + var/flick = istype(stowing) && stowing_owner?.is_holding(stowing) && stowing.is_bloody() + . = ..() + if(!.) + return + stance_off() + stop_glint() + if(!istype(stowing)) + return + stowing.mantis_stowed() + if(flick && !QDELETED(stowing_owner)) + flick_blood(stowing_owner, stowing) + +// A blade forced out of the hand some other way (a cut-off hand, a forced drop) ends the stance too. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/on_deployed_item_dropped(obj/item/source, mob/user) + SIGNAL_HANDLER + stance_off() + stop_glint() + return ..() + +// ---- The stance ---------------------------------------------------------- + +/// Raises this arm's fist to the guard. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/stance_on() + stance_off() + var/obj/item/bodypart/arm = owner?.get_bodypart(zone) + if(isnull(arm)) + return + stance_overlay = new + stance_limb = WEAKREF(arm) + arm.add_bodypart_overlay(stance_overlay) + +/// Lowers it again. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/stance_off() + if(isnull(stance_overlay)) + return + var/obj/item/bodypart/arm = stance_limb?.resolve() + if(arm && (stance_overlay in arm.bodypart_overlays)) + arm.remove_bodypart_overlay(stance_overlay) + QDEL_NULL(stance_overlay) + stance_limb = null + +/// How high the fist rides, for the blade's pose. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/set_stance_raise(raise) + if(isnull(stance_overlay) || stance_overlay.raise == raise) + return + stance_overlay.raise = raise + owner?.update_body_parts() + +/** + * The fist on an arm whose Mantis blade is out, raised to the guard. Draws nothing of its own: it moves + * the arm's hand up a few pixels, and is part of the arm's render key so it redraws when it changes. + */ +/datum/bodypart_overlay/mantis_stance + layers = NONE + blocks_emissive = EMISSIVE_BLOCK_NONE + /// Pixels the hand rides up. + var/raise = 2 + +/datum/bodypart_overlay/mantis_stance/generate_icon_cache() + return list("mantis_stance_[raise]") + +/datum/bodypart_overlay/mantis_stance/modify_bodypart_appearance(datum/appearance) + // Images and mutable appearances both: the hand and its emissive blocker move together. + var/image/part = appearance + if(isnull(part) || !findtext("[part.icon_state]", "_hand")) + return + part.pixel_y += raise + +// ---- The glint ----------------------------------------------------------- + +/// The next glint along the edge, 8 to 12 seconds out. One-shot timers only. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/schedule_glint() + stop_glint() + glint_timer = addtimer(CALLBACK(src, PROC_REF(stance_glint)), rand(8 SECONDS, 12 SECONDS), TIMER_STOPPABLE|TIMER_DELETE_ME) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/stop_glint() + if(glint_timer) + deltimer(glint_timer) + glint_timer = null + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/stance_glint() + glint_timer = null + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = active_item + if(!istype(blade) || !owner?.is_holding(blade)) + return + if(blade.mantis_pose == MANTIS_POSE_GUARD) + blade.play_glint() + schedule_glint() + +// ---- Blood ----------------------------------------------------------------- + +/// How long flicked blood is in the air: the flick's three frames. +#define MANTIS_FLICK_FLIGHT (0.3 SECONDS) + +/** + * What a flicked blade leaves on the tile ahead: a thin line of spatter from where the blade pointed, bending a + * little and breaking into drops toward its far end ("flick_spatter_[hand]", drawn for the tile ahead in each + * facing). One per hand, so two blades lay a line each. Its own kind of blood, not one of the sprays: a tile's + * spray is neither turned by a flick nor kept off the tile by one. + */ +/obj/effect/decal/cleanable/blood/mantis_flick + name = "blood spatter" + icon = MANTIS_GORE_ICON + icon_state = "flick_spatter_right" + random_icon_states = null + mergeable_decal = FALSE + // Thin enough that walking through it does not leave footprints. + bloodiness = 15 + drying_time = 3 MINUTES + beauty = -20 + base_name = null + base_suffix = "spatter" + /// The hand that flicked it: "left" or "right". + var/hand = "right" + +// Too thin to dive into. +/obj/effect/decal/cleanable/blood/mantis_flick/can_bloodcrawl_in() + return FALSE + +/// The [hand] blade's flicked blood lands on [ahead], thrown the way [flick_dir]. Wet spatter that hand already +/// left there is only freshened. Returns the spatter. +/proc/mantis_flick_land(turf/ahead, flick_dir, hand, list/blood_dna) + if(!isturf(ahead) || !length(blood_dna)) + return null + for(var/obj/effect/decal/cleanable/blood/mantis_flick/wet in ahead) + if(wet.hand != hand || wet.dir != flick_dir || wet.dried || QDELETED(wet)) + continue + wet.add_blood_DNA(blood_dna) + return wet + var/obj/effect/decal/cleanable/blood/mantis_flick/spatter = new(ahead, null, blood_dna) + if(QDELETED(spatter)) + return null + spatter.hand = hand + spatter.icon_state = "flick_spatter_[hand]" + spatter.setDir(flick_dir) + var/full_alpha = spatter.alpha + spatter.alpha = 0 + animate(spatter, alpha = full_alpha, time = 0.1 SECONDS) + return spatter + +/// The blade snaps back in bloody: the blood flies off ahead and lands as a line of spatter, and some stays on +/// the housing. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/flick_blood(mob/living/carbon/bearer, obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade) + var/blood_color = blade.get_blood_dna_color() + var/list/blood_dna = blade.blood_dna_copy() + var/obj/effect/abstract/cyberware_anim/fling = cyberware_anim_play(bearer, MANTIS_GORE_LARGE_ICON, "mantis_flick_[zone == BODY_ZONE_L_ARM ? "left" : "right"]", MANTIS_ANIM_BLADE_LAYER, 0.3 SECONDS) + if(fling) + fling.pixel_x = -16 + fling.pixel_y = -16 + fling.color = blood_color + var/turf/ahead = get_step(bearer, bearer.dir) + if(isturf(bearer.loc) && ahead && length(blood_dna) && !hv_blood_spray_blocked(ahead) && !isgroundlessturf(ahead) && ahead.has_gravity()) + // It lands once the flick has crossed to it, not as the blade starts back in. + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_flick_land), ahead, bearer.dir, zone == BODY_ZONE_L_ARM ? "left" : "right", blood_dna), MANTIS_FLICK_FLIGHT) + blade.wash(CLEAN_TYPE_BLOOD) + stained = TRUE + stain_color = blood_color + bearer.update_body_parts() + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/proc/on_bearer_washed(datum/source, clean_types) + SIGNAL_HANDLER + if(!(clean_types & CLEAN_TYPE_BLOOD)) + return + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = active_item + if(istype(blade) && blade.is_bloody()) + blade.wash(CLEAN_TYPE_BLOOD) + blade.stop_dripping() + blade.refresh_held() + if(!stained) + return + stained = FALSE + stain_color = null + owner?.update_body_parts() + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/cyberware_worn_key() + return "[..()][stained ? "-blood[stain_color]" : ""]" + +// The stain is drawn over the housing's own pixels, only in its plain look (an EMP's dark, a brownout or the +// fold hide it with the rest of the housing). +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + . = ..() + if(!stained || cyberware_worn_look()) + return + var/stain_state = "[aug_overlay]_[zone == BODY_ZONE_L_ARM ? "left" : "right"]_stain" + if(!icon_exists(MANTIS_GORE_ICON, stain_state)) + return + var/image/stain = image(icon = MANTIS_GORE_ICON, icon_state = stain_state, layer = image_layer) + stain.appearance_flags |= RESET_COLOR + stain.color = stain_color + . += stain + +#undef MANTIS_FLICK_FLIGHT diff --git a/voidcrew/modules/mantis_gore/mantis_impale.dm b/voidcrew/modules/mantis_gore/mantis_impale.dm new file mode 100644 index 00000000000..de703e5538e --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_impale.dm @@ -0,0 +1,602 @@ +// The impaling lunge. A lunge that arrives with both blades on someone already hurt or down skewers them: +// held up on the blades for a beat (pinned to the wall for longer, if one is right behind them), then the +// blades rip out with a spray and they drop. The bearer is rooted with full hands the whole time, and +// the victim's hands stay free, so they can still shoot. + +/// Whether a lunge arriving with [housings] skewers [victim]: both blades out and able, a person no bigger +/// than a person, and already hurt or down. +/proc/mantis_can_impale(mob/living/lunger, mob/living/victim, list/housings) + if(!iscarbon(victim) || victim.stat == DEAD || victim.mob_size > MOB_SIZE_HUMAN) + return FALSE + if(!lunger.hv_can_land_aimed_crits()) + return FALSE + var/list/blades = mantis_housing_blades(housings) + if(length(blades) < 2) + return FALSE + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in blades) + if(!(blade.gore_moves & MANTIS_MOVE_IMPALE)) + return FALSE + if(HAS_TRAIT_FROM(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT) || victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(victim.buckled || !isturf(victim.loc)) + return FALSE + if(victim.health <= victim.maxHealth * MANTIS_IMPALE_HEALTH_FRACTION) + return TRUE + return victim.body_position == LYING_DOWN || victim.stat >= SOFT_CRIT || HAS_TRAIT(victim, TRAIT_INCAPACITATED) + +/// Skewers [victim] on the blades of [housings]. Returns TRUE if it took hold. [from] is where the lunge set +/// out, for the [glide] the lunging body takes to reach them (mantis_lunge_dash_fx()). +/proc/mantis_start_impale(datum/action/cooldown/cyberware/mantis_lunge/lunge, mob/living/carbon/lunger, mob/living/carbon/victim, list/housings, knockdown, turf/from, glide = MANTIS_LUNGE_GLIDE) + var/datum/mantis_impale/skewer = new(lunger, victim, housings, knockdown) + var/turf/landed = get_turf(lunger) + if(from && landed && from != landed) + skewer.glide_w = (from.x - landed.x) * ICON_SIZE_X + skewer.glide_z = (from.y - landed.y) * ICON_SIZE_Y + skewer.glide_time = glide + if(!skewer.start()) + qdel(skewer) + return FALSE + lunge?.impale = skewer + return TRUE + +/datum/action/cooldown/cyberware/mantis_lunge + /// The skewer this lunge is holding, while it holds. + var/datum/mantis_impale/impale + +/** + * # The skewer + * + * The beats, from the approved storyboard: the lunging body glides in, blades out; the points reach them + * and go in, and the two are drawn together; the victim is held up on the blades; the bearer pulls back, rips + * the blades out and the victim drops. From the glide to the drop the bearer is drawn by the blade rig + * (mantis_rig.dm): the far blade, the far arm and the bearer's body under the victim, the near arm and the + * near blade over them, the steel inside them cut out. + */ +/datum/mantis_impale + var/mob/living/carbon/bearer + var/mob/living/carbon/victim + /// The housings whose blades went in. + var/list/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housings + /// The way the lunge travelled. The tips come out on that side. + var/lunge_dir + /// The way it is drawn (mantis_rig_facing()). + var/facing + /// TRUE when a wall right behind them takes the points. + var/wall_pin = FALSE + /// How long they are held. + var/hold_time = MANTIS_IMPALE_HOLD + /// How long they go down for when the blades come out. + var/knockdown = 2 SECONDS + var/started_at = 0 + /// Damage the bearer has taken since it began. + var/taken = 0 + var/tick_timer + /// The bearer drawn around the victim, from the start of the glide in. + var/datum/mantis_rig/rig + /// The landing, when the points reach them. + var/rig_timer + /// Where the lunging body glides in from, in pixels off the bearer's tile, and how long it takes. + var/glide_w = 0 + var/glide_z = 0 + var/glide_time = 0 + /// The beat drawn now. + var/beat + /// How far tg had them turned to lie down when they came up on the blades, or 0. + var/hoist_angle = 0 + /// Degrees they are tipped back about their feet at the rip, to take back out. + var/tilt_angle = 0 + /// The way they faced before they were turned onto the blades. + var/prior_dir + var/running = FALSE + /// TRUE from the pull-back until the blades are out. + var/ripping = FALSE + /// Pixels they hang up from where the hold has them: -1 while they sag on the blades (set_sag()). + var/sag = 0 + /// The bar over the bearer that everyone sees fill until the rip (finisher_bar.dm) + var/obj/effect/abstract/hv_exec_bar/exec_bar + +/datum/mantis_impale/New(mob/living/carbon/bearer, mob/living/carbon/victim, list/housings, knockdown) + . = ..() + src.bearer = bearer + src.victim = victim + src.housings = housings.Copy() + if(isnum(knockdown)) + src.knockdown = knockdown + +/datum/mantis_impale/Destroy() + if(running) + finish() + QDEL_NULL(rig) + bearer = null + victim = null + housings = null + return ..() + +/datum/mantis_impale/proc/start() + if(running || QDELETED(bearer) || QDELETED(victim)) + return FALSE + lunge_dir = get_dir(bearer, victim) || bearer.dir + facing = mantis_rig_facing(bearer, victim) + var/turf/behind = get_step(victim, lunge_dir) + wall_pin = !isnull(behind) && hv_blood_spray_blocked(behind) + hold_time = wall_pin ? MANTIS_IMPALE_PIN_HOLD : MANTIS_IMPALE_HOLD + running = TRUE + started_at = world.time + // Everyone in view sees the hold count down to the rip (finisher_bar.dm) + exec_bar = hv_exec_bar_start(bearer, hold_time) + bearer.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), MANTIS_IMPALE_TRAIT) + ADD_TRAIT(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT) + // Turned onto the blades and brought up off the floor if they were on it. The rig draws the lunging body + // gliding in, blades out and short of them (the bearer's own lunge pose would lie over them); the points + // reach them as it lands (begin_rig()). + prior_dir = victim.dir + victim.setDir(mantis_face_dir(victim, bearer)) + hoist() + place_victim(0, 0) + mantis_pose_blades(housings, MANTIS_POSE_LUNGE) + put_up_rig() + if(rig) + show_beat("arrive", glide_time, glide_w, glide_z) + rig_timer = addtimer(CALLBACK(src, PROC_REF(begin_rig)), max(glide_time, world.tick_lag), TIMER_STOPPABLE|TIMER_DELETE_ME) + // The lunge's own slash, drawn as it landed, would float over the blades. + for(var/obj/effect/abstract/cyberware_anim/picture in victim.vis_contents) + if(findtext(picture.icon_state, "impact_") == 1) + qdel(picture) + RegisterSignal(bearer, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_bearer_damaged)) + RegisterSignals(bearer, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED), PROC_REF(on_lost)) + RegisterSignals(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED), PROC_REF(on_lost)) + RegisterSignal(victim, COMSIG_LIVING_POST_UPDATE_TRANSFORM, PROC_REF(on_victim_turned)) + if(wall_pin) + victim.visible_message( + span_danger("[bearer] drives both blades through [victim] and pins [victim.p_them()] to the wall!"), + span_userdanger("Two blades punch through you and into the wall!"), + ) + else + victim.visible_message( + span_danger("[bearer] drives both blades through [victim] and lifts [victim.p_them()] off [victim.p_their()] feet!"), + span_userdanger("Two blades punch through you!"), + ) + schedule_tick() + return TRUE + +/// The bearer is drawn by the rig from here to the drop. +/datum/mantis_impale/proc/put_up_rig() + if(rig) + return + rig = new(bearer, victim, facing, model(), "impale") + if(!rig.start()) + QDEL_NULL(rig) + +/// The lunging body has landed: the two are drawn together, and the victim slides onto the blades. +/datum/mantis_impale/proc/begin_rig() + rig_timer = null + if(!running || ripping) + return + put_up_rig() + // Whole blades, the victim over the steel they have slid onto: they cover it as they come. + show_beat("contact", MANTIS_IMPALE_CONTACT_BEAT) + addtimer(CALLBACK(src, PROC_REF(stab)), MANTIS_IMPALE_CONTACT_BEAT, TIMER_DELETE_ME) + +/// The blades are in: the points come out of the back wet, with a burst of blood and a crackle of their light. +/datum/mantis_impale/proc/stab() + if(!running || ripping) + return + if(rig) + rig.wet = TRUE + show_beat("enter", 0) + // The crackle is at the points, so it lasts only while they are still: it is gone as the hoist begins. + switch(facing) + if(NORTH) + rig?.burst("enter_puff", 0.25 SECONDS, "T", under = TRUE) + rig?.burst("enter_spark", MANTIS_IMPALE_ENTER_BEAT, "tips", blood = FALSE) + if(SOUTH) + rig?.burst("enter_puff", 0.25 SECONDS, "tips") + rig?.burst("enter_spark", MANTIS_IMPALE_ENTER_BEAT, "tips", blood = FALSE) + else + rig?.burst("enter_puff", 0.25 SECONDS, "T") + rig?.burst("enter_spark", MANTIS_IMPALE_ENTER_BEAT, "near", blood = FALSE) + addtimer(CALLBACK(src, PROC_REF(lift), 1), MANTIS_IMPALE_ENTER_BEAT, TIMER_DELETE_ME) + +/** + * The hoist from the stab to the hold, a frame at a time: the victim and the blades in them rise together, + * in the same frame, so the steel stays where it went in. + */ +/datum/mantis_impale/proc/lift(step) + if(!running || ripping) + return + if(!mantis_rig_beat("impale", "lift[step]", facing)) + hang() + return + show_beat("lift[step]", 0) + addtimer(CALLBACK(src, PROC_REF(lift), step + 1), MANTIS_IMPALE_STEP, TIMER_DELETE_ME) + +/** + * Held up: the victim hangs on the blades until the pull-back. Twice they sag a pixel and are heaved back up, + * and the blades and the arms holding them dip with them in the same frame (set_sag()). + */ +/datum/mantis_impale/proc/hang() + show_beat("hold", 0) + if(!running || ripping || QDELETED(victim)) + return + for(var/again in 0 to 1) + var/at = MANTIS_IMPALE_SAG_AFTER + again * MANTIS_IMPALE_SAG_EVERY + addtimer(CALLBACK(src, PROC_REF(set_sag), -1), at, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(set_sag), 0), at + MANTIS_IMPALE_SAG_FOR, TIMER_DELETE_ME) + +/// In the hold they hang [new_sag] pixels up from where the hold has them (a sag is negative). +/datum/mantis_impale/proc/set_sag(new_sag) + if(!running || ripping || beat != "hold" || sag == new_sag) + return + sag = new_sag + show_beat("hold", 0) + +/** + * Draws [new_beat] (mantis_rig_beat()): the bearer's arms and lean, and the victim drawn onto the blades. + * Up against a wall the victim stays where they are and the bearer comes the whole way; from a diagonal the + * two meet halfway up or down the screen. Pictures only. + */ +/datum/mantis_impale/proc/show_beat(new_beat, time = 0.1 SECONDS, from_w = 0, from_z = 0) + if(!running || (ripping && (new_beat == "hold" || findtext(new_beat, "lift") == 1))) + return + var/list/spec = mantis_rig_beat("impale", new_beat, facing) + if(!spec) + return + beat = new_beat + var/target_w = spec[MANTIS_RIG_TARGET_W] + var/target_z = spec[MANTIS_RIG_TARGET_Z] + var/extra_w = 0 + var/extra_z = 0 + if((facing & (EAST|WEST)) && (lunge_dir & (NORTH|SOUTH))) + var/half = (lunge_dir & NORTH) ? ICON_SIZE_Y / 2 : -ICON_SIZE_Y / 2 + extra_z = half + target_z -= half + if(wall_pin) + extra_w -= target_w + extra_z -= target_z + target_w = 0 + target_z = 0 + // Sagging in the hold (set_sag()): the steel in them and the arms holding them go with them. Not against a + // wall: the points are in it. + var/dip = (new_beat == "hold" && !wall_pin) ? sag : 0 + // Facing south the victim is held up in front of the bearer, back to the camera, high enough to hide them + // whole. The bearer draws up tall behind them as they heave, head and shoulders over the victim's head, the + // fists with them; the points out of the victim's back stay where they are. + var/rise = (facing == SOUTH) ? max(0, spec[MANTIS_RIG_TARGET_Z] - MANTIS_IMPALE_SOUTH_RISE_FROM) : 0 + rig?.show(new_beat, extra_w, extra_z, time, from_w, from_z, ride_z = dip, body_z = rise) + // The bar over the bearer goes up with them, so it sits over their head rather than across their face. + if(!QDELETED(exec_bar) && !exec_bar.ending) + animate(exec_bar, pixel_z = exec_bar.rest_z + rise, time = time, easing = SINE_EASING, flags = ANIMATION_PARALLEL) + place_victim(target_w, target_z + dip, time) + set_tilt(spec[MANTIS_RIG_TILT]) + +/// Draws the victim [w], [z] pixels off their tile (and upright, if brought up off the floor), over [time]. +/datum/mantis_impale/proc/place_victim(w, z, time = 0.1 SECONDS) + // Brought upright off the floor: tg draws someone lying down a little low. + var/upright = hoist_angle ? -PIXEL_Y_OFFSET_LYING : 0 + // Drawn onto the blades as fast as the rig leans in (tg's own glide takes twice as long), so the points + // are at their front as they arrive, not after. + var/from_w = victim.pixel_w + var/from_z = victim.pixel_z + victim.add_offsets(MANTIS_IMPALE_TRAIT, w_add = w, z_add = z + upright, animate = FALSE) + var/to_w = victim.pixel_w + var/to_z = victim.pixel_z + if((to_w == from_w && to_z == from_z) || !time) + return + victim.pixel_w = from_w + victim.pixel_z = from_z + animate(victim, pixel_w = to_w, pixel_z = to_z, time = time, easing = SINE_EASING, flags = ANIMATION_PARALLEL) + +/// The matrix tipping a body [angle] degrees about its feet (clockwise), after [base]. +/proc/mantis_tip_matrix(matrix/base, angle) + var/matrix/turned = matrix(base) + turned.Translate(0, MANTIS_TIP_FEET) + turned.Turn(angle) + turned.Translate(0, -MANTIS_TIP_FEET) + return turned + +/// Where the point [x], [y] (pixels from a sprite's middle, y up) is drawn once the sprite is turned [angle] +/// degrees clockwise about [about_x], [about_y]. +/proc/mantis_turn_point(x, y, angle, about_x, about_y) + var/dx = x - about_x + var/dy = y - about_y + return list(about_x + dx * cos(angle) + dy * sin(angle), about_y - dx * sin(angle) + dy * cos(angle)) + +/** + * [body] falls in one motion from how it was drawn on the blades (offsets [from_w], [from_z], [from_y], upright + * and tipped back [from_angle] degrees about its feet) to how tg has it now, lying or standing, turning about + * the point [pivot] pixels under its middle (its feet for a fall backwards, 0 for a drop in place). It is the + * real body the whole way and ends on tg's own turn and offsets, so nothing takes over at the end. Gravity: + * slow off the blades, fast at the floor. A picture only: tg's lying state is set before this. + */ +/proc/mantis_drop_body(mob/living/body, from_w, from_z, from_y, from_angle, pivot, time) + if(QDELETED(body)) + return + var/matrix/to_transform = matrix(body.transform) + var/to_w = body.pixel_w + var/to_z = body.pixel_z + var/to_y = body.pixel_y + var/to_angle = (body.body_position == LYING_DOWN && body.rotate_on_lying) ? body.lying_angle : 0 + if(to_angle > 180) + to_angle -= 360 + // The turning point at the start (the picture tipped about the feet) and at the end (tg turns about the middle). + var/list/start = mantis_turn_point(0, -pivot, from_angle, 0, -MANTIS_TIP_FEET) + var/start_x = start[1] + from_w + var/start_y = start[2] + from_z + from_y + var/list/end = mantis_turn_point(0, -pivot, to_angle, 0, 0) + var/end_x = end[1] + to_w + var/end_y = end[2] + to_z + to_y + var/steps = 4 + for(var/step in 0 to steps) + var/fallen = (step / steps) ** 2 + var/matrix/turned = to_transform + var/step_w = to_w + var/step_z = to_z + if(step < steps) + turned = matrix() + turned.Translate(0, pivot) + turned.Turn(from_angle + (to_angle - from_angle) * fallen) + turned.Translate(0, -pivot) + step_w = round(start_x + (end_x - start_x) * fallen, 1) + step_z = round(start_y + (end_y - start_y) * fallen + pivot - to_y, 1) + // The first step puts the picture back where it was drawn at once: it replaces tg's own turn, and + // whatever else this tick started on the body. + if(step == 0) + animate(body, transform = turned, pixel_w = step_w, pixel_z = step_z, pixel_y = to_y, time = 0) + else + animate(transform = turned, pixel_w = step_w, pixel_z = step_z, time = time / steps) + +/// Tips the victim back to [angle] degrees about their feet. +/datum/mantis_impale/proc/set_tilt(angle) + if(angle == tilt_angle || QDELETED(victim)) + return + var/matrix/turned = mantis_tip_matrix(victim.transform, angle - tilt_angle) + tilt_angle = angle + animate(victim, transform = turned, time = 0.1 SECONDS, flags = ANIMATION_PARALLEL) + +/// Takes the tip back out at once, before anything else turns them. +/datum/mantis_impale/proc/untilt() + if(tilt_angle && !QDELETED(victim)) + victim.transform = mantis_tip_matrix(victim.transform, -tilt_angle) + tilt_angle = 0 + +/** + * Someone skewered off the floor comes up with the blades and is drawn upright for the hold. Only the + * picture turns: tg still has them lying down, with everything that goes with it. Skipped for a resized + * body, whose lying turn carries a shift that wouldn't come back out cleanly. + */ +/datum/mantis_impale/proc/hoist() + if(hoist_angle || victim.body_position != LYING_DOWN || !victim.rotate_on_lying || !victim.lying_angle) + return + if(victim.current_size != RESIZE_DEFAULT_SIZE) + return + hoist_angle = victim.lying_angle + var/matrix/upright = matrix(victim.transform) + upright.Turn(-hoist_angle) + victim.transform = upright + +/// Gives back tg's lying turn. [fall] drops them into it instead of snapping. +/datum/mantis_impale/proc/unhoist(fall = FALSE) + if(!hoist_angle) + return + var/matrix/lying = matrix(victim.transform) + lying.Turn(hoist_angle) + hoist_angle = 0 + if(fall) + animate(victim, transform = lying, time = 0.2 SECONDS, easing = EASE_IN, flags = ANIMATION_PARALLEL) + else + victim.transform = lying + +/// tg turned them while they hang on the blades (got up, or went down): its turn stands, the picture stays upright. +/datum/mantis_impale/proc/on_victim_turned(datum/source, resize, new_lying_angle, is_opposite_angle) + SIGNAL_HANDLER + if(!running) + return + // tg turned the upright picture by the change, and turns add up: undoing the hoist leaves tg's own. + untilt() + unhoist() + hoist() + if(beat) + show_beat(beat) + else + place_victim(0, 0) + +/// The model of the blades in them, for the pictures. +/datum/mantis_impale/proc/model() + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_housing_blades(housings)) + return blade.base_icon_state + return "mantis" + +/// Ends the bar over the bearer: full when the rip comes on time, failed otherwise +/datum/mantis_impale/proc/end_exec_bar(success) + if(!QDELETED(exec_bar)) + exec_bar.finish(success) + exec_bar = null + +/datum/mantis_impale/proc/schedule_tick() + tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), 0.2 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/mantis_impale/proc/tick() + tick_timer = null + if(!running) + return + if(!holds()) + finish() + return + if(world.time - started_at >= hold_time) + rip() + return + schedule_tick() + +/// Whether the hold still holds: both alive and side by side, the blades still out, the bearer on their feet. +/datum/mantis_impale/proc/holds() + if(QDELETED(bearer) || QDELETED(victim) || bearer.stat != CONSCIOUS || victim.stat == DEAD) + return FALSE + if(HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || HAS_TRAIT(bearer, TRAIT_FLOORED)) + return FALSE + if(!isturf(bearer.loc) || !isturf(victim.loc) || bearer.z != victim.z || get_dist(bearer, victim) > 1) + return FALSE + return length(mantis_housing_blades(housings)) >= 1 + +/** + * The blades rip out. [half] when the bearer lost their grip: it tears less on the way out, with no + * pull-back. Otherwise first the pull-back: the bearer draws back while they still hang there, then tears + * the blades out, and a beat later they come free and the body drops (tear_out()). + */ +/datum/mantis_impale/proc/rip(half = FALSE) + if(!running || ripping) + return + end_exec_bar(!half) + // A lost grip is a jolt, not a pull: they come straight off. + if(half) + tear_out(TRUE) + return + ripping = TRUE + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + // The pull-back in steps, the steel coming out a little at a time. + for(var/step in 1 to 3) + addtimer(CALLBACK(src, PROC_REF(show_beat), "pull[step]", 0), (step - 1) * MANTIS_IMPALE_PULL_STEP, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(show_rip)), 3 * MANTIS_IMPALE_PULL_STEP, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(tear_out), half), 3 * MANTIS_IMPALE_PULL_STEP + MANTIS_IMPALE_PULL_BEAT - MANTIS_IMPALE_RIP_BEAT, TIMER_DELETE_ME) + +/// The blades tear up and down out of them: blood sprays out of the back as they come free. +/datum/mantis_impale/proc/show_rip() + if(!running) + return + show_beat("rip") + rig?.burst("rip_spray", 0.4 SECONDS, "T", under = (facing == NORTH)) + +/// The blades come out after the pull-back: the damage, the spray and the fall. The bearer holds the +/// last pose for the drop, then is drawn as usual again. +/datum/mantis_impale/proc/tear_out(half = FALSE) + if(!running) + return + var/list/blades = mantis_housing_blades(housings) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/first_blade = length(blades) ? blades[1] : null + var/damage = half ? MANTIS_IMPALE_RIP_DAMAGE * 0.5 : MANTIS_IMPALE_RIP_DAMAGE + var/mob/living/carbon/held = victim + var/mob/living/carbon/holder = bearer + var/rip_dir = lunge_dir + // The rig stays up for the drop; finish() would take it down. + var/datum/mantis_rig/dropping = rig + rig = null + // They fall from where they are drawn now: tipped back about their feet, up on the blades. + var/fall_w = held?.pixel_w + var/fall_z = held?.pixel_z + var/fall_y = held?.pixel_y + var/fall_tilt = tilt_angle + // Only a body drawn upright on the blades (brought up off the floor, or standing) at its usual size. + var/can_fall = held && held.current_size == RESIZE_DEFAULT_SIZE && (hoist_angle || held.body_position != LYING_DOWN || !held.rotate_on_lying || !held.lying_angle) + var/side = facing & (EAST|WEST) + var/face = held?.dir + finish(settle = !can_fall) + if(!QDELETED(held) && held.stat != DEAD) + mantis_note_hit(held, holder) + if(half) + held.hv_hold_attack_message("[holder] loses [holder.p_their()] grip on [held]!", "[holder] loses [holder.p_their()] grip on you!", null, null, null, COMBAT_MESSAGE_RANGE) + else + held.hv_hold_attack_message("[holder] rips both blades out of [held]!", "[holder] rips both blades out of you!", null, null, null, COMBAT_MESSAGE_RANGE) + var/armour = held.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = first_blade?.armour_penetration, silent = TRUE) + for(var/i in 1 to max(1, length(blades))) + held.apply_damage( + damage, + BRUTE, + BODY_ZONE_CHEST, + armour, + wound_bonus = MANTIS_IMPALE_RIP_WOUND_BONUS, + sharpness = SHARP_EDGED, + attack_direction = rip_dir, + attacking_item = first_blade, + ) + if(QDELETED(held)) + break + if(!QDELETED(held)) + held.hv_flush_hit() + if(!QDELETED(held)) + held.hv_blood_spray(rip_dir, half ? 2 : 3) + if(wall_pin) + held.hv_blood_lay_wall_spray(get_turf(held), rip_dir, held.get_blood_dna_list(), held.get_static_viruses()) + // Someone held up standing goes down now. tg would slide them the 6 px down into its lying offset + // on its own clock while the fall draws them; give them the offset first, so the fall draws it. + var/was_standing = can_fall && held.body_position != LYING_DOWN && held.rotate_on_lying + if(was_standing) + held.add_offsets(LYING_DOWN_TRAIT, y_add = PIXEL_Y_OFFSET_LYING, animate = FALSE) + held.Knockdown(knockdown) + if(was_standing && held.body_position != LYING_DOWN) + held.remove_offsets(LYING_DOWN_TRAIT, animate = FALSE) + playsound(held, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in blades) + blade.drench(held, light = TRUE) + if(half && !QDELETED(holder)) + holder.set_staggered_if_lower(1.5 SECONDS) + log_combat(holder, held, half ? "lost the grip on" : "ripped the mantis blades out of") + // The fall: one motion from the blades to the floor, ending where tg lays them. Seen from the side they + // were tipped back, so they land on their back, head away from the bearer, still facing them. + if(can_fall && !QDELETED(held)) + if(side && held.body_position == LYING_DOWN && held.rotate_on_lying) + held.set_lying_angle(side == EAST ? LYING_ANGLE_EAST : LYING_ANGLE_WEST) + if(face) + held.setDir(face) + mantis_drop_body(held, fall_w, fall_z, fall_y, fall_tilt, side ? MANTIS_TIP_FEET : 0, MANTIS_IMPALE_DROP_BEAT) + // The drop, drawn once they are down (their layer is the floor's now), then the real bearer fades back in. + if(dropping && !QDELETED(dropping)) + dropping.show("drop", time = MANTIS_IMPALE_DROP_BEAT) + addtimer(CALLBACK(dropping, TYPE_PROC_REF(/datum/mantis_rig, hand_back)), MANTIS_IMPALE_DROP_BEAT, TIMER_DELETE_ME) + +/// The bearer is hit while holding them up. Enough of it and they lose the grip. +/datum/mantis_impale/proc/on_bearer_damaged(datum/source, damage_dealt, damagetype) + SIGNAL_HANDLER + if(!running || damage_dealt <= 0) + return + taken += damage_dealt + if(taken >= MANTIS_IMPALE_BREAK_DAMAGE) + INVOKE_ASYNC(src, PROC_REF(rip), TRUE) + +/// Either of them died, was deleted or was moved off the other. +/datum/mantis_impale/proc/on_lost(datum/source) + SIGNAL_HANDLER + if(!running) + return + // Moving within the hold (a nudge onto the same spot) is fine; apart is not. + if(!holds()) + finish() + +/** + * Lets go of both, puts everything back, and ends. Without [settle] the victim's picture is put back at once + * for the drop to draw the fall from (tear_out()); with it they ease back the way tg would. + */ +/datum/mantis_impale/proc/finish(settle = TRUE) + if(!running) + return + running = FALSE + end_exec_bar(FALSE) + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + if(rig_timer) + deltimer(rig_timer) + rig_timer = null + QDEL_NULL(rig) + if(bearer) + UnregisterSignal(bearer, list(COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + bearer.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), MANTIS_IMPALE_TRAIT) + for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge as anything in mantis_lunge_actions(bearer)) + if(lunge.impale == src) + lunge.impale = null + if(victim) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED, COMSIG_LIVING_POST_UPDATE_TRANSFORM)) + REMOVE_TRAIT(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT) + victim.remove_offsets(MANTIS_IMPALE_TRAIT, animate = settle) + if(!QDELETED(victim)) + untilt() + unhoist(fall = settle) + // Still on the floor: back to the way tg laid them. + if(settle && victim.body_position == LYING_DOWN && prior_dir) + victim.setDir(prior_dir) + if(housings) + mantis_pose_blades(housings, MANTIS_POSE_GUARD) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_housing_blades(housings)) + blade.show_inhand() + if(!QDELETED(src)) + qdel(src) diff --git a/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm b/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm new file mode 100644 index 00000000000..72f1f089403 --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm @@ -0,0 +1,509 @@ +// Pictures for the Mantis line's gore. Nothing here changes who gets hit, when or how hard: the +// mechanics call in after they have happened. One-shot holders and temp visuals only; every animate() +// on a mob here is relative and parallel and ends where it began, so tg's offsets, lying turn and #509's +// death throes stay right. (The impale's hoist, which turns a body upright for the hold, is in +// mantis_impale.dm and gives tg's turn back. The blades going into a body are drawn by the blade rig, +// mantis_rig.dm.) + +// ========================================================================= +// THE LUNGE +// ========================================================================= + +/// How long a charged leap's body takes to arc in: longer than the lunge's glide (MANTIS_LUNGE_GLIDE). +#define MANTIS_LEAP_GLIDE 2 + +/// Whether a dash must stop before [step]: a drop, lava or open space. Never crossed, whatever the reach. +/proc/mantis_lunge_unsafe_turf(turf/step) + if(isnull(step)) + return TRUE + return istype(step, /turf/open/chasm) || islava(step) || isspaceturf(step) || istype(step, /turf/open/openspace) + +/// A hardlight afterimage left along the dash. +/obj/effect/temp_visual/decoy/fading/mantis_afterimage + duration = 4 + +/obj/effect/temp_visual/decoy/fading/mantis_afterimage/Initialize(mapload, atom/mimiced_atom, model, fade = 1) + . = ..() + // Washed out to the blade's light, fainter the further back down the line. + color = model == "mantis_scorch" ? list(0.5, 0.3, 0.15, 0.5, 0.3, 0.15, 0.5, 0.3, 0.15, 0.45, 0.25, 0.1) : list(0.25, 0.35, 0.5, 0.25, 0.35, 0.5, 0.25, 0.35, 0.5, 0.15, 0.3, 0.45) + alpha = round(200 * fade) + animate(src, alpha = 0, time = duration) + +/// The streak a lunge leaves at blade height across a tile it passed. +/obj/effect/temp_visual/mantis_trail + icon = MANTIS_GORE_ICON + icon_state = "mantis_trail" + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 5 + +/obj/effect/temp_visual/mantis_trail/Initialize(mapload, trail_dir, model) + . = ..() + if(model == "mantis_scorch") + icon_state = "mantis_scorch_trail" + // The art runs east-west and north-south; a diagonal dash uses the nearer of the two. + setDir((trail_dir & (EAST|WEST)) ? ((trail_dir & EAST) ? EAST : WEST) : ((trail_dir & NORTH) ? NORTH : SOUTH)) + add_overlay(emissive_appearance(icon, icon_state, src)) + +/** + * The dash, drawn after the lunger has already arrived: the body glides in from where it started, and + * each afterimage and streak shows as the body passes it, so the line trails behind. Returns how long the + * body takes to glide in, 0 if it went nowhere: what the dash does to its target waits that long, to land + * as the body does. + */ +/proc/mantis_lunge_dash_fx(mob/living/lunger, turf/start, list/passed, charged, model) + var/turf/finish = get_turf(lunger) + if(isnull(finish) || isnull(start) || !length(passed)) + return 0 + var/dash_dir = get_dir(start, finish) + var/count = length(passed) + var/glide = charged ? MANTIS_LEAP_GLIDE : MANTIS_LUNGE_GLIDE + // The body is already there: draw it back where it started and let it slide in. + var/back_w = (start.x - finish.x) * ICON_SIZE_X + var/back_z = (start.y - finish.y) * ICON_SIZE_Y + // The client glides a move of one tile by itself. Drawn back a tile on top of that, the body would start a + // tile behind where it stood, so here it is not drawn back: its own glide is only made to take as long. + if(get_dist(start, finish) <= 1) + lunger.set_glide_size(DELAY_TO_GLIDE_SIZE(glide)) + back_w = 0 + back_z = 0 + animate(lunger, pixel_w = back_w, pixel_z = back_z, time = 0, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + if(charged) + // A pounce arcs, higher the further it goes. + var/arc = min(2 * get_dist(start, finish) + 4, 16) + animate(pixel_w = -back_w * 0.5, pixel_z = -back_z * 0.5 + arc, time = glide * 0.5, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE) + animate(pixel_w = -back_w * 0.5, pixel_z = -back_z * 0.5 - arc, time = glide * 0.5, easing = SINE_EASING|EASE_IN, flags = ANIMATION_RELATIVE) + cyberware_grey_dust(start) + else + animate(pixel_w = -back_w, pixel_z = -back_z, time = glide, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE) + var/list/spots = list() + for(var/turf/spot as anything in passed) + spots += WEAKREF(spot) + for(var/i in 1 to count) + var/delay = round(glide * (i - 1) / count, world.tick_lag) + // Dashing away from the viewer, the last half-step afterimage would stand in front of the body it trails, + // over its legs: that one is left out. + var/half_step = !(i == count && (dash_dir & NORTH)) + if(delay <= 0) + mantis_dash_ghost(WEAKREF(lunger), spots[i], dash_dir, i / (count + 1), model, half_step) + else + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_dash_ghost), WEAKREF(lunger), spots[i], dash_dir, i / (count + 1), model, half_step), delay) + return glide + +/** + * The blades of [housings], laid flat for the dash, come back to the guard once the body has glided in, [delay] + * from now. A blade that has taken another pose since (a crouch, a raise) keeps it. + */ +/proc/mantis_lunge_settle(list/housings, delay = 0) + if(delay > 0) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_lunge_settle), housings), delay) + return + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_housing_blades(housings)) + if(blade.mantis_pose == MANTIS_POSE_LUNGE) + blade.set_mantis_pose(MANTIS_POSE_GUARD) + +/// One step of the dash's wake: two afterimages half a tile apart (one without [half_step]) and the streak, on +/// the tile the body just crossed. +/proc/mantis_dash_ghost(datum/weakref/lunger_ref, datum/weakref/spot_ref, dash_dir, fade, model, half_step = TRUE) + var/mob/living/lunger = lunger_ref?.resolve() + var/turf/spot = spot_ref?.resolve() + if(QDELETED(lunger) || isnull(spot)) + return + new /obj/effect/temp_visual/decoy/fading/mantis_afterimage(spot, lunger, model, fade * 0.7) + if(half_step) + var/obj/effect/temp_visual/decoy/fading/mantis_afterimage/half = new(spot, lunger, model, fade) + half.pixel_x += (dash_dir & EAST) ? 16 : ((dash_dir & WEST) ? -16 : 0) + half.pixel_y += (dash_dir & NORTH) ? 16 : ((dash_dir & SOUTH) ? -16 : 0) + new /obj/effect/temp_visual/mantis_trail(spot, dash_dir, model) + +/// The lunge lands: one impact and a puff of dust, as the gliding body reaches them, [delay] from now (the +/// glide mantis_lunge_dash_fx() gave; none for a body already there). A [pounce] lands with both blades, the +/// second mirrored a beat later. The flash comes with the knockdown (mantis_lunge_knockdown()). +/proc/mantis_lunge_arrival_fx(mob/living/lunger, mob/living/victim, impact_state, pounce = FALSE, delay = MANTIS_LUNGE_GLIDE) + if(QDELETED(victim)) + return + if(delay <= 0) + mantis_lunge_land_fx(WEAKREF(lunger), WEAKREF(victim), impact_state, pounce) + return + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_lunge_land_fx), WEAKREF(lunger), WEAKREF(victim), impact_state, pounce), delay) + +/// Knocks [victim] down when the lunging body lands on them, with the impact, rather than as the lunge +/// starts its glide in: [delay] from now, as for the impact. They flash as they go: after the fall has begun, +/// because tg's fall is a plain animate() and would end a flash already running. +/proc/mantis_lunge_knockdown(mob/living/victim, amount, delay = MANTIS_LUNGE_GLIDE) + if(QDELETED(victim)) + return + if(delay <= 0) + mantis_lunge_knockdown_now(WEAKREF(victim), amount) + return + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_lunge_knockdown_now), WEAKREF(victim), amount), delay) + +/proc/mantis_lunge_knockdown_now(datum/weakref/victim_ref, amount) + var/mob/living/victim = victim_ref?.resolve() + if(QDELETED(victim)) + return + victim.Knockdown(amount) + cyberware_flash_fx(victim, 0.2 SECONDS) + +/proc/mantis_lunge_land_fx(datum/weakref/lunger_ref, datum/weakref/victim_ref, impact_state, pounce) + var/mob/living/lunger = lunger_ref?.resolve() + var/mob/living/victim = victim_ref?.resolve() + if(QDELETED(lunger) || QDELETED(victim)) + return + // Skewered: the blades in them are the picture of this hit. + if(HAS_TRAIT_FROM(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT)) + return + cyberware_play_impact(lunger, victim, impact_state) + if(pounce) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_mirrored_impact), lunger_ref, victim_ref, impact_state), 0.4) + var/obj/effect/temp_visual/mook_dust/dust = cyberware_grey_dust(get_turf(victim)) + if(dust) + dust.transform = matrix(0.5, 0, 0, 0, 0.5, 0) + +/// The second blade's impact, from the other side. +/proc/mantis_mirrored_impact(datum/weakref/lunger_ref, datum/weakref/victim_ref, impact_state) + var/mob/living/lunger = lunger_ref?.resolve() + var/mob/living/victim = victim_ref?.resolve() + if(QDELETED(lunger) || QDELETED(victim)) + return + // Skewered: the blades in them are the picture of this hit. + if(HAS_TRAIT_FROM(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT)) + return + var/obj/effect/abstract/cyberware_anim/impact = cyberware_play_impact(lunger, victim, impact_state) + if(impact) + var/matrix/flipped = matrix(impact.transform) + flipped.a = -flipped.a + impact.transform = flipped + +// ========================================================================= +// THE CUTS +// ========================================================================= + +/// How far over the floor the artery of [zone] opens on [victim], as the spurt's art comes: "low" (a leg, or +/// anyone lying down), "mid" (an arm, the chest) or "high" (the neck). +/proc/mantis_artery_height(mob/living/victim, zone) + if(victim.body_position == LYING_DOWN) + return "low" + switch(zone) + if(BODY_ZONE_HEAD) + return "high" + if(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + return "low" + return "mid" + +/// Where the artery of [zone] opens on [body], as it stands, faces and lies now, from the middle of its tile: +/// list(x, y) in pixels. The part as it is drawn (mantis_part_point()); the neck for the head. +/proc/mantis_artery_point(mob/living/body, zone) + if(zone != BODY_ZONE_HEAD) + return mantis_part_point(body, zone) + var/across = (body.dir == EAST) ? 2 : ((body.dir == WEST) ? -3 : 0) + var/matrix/shape = body.transform + return list(round(shape.a * across + shape.b * 5 + shape.c, 1), round(shape.d * across + shape.e * 5 + shape.f, 1)) + +/// How long a spurt's picture shows nothing before its drops leave the wound: the half beat a swinging blade cocks +/// back before it cuts (the slash over the target waits for it the same way, mantis_swing.dm). A tear comes with +/// the click, so without the wait its drops were in the air a frame ahead of the cut. It is the art's own first +/// frame, empty: the client plays it from the same moment as the swing, where a timer on the server could not +/// be held to it. +#define MANTIS_SPURT_WAIT (0.5 DECISECONDS) + +/// The spurt's art, by [height] and whether it is a [weak] one: list(how far out its drops come down, how far +/// below the wound, how long they are in the air). Pixels and deciseconds, as the art is drawn. +/proc/mantis_spurt_shape(height, weak) + switch(height) + if("low") + return list(weak ? 6.7 : 16.16, 6, 3) + if("high") + return list(weak ? 11.63 : 28.18, 24, 4.5) + return list(weak ? 9.8 : 23.74, 16, 4) + +/** + * One spurt out of a torn artery: a handful of drops thrown from the wound the way the cut went ([spurt_dir]), + * big and small in turn, that arc down and land as spots about a tile off ("spurt_[height]" in + * MANTIS_ARTERY_ICON, drawn for each of the eight ways). A [weak] one is fewer drops that fall close by: a + * seared artery, or a wall where the drops would have come down. Upright in the world (it does not lie down + * with the body), in the blood's own colour. Thrown away from the viewer, or out of a limb on the body's far + * side, the drops leave from behind the body; otherwise in front of it. Thrown straight up or down the screen + * they lean out to the wound's side of the body (mantis_spurt_lean()). The drops leave half a beat after the + * call (MANTIS_SPURT_WAIT), with the cut that tore the artery and never ahead of it. Returns the picture. + */ +/proc/mantis_spurt_fx(mob/living/victim, zone, spurt_dir, weak = FALSE) + if(QDELETED(victim) || !isturf(victim.loc) || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return null + if(!spurt_dir) + spurt_dir = pick(GLOB.alldirs) + var/height = mantis_artery_height(victim, zone) + var/list/point = mantis_artery_point(victim, zone) + // Which side of the body's middle line, as it is drawn, the wound is on. One on the line counts as the right. + var/side = point[1] < 0 ? -1 : 1 + // The wound on the floor's own grid: the body may be drawn off the middle of its tile (lying down, shoved). + var/list/from = list(point[1] + victim.pixel_x + victim.pixel_w, point[2] + victim.pixel_y + victim.pixel_z) + var/turf/land = weak ? null : mantis_spurt_turf(victim, from, height, FALSE, spurt_dir, side) + if(!weak && (isnull(land) || hv_blood_spray_blocked(land))) + weak = TRUE + if(weak) + land = mantis_spurt_turf(victim, from, height, TRUE, spurt_dir, side) + var/list/shape = mantis_spurt_shape(height, weak) + var/state = "[height][weak ? "_weak" : ""]" + // The wait, the flight, and one beat past the landing on its last frame: the spots it shows there are the ones + // laid on the floor. + var/obj/effect/abstract/cyberware_anim/jet = cyberware_anim_play(victim, MANTIS_ARTERY_ICON, "spurt_[state]", MANTIS_ANIM_TOP_LAYER, MANTIS_SPURT_WAIT + shape[3] + world.tick_lag) + if(!jet) + return null + jet.vis_flags &= ~VIS_INHERIT_DIR + jet.setDir(spurt_dir) + // Blood in the air is no part of the body: drawn apart from it, so its layer sorts it against the whole body. + jet.appearance_flags |= RESET_TRANSFORM|RESET_COLOR|KEEP_APART + // 96x96, the wound above its middle so the drops that fall furthest (the neck's, thrown down the screen) stay + // in the picture until they land. Drawn elsewhere, not moved: it stays on the body's own row. + jet.pixel_w = -32 + point[1] + jet.pixel_z = -32 - MANTIS_ARTERY_WOUND_RISE + point[2] + jet.color = victim.get_blood_dna_color() || BLOOD_COLOR_RED + jet.layer = ((spurt_dir & NORTH) || wire_limb_far(zone, victim.dir)) ? BELOW_MOB_LAYER : MANTIS_OVER_VICTIM_LAYER + var/lean = mantis_spurt_lean(weak, spurt_dir, side) + if(lean) + // They lean from when they leave the wound, not while the picture waits. + var/wound_w = jet.pixel_w + animate(jet, pixel_w = wound_w, time = MANTIS_SPURT_WAIT) + animate(pixel_w = wound_w + lean, time = shape[3], easing = SINE_EASING|EASE_OUT) + if(land) + var/list/spot = mantis_spurt_spot(from, height, weak, spurt_dir, side) + var/offset_x = spot[1] - (land.x - victim.x) * ICON_SIZE_X + var/offset_y = spot[2] - (land.y - victim.y) * ICON_SIZE_Y + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_spurt_land), land, "artery_drops_[state]", spurt_dir, offset_x, offset_y, victim.get_blood_dna_list(), victim.get_static_viruses()), MANTIS_SPURT_WAIT + shape[3]) + return jet + +/// How far a spurt's drops lean sideways by the time they come down, in pixels. Thrown straight up or down the +/// screen out of a wound on one [side] of the body's middle line (-1 left as drawn, 1 right, 0 for no lean) they +/// would fly along the body, behind it or over it: they lean out to the wound's side and land clear of it. +/proc/mantis_spurt_lean(weak, spurt_dir, side) + if(spurt_dir != NORTH && spurt_dir != SOUTH) + return 0 + return side * (weak ? 4 : 9) + +/// Where the middle of a spurt's drops comes down, from the middle of the tile the wound is on: list(x, y). +/// [point] is the wound (mantis_artery_point()), on the [side] of the body mantis_spurt_lean() takes. +/proc/mantis_spurt_spot(list/point, height, weak, spurt_dir, side = 0) + var/list/shape = mantis_spurt_shape(height, weak) + var/out_x = (spurt_dir & EAST) ? 1 : ((spurt_dir & WEST) ? -1 : 0) + var/out_y = (spurt_dir & NORTH) ? 1 : ((spurt_dir & SOUTH) ? -1 : 0) + var/reach = shape[1] * ((out_x && out_y) ? 0.7071 : 1) + return list(point[1] + round(out_x * reach, 1) + mantis_spurt_lean(weak, spurt_dir, side), point[2] - shape[2] + round(out_y * reach, 1)) + +/// The tile a spurt's drops come down on, or null if there is none. +/proc/mantis_spurt_turf(mob/living/victim, list/point, height, weak, spurt_dir, side = 0) + var/list/spot = mantis_spurt_spot(point, height, weak, spurt_dir, side) + return locate(victim.x + round(spot[1] / ICON_SIZE_X, 1), victim.y + round(spot[2] / ICON_SIZE_Y, 1), victim.z) + +/** + * The spots a spurt of arterial blood leaves where its drops come down ("artery_drops_[...]", the last frame + * of the spurt's own art, eight ways), and the drips at the feet off the run down the body ("artery_feet_[...]"). + * Its own kind of blood, not one of the sprays: thin, and a tile keeps only a few. + * + * It is one of tg's drops of blood on purpose, with tg's own list of drop pictures. tg's bleeding feeds its + * drips to the first wet blood it finds on a tile unless that blood is drops, so someone bleeding over spots + * that were plain blood would never stand in a pool. Onto drops it lets its own drips fall as ever, and turns + * the lot into its pool in the end. + */ +/obj/effect/decal/cleanable/blood/drip/mantis_drops + name = "blood drops" + icon = MANTIS_GORE_ICON + icon_state = "artery_drops_mid" + mergeable_decal = FALSE + // Thin enough that walking through it does not leave footprints. + bloodiness = 10 + drying_time = 3 MINUTES + beauty = -15 + base_name = null + base_suffix = "drops" + /// How many drips off a run have landed here, if this is the pool at someone's feet. + var/feet_drips = 0 + +// Too thin to dive into. +/obj/effect/decal/cleanable/blood/drip/mantis_drops/can_bloodcrawl_in() + return FALSE + +/// A spurt's drops come down on [land]: its spots are laid, [offset_x] and [offset_y] pixels from the tile's +/// middle. Wet spots already there from the same beat of the same wound are only freshened, and a tile holds +/// no more than a few. Returns the spots. +/proc/mantis_spurt_land(turf/land, state, spurt_dir, offset_x, offset_y, list/blood_dna, list/viruses) + if(!isturf(land) || !length(blood_dna) || hv_blood_spray_blocked(land) || isgroundlessturf(land) || !land.has_gravity()) + return null + var/laid = 0 + for(var/obj/effect/decal/cleanable/blood/drip/mantis_drops/wet in land) + if(wet.dried || wet.feet_drips || QDELETED(wet)) + continue + laid++ + if(laid >= 4 || (wet.icon_state == state && wet.dir == spurt_dir && wet.pixel_x == offset_x && wet.pixel_y == offset_y)) + wet.add_blood_DNA(blood_dna, viruses) + return wet + var/obj/effect/decal/cleanable/blood/drip/mantis_drops/drops = new(land, viruses, blood_dna) + if(QDELETED(drops)) + return null + drops.icon_state = state + drops.setDir(spurt_dir) + drops.pixel_x = offset_x + drops.pixel_y = offset_y + return drops + +/// The blood run down a body from a torn artery (mantis_artery_run_fx()). +/obj/effect/abstract/cyberware_anim/mantis_run + icon = MANTIS_GORE_ICON + layer = MANTIS_ANIM_TOP_LAYER + /// The part it runs from. + var/zone + +/// Run its length: a still picture from here on, whichever way the body turns. +/obj/effect/abstract/cyberware_anim/mantis_run/proc/ran() + icon_state = "artery_ran_[zone]" + +/// A drip falls off the run's lower end and lands at the feet. +/obj/effect/abstract/cyberware_anim/mantis_run/proc/drip() + var/mob/living/body = bearer + if(QDELETED(src) || !istype(body) || QDELETED(body) || body.body_position == LYING_DOWN) + return + mantis_feet_drip(body, zone) + +/// Where the run down a body from [zone] comes to the sole of a body facing [facing], in pixels across from the +/// middle of its tile: the lower end of the art's own run ("artery_ran_[zone]"). Where the part is on the body's +/// far side and the run can't be seen, the sole the blood comes down to all the same. +/proc/mantis_artery_run_foot(zone, facing) + var/static/list/feet = list( + BODY_ZONE_HEAD = list("[SOUTH]" = 0, "[NORTH]" = 0, "[EAST]" = 3, "[WEST]" = -2), + BODY_ZONE_CHEST = list("[SOUTH]" = 1, "[NORTH]" = 0, "[EAST]" = 3, "[WEST]" = -2), + BODY_ZONE_L_ARM = list("[SOUTH]" = 5, "[NORTH]" = -7, "[EAST]" = -1, "[WEST]" = 2), + BODY_ZONE_R_ARM = list("[SOUTH]" = -6, "[NORTH]" = 5, "[EAST]" = -1, "[WEST]" = 2), + BODY_ZONE_L_LEG = list("[SOUTH]" = 4, "[NORTH]" = -2, "[EAST]" = 0, "[WEST]" = 2), + BODY_ZONE_R_LEG = list("[SOUTH]" = -3, "[NORTH]" = 2, "[EAST]" = 0, "[WEST]" = 2), + ) + var/list/by_facing = feet[zone] + return by_facing?["[facing]"] + +/** + * A drip off the blood run down [victim] from [zone] lands at the feet, two pixels under the sole the run comes + * down to: a spot there ("artery_feet_1"), wider with the next, a small pool with the third. Nothing where no + * blood would lie. Returns the spot. + */ +/proc/mantis_feet_drip(mob/living/victim, zone) + if(QDELETED(victim) || !isturf(victim.loc) || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return null + var/foot = mantis_artery_run_foot(zone, victim.dir) + if(isnull(foot)) + return null + // The sole is the body picture's lowest row, 15 pixels under the middle of the tile, wherever the body is drawn. + var/matrix/shape = victim.transform + var/drip_x = round(shape.a * foot + shape.b * -15 + shape.c, 1) + victim.pixel_x + victim.pixel_w + var/drip_y = round(shape.d * foot + shape.e * -15 + shape.f, 1) + victim.pixel_y + victim.pixel_z - 2 + var/turf/land = locate(victim.x + round(drip_x / ICON_SIZE_X, 1), victim.y + round(drip_y / ICON_SIZE_Y, 1), victim.z) + if(!isturf(land) || isgroundlessturf(land) || !land.has_gravity() || (land != victim.loc && hv_blood_spray_blocked(land))) + return null + var/offset_x = drip_x - (land.x - victim.x) * ICON_SIZE_X + var/offset_y = drip_y - (land.y - victim.y) * ICON_SIZE_Y + for(var/obj/effect/decal/cleanable/blood/drip/mantis_drops/wet in land) + if(!wet.feet_drips || wet.dried || QDELETED(wet) || wet.pixel_x != offset_x || wet.pixel_y != offset_y) + continue + wet.feet_drips = min(wet.feet_drips + 1, 3) + wet.icon_state = "artery_feet_[wet.feet_drips]" + wet.add_blood_DNA(victim.get_blood_dna_list(), victim.get_static_viruses()) + return wet + var/obj/effect/decal/cleanable/blood/drip/mantis_drops/pool = new(land, victim.get_static_viruses(), victim.get_blood_dna_list()) + if(QDELETED(pool)) + return null + pool.feet_drips = 1 + pool.icon_state = "artery_feet_1" + pool.pixel_x = offset_x + pool.pixel_y = offset_y + return pool + +/** + * The blood of a torn artery runs down [victim]'s body from the wound on [zone] to the feet: a thin broken + * line that grows over half a second and stays ("artery_run_[zone]", then "artery_ran_[zone]", drawn for the + * body's four facings; nothing where the part is on the body's far side). Part of the body: it turns and lies + * down with it. It lasts as long as [torn], the wound. Only on someone upright: lying down, the blood goes + * straight to the floor. Returns the picture. + */ +/proc/mantis_artery_run_fx(mob/living/victim, zone, datum/wound/torn) + if(QDELETED(victim) || QDELETED(torn) || victim.body_position == LYING_DOWN) + return null + if(!icon_exists(MANTIS_GORE_ICON, "artery_run_[zone]")) + return null + var/obj/effect/abstract/cyberware_anim/mantis_run/run = new(null) + run.bearer = victim + run.zone = zone + run.icon_state = "artery_run_[zone]" + run.appearance_flags |= RESET_COLOR + run.color = victim.get_blood_dna_color() || BLOOD_COLOR_RED + run.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + // The wound closing, or going with its limb, takes the run with it. + run.RegisterSignal(torn, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + victim.vis_contents += run + addtimer(CALLBACK(run, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/mantis_run, ran)), 0.8 SECONDS) + // It comes to the feet, and three drips fall off it there. + for(var/wait in list(0.6 SECONDS, 0.95 SECONDS, 1.25 SECONDS)) + addtimer(CALLBACK(run, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/mantis_run, drip)), wait) + return run + +/// A Scorch cut: a white-hot seam across the limb that cools to char. +/proc/mantis_scorch_seam_fx(mob/living/carbon/victim, obj/item/bodypart/limb) + var/state = "scorch_gash_[limb.body_zone]" + if(!icon_exists(MANTIS_GORE_ICON, state)) + return null + return cyberware_anim_play(victim, MANTIS_GORE_ICON, state, MANTIS_ANIM_BLADE_LAYER, 1.5 SECONDS, glow = TRUE) + +/// The X-slash lands: a flash of light off the blades. The two crossed strokes are drawn with the blades' own +/// swings (mantis_xslash_cross(), mantis_swing.dm). +/proc/mantis_xslash_fx(mob/living/user, mob/living/victim, impact_state) + if(QDELETED(victim)) + return + cyberware_flash_fx(victim, 0.15 SECONDS) + victim.mob_light(2, 1.5, impact_state == "impact_scorch" ? LIGHT_COLOR_FIRE : "#bfe6ff", 0.2 SECONDS) + +// ========================================================================= +// IMPALE, SCISSOR, CROUCH +// ========================================================================= + +/// The way [looker] turns to look at [seen], as face_atom() picks it, whatever state [looker] is in. +/proc/mantis_face_dir(atom/looker, atom/seen) + var/dx = seen.x - looker.x + var/dy = seen.y - looker.y + if(abs(dx) < abs(dy)) + return dy > 0 ? NORTH : SOUTH + return dx > 0 ? EAST : WEST + +/// How [victim] lies, for the scissor's art: "stand", or "lie90"/"lie270" by the way tg has turned them. +/proc/mantis_scissor_posture(mob/living/victim) + if(victim.body_position != LYING_DOWN || !victim.rotate_on_lying) + return "stand" + return victim.lying_angle == 270 ? "lie270" : "lie90" + +/// Someone under the raised blades shudders: small sideways jolts, relative, ending where they began. +/proc/mantis_struggle_fx(mob/living/victim, duration) + if(QDELETED(victim)) + return + var/static/list/jolts = list(1, -1, 1, 0, -1, 1, -1, 0) + var/step_time = duration / length(jolts) + var/offset = 0 + for(var/i in 1 to length(jolts)) + var/shift = jolts[i] - offset + offset = jolts[i] + if(i == 1) + animate(victim, pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + else + animate(pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE) + +/// The crouched blade's glow, brightening over the charge. Gone with the crouch. +/proc/mantis_coil_glow_fx(mob/living/bearer, obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade) + var/state = "[blade.base_icon_state]_coil_glow_[blade.hand_side()]" + if(!icon_exists(MANTIS_GORE_ICON, state)) + return null + var/obj/effect/abstract/cyberware_anim/glow = cyberware_anim_play(bearer, MANTIS_GORE_ICON, state, MANTIS_ANIM_BLADE_LAYER, MANTIS_CHARGE_MAX_HOLD + 1 SECONDS, glow = TRUE) + if(!glow) + return null + // Side on, the far blade's glow stays behind the near blade. + glow.sort_as_arm(blade.hand_side()) + glow.alpha = 0 + animate(glow, alpha = 255, time = MANTIS_CHARGE_FULL - MANTIS_CHARGE_TAP) + return glow + +#undef MANTIS_LEAP_GLIDE +#undef MANTIS_SPURT_WAIT diff --git a/voidcrew/modules/mantis_gore/mantis_rig.dm b/voidcrew/modules/mantis_gore/mantis_rig.dm new file mode 100644 index 00000000000..ce74f3981f6 --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_rig.dm @@ -0,0 +1,568 @@ +/** + * # The blade rig: the impale and the scissor, drawn around the victim + * + * Both moves need parts of the bearer under the victim and parts over it: in the side view the far blade + * and the bearer's body go under the person skewered and the near arm and blade over them, and the far + * blade passes under a neck on the floor while the bearer stands over it. Pictures attached to a mob + * (overlays, underlays, vis_contents) are drawn with the mob, so they can't sit between two mobs. So for + * these moves the bearer is drawn as separate pictures ("puppets") standing on their tile, each with its + * own layer next to the victim's, the way the Gorilla Arms finishers draw their arm rig + * (voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm): + * + * - "body": a copy of the bearer's appearance without the held blades, and without the hands seen from the + * front or back, where the blades' forearms take their place. + * - The arms and blades of each beat, from mantis_gore_reach.dmi: "___", 96x96 with + * the bearer's tile in the middle. Steel inside the victim on the camera's side of them is cut out of its + * piece; steel on their far side is drawn under them and left to their outline. Blood on the blades is a coat over + * each piece ("_blood" or "_soak", the held art's two coats) tinted by the blood, read off the held + * blades, so a blade already wet stays wet and one drenched in the move turns red as it happens. The impale + * also has wet points from the moment they come out of the back, in the victim's blood. + * - Pieces of the victim's blood: "wound" (where the steel goes in and out) and "drip" (drops off the points), + * and "glint", the light on the near blade before the scissor. Sprays, and the crackle of the blades' light + * at the points as they punch out, are loose pictures (burst()) that stay where they started and outlive the beat. + * - The map is SIDE_MAP: rows sort before layers, so every picture is centred with pixel_w/pixel_z and stands on + * the row of what it must sort against (sort_y()). + * + * mantis_rig_beat() has each beat's stack, back to front, with "T" where the victim goes, and the lean. + * Puppets are plain objects on the bearer's tile, so nothing touches the real bearer's own picture, + * offsets or layer; the real bearer is hidden with a colour filter (still clickable over its whole tile) + * and comes back when the rig is deleted. Its owner deletes it on every way the move ends. + */ + +#define MANTIS_RIG_HIDE_FILTER "mantis_rig_hide" +/// The gap between two neighbours in the stack around the victim. +#define MANTIS_RIG_LAYER_STEP 0.001 + +/** + * One beat of [move] seen facing [facing], from the approved storyboards (S/mantis-storyboard): list(order, + * bearer_w, bearer_z, target_w, target_z, tilt, raise). Order is the stack back to front: "T" is the victim, + * "body" the bearer's flat picture without the held blades, "whole" that picture as the bearer is held (blades + * and all), anything else a piece. Bearer and target are pixel offsets from their own tiles; tilt turns the + * victim about the feet (degrees, clockwise); raise is how high the fists ride in the body picture (0 leaves + * it). West is east mirrored. + */ +/proc/mantis_rig_beat(move, beat, facing) + var/view = (facing & (EAST|WEST)) ? "E" : (facing == NORTH ? "N" : "S") + var/list/spec + switch("[move]_[beat]_[view]") + // ---- The impale. "arrive" glides in with the lunging body. "contact" is the slide onto the blades: whole + // blades, and everything on the far side of the target (the near blade past the fist, "nrest", in the side + // view) drawn under them, so the target itself covers the steel it has slid onto. Then they are in: "enter". + if("impale_arrive_E") + spec = list(list("T", "far", "body", "near"), 2, 0, 0, 0, 0, 2) + if("impale_contact_E") + spec = list(list("far", "body", "nrest", "T", "narm", "near"), 7, 0, -17, 0, 0, 1) + // From the stab to the hold the blades go through the same place on the target: "enter", "lift1" and "lift2" + // are the hold's blades 4, 2 and 1 px lower, with the target. The steel inside them is cut out of the near + // blade; the far blade is under them. + if("impale_enter_E") + spec = list(list("far", "body", "T", "wound", "narm", "near"), 7, 0, -17, 0, 0, 1) + if("impale_lift1_E") + spec = list(list("far", "body", "T", "wound", "narm", "near"), 7, 0, -17, 2, 0, 2) + if("impale_lift2_E") + spec = list(list("far", "body", "T", "wound", "narm", "near"), 7, 0, -17, 3, 0, 2) + if("impale_hold_E") + spec = list(list("far", "body", "T", "wound", "drip", "narm", "near"), 7, 0, -17, 4, 0, 2) + // The pull-back in three steps: the bearer leans back, the arms draw back and the target slides off, + // 3 px of steel out a step. Only what has come out of the front shows. + if("impale_pull1_E") + spec = list(list("far", "body", "T", "wound", "narm", "near"), 6, 0, -16, 3, 0, 2) + if("impale_pull2_E") + spec = list(list("far", "body", "T", "wound", "narm", "near"), 5, 0, -15, 2, 0, 2) + if("impale_pull3_E") + spec = list(list("far", "body", "T", "wound", "narm", "near"), 4, 0, -14, 1, 0, 2) + if("impale_rip_E") + spec = list(list("far", "body", "T", "near"), 6, 0, -12, 2, 14, 1) + if("impale_drop_E") + spec = list(list("T", "far", "body", "near"), 0, 0, 0, 0, 0, 2) + // Seen from behind the target is on the row beyond the bearer, facing the camera. The blades go into the + // chest a torso's width apart: "arms" is the forearms and the fists, which the bearer's back hides; "tips" + // is the blades, drawn behind the target: what shows of them is the points past the shoulders by the neck. + if("impale_arrive_N") + spec = list(list("T", "arms", "body"), 0, 2, 0, 0, 0, 0) + if("impale_contact_N", "impale_enter_N") + spec = list(list("tips", "T", "arms", "body"), 0, 24, 0, 0, 0, 0) + if("impale_lift1_N") + spec = list(list("tips", "T", "arms", "body"), 0, 24, 0, 2, 0, 0) + if("impale_lift2_N") + spec = list(list("tips", "T", "arms", "body"), 0, 24, 0, 3, 0, 0) + if("impale_hold_N") + spec = list(list("tips", "T", "arms", "body"), 0, 24, 0, 4, 0, 0) + if("impale_pull1_N") + spec = list(list("tips", "T", "arms", "body"), 0, 23, 0, 3, 0, 0) + if("impale_pull2_N") + spec = list(list("tips", "T", "arms", "body"), 0, 22, 0, 2, 0, 0) + if("impale_pull3_N") + spec = list(list("tips", "T", "arms", "body"), 0, 21, 0, 1, 0, 0) + if("impale_rip_N") + spec = list(list("T", "arms", "body"), 0, 15, 0, 0, 0, 0) + // The drop from the front and the back is the bearer as they are held again ("whole": the guard, blood and + // all), so nothing changes when the real bearer comes back. + if("impale_drop_N") + spec = list(list("T", "whole"), 0, 0, 0, 0, 0, 0) + // Seen from the front the target is on the row before the bearer, their back to the camera, and covers the + // bearer's arms and the steel going in. "tips" is the two points out of their back, over them. + if("impale_arrive_S") + spec = list(list("body", "arms", "T"), 0, -2, 0, 0, 0, 0) + if("impale_contact_S") + spec = list(list("body", "arms", "T"), 0, 0, 0, 24, 0, 0) + if("impale_enter_S") + spec = list(list("body", "arms", "T", "wound", "tips"), 0, 0, 0, 24, 0, 0) + if("impale_lift1_S") + spec = list(list("body", "arms", "T", "wound", "tips"), 0, 0, 0, 26, 0, 0) + if("impale_lift2_S") + spec = list(list("body", "arms", "T", "wound", "tips"), 0, 0, 0, 27, 0, 0) + if("impale_hold_S") + spec = list(list("body", "arms", "T", "wound", "tips", "drip"), 0, 0, 0, 28, 0, 0) + if("impale_pull1_S") + spec = list(list("body", "arms", "T", "wound", "tips"), 0, 0, 0, 27, 0, 0) + if("impale_pull2_S") + spec = list(list("body", "arms", "T", "wound", "tips"), 0, 0, 0, 25, 0, 0) + if("impale_pull3_S") + spec = list(list("body", "arms", "T", "wound", "tips"), 0, 0, 0, 23, 0, 0) + if("impale_rip_S") + spec = list(list("body", "arms", "T"), 0, -2, 0, 13, 0, 0) + // They fall in front of the bearer, and are drawn over them all the way down. + if("impale_drop_S") + spec = list(list("whole", "T"), 0, 0, 0, 0, 0, 0) + // ---- The scissor. + if("scissor_raise_E") + spec = list(list("T", "far", "body", "near"), 0, 0, 0, 0, 0, 3) + if("scissor_glint_E") + spec = list(list("T", "far", "body", "near", "glint"), 0, 0, 0, 0, 0, 3) + // Half way down from the raise to the close: the swing. + if("scissor_swing_E") + spec = list(list("T", "far", "body", "near"), 5, 0, 0, 0, 0, 2) + if("scissor_close_E", "scissor_cut_E") + spec = list(list("fblade", "T", "farm", "body", "near"), 10, 0, 0, 0, 0, 1) + if("scissor_recover_E") + spec = list(list("T", "far", "body", "near"), 0, 0, 0, 0, 0, 2) + if("scissor_raise_N", "scissor_recover_N") + spec = list(list("T", "arms", "body"), 0, 0, 0, 0, 0, 0) + if("scissor_glint_N") + spec = list(list("T", "arms", "glint", "body"), 0, 0, 0, 0, 0, 0) + if("scissor_close_N", "scissor_cut_N") + spec = list(list("T", "arms", "body"), 0, 10, 0, 0, 0, 0) + if("scissor_raise_S", "scissor_recover_S") + spec = list(list("T", "body", "arms"), 0, 0, 0, 0, 0, 0) + if("scissor_glint_S") + spec = list(list("T", "body", "arms", "glint"), 0, 0, 0, 0, 0, 0) + if("scissor_swing_S") + spec = list(list("T", "body", "arms"), 0, -8, 0, 0, 0, 0) + if("scissor_close_S", "scissor_cut_S") + spec = list(list("T", "body", "arms"), 0, -16, 0, 0, 0, 0) + if(isnull(spec)) + return null + if(facing & WEST) + spec[MANTIS_RIG_BEARER_W] = -spec[MANTIS_RIG_BEARER_W] + spec[MANTIS_RIG_TARGET_W] = -spec[MANTIS_RIG_TARGET_W] + spec[MANTIS_RIG_TILT] = -spec[MANTIS_RIG_TILT] + return spec + +/// The way the rig draws a move from [user] to [victim]: the side view for anything off to the side. +/proc/mantis_rig_facing(atom/user, atom/victim) + var/toward = get_dir(user, victim) || user.dir + if(toward & EAST) + return EAST + if(toward & WEST) + return WEST + return toward & NORTH ? NORTH : SOUTH + +/// One picture of the rig on the bearer's tile. +/obj/effect/abstract/mantis_puppet + name = "body" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + appearance_flags = PIXEL_SCALE | TILE_BOUND | LONG_GLIDE + animate_movement = NO_STEPS + plane = GAME_PLANE + layer = MOB_LAYER + vis_flags = NONE + /// Pixels it is drawn off the bearer's lean (pixel_w, pixel_z): a 96x96 piece is centred on the tile, and + /// one standing on another row (mantis_rig/proc/set_row()) is drawn back where it belongs. + var/shift_w = 0 + var/shift_z = 0 + +/// A spray of the victim's blood drawn by the rig: it stays where it started and goes on its own. +/obj/effect/abstract/mantis_rig_fx + name = "blood" + icon = MANTIS_GORE_REACH_ICON + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + appearance_flags = PIXEL_SCALE | TILE_BOUND | LONG_GLIDE + animate_movement = NO_STEPS + plane = GAME_PLANE + layer = MOB_LAYER + vis_flags = NONE + +/datum/mantis_rig + /// Who is drawn + var/mob/living/carbon/bearer + /// Whom the stack is built around + var/mob/living/victim + /// The way the pictures face + var/facing + /// The blades' model, for the pieces + var/model + /// "impale" or "scissor" + var/move + /// Key -> /obj/effect/abstract/mantis_puppet + var/list/puppets = list() + /// The beat drawn now + var/beat + /// How high the fists ride in the body picture now + var/raise = 0 + /// The bearer's own offsets when the rig went up, under every lean + var/base_w = 0 + var/base_z = 0 + /// The lean drawn now, from base_w and base_z + var/lean_w = 0 + var/lean_z = 0 + /// TRUE while the real bearer is hidden + var/hidden = FALSE + var/old_mouse_opacity + /// The victim's layer when the beat drawn now went up + var/victim_layer = MOB_LAYER + /// The colour of the victim's blood, or null if they don't bleed + var/victim_color + /// TRUE once the points have been through the victim: wet with their blood even before a drench + var/wet = FALSE + /// The blood on the blades drawn now: null, "blood" or "soak" (the held art's coats), and its colour + var/coat + var/coat_color + /// TRUE while the pictures fade out and the real bearer fades back in (hand_back()) + var/handing_back = FALSE + /// TRUE while the body picture is the bearer as they are held, blades and all ("whole") + var/whole = FALSE + +/datum/mantis_rig/New(mob/living/carbon/bearer, mob/living/victim, facing, model, move) + . = ..() + src.bearer = bearer + src.victim = victim + src.facing = facing + src.model = model + src.move = move + RegisterSignal(bearer, COMSIG_QDELETING, PROC_REF(on_bearer_deleted)) + // A kill while the rig draws the blades only stains them: the rig draws the blood it leaves (mantis_kill_credit()). + bearer.get_mantis_state().rig = src + if(victim) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + if(victim.can_bleed(BLOOD_COVER_TURFS) == BLEED_SPLATTER) + var/datum/blood_type/blood = victim.get_bloodtype() + victim_color = blood?.get_color() + +/datum/mantis_rig/Destroy() + reveal() + for(var/key in puppets) + qdel(puppets[key]) + puppets = null + if(bearer) + UnregisterSignal(bearer, COMSIG_QDELETING) + if(bearer.mantis_state?.rig == src) + bearer.mantis_state.rig = null + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + blade.show_inhand() + if(victim) + UnregisterSignal(victim, COMSIG_QDELETING) + bearer = null + victim = null + return ..() + +/datum/mantis_rig/proc/on_bearer_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/mantis_rig/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + UnregisterSignal(victim, COMSIG_QDELETING) + victim = null + +/** + * Puts the rig up: hides the held blades and the real bearer and stands the body picture in its place. + * FALSE if the bearer can't be drawn this way (not on a turf); the move goes on without the poses. + */ +/datum/mantis_rig/proc/start() + if(QDELETED(bearer) || !isturf(bearer.loc)) + return FALSE + base_w = bearer.pixel_w - bearer.has_offset(HV_FINISHER_OFFSET, PIXEL_W_OFFSET) + base_z = bearer.pixel_z - bearer.has_offset(HV_FINISHER_OFFSET, PIXEL_Z_OFFSET) + snapshot() + conceal() + return TRUE + +/** + * Draws the body puppet as the bearer looks now, without the held blades: a copy of the bearer's own + * appearance, so the client already has every icon in it and it never blinks while one downloads. Seen from + * the front or back the hands and gloves are left off: the forearms of the blades start at the elbows. + * [as_held]: the bearer exactly as they are held, blades, hands and all, for a beat that ends on the held art. + */ +/datum/mantis_rig/proc/snapshot(as_held = FALSE) + whole = as_held + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + if(as_held) + blade.show_inhand() + else + blade.hide_inhand_for(10 SECONDS) + var/mutable_appearance/look = new(bearer.appearance) + look.filters = null + if(!as_held && (facing & (NORTH|SOUTH))) + for(var/image/part as anything in look.overlays.Copy()) + if(part.layer == -GLOVES_LAYER || findtext("[part.icon_state]", "_hand")) + look.overlays -= part + var/obj/effect/abstract/mantis_puppet/body = puppet("body") + var/body_layer = body.layer + var/body_row = body.pixel_y + body.appearance = look + body.dir = facing + body.layer = body_layer + body.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + body.pixel_x = own_x() + body.pixel_y = body_row + body.pixel_w = base_w + lean_w + body.shift_w + body.pixel_z = base_z + lean_z + body.shift_z + +/** + * The bearer's pixel_x and pixel_y under the rig's own lean: without a #509 finisher's lean (pose_user()), which + * the rig draws itself. pixel_y is also the row the pictures sort on. + */ +/datum/mantis_rig/proc/own_x() + return bearer.pixel_x - bearer.has_offset(HV_FINISHER_OFFSET, PIXEL_X_OFFSET) + +/datum/mantis_rig/proc/own_y() + return bearer.pixel_y - bearer.has_offset(HV_FINISHER_OFFSET, PIXEL_Y_OFFSET) + +/// The fists ride [new_raise] pixels high in the body picture (mantis_stance_raise()). +/datum/mantis_rig/proc/set_raise(new_raise) + if(!new_raise || new_raise == raise || QDELETED(bearer)) + return + raise = new_raise + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = blade.housing_ref?.resolve() + housing?.set_stance_raise(new_raise) + if(puppets["body"]) + snapshot(whole) + +/// The puppet under [key], made on the bearer's tile at the lean drawn now if there isn't one. +/datum/mantis_rig/proc/puppet(key) + var/obj/effect/abstract/mantis_puppet/picture = puppets[key] + if(picture) + return picture + picture = new(get_turf(bearer)) + picture.name = bearer.name + picture.pixel_x = own_x() + picture.pixel_y = own_y() + // Centred with pixel_w and pixel_z only: pixel_y picks the row it sorts on (set_row()). + if(key != "body") + picture.icon = MANTIS_GORE_REACH_ICON + picture.shift_w = -ICON_SIZE_X + picture.shift_z = -ICON_SIZE_Y + picture.pixel_w = base_w + lean_w + picture.shift_w + picture.pixel_z = base_z + lean_z + picture.shift_z + puppets[key] = picture + return picture + +/** + * The pixel_y, from the bearer's tile, of the row a picture drawn [behind] the victim (or in front of them) + * sorts on. The map is SIDE_MAP: BYOND sorts by row (a tile's y plus pixel_y) before layer, so a picture + * only sorts against the victim by its layer on the victim's own row. One the bearer's row already puts on + * the right side of the victim stays on the bearer's row, under or over everything else there as before. + */ +/datum/mantis_rig/proc/sort_y(behind) + var/own = own_y() + if(!victim || QDELETED(victim) || victim.z != bearer.z || !isturf(victim.loc)) + return own + var/theirs = (victim.y - bearer.y) * ICON_SIZE_Y + victim.pixel_y + if(behind ? own > theirs : own < theirs) + return own + return theirs + +/// Stands [picture] on the row [row_y], still drawn where it was. +/datum/mantis_rig/proc/set_row(obj/effect/abstract/mantis_puppet/picture, row_y) + var/change = row_y - picture.pixel_y + if(!change) + return + picture.pixel_y = row_y + picture.shift_z -= change + picture.pixel_z -= change + +/** + * Draws [new_beat]: each piece of its stack is swapped in at its layer next to the victim's, pieces it + * doesn't use go, and the whole bearer leans to the beat's lean, plus [extra_w] and [extra_z], over [time]. + * With [from_w] or [from_z] every picture starts that far off and glides into place over [time] instead, the + * way the lunging body glides in (mantis_lunge_dash_fx()): one animation, so the client sees the start. + * [ride_z] lifts everything but the bearer's body that far: the arms and the blades riding with someone who + * sags on them, so the steel in them stays where it is in them. [body_z] lifts the bearer's body and the arms + * on it ("arms": the forearms and fists) that far: the hands stay on the wrists while the blades' points stay put. + */ +/datum/mantis_rig/proc/show(new_beat, extra_w = 0, extra_z = 0, time = 0.1 SECONDS, from_w = 0, from_z = 0, easing = SINE_EASING, ride_z = 0, body_z = 0) + if(QDELETED(bearer)) + return + var/list/spec = mantis_rig_beat(move, new_beat, facing) + if(!spec) + return + beat = new_beat + set_raise(spec[MANTIS_RIG_RAISE]) + var/list/order = spec[MANTIS_RIG_ORDER] + var/victim_at = order.Find("T") + victim_layer = (victim && !QDELETED(victim)) ? victim.layer : bearer.layer + var/list/used = list() + for(var/i in 1 to length(order)) + var/key = order[i] + if(key == "T") + continue + // The victim's own blood: nothing to draw on someone who doesn't bleed. + var/bloody = (key == "wound" || key == "drip") + if(bloody && !victim_color) + continue + // The body picture, with or without the held blades. + if(key == "whole" || key == "body") + if((key == "whole") != whole) + snapshot(key == "whole") + key = "body" + var/obj/effect/abstract/mantis_puppet/picture = puppet(key) + if(key != "body") + var/state = "[model]_[move]_[beat]_[key]" + // A looping picture (the drops) would start over if it were set again. + if(picture.icon_state != state) + picture.icon_state = state + picture.dir = facing + if(bloody) + picture.color = victim_color + picture.layer = victim_layer + (i - victim_at) * MANTIS_RIG_LAYER_STEP + set_row(picture, sort_y(i < victim_at)) + used[key] = TRUE + for(var/key in puppets.Copy()) + if(!used[key]) + qdel(puppets[key]) + puppets -= key + dress() + lean_w = spec[MANTIS_RIG_BEARER_W] + extra_w + lean_z = spec[MANTIS_RIG_BEARER_Z] + extra_z + for(var/key in puppets) + var/obj/effect/abstract/mantis_puppet/picture = puppets[key] + var/to_w = base_w + lean_w + picture.shift_w + var/to_z = base_z + lean_z + picture.shift_z + (key == "body" ? body_z : ride_z) + (key == "arms" ? body_z : 0) + if(from_w || from_z) + picture.pixel_w = to_w + from_w + picture.pixel_z = to_z + from_z + // At once, ending whatever was still moving it. + if(!time) + animate(picture, pixel_w = to_w, pixel_z = to_z, time = 0) + continue + animate(picture, pixel_w = to_w, pixel_z = to_z, time = time, easing = easing) + +/** + * Blood on the blades as the held blades have it now: the heavier coat while one is drenched, the light one + * while one is only stained, and at least the light one in the victim's blood once the points have been + * through them. A blade that keeps itself clean (the Scorch) shows none. + */ +/datum/mantis_rig/proc/read_coat() + coat = null + coat_color = null + if(QDELETED(bearer)) + return + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + if(blade.item_flags & NO_BLOOD_ON_ITEM) + coat = null + return + if(!blade.is_bloody() || coat == "soak") + continue + coat = world.time < blade.soaked_until ? "soak" : "blood" + coat_color = blade.get_blood_dna_color() + if(!coat && wet && victim_color) + coat = "blood" + coat_color = victim_color + +/// Puts the blood on every blade piece drawn now (read_coat()). +/datum/mantis_rig/proc/dress() + read_coat() + for(var/key in puppets) + if(key == "body" || key == "wound" || key == "drip" || key == "glint") + continue + var/obj/effect/abstract/mantis_puppet/picture = puppets[key] + picture.cut_overlays() + if(!coat) + continue + var/state = "[picture.icon_state]_[coat]" + if(!icon_exists(MANTIS_GORE_REACH_ICON, state)) + continue + var/mutable_appearance/blood = mutable_appearance(MANTIS_GORE_REACH_ICON, state) + blood.color = coat_color + picture.add_overlay(blood) + +/** + * A spray of the victim's blood, "__", drawn where the beat drawn now puts it and left + * there for [lifetime]: just over (or, with [under], just under) the piece [key], or the victim for "T". + * Not [blood]: a flash of the blades' own light, drawn in its own colours. + */ +/datum/mantis_rig/proc/burst(name, lifetime, key = "T", under = FALSE, blood = TRUE) + if(QDELETED(bearer) || (blood && !victim_color)) + return null + var/state = "[model]_[move]_[name]" + if(!icon_exists(MANTIS_GORE_REACH_ICON, state)) + return null + var/obj/effect/abstract/mantis_rig_fx/spray = new(get_turf(bearer)) + spray.icon_state = state + spray.dir = facing + if(blood) + spray.color = victim_color + else + spray.name = "light" + var/obj/effect/abstract/mantis_puppet/beside = puppets[key] + var/beside_layer = beside ? beside.layer : victim_layer + spray.layer = beside_layer + (under ? -0.5 : 0.5) * MANTIS_RIG_LAYER_STEP + // On the row of what it sorts against, centred with pixel_w and pixel_z (set_row()). + spray.pixel_x = own_x() + spray.pixel_y = beside ? beside.pixel_y : sort_y(under) + spray.pixel_w = base_w + lean_w - ICON_SIZE_X + spray.pixel_z = base_z + lean_z - ICON_SIZE_Y + own_y() - spray.pixel_y + QDEL_IN(spray, lifetime) + return spray + +/** + * The move is over: the real bearer, held blades and all, is drawn again at once, and the pictures, lifted + * over it in the same order, fade out over [time]. A pose that differs from the held art eases into it + * instead of jumping, and nothing is ever see-through: the real bearer underneath is whole. Then the rig goes. + */ +/datum/mantis_rig/proc/hand_back(time = MANTIS_RIG_HANDBACK) + if(handing_back || QDELETED(src)) + return + handing_back = TRUE + if(QDELETED(bearer) || !time) + qdel(src) + return + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + blade.show_inhand() + reveal() + var/lowest + for(var/key in puppets) + var/obj/effect/abstract/mantis_puppet/picture = puppets[key] + lowest = isnull(lowest) ? picture.layer : min(lowest, picture.layer) + var/lift = isnull(lowest) ? 0 : max(0, bearer.layer - lowest + MANTIS_RIG_LAYER_STEP) + for(var/key in puppets) + var/obj/effect/abstract/mantis_puppet/picture = puppets[key] + picture.layer += lift + animate(picture, alpha = 0, time = time, flags = ANIMATION_PARALLEL) + QDEL_IN(src, time) + +/// Hides the real bearer. Its whole tile still takes clicks. +/datum/mantis_rig/proc/conceal() + if(hidden || QDELETED(bearer)) + return + hidden = TRUE + old_mouse_opacity = bearer.mouse_opacity + bearer.mouse_opacity = MOUSE_OPACITY_OPAQUE + bearer.add_filter(MANTIS_RIG_HIDE_FILTER, 1, color_matrix_filter(list(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0))) + +/// Shows the real bearer again. +/datum/mantis_rig/proc/reveal() + if(!hidden) + return + hidden = FALSE + if(QDELETED(bearer)) + return + bearer.mouse_opacity = old_mouse_opacity + bearer.remove_filter(MANTIS_RIG_HIDE_FILTER) + +#undef MANTIS_RIG_HIDE_FILTER +#undef MANTIS_RIG_LAYER_STEP diff --git a/voidcrew/modules/mantis_gore/mantis_scissor.dm b/voidcrew/modules/mantis_gore/mantis_scissor.dm new file mode 100644 index 00000000000..0972323df06 --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_scissor.dm @@ -0,0 +1,348 @@ +// The scissor: both blades out, head aimed, a swing at someone adjacent who is dying. The blades rise, +// catch the light, then come down and close on the neck, one blade behind it and one in front, and #509's +// decapitation takes the head. The bearer is drawn by the blade rig (mantis_rig.dm) from the raise to the +// recovery, so the far blade can pass under the neck while the bearer stands over the body. A #509 finisher +// (voidcrew/modules/hyperviolence/finishers/), drawn the way the bosses' are rather than as real blows, +// so the bearer stands rooted and exposed through the wind-up and a hit makes them reel. No mark over the +// victim (owner D34): the wind-up reads only from the bodies. An Atlas frame stops the cut. + +/// Starts the scissor on [target] if this swing is one. Returns TRUE if it started. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_try_scissor(mob/living/target, mob/living/user) + if(!(gore_moves & MANTIS_MOVE_SCISSOR) || !iscarbon(target) || target == user) + return FALSE + if(!user.combat_mode || user.zone_selected != BODY_ZONE_HEAD || HAS_TRAIT(user, TRAIT_PACIFISM)) + return FALSE + // The cheap checks first: most head swings are at someone on their feet. + if(!mantis_in_crit(target) || length(mantis_held_blades(user, MANTIS_MOVE_SCISSOR)) < 2) + return FALSE + var/datum/mantis_bearer/state = user.get_mantis_state() + if(world.time < state.next_scissor || state.scissor) + return FALSE + var/datum/hv_finisher/mantis_scissor/scissor = new(user, target, CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_scissor_ended))) + scissor.model = base_icon_state + if(!scissor.start()) + scissor.on_end = null + qdel(scissor) + return FALSE + state.scissor = scissor + state.next_scissor = world.time + MANTIS_SCISSOR_COOLDOWN + // The swing never reaches the attack chain, so it counts at a trader outpost here, as the Gorilla finishers do. + gorilla_register_aggression(target, user) + return TRUE + +/// The scissor ended, however it ended: the blades go back to the guard, and the rig fades into the real bearer. +/proc/mantis_scissor_ended(datum/hv_finisher/mantis_scissor/scissor, result) + var/mob/living/bearer = scissor.user + var/datum/mantis_rig/rig = scissor.rig + scissor.rig = null + if(!QDELETED(bearer)) + if(bearer.mantis_state?.scissor == scissor) + bearer.mantis_state.scissor = null + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + blade.set_mantis_pose(MANTIS_POSE_GUARD) + rig?.hand_back() + qdel(scissor) + +/datum/hv_finisher/mantis_scissor + name = "mantis scissor" + interrupt_damage = 15 + vulnerability = 1.25 + stagger_time = 1 SECONDS + zone_preference = list(BODY_ZONE_HEAD) + stages = list( + /datum/hv_finisher_stage/windup/mantis, + /datum/hv_finisher_stage/kill/decapitate/mantis, + /datum/hv_finisher_stage/recover/mantis, + ) + /// The model of the blades doing it, for the pictures. + var/model = "mantis" + /// The bearer drawn around the victim, while it runs. + var/datum/mantis_rig/rig + +/datum/hv_finisher/mantis_scissor/Destroy() + QDEL_NULL(rig) + return ..() + +/// Draws [beat] of the scissor, if the bearer is drawn by the rig, leaning into it over [time]. +/datum/hv_finisher/mantis_scissor/proc/show_beat(beat, time = 0.1 SECONDS) + rig?.show(beat, time = time) + +// Drawn, not real blows: a bearer with two blades fused to the arms makes no aggressive grab, and the +// head comes off by the finisher's own hand. That also roots them and makes them take more damage. +/datum/hv_finisher/mantis_scissor/New(mob/living/user, mob/living/carbon/victim, datum/callback/on_end) + . = ..() + real_hits = FALSE + +/** + * #509's check, made for people: it refuses anyone of the attacker's faction, and every player shares + * one. The scissor goes for anyone dying, as a gun execution does (owner D34). Dying means crit, not + * just lying down: a lunge knockdown alone is not enough. A human NPC only scissors at execution rank. + */ +/datum/hv_finisher/mantis_scissor/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(user, TRAIT_PACIFISM)) + return FALSE + if(!iscarbon(victim) || QDELETED(victim) || victim == user || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!mantis_in_crit(victim)) + return FALSE + if(victim.has_status_effect(/datum/status_effect/hv_finisher_hold) || HAS_TRAIT_FROM(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + if(isnull(victim.get_bodypart(BODY_ZONE_HEAD))) + return FALSE + if(!user.hv_can_execute()) + return FALSE + if(length(mantis_held_blades(user, MANTIS_MOVE_SCISSOR)) < 2) + return FALSE + // Square on only: from a corner the swing is just a swing + return get_dist(user, victim) <= 1 && user.Adjacent(victim) && finisher_square_on(user, victim) + +// A blade stowed (an EMP, a retract) or the hands bound: it can't close. +/datum/hv_finisher/mantis_scissor/safety_check() + . = ..() + if(. || completed) + return + if(length(mantis_held_blades(user, MANTIS_MOVE_SCISSOR)) < 2 || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return HV_FINISHER_ATTACKER_DOWN + if(finisher_on_corner(user, victim)) + return HV_FINISHER_LOST_GRIP + +// #509's wording, but the bearer reels back rather than letting go of a grip they never took. +/datum/hv_finisher/mantis_scissor/interrupt(reason) + if(!running || completed) + return + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_interrupt() + if(reason == HV_FINISHER_DAMAGED) + user.visible_message(span_danger("[user] reels back from [victim]!")) + user.apply_status_effect(/datum/status_effect/hv_finisher_reeling, stagger_time) + stop(reason) + +/// Puts the bearer's blades in [pose]. +/datum/hv_finisher/mantis_scissor/proc/pose_blades(pose) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(user)) + blade.set_mantis_pose(pose) + +// ---- The wind-up ----------------------------------------------------------- + +/// Someone on the floor is turned so their head lies toward [bearer], the way tg turns someone dragged. +/// Straight above or below them, the head goes to the left, where the blades are drawn to close on it. +/proc/mantis_turn_head_toward(mob/living/victim, mob/living/bearer) + if(victim.body_position != LYING_DOWN || !victim.rotate_on_lying || !victim.lying_angle) + return + if(bearer.x == victim.x) + victim.set_lying_angle(LYING_ANGLE_WEST) + return + victim.set_lying_angle(bearer.x > victim.x ? LYING_ANGLE_EAST : LYING_ANGLE_WEST) + +/// Both blades rise. A glint runs along them, and the victim shudders. +/datum/hv_finisher_stage/windup/mantis + duration = MANTIS_SCISSOR_WINDUP + rise = 1 + enter_sound = 'sound/items/unsheath.ogg' + enter_volume = 50 + /// Timer of the glint halfway through. + var/glint_timer + +/datum/hv_finisher_stage/windup/mantis/enter() + . = ..() + var/datum/hv_finisher/mantis_scissor/scissor = host + scissor.pose_blades(MANTIS_POSE_RAISE) + mantis_turn_head_toward(host.victim, host.user) + QDEL_NULL(scissor.rig) + scissor.rig = new(host.user, host.victim, mantis_rig_facing(host.user, host.victim), scissor.model, "scissor") + if(!scissor.rig.start()) + QDEL_NULL(scissor.rig) + scissor.show_beat("raise") + glint_timer = addtimer(CALLBACK(src, PROC_REF(glint)), duration * 0.5, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Halfway through the wind-up the light runs along the near blade (the rig draws it) and the victim shudders. +/datum/hv_finisher_stage/windup/mantis/proc/glint() + glint_timer = null + if(!host?.running) + return + var/datum/hv_finisher/mantis_scissor/scissor = host + scissor.show_beat("glint") + mantis_struggle_fx(host.victim, duration * 0.5) + // Not drawn by the rig: the light runs along one held blade instead. + if(!scissor.rig) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = locate() in host.user.held_items + blade?.play_glint(MANTIS_POSE_RAISE) + +/datum/hv_finisher_stage/windup/mantis/on_stop() + if(glint_timer) + deltimer(glint_timer) + glint_timer = null + +/datum/hv_finisher_stage/windup/mantis/Destroy() + if(glint_timer) + deltimer(glint_timer) + glint_timer = null + return ..() + +/datum/hv_finisher_stage/windup/mantis/enter_message() + return "[host.user] raises both blades over [host.victim]!" + +// ---- The cut --------------------------------------------------------------- + +/datum/hv_finisher_stage/kill/decapitate/mantis + duration = 0.6 SECONDS + // The swing shuts the blades itself, on its own clock (strike()); this only catches a swing that never got there. + effect_delay = 0.4 SECONDS + // The blade rig draws the bearer's lean; without it, enter() leans the real bearer. + lunge = 0 + effect_sound = 'sound/items/weapons/bladeslice.ogg' + spray_strength = 4 + /// The swing's next frame and the blades shutting, to come. + var/close_timer + var/strike_timer + /// The bearer straightening up out of the cut, to come. + var/recover_timer + +/datum/hv_finisher_stage/kill/decapitate/mantis/Destroy() + stop_timers() + return ..() + +/datum/hv_finisher_stage/kill/decapitate/mantis/proc/stop_timers() + for(var/timer in list(close_timer, strike_timer, recover_timer)) + if(timer) + deltimer(timer) + close_timer = null + strike_timer = null + recover_timer = null + +/** + * The swing: the blades come down out of the raise, close on the neck (the far one passes under it, the near + * one over it; the blade rig draws both) and shut through it, a frame apart, and the head comes off as they + * shut. The bearer leans in as they come down. + */ +/datum/hv_finisher_stage/kill/decapitate/mantis/enter() + . = ..() + var/datum/hv_finisher/mantis_scissor/scissor = host + scissor.pose_blades(MANTIS_POSE_GUARD) + if(!scissor.rig) + // Drawn as the bearer: a lean, further if the head lies on the far side of the body. + var/posture = mantis_scissor_posture(host.victim) + var/toward = get_dir(host.user, host.victim) + var/far = (posture == "lie90" && (toward & EAST)) || (posture == "lie270" && (toward & WEST)) + host.pose_user(far ? MANTIS_SCISSOR_FAR_LEAN : MANTIS_SCISSOR_LEAN, rise) + scissor.show_beat("swing", MANTIS_SCISSOR_SWING_STEP) + close_timer = addtimer(CALLBACK(src, PROC_REF(close)), MANTIS_SCISSOR_SWING_STEP, TIMER_STOPPABLE|TIMER_DELETE_ME) + strike_timer = addtimer(CALLBACK(src, PROC_REF(strike)), 1.5 * MANTIS_SCISSOR_SWING_STEP, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// The blades meet at the neck. +/datum/hv_finisher_stage/kill/decapitate/mantis/proc/close() + close_timer = null + if(!host?.running) + return + var/datum/hv_finisher/mantis_scissor/scissor = host + scissor.show_beat("close", MANTIS_SCISSOR_SWING_STEP) + +/// The blades shut through the neck: the stage's beat (effect()), checked the way #509's tick checks it. +/datum/hv_finisher_stage/kill/decapitate/mantis/proc/strike() + strike_timer = null + if(!host?.running || host.current_stage != src || effect_done) + return + var/reason = host.safety_check() + if(reason) + host.end_early(reason) + return + host.fire_effect() + +/// The blades are through: the bearer straightens back to the guard. +/datum/hv_finisher_stage/kill/decapitate/mantis/proc/recover() + recover_timer = null + if(!host?.running) + return + var/datum/hv_finisher/mantis_scissor/scissor = host + scissor.show_beat("recover", MANTIS_SCISSOR_STRAIGHTEN) + +/datum/hv_finisher_stage/kill/decapitate/mantis/on_stop() + . = ..() + stop_timers() + +/** + * The blades come together at the neck: one line for the cut, a huge one if the head comes off. An + * Atlas frame (or anything else that won't let the head go) takes a deep cut instead. + */ +/datum/hv_finisher_stage/kill/decapitate/mantis/effect() + var/mob/living/carbon/victim = host.victim + var/mob/living/bearer = host.user + var/datum/hv_finisher/mantis_scissor/scissor = host + scissor.show_beat("cut", MANTIS_SCISSOR_SWING_STEP) + // The cut holds, then the bearer straightens. + if(!recover_timer) + recover_timer = addtimer(CALLBACK(src, PROC_REF(recover)), MANTIS_SCISSOR_RECOVER_BEAT, TIMER_STOPPABLE|TIMER_DELETE_ME) + if(effect_sound) + playsound(victim, effect_sound, effect_volume, TRUE) + var/spray_dir = away_dir() + mantis_note_hit(victim, bearer) + victim.hv_hold_attack_message( + "[bearer] brings both blades together at [victim]'s neck!", + "[bearer] brings both blades together at your neck!", + null, + null, + null, + DEFAULT_MESSAGE_RANGE, + ) + var/beheads = can_gore() + if(beheads) + victim.hv_add_hit_line("[victim]'s head comes off!", HV_LINE_HUGE, "Your head comes off!", DEFAULT_MESSAGE_RANGE) + gore(spray_dir) + else + fallback(spray_dir) + if(!QDELETED(victim)) + victim.hv_flush_hit() + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(bearer)) + blade.drench(victim, light = TRUE) + // The blades come out of the neck red, and it sprays. + if(scissor.rig) + scissor.rig.dress() + switch(scissor.rig.facing) + if(NORTH) + scissor.rig.burst("cut_spray", 0.4 SECONDS, "T") + if(SOUTH) + scissor.rig.burst("cut_spray", 0.4 SECONDS, "arms") + else + scissor.rig.burst("cut_spray", 0.4 SECONDS, "near", under = TRUE) + log_combat(bearer, victim, "scissored", addition = "([beheads ? "beheaded" : "cut"])") + +// The head stays on: a deep cut across the neck. Atlas plating stops the blades on metal. +/datum/hv_finisher_stage/kill/decapitate/mantis/fallback(spray_dir) + var/mob/living/carbon/victim = host.victim + var/mob/living/bearer = host.user + if(HAS_TRAIT(victim, TRAIT_NODISMEMBER)) + victim.hv_add_hit_line("The blades grind to a stop on metal!", HV_LINE_BOLD, null, DEFAULT_MESSAGE_RANGE) + playsound(victim, 'sound/items/weapons/block_blade.ogg', 70, TRUE) + else + victim.hv_add_hit_line("[bearer]'s blades bite deep into [victim]'s neck!", HV_LINE_BOLD, "[bearer]'s blades bite deep into your neck!", DEFAULT_MESSAGE_RANGE) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = locate() in bearer.held_items + victim.apply_damage(MANTIS_SCISSOR_FALLBACK_DAMAGE, BRUTE, BODY_ZONE_HEAD, wound_bonus = 20, sharpness = SHARP_EDGED, attack_direction = spray_dir, attacking_item = blade) + if(!QDELETED(victim)) + victim.hv_blood_spray(spray_dir, 2) + +// #509's: the spray, and the head comes off. Then it comes away from the neck on the floor. +/datum/hv_finisher_stage/kill/decapitate/mantis/gore(spray_dir) + var/mob/living/carbon/victim = host.victim + var/angle = victim.lying_angle + victim.hv_blood_spray(spray_dir, spray_strength) + var/obj/item/bodypart/head/head = hv_finisher_take_head(victim, WOUND_SLASH) + mantis_roll_head(head, angle) + +/// [head], just cut off a body lying at [angle], comes away from the neck on the floor: from where the neck +/// was to a hand's width off it. Only where it is drawn on its tile. +/proc/mantis_roll_head(obj/item/bodypart/head/head, angle) + if(QDELETED(head) || !isturf(head.loc) || !angle) + return + var/side = angle == LYING_ANGLE_EAST ? 1 : -1 + head.pixel_w = side * MANTIS_HEAD_ROLL_FROM + head.pixel_z = -MANTIS_HEAD_ROLL_DROP + animate(head, pixel_w = side * MANTIS_HEAD_ROLL_TO, pixel_z = -MANTIS_HEAD_ROLL_DROP - 2, time = 0.3 SECONDS, easing = SINE_EASING|EASE_OUT) + +// ---- After ------------------------------------------------------------------- + +/// The bearer straightens up out of the cut. +/datum/hv_finisher_stage/recover/mantis + duration = 0.5 SECONDS diff --git a/voidcrew/modules/mantis_gore/mantis_swing.dm b/voidcrew/modules/mantis_gore/mantis_swing.dm new file mode 100644 index 00000000000..86f02e9f38b --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_swing.dm @@ -0,0 +1,304 @@ +// The ordinary hit with a Mantis blade: the hand that hit swings its blade. The held blade cocks back, cuts +// through and follows down, then is back in the guard. Only the picture: the damage and tg's attack step land +// at the click as before. See mantis_core.dm for the overview. +// +// The swing is a fresh picture on the bearer (a fresh object always plays its frames from the first, an +// overlay does not), the held blade hidden under it while it plays. Its last frame is the guard itself, so the +// held blade comes back under an exact copy of itself whichever reaches the client first. +// +// Side on, the arm on the far side of the body swings under the near arm's swing, and whatever the near hand +// holds is cut out of it (sort_as_arm(), voidcrew/modules/cyberware/cyberware_anim.dm). The blood coat goes on +// after its swing, so it draws over it. + +/// 64x64 frames on the bearer's tile: "[model]_swing_[side]" and its "_blood" and "_soak" coats. And the slash +/// over the target, by the blade's impact_state: the impact art after half a beat of nothing, the swing's cock. +/// And one stroke of the X-slash, "xslash_[model]": a straight cut from the upper left to the lower right after +/// the same half beat, drawn so that it and its mirror image cross in the exact middle of the picture. +#define MANTIS_SWING_ICON 'voidcrew/modules/mantis_gore/icons/mantis_swing.dmi' +/// How long the held blade is hidden: the cock, the strike and the follow-through. +#define MANTIS_SWING_TIME (2.5 DECISECONDS) +/// How long the swing picture stays: one beat of the guard frame past the hide. +#define MANTIS_SWING_LIFETIME (3 DECISECONDS) +/// The impact over the target waits for the strike frame. +#define MANTIS_SWING_IMPACT_DELAY (0.5 DECISECONDS) +/// How long the slash over the target stays: that half beat, then its four frames. +#define MANTIS_SWING_IMPACT_LIFETIME (4.5 DECISECONDS) +/// The X-slash's other blade swings one beat behind the first, so its stroke crosses one already through. +#define MANTIS_XSLASH_SWING_DELAY (1 DECISECONDS) +/// Where an X-slash on a leg is centred, in pixels up from the middle of the tile: half way down the leg, whichever +/// way the body faces (a human leg runs from 7.5 to 15.5 pixels below the middle, and the strokes cross half a pixel +/// above the middle of their art). +#define MANTIS_XSLASH_LEG_Y -12 + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis + /// The swing playing for this blade right now, if any. Weak: it deletes itself. + var/datum/weakref/swing_ref + /// The slash over the target of that swing, if any. Weak: it deletes itself. + var/datum/weakref/swing_impact_ref + +/// The swing playing for this blade right now, if any. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_swing() + return swing_ref?.resolve() + +/** + * The hand holding this blade swings it at [attacked_atom]. Called with tg's attack step, so it plays for + * every swing that steps, landed or blocked. Only from the guard, and never over a blade already drawn + * elsewhere (the unfold, the impale, the scissor). Returns the swing, or null if none plays. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_play_swing(mob/living/attacker, atom/attacked_atom) + if(!isliving(attacker) || QDELETED(attacker) || attacked_atom == attacker) + return null + var/held_index = attacker.get_held_index_of_item(src) + if(!held_index || mantis_pose != MANTIS_POSE_GUARD || world.time < inhand_hidden_until) + return null + var/side = IS_LEFT_INDEX(held_index) ? "left" : "right" + var/state = "[base_icon_state]_swing_[side]" + if(!icon_exists(MANTIS_SWING_ICON, state)) + return null + qdel(mantis_swing()) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = housing_ref?.resolve() + housing?.unfold_overtaken(src) + var/obj/effect/abstract/cyberware_anim/mantis_swing/swing = new(null) + swing.start(attacker, src, state, side) + if(is_bloody()) + var/coat_state = "[state]_[world.time < soaked_until ? "soak" : "blood"]" + if(icon_exists(MANTIS_SWING_ICON, coat_state)) + swing.add_coat(coat_state, get_blood_dna_color()) + swing_ref = WEAKREF(swing) + // This blade leaves its guard, so a far swing behind it is no longer cut around it: its own swing is in + // front now. The hands redraw, and every arm's picture on the bearer sorts itself again. + hide_inhand_for(MANTIS_SWING_TIME) + return swing + +/** + * The slash over [attacked_atom] for this blade's swing, cut the way the blade crosses the target in the strike + * frame for the way [attacker] faces now, or [mirrored] if given. It goes up with the swing and shows nothing + * for the half beat the blade cocks back: the client plays both pictures from the same moment, so the slash + * comes up with the strike frame and never ahead of it (a timer on the server could). Returns it, or null. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_swing_impact(atom/movable/attacker, atom/attacked_atom, mirrored = null) + if(!impact_state || !ismovable(attacked_atom)) + return null + if(!icon_exists(MANTIS_SWING_ICON, impact_state)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_play_impact), attacker, attacked_atom, impact_state, mirrored), MANTIS_SWING_IMPACT_DELAY) + return null + if(isnull(mirrored)) + var/obj/effect/abstract/cyberware_anim/mantis_swing/swing = mantis_swing() + if(swing && !QDELETED(attacker)) + mirrored = mantis_swing_impact_mirrored(attacker.dir, swing.side) + else + // Turned as any chrome hit's is (cyberware_play_impact()). + var/direction = get_dir(attacker, attacked_atom) + mirrored = (direction & WEST) || (!(direction & EAST) && prob(50)) + var/obj/effect/abstract/cyberware_anim/impact = cyberware_anim_play(attacked_atom, MANTIS_SWING_ICON, impact_state, MANTIS_ANIM_TOP_LAYER, MANTIS_SWING_IMPACT_LIFETIME, glow = impact_state != "impact_shiv") + if(isnull(impact)) + return null + // 64x64 around the target's tile, upright even on someone lying down. + impact.pixel_w = -16 + impact.pixel_z = -16 + impact.appearance_flags |= RESET_TRANSFORM + if(mirrored) + impact.transform = matrix(-1, 0, 0, 0, 1, 0) + swing_impact_ref = WEAKREF(impact) + return impact + +/** + * How far the [zone] part of [body] is drawn from the middle of its chest, as it stands, faces and lies now + * (wire_part_offset(), hyperviolence/wire/wire_held.dm): list(x, y) in pixels. The body's left and right swap + * on screen as it turns. + */ +/proc/mantis_part_shift(mob/living/body, zone) + var/list/part = wire_part_offset(zone, body.dir) + var/list/chest = wire_part_offset(BODY_ZONE_CHEST, body.dir) + var/across = part[1] - chest[1] + var/up = part[2] - chest[2] + var/matrix/shape = body.transform + return list(round(shape.a * across + shape.b * up), round(shape.d * across + shape.e * up)) + +/** + * Where the [zone] part of [body] is drawn, as it stands, faces and lies now, from the middle of its tile + * (wire_part_offset()): list(x, y) in pixels. Facing west it is the mirror of facing east. + */ +/proc/mantis_part_point(mob/living/body, zone) + var/list/part = wire_part_offset(zone, body.dir) + var/matrix/shape = body.transform + return list(round(shape.a * part[1] + shape.b * part[2] + shape.c), round(shape.d * part[1] + shape.e * part[2] + shape.f)) + +/// The slash of the swing playing now is set on the part of [victim] it cut ([zone]). +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_swing_impact_onto(mob/living/victim, zone) + var/obj/effect/abstract/cyberware_anim/impact = swing_impact_ref?.resolve() + if(isnull(impact) || impact.bearer != victim || !zone) + return + var/list/shift = mantis_part_shift(victim, zone) + impact.pixel_w = -16 + shift[1] + impact.pixel_z = -16 + shift[2] + +/** + * Whether the [side] hand's swing, its bearer facing [facing], shows the impact art mirrored. The art cuts + * clockwise: in along the top from the left, then down on the right. In mantis_swing.dmi, from the cock frame + * to the strike frame, facing south or north each hand cuts across the body toward the other: facing south the + * right hand cuts clockwise and the left anticlockwise, facing north the other way round. Side on both hands + * chop down in front, clockwise facing east and anticlockwise facing west. + */ +/proc/mantis_swing_impact_mirrored(facing, side) + if(facing & WEST) + return TRUE + if(facing & EAST) + return FALSE + return (facing == NORTH) == (side == "right") + +/** + * The X-slash as it looks: two straight strokes that cross on the part cut ([zone]). This blade's stroke comes + * up with its strike in place of its ordinary slash; the other blade swings a beat behind and its stroke, turned + * the other way, comes up with its own strike and crosses the first; the flash lands then. Without a swing + * playing (a blade out of its guard) the whole cross lands at once. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_xslash_cross(mob/living/user, mob/living/victim, obj/item/melee/energy/blade/hardlight/cyberware_mantis/off_blade, zone) + var/obj/effect/abstract/cyberware_anim/first = swing_impact_ref?.resolve() + if(first?.bearer != victim) + first = null + // This blade's stroke runs the way its own slash does. + var/flipped + if(first) + var/matrix/turned = first.transform + flipped = turned.a < 0 + else + flipped = mantis_swing_impact_mirrored(user.dir, hand_side()) + if(!mantis_swing() || isnull(first)) + mantis_xslash_stroke_fx(victim, impact_state, zone, flipped) + mantis_xslash_stroke_fx(victim, impact_state, zone, !flipped) + mantis_xslash_land(user, victim, off_blade) + return + // Made in the same instant as the slash it replaces, so only the stroke is ever seen. + var/obj/effect/abstract/cyberware_anim/stroke = mantis_xslash_stroke_fx(victim, impact_state, zone, flipped) + if(stroke) + qdel(first) + swing_impact_ref = WEAKREF(stroke) + addtimer(CALLBACK(src, PROC_REF(mantis_xslash_second), user, victim, off_blade, zone, !flipped), MANTIS_XSLASH_SWING_DELAY) + addtimer(CALLBACK(src, PROC_REF(mantis_xslash_land), user, victim, off_blade), MANTIS_XSLASH_SWING_DELAY + MANTIS_SWING_IMPACT_DELAY) + +/** + * One stroke of the X-slash over [victim], by [impact_state]'s model: a straight cut through the [zone] part from + * the upper left to the lower right, or the other way if [flipped]. A stroke and its mirror image cross in the + * middle of the art, and the art is centred on the part as it is drawn now, so the cross sits at the same place + * on the part whichever way the target faces. Returns the picture, or null if that model has no stroke art. + */ +/proc/mantis_xslash_stroke_fx(mob/living/victim, impact_state, zone, flipped) + if(QDELETED(victim) || !impact_state) + return null + var/state = replacetext(impact_state, "impact_", "xslash_") + if(!icon_exists(MANTIS_SWING_ICON, state)) + return null + var/obj/effect/abstract/cyberware_anim/stroke = cyberware_anim_play(victim, MANTIS_SWING_ICON, state, MANTIS_ANIM_TOP_LAYER, MANTIS_SWING_IMPACT_LIFETIME, glow = TRUE) + if(isnull(stroke)) + return null + // A body on fire draws its flames apart from the rest of it and over all of its own pictures, and tg throws + // its embers over those (a particle holder at ABOVE_ALL_MOB_LAYER): a cross on it lost its middle to them. + // There the stroke is drawn apart as well, just over the embers. It stays on the body's own row, so whoever + // stands in front of the body still covers it. + if(victim.on_fire) + stroke.appearance_flags |= KEEP_APART + stroke.layer = ABOVE_ALL_MOB_LAYER + 0.01 + var/list/point = mantis_xslash_point(victim, zone || BODY_ZONE_CHEST) + // 64x64 around the part, upright even on someone lying down. + stroke.pixel_w = -16 + point[1] + stroke.pixel_z = -16 + point[2] + stroke.appearance_flags |= RESET_TRANSFORM + if(flipped) + stroke.transform = matrix(-1, 0, 0, 0, 1, 0) + return stroke + +/** + * Where the X-slash's strokes cross on the [zone] part of [body], as mantis_part_point() gives it, but on a leg half + * way down it rather than at the hip a wire's lash ends on. + */ +/proc/mantis_xslash_point(mob/living/body, zone) + if(zone != BODY_ZONE_L_LEG && zone != BODY_ZONE_R_LEG) + return mantis_part_point(body, zone) + var/list/part = wire_part_offset(zone, body.dir) + var/matrix/shape = body.transform + return list(round(shape.a * part[1] + shape.b * MANTIS_XSLASH_LEG_Y + shape.c), round(shape.d * part[1] + shape.e * MANTIS_XSLASH_LEG_Y + shape.f)) + +/// The second stroke of the X-slash: [off_blade] swings, and its stroke goes up [flipped] to cross the first. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_xslash_second(mob/living/user, mob/living/victim, obj/item/melee/energy/blade/hardlight/cyberware_mantis/off_blade, zone, flipped) + if(QDELETED(victim) || QDELETED(user) || QDELETED(off_blade) || !user.is_holding(off_blade)) + return + off_blade.mantis_play_swing(user, victim) + // A model with no stroke art crosses with its ordinary slash. + if(!mantis_xslash_stroke_fx(victim, impact_state, zone, flipped)) + off_blade.mantis_swing_impact(user, victim, flipped) + +/// The crossed slash lands on [victim]: the flash, and both blades cut, so both run red. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/mantis_xslash_land(mob/living/user, mob/living/victim, obj/item/melee/energy/blade/hardlight/cyberware_mantis/off_blade) + if(QDELETED(victim)) + return + mantis_xslash_fx(user, victim, impact_state) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/crossed as anything in list(src, off_blade)) + if(!QDELETED(crossed) && user?.is_holding(crossed)) + crossed.drench(victim) + +// ========================================================================= +// THE PICTURE +// ========================================================================= + +/// One swing of one blade on its bearer, and the blood on it. Ends early if the blade leaves the hand. +/obj/effect/abstract/cyberware_anim/mantis_swing + icon = MANTIS_SWING_ICON + // The frames are 64x64 around the bearer's tile. Centred with pixel_w/pixel_z, which only draw elsewhere: + // the picture stays on the bearer's row, so it sorts against other mobs as the bearer does. + pixel_w = -16 + pixel_z = -16 + layer = MANTIS_ANIM_BLADE_LAYER + /// The blade swinging. + var/obj/item/blade + /// The hand it swings in: "left" or "right". + var/side + /// The blood on it, a picture of its own so its frames start with the swing's. + var/obj/effect/abstract/cyberware_anim/mantis_swing/coat + +/obj/effect/abstract/cyberware_anim/mantis_swing/Destroy() + if(blade) + UnregisterSignal(blade, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + blade = null + QDEL_NULL(coat) + return ..() + +/// Starts playing [state] for [swung], held in [bearer]'s [hand_side] hand. +/obj/effect/abstract/cyberware_anim/mantis_swing/proc/start(mob/living/bearer, obj/item/swung, state, hand_side) + icon_state = state + side = hand_side + blade = swung + RegisterSignals(blade, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING), PROC_REF(on_blade_gone)) + attach(bearer) + sort_as_arm(side) + +/// Lays the blood coat [coat_state] in [blood_color] over the swing. +/obj/effect/abstract/cyberware_anim/mantis_swing/proc/add_coat(coat_state, blood_color) + coat = new(null) + coat.icon = MANTIS_SWING_ICON + coat.icon_state = coat_state + coat.pixel_w = pixel_w + coat.pixel_z = pixel_z + coat.color = blood_color + coat.appearance_flags |= RESET_COLOR + coat.attach(bearer) + coat.sort_as_arm(side) + +/obj/effect/abstract/cyberware_anim/mantis_swing/proc/on_blade_gone(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// Puts this picture on [target] for the swing's lifetime. +/obj/effect/abstract/cyberware_anim/mantis_swing/proc/attach(atom/movable/target) + bearer = target + RegisterSignal(bearer, COMSIG_QDELETING, PROC_REF(on_bearer_deleted)) + bearer.vis_contents += src + QDEL_IN(src, MANTIS_SWING_LIFETIME) + +#undef MANTIS_SWING_ICON +#undef MANTIS_SWING_TIME +#undef MANTIS_SWING_LIFETIME +#undef MANTIS_SWING_IMPACT_DELAY +#undef MANTIS_SWING_IMPACT_LIFETIME +#undef MANTIS_XSLASH_SWING_DELAY +#undef MANTIS_XSLASH_LEG_Y diff --git a/voidcrew/modules/mantis_gore/mantis_wounds.dm b/voidcrew/modules/mantis_gore/mantis_wounds.dm new file mode 100644 index 00000000000..f93d959178e --- /dev/null +++ b/voidcrew/modules/mantis_gore/mantis_wounds.dm @@ -0,0 +1,90 @@ +// The torn limb artery the Mantis line leaves. A sibling of #509's slit throat +// (voidcrew/modules/hyperviolence/crits/crit_wounds.dm): the same clamp, then sutures or cautery, the same +// heavy bleed that never clots, but on an arm or a leg, with no gargle, and it pumps the way the cut went. +// Like the crit wounds, nothing in tg's random wound roll makes it; only the Mantis cut count does. + +/datum/wound/slash/flesh/critical/hv_carotid/limb + name = "Torn Artery" + undiagnosed_name = "Torn Artery" + desc = "Patient's limb is cut open through a major artery. Blood loss will kill within minutes." + treat_text = "Clamp the artery with a hemostat, then suture or cauterize the wound. Transfuse blood." + treat_text_short = "Clamp with a hemostat, then suture or cauterize." + simple_desc = "Patient's limb is cut open and pumping blood." + simple_treat_text = "Clamp the artery with a hemostat, then suture or cauterize it." + homemade_treat_text = "Tie it off tight above the cut and keep them still." + examine_desc = "is cut open to the artery, pumping out blood" + occur_text = "is cut open to the artery" + initial_flow = 3.5 + /// The way the cut travelled. The artery pumps along it. + var/spurt_dir + /// How far each pulse throws blood, in tiles. + var/spurt_strength = 2 + /// world.time of the next pulse. + var/next_pulse = 0 + +/datum/wound/slash/flesh/critical/hv_carotid/limb/wound_injury(datum/wound/old_wound = null, attack_direction = null) + // tg's own wound is told no direction. With one it throws its spray along it, a line of blood three tiles + // long, and this artery throws its own drops instead (mantis_spurt_fx()). + . = ..(old_wound, null) + spurt_dir = attack_direction || pick(GLOB.alldirs) + next_pulse = world.time + rand(2 SECONDS, 3 SECONDS) + // The throat's own pulse throws blood every which way. This one has its own, along the cut. + next_spurt = INFINITY + +// An arm or a leg doesn't drown the voice. +/datum/wound/slash/flesh/critical/hv_carotid/limb/gargle_speech(datum/source, list/message_args) + return + +/datum/wound/slash/flesh/critical/hv_carotid/limb/handle_process(seconds_per_tick, times_fired) + . = ..() + if(QDELETED(src) || !victim || !limb || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_STASIS)) + return + if(clamped || blood_flow < MANTIS_ARTERY_OPEN_FLOW || world.time < next_pulse || !limb.can_bleed()) + return + next_pulse = world.time + rand(2 SECONDS, 3 SECONDS) + pulse() + +/// One heartbeat out of the open artery. +/datum/wound/slash/flesh/critical/hv_carotid/limb/proc/pulse() + mantis_spurt_fx(victim, limb.body_zone, spurt_dir, spurt_strength < 2) + +/datum/wound/slash/flesh/critical/hv_carotid/limb/clamp_artery(obj/item/tool, mob/user) + if(clamped) + to_chat(user, span_warning("The artery is already clamped.")) + return TRUE + var/whose = (user == victim) ? "your" : "[victim]'s" + user.visible_message(span_danger("[user] reaches into the cut on [victim]'s [limb.plaintext_zone] with [tool]..."), span_warning("You reach into the cut on [whose] [limb.plaintext_zone] with [tool]...")) + if(!do_after(user, base_treat_time * (user == victim ? 2 : 1.5), target = victim, extra_checks = CALLBACK(src, PROC_REF(still_exists)))) + return TRUE + clamped = TRUE + user.visible_message(span_green("[user] clamps the artery in [victim]'s [limb.plaintext_zone]."), span_green("You clamp the artery in [whose] [limb.plaintext_zone].")) + adjust_blood_flow(-2.5) + return TRUE + +/datum/wound_pregen_data/flesh_slash/hv_limb_artery + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/critical/hv_carotid/limb + wound_series = WOUND_SERIES_HV_LIMB_ARTERY + required_limb_biostate = (BIO_FLESH|BIO_BLOODED) + ignore_cannot_bleed = FALSE + viable_zones = list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + can_be_randomly_generated = FALSE + threshold_minimum = 80 + +/** + * The Scorch's tear: the heat closes part of it as it cuts. It bleeds less, pumps weaker, and clots + * shut on its own in under a minute instead of killing. + */ +/datum/wound/slash/flesh/critical/hv_carotid/limb/seared + name = "Seared Artery" + undiagnosed_name = "Seared Artery" + desc = "Patient's limb is burned open to a major artery. The heat has partly closed it." + simple_desc = "Patient's limb is burned open and bleeding." + examine_desc = "is burned open to the artery, oozing blood" + occur_text = "is seared open to the artery" + initial_flow = 2 + clot_rate = 0.04 + spurt_strength = 1 + +/datum/wound_pregen_data/flesh_slash/hv_limb_artery/seared + wound_path_to_generate = /datum/wound/slash/flesh/critical/hv_carotid/limb/seared diff --git a/voidcrew/modules/missions/_missions.dm b/voidcrew/modules/missions/_missions.dm index a1108c51fd5..a8afff5c9f5 100644 --- a/voidcrew/modules/missions/_missions.dm +++ b/voidcrew/modules/missions/_missions.dm @@ -952,6 +952,8 @@ GLOBAL_VAR_INIT(mission_work_serial, 0) var/result = objective.accept_item(turned_in_item, reward_anchor) if(result == MISSION_ITEM_REFUSED) return FALSE + // The pad took something: every hand-over gets the effect, counted ones included + flash_reward_anchor(reward_anchor) if(result == MISSION_ITEM_PROGRESS) return TRUE // partial hand-over; mission continues if(!all_objectives_satisfied()) @@ -1222,6 +1224,8 @@ GLOBAL_VAR_INIT(mission_work_serial, 0) if(completed && !failed && guarded_prize_family && !guarded_prize_rolled) guarded_prize_rolled = TRUE guarded_prize = servant.award_guarded_prize(guarded_prize_family, quote_zone, reward_anchor, type) + if(guarded_prize) + flash_reward_anchor(reward_anchor) /** * Turn-in visual: pads get their teleport effect, other anchors stay quiet diff --git a/voidcrew/modules/missions/mission_pad.dm b/voidcrew/modules/missions/mission_pad.dm index 3c59618a55b..d2ef0583733 100644 --- a/voidcrew/modules/missions/mission_pad.dm +++ b/voidcrew/modules/missions/mission_pad.dm @@ -23,6 +23,8 @@ var/datum/pirate_negotiation/tribute_negotiation /// The ship this pad is installed on var/obj/structure/overmap/ship/linked_ship + /// world.time of the last teleport effect, so several things moving through in one tick make one flash + var/last_teleport_effect = -1 /obj/machinery/mission_pad/Initialize(mapload) . = ..() @@ -160,6 +162,11 @@ * Called when items are teleported in (rewards) or out (turn-ins). */ /obj/machinery/mission_pad/proc/do_teleport_effect() + // A turn-in and its payout land in the same tick: one flash, one sound + if(last_teleport_effect == world.time) + return + last_teleport_effect = world.time + // Visual effect flick("lpad-idle", src) @@ -171,6 +178,14 @@ // Sound effect playsound(src, 'sound/effects/magic/teleport_diss.ogg', 50, TRUE) +/** + * Plays the pad effect for something a mission has just put on a pad's tile + * (kits, chips, finished batches). Does nothing where no pad stands there. + */ +/proc/mission_pad_delivery_effect(turf/where) + var/obj/machinery/mission_pad/pad = locate() in where + pad?.do_teleport_effect() + /** * Handle items entering the pad's turf (via signal from connect_loc element). * If we're linked to a tribute negotiation, check if the item is valid tribute. @@ -181,7 +196,7 @@ if(arrived.loc != loc) return // A wanted criminal or its proof of death: the pad says what Turn In would pay (voidcrew/modules/bounties/bounty_turn_in.dm) - if(istype(arrived, /mob/living/basic/bounty_criminal) || istype(arrived, /obj/item/bounty_proof)) + if(istype(arrived, /mob/living/carbon/human/npc/bounty_criminal) || istype(arrived, /obj/item/bounty_proof)) INVOKE_ASYNC(src, PROC_REF(bounty_pad_announce), arrived) if(!isitem(arrived)) return diff --git a/voidcrew/modules/missions/mission_target.dm b/voidcrew/modules/missions/mission_target.dm index 00f7d84bc2c..7fb44cf6f05 100644 --- a/voidcrew/modules/missions/mission_target.dm +++ b/voidcrew/modules/missions/mission_target.dm @@ -62,7 +62,8 @@ /datum/mission_target/proc/notify_when_loaded() return -/// A random open turf inside the loaded interior for objective spawns +/// A random open turf inside the loaded interior for objective spawns, out of +/// every living player's sight when there is one (see spawn_cover.dm) /datum/mission_target/proc/get_spawn_turf() return null @@ -222,7 +223,7 @@ mission?.on_target_interior_loaded() /datum/mission_target/space_ruin/get_spawn_turf() - return ruin?.get_random_interior_turf() || ruin?.ruin_bottom_left + return ruin?.get_random_interior_turf(unwatched = TRUE) || ruin?.ruin_bottom_left /datum/mission_target/space_ruin/contains_turf(turf/T) if(!ruin || !T) @@ -405,16 +406,23 @@ min_y = max(min_y, above_docks) if(min_x > max_x || min_y > max_y) return null + // A crew already on the surface must not watch the objective appear. Every + // sample that passes is kept as a fallback, farthest from them wins. + var/datum/spawn_cover/cover = new(site_z) for(var/_ in 1 to 40) var/turf/candidate = locate(rand(min_x, max_x), rand(min_y, max_y), site_z) if(!candidate || !isopenturf(candidate) || isspaceturf(candidate)) continue + // Lava burns the objective and a chasm swallows it before anyone arrives + if(islava(candidate) || ischasm(candidate)) + continue if(istype(get_area(candidate), /area/shuttle)) continue if(candidate.is_blocked_turf(exclude_mobs = TRUE)) continue - return candidate - return null + if(cover.is_hidden(candidate)) + return candidate + return cover.fallback /** * "Is the player at the site" - the primitive the pylon scatter and the hunting diff --git a/voidcrew/modules/missions/missions/big_game_hunt.dm b/voidcrew/modules/missions/missions/big_game_hunt.dm index ce3d5d7a760..511c4d39225 100644 --- a/voidcrew/modules/missions/missions/big_game_hunt.dm +++ b/voidcrew/modules/missions/missions/big_game_hunt.dm @@ -16,10 +16,32 @@ * the trophy after that. Losing any of them voids the contract (FAIL policy, * with exactly one planet of each type per round, a filtered retarget has * nowhere to go, and set_lure's one kit can't re-arm a fresh site anyway). + * + * The staked lure stays behind as an anchored rig that thumps and stinks of + * chum. It is scenery, not a quest atom. The beast does not appear beside it: + * a roar rolls in from one side, then after a short wait the beast and its + * entourage come into being out of every player's sight, a good way off, and + * walk in on the stake (AI planning subtree at the bottom of this file). The + * rig is trampled when the beast reaches it, or goes quiet when it dies. */ /// How long staking the hunting lure takes #define LURE_STAKE_TIME (8 SECONDS) +/// Wait between the lure going in and its quarry setting out (the walk in comes on top) +#define LURE_ARRIVAL_MIN (9 SECONDS) +#define LURE_ARRIVAL_MAX (16 SECONDS) +/// How far from the stake the quarry starts out, in tiles. An AI drops a movement +/// goal more than 14 tiles away, so the far edge stays under that. +#define LURE_APPROACH_MIN 10 +#define LURE_APPROACH_MAX 13 +/// Nearest a player may stand to a starting tile. A screen reaches 7 tiles each way. +#define LURE_UNSEEN_RANGE 9 +/// How long an animal keeps heading for the lure before it goes back to its own business +#define LURE_CHARGE_TIME (45 SECONDS) +/// The rumble before a beast climbs out of the ground (matches zone_mobs_arrival.dm) +#define LURE_EMERGE_TIME (2.5 SECONDS) +/// Blackboard key: world.time after which a lured animal stops heading for the lure +#define BB_LURE_CHARGE_EXPIRES "BB_lure_charge_expires" /datum/mission/big_game_hunt name = "Big Game Hunt" @@ -208,8 +230,8 @@ * A lure kit is dispensed at the servant's mission pad on accept (the * prospect-stake kit pattern) and tracked as the quest atom, burn the lure, * void the hunt. Staking it in open ground on the target planet records the - * stake position for the kill step and completes; the beast's spawn devours - * the kit. Assumes the FAIL loss policy: the one kit can't re-arm after a + * stake position for the kill step, stands the rig on that spot and + * completes. Assumes the FAIL loss policy: the one kit can't re-arm after a * retarget reset. */ /datum/mission_objective/set_lure @@ -217,12 +239,21 @@ var/obj/item/hunting_lure/kit /// The kill step this lure feeds its stake position to var/datum/mission_objective/field/kill_named/big_game/hunt_objective + /// The kit went into the ground; a rewind (the site unloaded) needs a fresh one + var/kit_spent = FALSE /datum/mission_objective/set_lure/Destroy() kit = null // the item survives; it self-reports expiry on use hunt_objective = null return ..() +/// Back on this step after the site unloaded with the beast in it: send a new lure +/datum/mission_objective/set_lure/activate() + . = ..() + if(kit_spent && QDELETED(kit)) + kit_spent = FALSE + dispense_kit() + /// Called by the mission at start: hand the lure over at the ship's pad /datum/mission_objective/set_lure/proc/dispense_kit() var/obj/structure/overmap/ship/ship = get_servant() @@ -235,6 +266,7 @@ mission.fail("No mission pad to dispense the hunting lure to.") return FALSE kit = new(kit_turf) + mission_pad_delivery_effect(kit_turf) kit.objective_ref = WEAKREF(src) // Losing the lure voids the hunt (and points the GPS chain at it meanwhile) mission.register_quest_atom(kit) @@ -245,13 +277,14 @@ /datum/mission_objective/set_lure/proc/on_planted(mob/living/user, turf/where) if(completed || !active || !where || !mission || mission.failed || mission.completed) return - if(hunt_objective) - hunt_objective.lure_turf = where + // The rig goes into the ground here and the kill step takes it from there + hunt_objective?.plant_lure(where) // The staked lure stops being the tracked objective; the beast that - // answers takes over the slot as soon as complete() advances the chain + // answers takes over the slot once it has been called in mission.forget_quest_atom(kit) QDEL_NULL(kit) - notify_crew("Lure staked. Something big is moving toward it - stand ready.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + kit_spent = TRUE + notify_crew("Lure staked.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') complete() /datum/mission_objective/set_lure/get_progress_string() @@ -266,36 +299,231 @@ /** * kill_named pointed at the lure instead of a random stretch of surface: the - * beast (a trophy-grade specimen of the planet's apex fauna) breaks cover - * next to the stake the moment the lure objective completes, with its - * entourage of lesser fauna. Death drops the mission-bound trophy. + * beast (a trophy-grade specimen of the planet's apex fauna) answers the + * stake with its entourage of lesser fauna. It is called in after a roar and a + * short wait, starts well out of sight and heads for the stake; a site too + * cramped to hide an approach gets the beast climbing out of the ground next + * to it instead. Death drops the mission-bound trophy. */ /datum/mission_objective/field/kill_named/big_game proof_type = /obj/item/mission_recovery/trophy - /// Where the lure was staked; the beast breaks cover next to it + /// Where the lure was staked; the beast comes for it var/turf/lure_turf + /// The rig standing on lure_turf (weak: it goes with the site) + var/datum/weakref/lure_stake_ref + /// Pending arrival timer: the wait before the quarry sets out, or the rumble before it digs out + var/arrival_timer + /// Whether the beast has been called but is not out yet + var/arrival_pending = FALSE + /// Where the first roar came from; the quarry comes in from the same side + var/turf/approach_hint /// The trophy the beast drops ("tendril-crowned skull"), set by the mission var/trophy_part = "trophy" /// Trophy-grade specimen: raise maxHealth/health to this on spawn (0 = leave alone). /// A plain var-level scale-up, no new buff system. var/health_override = 0 +/datum/mission_objective/field/kill_named/big_game/Destroy() + cancel_arrival() + // Never leave the rig behind for a contract that no longer exists + var/obj/structure/hunting_lure_stake/stake = lure_stake_ref?.resolve() + stake?.topple() + lure_stake_ref = null + approach_hint = null + lure_turf = null + return ..() + +/datum/mission_objective/field/kill_named/big_game/deactivate() + // Completed, failed or rewound: nothing is on its way and the rig goes quiet + cancel_arrival() + var/obj/structure/hunting_lure_stake/stake = lure_stake_ref?.resolve() + stake?.topple() + lure_stake_ref = null + return ..() + /datum/mission_objective/field/kill_named/big_game/reset() . = ..() lure_turf = null - -/// The beast answers the lure, not a random spawn point + approach_hint = null + +/// Stops a call that is still waiting to be answered +/datum/mission_objective/field/kill_named/big_game/proc/cancel_arrival() + if(arrival_timer) + deltimer(arrival_timer) + arrival_timer = null + arrival_pending = FALSE + +/// The kit went into the ground: remember the spot and stand the rig on it +/datum/mission_objective/field/kill_named/big_game/proc/plant_lure(turf/where) + lure_turf = where + var/obj/structure/hunting_lure_stake/stake = new(where) + lure_stake_ref = WEAKREF(stake) + +/// The beast answers the lure, not a random spawn point, and not at once /datum/mission_objective/field/kill_named/big_game/do_field_spawn() if(spawned) return if(!lure_turf) return ..() spawned = TRUE - spawn_field_objects(get_nearby_open_turf(lure_turf, 4)) + arrival_pending = TRUE + // First sign of it: a roar from the side it will come in on (or from the + // ground itself where there is no hiding place) + var/list/approaches = approach_turfs() + approach_hint = length(approaches) ? pick(approaches) : null + var/turf/roar_from = approach_hint || lure_turf + playsound(roar_from, 'sound/mobs/non-humanoids/space_dragon/space_dragon_roar.ogg', 40, TRUE, extrarange = 20) + for(var/mob/living/hearer in GLOB.player_list) + if(hearer.z == lure_turf.z && get_dist(hearer, lure_turf) <= 14 && hearer.can_hear()) + to_chat(hearer, span_boldwarning("Something huge bellows in the distance.")) + arrival_timer = addtimer(CALLBACK(src, PROC_REF(answer_lure)), rand(LURE_ARRIVAL_MIN, LURE_ARRIVAL_MAX), TIMER_STOPPABLE | TIMER_DELETE_ME) + +/// The wait is over: the beast and its entourage set out for the stake +/datum/mission_objective/field/kill_named/big_game/proc/answer_lure() + arrival_timer = null + if(!arrival_still_wanted()) + return + var/datum/mission_target/site = mission.target + if(!lure_turf || !site?.is_valid() || !site.is_interior_loaded() || !site.contains_turf(lure_turf)) + // The hunting ground went away during the wait; arm again the ordinary way + cancel_arrival() + spawned = FALSE + lure_turf = null + arm() + return + var/list/approaches = approach_turfs() + if(!length(approaches)) + dig_out() + return + arrival_pending = FALSE + // Start on the side the roar came from where there is room, and where a + // straight run at the stake looks open + var/list/same_side = list() + for(var/turf/candidate as anything in approaches) + if(approach_hint && get_dist(candidate, approach_hint) <= 6) + same_side += candidate + var/list/pool = length(same_side) ? same_side : approaches + var/turf/lead = pick(pool) + for(var/_ in 1 to 12) + var/turf/candidate = pick(pool) + if(approach_is_clear(candidate)) + lead = candidate + break + // The entourage trails in beside it, on tiles that are also out of sight + var/list/guard_spots = list() + for(var/_ in guard_types) + var/list/beside = list() + for(var/turf/candidate as anything in approaches) + if(get_dist(candidate, lead) <= 4) + beside += candidate + guard_spots += length(beside) ? pick(beside) : lead + spawn_field_objects(lead, guard_spots) + playsound(lead, 'sound/mobs/non-humanoids/space_dragon/space_dragon_roar.ogg', 50, TRUE, extrarange = 20) + +/// Whether a delayed arrival should still go ahead +/datum/mission_objective/field/kill_named/big_game/proc/arrival_still_wanted() + return active && !completed && mission && !mission.failed && !mission.completed && !mission.named_kill_completed && !target_mob -/datum/mission_objective/field/kill_named/big_game/spawn_field_objects(turf/spawn_turf) +/** + * Open ground a good way from the stake that nobody is close enough to see: + * the quarry's starting tiles. Empty where the site is too small, too crowded + * or too cluttered to hide an approach. + */ +/datum/mission_objective/field/kill_named/big_game/proc/approach_turfs() + . = list() + var/datum/mission_target/site = mission?.target + if(!lure_turf || !site) + return + var/list/watchers = list() + for(var/mob/living/player in GLOB.player_list) + var/turf/watcher_turf = get_turf(player) + if(watcher_turf?.z == lure_turf.z && player.stat != DEAD) + watchers += watcher_turf + for(var/turf/open/candidate in RANGE_TURFS(LURE_APPROACH_MAX, lure_turf)) + if(get_dist(candidate, lure_turf) < LURE_APPROACH_MIN) + continue + if(isspaceturf(candidate) || islava(candidate) || ischasm(candidate)) + continue + // Open planet ground only: no landed ship, no ruin, nothing past the site's edge + if(!istype(get_area(candidate), /area/overmap_encounter/planetoid) || !site.contains_turf(candidate)) + continue + // Not the docking strip: a ship coming in to land would crush whatever stands on it + if((candidate.turf_flags & NO_RUINS) || candidate.is_blocked_turf(exclude_mobs = TRUE)) + continue + var/seen = FALSE + for(var/turf/watcher_turf as anything in watchers) + if(get_dist(watcher_turf, candidate) < LURE_UNSEEN_RANGE) + seen = TRUE + break + if(!seen) + . += candidate + +/// Whether a straight run from the tile to the stake is open ground +/datum/mission_objective/field/kill_named/big_game/proc/approach_is_clear(turf/from) + for(var/turf/along as anything in get_line(from, lure_turf)) + if(!isopenturf(along) || islava(along) || ischasm(along) || along.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + return TRUE + +/** + * No hiding place for an approach: the ground shakes beside the lure and the + * pack climbs out of it, the same rumble the mission waves use. + */ +/datum/mission_objective/field/kill_named/big_game/proc/dig_out() + var/turf/lead = get_nearby_open_turf(lure_turf, 4) + var/list/guard_spots = list() + for(var/_ in guard_types) + guard_spots += get_nearby_open_turf(lure_turf, 4) + for(var/turf/spot as anything in list(lead) + guard_spots) + new /obj/effect/temp_visual/zone_mobs_emerge(spot) + playsound(lead, 'sound/effects/break_stone.ogg', 60, TRUE) + arrival_timer = addtimer(CALLBACK(src, PROC_REF(emerge_pack), lead, guard_spots), LURE_EMERGE_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME) + +/datum/mission_objective/field/kill_named/big_game/proc/emerge_pack(turf/lead, list/guard_spots) + arrival_timer = null + arrival_pending = FALSE + if(!arrival_still_wanted()) + return + spawn_field_objects(lead, guard_spots, emerge = TRUE) + +/// The ground gives up a body: rubble underneath, then it rises out of the dirt +/datum/mission_objective/field/kill_named/big_game/proc/rise_from_ground(mob/living/body) + if(QDELETED(body)) + return + var/turf/where = get_turf(body) + if(where) + new /obj/effect/decal/cleanable/rubble(where) + playsound(where, 'sound/effects/shovel_dig.ogg', 60, TRUE) + // Parallel and relative so a mob's own idle animation (floating, bobbing) survives + var/final_alpha = body.alpha + body.alpha = 0 + body.pixel_z -= 16 + animate(body, pixel_z = 16, time = 0.6 SECONDS, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(body, alpha = final_alpha, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) + +/** + * Points a lured animal at the stake. It walks there whenever it has nothing + * of its own to hunt, so it goes for the first crew member it sees and + * otherwise comes straight in. + */ +/datum/mission_objective/field/kill_named/big_game/proc/send_to_lure(mob/living/body) + var/datum/ai_controller/brain = body?.ai_controller + if(QDELETED(body) || !istype(brain) || !lure_turf) + return + brain.set_blackboard_key(BB_TRAVEL_DESTINATION, lure_turf) + brain.set_blackboard_key(BB_LURE_CHARGE_EXPIRES, world.time + LURE_CHARGE_TIME) + var/list/subtrees = brain.planning_subtrees ? brain.planning_subtrees.Copy() : list() + subtrees += GLOB.ai_subtrees[/datum/ai_planning_subtree/travel_to_point/lure_charge] + brain.planning_subtrees = subtrees + // An animal this far from anyone would otherwise stand about until somebody wandered close + brain.can_idle = FALSE + brain.reset_ai_status() + +/datum/mission_objective/field/kill_named/big_game/spawn_field_objects(turf/spawn_turf, list/guard_spots, emerge = FALSE) if(mission.named_kill_completed) return ..() + // Anything that did not climb out of the ground has come a long way to get here + var/charging = !emerge && lure_turf var/mob/living/beast = spawn_field_npc(target_mob_type, spawn_turf, finalize = FALSE) beast.name = mission.objective_name beast.desc += " This one is old, scarred, and much too big. The guild wants it as a wall piece." @@ -307,11 +535,20 @@ // The entourage: lesser fauna drawn in by the blood on the wind. Killing // them pays nothing - the contract is the name on the trophy. for(var/guard_type in guard_types) - var/mob/living/guard = spawn_field_npc(guard_type, get_nearby_open_turf(spawn_turf), finalize = FALSE) + var/turf/guard_turf = length(guard_spots) ? pick_n_take(guard_spots) : get_nearby_open_turf(spawn_turf) + var/mob/living/guard = spawn_field_npc(guard_type, guard_turf, finalize = FALSE) guard.desc += " It followed something much bigger here." guard.npc_spawn_context.finalize() - beast.visible_message(span_boldwarning("[beast] comes crashing in and goes straight for the lure!")) - notify_crew("The lure's been taken. [mission.objective_name] is out in the open - good hunting.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + if(charging) + send_to_lure(guard) + if(emerge) + rise_from_ground(guard) + if(charging) + send_to_lure(beast) + if(emerge) + rise_from_ground(beast) + var/obj/structure/hunting_lure_stake/stake = lure_stake_ref?.resolve() + stake?.watch(beast) /datum/mission_objective/field/kill_named/big_game/get_proof_name() return "[mission.objective_name]'s [trophy_part]" @@ -319,6 +556,8 @@ /datum/mission_objective/field/kill_named/big_game/get_progress_string() if(!spawned) return "Draw [mission?.objective_name || "the beast"] out with the lure" + if(arrival_pending) + return "[mission?.objective_name || "The beast"] is coming for the lure" return "Bring down [mission?.objective_name || "the beast"]" // ========================================================================= @@ -335,8 +574,8 @@ * # Hunting Lure * * The kit handed over at accept. Stake it in hand, in open ground on the - * contracted planet's surface; the staking channel is not subtle, and the - * thing it calls arrives immediately. + * contracted planet's surface. The staking channel is not subtle; what it + * calls takes a little while to get there. */ /obj/item/hunting_lure name = "apex hunting lure" @@ -376,6 +615,87 @@ objective.on_planted(user, get_turf(user)) return TRUE +/** + * # Staked Hunting Lure + * + * What the kit turns into once it is in the ground: an anchored rig that + * thumps and stinks of chum until the beast it called tramples it or dies. + * Scenery only: not a quest atom, not an item, nothing to salvage, and the hunt + * carries on whatever happens to it. + */ +/obj/structure/hunting_lure_stake + name = "hunting lure" + desc = "A guild lure rig staked into the dirt. It stinks of chum and thumps like a heartbeat." + icon = 'voidcrew/modules/missions/icons/recovery.dmi' + icon_state = "recovery_anchored" + anchored = TRUE + density = FALSE + move_resist = INFINITY + resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF + /// Weakref to the beast that was called here + var/datum/weakref/watched_ref + /// Whether it is already coming down + var/toppled = FALSE + /// Process ticks since the last thump + var/thump_ticks = 0 + +/obj/structure/hunting_lure_stake/Initialize(mapload) + . = ..() + START_PROCESSING(SSobj, src) + +/obj/structure/hunting_lure_stake/Destroy() + STOP_PROCESSING(SSobj, src) + unwatch() + return ..() + +/obj/structure/hunting_lure_stake/process(seconds_per_tick) + thump_ticks++ + if(thump_ticks < 2) + return + thump_ticks = 0 + playsound(src, 'sound/effects/singlebeat.ogg', 40, TRUE, extrarange = 6) + animate(src, pixel_y = 2, time = 1) + animate(pixel_y = 0, time = 2) + +/// The beast it called is out: trample the rig when it gets here, drop it if the beast dies first +/obj/structure/hunting_lure_stake/proc/watch(mob/living/beast) + if(toppled || QDELETED(beast)) + return + unwatch() + watched_ref = WEAKREF(beast) + RegisterSignal(beast, COMSIG_MOVABLE_MOVED, PROC_REF(on_beast_moved)) + RegisterSignal(beast, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING), PROC_REF(on_beast_gone)) + +/obj/structure/hunting_lure_stake/proc/unwatch() + var/mob/living/beast = watched_ref?.resolve() + if(beast) + UnregisterSignal(beast, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_DEATH, COMSIG_QDELETING)) + watched_ref = null + +/obj/structure/hunting_lure_stake/proc/on_beast_moved(mob/living/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(get_dist(source, src) <= 1) + topple(source) + +/obj/structure/hunting_lure_stake/proc/on_beast_gone(datum/source) + SIGNAL_HANDLER + topple() + +/// The rig comes down and goes quiet, trampled by the beast if it got here +/obj/structure/hunting_lure_stake/proc/topple(mob/living/by) + if(toppled) + return + toppled = TRUE + STOP_PROCESSING(SSobj, src) + unwatch() + if(by) + visible_message(span_danger("[by] tramples [src].")) + else + visible_message(span_warning("[src] topples over and goes quiet.")) + playsound(src, 'sound/effects/clang.ogg', 50, TRUE) + animate(src, transform = turn(transform, 80), pixel_y = -4, alpha = 0, time = 1 SECONDS, easing = QUAD_EASING | EASE_IN) + QDEL_IN(src, 1 SECONDS) + /** * # Hunting Trophy * @@ -388,4 +708,39 @@ icon_state = "recovery" w_class = WEIGHT_CLASS_BULKY +// ========================================================================= +// THE CHARGE, lured animals walk in on the stake +// ========================================================================= + +/** + * The last thing a lured animal plans. Everything it can hunt on its own comes + * first, so it goes for the first crew member it sees and otherwise heads for + * the stake. Gives up after LURE_CHARGE_TIME and goes back to its own business. + */ +/datum/ai_planning_subtree/travel_to_point/lure_charge + travel_behaviour = /datum/ai_behavior/travel_towards/lure_charge + +/datum/ai_planning_subtree/travel_to_point/lure_charge/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + if(controller.blackboard[location_key] && world.time > controller.blackboard[BB_LURE_CHARGE_EXPIRES]) + controller.clear_blackboard_key(location_key) + return + return ..() + +/// Close enough to the stake counts as there; the goal is dropped once it is reached +/datum/ai_behavior/travel_towards/lure_charge + required_distance = 2 + +/datum/ai_behavior/travel_towards/lure_charge/finish_action(datum/ai_controller/controller, succeeded, target_key) + . = ..() + if(succeeded) + controller.clear_blackboard_key(target_key) + #undef LURE_STAKE_TIME +#undef LURE_ARRIVAL_MIN +#undef LURE_ARRIVAL_MAX +#undef LURE_APPROACH_MIN +#undef LURE_APPROACH_MAX +#undef LURE_UNSEEN_RANGE +#undef LURE_CHARGE_TIME +#undef LURE_EMERGE_TIME +#undef BB_LURE_CHARGE_EXPIRES diff --git a/voidcrew/modules/missions/missions/exploration.dm b/voidcrew/modules/missions/missions/exploration.dm index 9f94c4a82da..d719f8f0056 100644 --- a/voidcrew/modules/missions/missions/exploration.dm +++ b/voidcrew/modules/missions/missions/exploration.dm @@ -1,9 +1,15 @@ /** * # Exploration Mission * - * Fly the ship to marked coordinates, then come home for the pay. The - * coordinates come from the zone controller's own turf sets (no private ring - * math), and pay scales through the shared zone table. + * Fly the ship to marked coordinates, hold station there while the sensors sweep + * the sector, then come home for the pay. The coordinates come from the zone + * controller's own turf sets (no private ring math), and pay scales through the + * shared zone table. + * + * The sweep is the arrival beat: half a minute on station, a sonar ping, and + * sometimes a derelict signal nobody aboard had charted yet, pinned on the helm + * so the crew can decide whether to go and look. The signal is always one that + * is already out there; the sweep never makes anything. */ /datum/mission/exploration name = "Exploration Contract" @@ -12,6 +18,12 @@ // Green-band pay; apply_zone_scaling() raises it for deeper zones value_min = 150 value_max = 250 + /// The sweep step, for the waypoint label + var/datum/mission_objective/sector_sweep/sweep + +/datum/mission/exploration/Destroy() + sweep = null + return ..() /datum/mission/exploration/setup_target() var/datum/mission_target/coords/coords = new(src) @@ -28,17 +40,274 @@ return TRUE /datum/mission/exploration/build_objectives() - add_objective(new /datum/mission_objective/goto_coords) + var/datum/mission_objective/goto_coords/approach = new + // The sweep announces the arrival itself + approach.arrival_message = null + add_objective(approach) + sweep = new + add_objective(sweep) /datum/mission/exploration/update_text() name = "Exploration Contract: ([target.target_x], [target.target_y])" desc = "Chart the sector at ([target.target_x], [target.target_y]) in the [target_zone_name]." /datum/mission/exploration/waypoint_label() - return "Survey site" + return sweep?.completed ? "Charted sector" : "Survey site" /datum/mission/exploration/get_ui_data() var/list/data = ..() data["target_x"] = target.target_x data["target_y"] = target.target_y return data + +// ========================================================================= +// SECTOR SWEEP: hold station at the site while the sensors work +// ========================================================================= + +/** + * The ship holds within hold_range of the mission target for sweep_time. Drifting + * off pauses the sweep and keeps what it had; coming back resumes it. The possible + * find (an uncharted ordinary derelict signal near the site) is rolled once, the + * first time the sweep starts, so breaking off and coming back never rerolls it. + */ +/datum/mission_objective/sector_sweep + /// How long the ship has to hold station + var/sweep_time = 30 SECONDS + /// Overmap tiles from the site the ship may sit and keep sweeping + var/hold_range = 2 + /// How far from the site a derelict signal can show up on the sweep + var/find_range = 7 + /// Percent chance the sweep turns up a derelict signal, when one is out there + var/find_chance = 50 + /// Gap between "broken off" lines, so a ship drifting on the edge doesn't spam the crew + var/notice_gap = 10 SECONDS + /// Sweep time banked from earlier runs + var/banked_time = 0 + /// Whether the ship is on station and the sweep is running + var/running = FALSE + /// world.time the current run started + var/run_started = 0 + /// Whether the sweep has started at least once + var/started = FALSE + /// Timer: the sweep finishing + var/finish_timer + /// Timer: the faint return halfway through, when there is a find + var/return_timer + /// Whether the faint return has been called out + var/return_reported = FALSE + /// Whether a "broken off" line went out that still needs its "resumed" + var/break_reported = FALSE + /// Whether the find has been rolled for this sweep + var/find_rolled = FALSE + /// The derelict signal the sweep picked up, if any + var/datum/weakref/find_ref + COOLDOWN_DECLARE(break_notice_cooldown) + +/datum/mission_objective/sector_sweep/Destroy() + find_ref = null + return ..() + +/datum/mission_objective/sector_sweep/activate() + . = ..() + var/obj/structure/overmap/ship/ship = get_servant() + if(!ship) + return + RegisterSignal(ship, COMSIG_MOVABLE_MOVED, PROC_REF(on_ship_moved)) + check_station() + +/datum/mission_objective/sector_sweep/deactivate() + var/obj/structure/overmap/ship/ship = get_servant() + if(ship) + UnregisterSignal(ship, COMSIG_MOVABLE_MOVED) + stop_run() + return ..() + +/datum/mission_objective/sector_sweep/reset() + . = ..() + banked_time = 0 + running = FALSE + run_started = 0 + started = FALSE + return_reported = FALSE + break_reported = FALSE + find_rolled = FALSE + find_ref = null + +/datum/mission_objective/sector_sweep/proc/on_ship_moved(datum/source) + SIGNAL_HANDLER + check_station() + +/// Wrapped overmap distance from relative coordinates to the mission site, or null +/datum/mission_objective/sector_sweep/proc/distance_to_site(list/coords) + var/datum/mission_target/target = mission?.target + if(!coords || !target) + return null + var/dx = overmap_wrapped_delta(coords[1] - target.target_x, OVERMAP_SIZE - 2) + var/dy = overmap_wrapped_delta(coords[2] - target.target_y, OVERMAP_SIZE - 2) + return sqrt(dx * dx + dy * dy) + +/// Whether the servant ship is close enough to the site to sweep it +/datum/mission_objective/sector_sweep/proc/is_on_station() + var/obj/structure/overmap/ship/ship = get_servant() + if(!ship) + return FALSE + var/dist = distance_to_site(ship.get_relative_overmap_coords()) + return !isnull(dist) && dist <= hold_range + +/// Starts or pauses the sweep to match where the ship is +/datum/mission_objective/sector_sweep/proc/check_station() + if(completed || !active) + return + var/on_station = is_on_station() + if(on_station && !running) + start_run() + else if(!on_station && running) + stop_run() + if(COOLDOWN_FINISHED(src, break_notice_cooldown)) + COOLDOWN_START(src, break_notice_cooldown, notice_gap) + break_reported = TRUE + notify_crew("Sweep broken off.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + +/datum/mission_objective/sector_sweep/proc/start_run() + running = TRUE + run_started = world.time + if(!find_rolled) + find_rolled = TRUE + roll_find() + finish_timer = addtimer(CALLBACK(src, PROC_REF(finish_sweep)), max(sweep_time - banked_time, 1), TIMER_STOPPABLE) + var/halfway = sweep_time / 2 + if(find_ref && !return_reported && banked_time < halfway) + return_timer = addtimer(CALLBACK(src, PROC_REF(report_return)), halfway - banked_time, TIMER_STOPPABLE) + if(!started) + started = TRUE + notify_crew("Survey site reached. Sweeping the sector.", sound = 'sound/machines/sonar-ping.ogg') + else if(break_reported) + break_reported = FALSE + notify_crew("Back on station. Sweep resumed.", sound = 'sound/machines/sonar-ping.ogg') + +/// Banks the current run and stops its timers (safe to call while already stopped) +/datum/mission_objective/sector_sweep/proc/stop_run() + if(running) + banked_time += world.time - run_started + running = FALSE + if(finish_timer) + deltimer(finish_timer) + finish_timer = null + if(return_timer) + deltimer(return_timer) + return_timer = null + +/** + * Picks what the sweep turns up: the nearest ordinary derelict signal just past the + * ship's own sight from the site, that this ship has neither seen nor charted. Only + * plain naturally seeded signals with nobody's claim on them count, so the sweep + * never points at a rumour site, a contract's site, a bounty lair or a broadcast. + */ +/datum/mission_objective/sector_sweep/proc/roll_find() + find_ref = null + if(!prob(find_chance)) + return + var/obj/structure/overmap/ship/ship = get_servant() + var/turf/ship_turf = get_turf(ship) + if(!ship_turf) + return + var/obj/structure/overmap/space_ruin/best + var/best_dist + for(var/obj/structure/overmap/space_ruin/ruin as anything in GLOB.space_ruin_signals) + if(!is_ordinary_signal(ruin)) + continue + var/turf/ruin_turf = get_turf(ruin) + if(ruin_turf?.z != ship_turf.z) + continue + var/ruin_ref = REF(ruin) + if(ship.get_waypoint(ruin_ref)) + continue + var/datum/weakref/seen = ship.discovered_contacts[ruin_ref] + if(seen?.resolve() == ruin) + continue + var/dist = distance_to_site(ruin.get_relative_overmap_coords()) + if(isnull(dist) || dist <= SHIP_VIEW_RANGE || dist > find_range) + continue + if(isnull(best_dist) || dist < best_dist) + best = ruin + best_dist = dist + if(best) + find_ref = WEAKREF(best) + +/// A plain derelict signal from natural seeding, with no claim, lock, broadcast or rumour on it +/datum/mission_objective/sector_sweep/proc/is_ordinary_signal(obj/structure/overmap/space_ruin/ruin) + if(QDELETED(ruin) || ruin.type != /obj/structure/overmap/space_ruin || !ruin.sensor_detectable) + return FALSE + if(ruin.rare || ruin.no_replacement || ruin.mission_locked || ruin.mission_exclusive || ruin.mission_claims) + return FALSE + return !(ruin in GLOB.overmap_fleet_beacons) + +/// The find, if it is still out there +/datum/mission_objective/sector_sweep/proc/get_find() + var/obj/structure/overmap/space_ruin/find = find_ref?.resolve() + if(QDELETED(find)) + find_ref = null + return null + return find + +/// " to the northeast" from the ship to `object`, or "" when there is no bearing +/datum/mission_objective/sector_sweep/proc/bearing_to(obj/structure/overmap/object) + var/static/list/compass_words = list( + "N" = "north", "NE" = "northeast", "E" = "east", "SE" = "southeast", + "S" = "south", "SW" = "southwest", "W" = "west", "NW" = "northwest", + ) + var/obj/structure/overmap/ship/ship = get_servant() + var/list/from = ship?.get_relative_overmap_coords() + var/list/to_coords = object?.get_relative_overmap_coords() + if(!from || !to_coords) + return "" + var/dx = overmap_wrapped_delta(to_coords[1] - from[1], OVERMAP_SIZE - 2) + var/dy = overmap_wrapped_delta(to_coords[2] - from[2], OVERMAP_SIZE - 2) + var/compass = overmap_delta_to_compass(dx, dy) + if(!compass) + return "" + return " to the [compass_words[compass]]" + +/datum/mission_objective/sector_sweep/proc/report_return() + return_timer = null + if(!active || completed) + return + var/obj/structure/overmap/space_ruin/find = get_find() + if(!find) + return + return_reported = TRUE + notify_crew("Faint return[bearing_to(find)].", sound = 'sound/machines/sonar-ping.ogg') + +/datum/mission_objective/sector_sweep/proc/finish_sweep() + finish_timer = null + if(!active || completed) + return + running = FALSE + banked_time = sweep_time + if(return_timer) + deltimer(return_timer) + return_timer = null + var/static/list/quiet_lines = list( + "Sector charted. Quiet out here.", + "Sector charted. Nothing but dust.", + "Sector charted. Just old drive wakes.", + ) + var/line = pick(quiet_lines) + var/obj/structure/overmap/ship/ship = get_servant() + var/obj/structure/overmap/space_ruin/find = get_find() + // A scan the crew ran themselves meanwhile may have charted it already: no news then + if(ship && find && ship.chart_as_waypoint(find)) + line = "Sector charted. Derelict signal logged[bearing_to(find)]." + notify_crew("[line] Pay's waiting on the board.", sound = 'voidcrew/sound/notify2.ogg') + var/datum/mission/owner = mission + complete() + owner?.push_waypoint() + +/datum/mission_objective/sector_sweep/get_progress_string() + if(completed) + return "Sector charted" + if(running) + return "Sweeping the sector" + if(started) + return "Sweep broken off" + return "Sweep the sector" diff --git a/voidcrew/modules/missions/missions/field_expedition.dm b/voidcrew/modules/missions/missions/field_expedition.dm index 81fd90250d9..ce12215fa5c 100644 --- a/voidcrew/modules/missions/missions/field_expedition.dm +++ b/voidcrew/modules/missions/missions/field_expedition.dm @@ -51,7 +51,9 @@ )) /datum/mission/field_expedition/build_objectives() - add_objective(new /datum/mission_objective/field/plant_quest) + var/datum/mission_objective/field/plant_quest/plant = new + plant.scene_type = /datum/mission_scene/crash + add_objective(plant) add_objective(new /datum/mission_objective/deliver/bound) /// Picking the core up can wake the neighborhood (once per era) @@ -64,7 +66,7 @@ UnregisterSignal(source, COMSIG_ITEM_PICKUP) if(failed || completed || !wave_theme || !prob(ambush_chance)) return - new wave_theme(get_turf(taker), list(1, 2)) + new wave_theme(get_turf(taker), list(1, 2), TRUE) servant?.ship_notify("[name]: ground team disturbed something at the crash site.", "MISSION UPDATE", SHIP_NOTIFY_WARNING, 'voidcrew/sound/notify2.ogg', 50) /datum/mission/field_expedition/update_text() diff --git a/voidcrew/modules/missions/missions/live_capture.dm b/voidcrew/modules/missions/missions/live_capture.dm index 17090950e32..494fd5a7763 100644 --- a/voidcrew/modules/missions/missions/live_capture.dm +++ b/voidcrew/modules/missions/missions/live_capture.dm @@ -232,6 +232,7 @@ mission.fail("No mission pad to dispense the capture crate to.") return FALSE kit = new(kit_turf) + mission_pad_delivery_effect(kit_turf) kit.objective_ref = WEAKREF(src) mission.bind_item(kit) // One crate per contract: losing it voids the capture diff --git a/voidcrew/modules/missions/missions/poacher_bust.dm b/voidcrew/modules/missions/missions/poacher_bust.dm index 07cf53dc8f8..e664c33b809 100644 --- a/voidcrew/modules/missions/missions/poacher_bust.dm +++ b/voidcrew/modules/missions/missions/poacher_bust.dm @@ -12,9 +12,11 @@ * filter on the planet target), and the protected species, terrain and * evidence all come from a per-planet table. * - * The squad is the fork's hermit family (simple_animal humanoids): scruffy - * wild-country gunmen with working c38/c46x30mm casing fire, each renamed to - * a generated poacher on spawn. Every death is attributed suppression-style + * The squad is the wasteland hermits as human NPCs + * (voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm): scruffy wild-country + * gunmen with real .38 revolvers and WT-550s and their spare ammunition, each + * renamed to a generated poacher on spawn. They are queued and built a body a + * tick; on an ice planet they are dressed for the cold. Every death is attributed suppression-style * (last-attacker ckey or a living crew member in confirm range); unattributed * deaths still count - dead is dead, and the telemetry logs it - so the wipe * can never stall on a weather or wildlife kill. @@ -194,26 +196,25 @@ wipe = new // The squad scales with the zone: green gets rifles and a knife, deeper - // zones add the trailboss's SMG and more guns. Exact subtypes only, the - // /survivor/random variant self-replaces on Initialize and would escape - // tracking. + // zones add the trailboss's SMG and more guns. Exact types only: a type + // that replaces itself on Initialize would escape tracking. var/list/squad_types = list() switch(difficulty) if(MISSION_DIFFICULTY_MEDIUM) - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/survivor + squad_types += /mob/living/carbon/human/npc/ruin/hermit/gunslinger + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit if(MISSION_DIFFICULTY_HARD) - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/survivor + squad_types += /mob/living/carbon/human/npc/ruin/hermit/gunslinger + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit else - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/survivor + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit wipe.squad_types = squad_types wipe.squad_names = squad_names wipe.evidence_names = bust_row["evidence"] @@ -264,6 +265,8 @@ var/list/squad_names /// Live squad members still being tracked var/list/mob/living/squad = list() + /// Queued orders for human NPC poachers not built yet (voidcrew/modules/human_npcs/) + var/list/datum/human_npc_order/pending_poachers = list() /// Total squad size at spawn / members down so far var/squad_size = 0 var/squad_down = 0 @@ -301,29 +304,39 @@ squad_size = 0 cache = null -/// Stops tracking every remaining squad member (mission over / reset) +/// Stops tracking every remaining squad member (mission over / reset), and cancels the ones still queued /datum/mission_objective/field/wipe_camp/proc/release_squad() for(var/mob/living/poacher as anything in squad) UnregisterSignal(poacher, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) squad.Cut() + for(var/datum/human_npc_order/order as anything in pending_poachers) + qdel(order) + pending_poachers.Cut() /datum/mission_objective/field/wipe_camp/spawn_field_objects(turf/camp_turf) - // The squad, scattered around the campsite + // The squad, scattered around the campsite. Human poachers are queued and join + // the squad as they are built; the head count includes them from the start. for(var/i in 1 to length(squad_types)) var/poacher_type = squad_types[i] - var/mob/living/simple_animal/poacher = spawn_field_npc(poacher_type, get_nearby_open_turf(camp_turf, 3), finalize = FALSE) - if(i <= length(squad_names)) - poacher.name = squad_names[i] + var/poacher_name = i <= length(squad_names) ? squad_names[i] : null + var/turf/poacher_spot = get_nearby_open_turf(camp_turf, 3) + if(ispath(poacher_type, /mob/living/carbon/human/npc)) + // Their outfits keep an ice-planet camp from freezing (human_npc_outfits.dm site layers). + var/datum/npc_spawn_context/context = new(mission?.target || src, mission?.target?.get_zone_type(), "auto", source_turf = poacher_spot) + pending_poachers += spawn_human_npc(poacher_type, poacher_spot, CALLBACK(src, PROC_REF(on_human_poacher_built)), name = poacher_name, context = context) + continue + var/mob/living/simple_animal/poacher = spawn_field_npc(poacher_type, poacher_spot, finalize = FALSE) + if(poacher_name) + poacher.name = poacher_name poacher.desc += " The kit is professional: snare wire, skinning knives, and the brand of [mission.objective_name] burned into the stock." // Outfitted expedition: cold-weather gear keeps an ice-planet camp // from freezing to death before the crew arrives poacher.minbodytemp = 0 poacher.npc_spawn_context.finalize() - protect_field_mob(poacher) - RegisterSignal(poacher, COMSIG_LIVING_DEATH, PROC_REF(on_poacher_death)) - RegisterSignal(poacher, COMSIG_QDELETING, PROC_REF(on_poacher_removed)) - squad += poacher - squad_size = length(squad) + enlist_poacher(poacher) + squad_size = length(squad) + length(pending_poachers) + if(length(pending_poachers)) + addtimer(CALLBACK(src, PROC_REF(check_pending_poachers)), HUMAN_NPC_ZONE_WAIT + 10 SECONDS) // Camp dressing: fire, crude cover, bedrolls. The dense prelit bonfire // can't be pathed into, so the squad won't cook itself. @@ -366,6 +379,41 @@ notify_crew("[mission.objective_name] camp sighted - [squad_size] armed poachers holding a sealed trophy cache. The case beacon marks the cache ([mission.gps_tag]).", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') +/// Tracks a squad member: spared by the planet's fauna sweep, counted when it dies. +/datum/mission_objective/field/wipe_camp/proc/enlist_poacher(mob/living/poacher) + protect_field_mob(poacher) + RegisterSignal(poacher, COMSIG_LIVING_DEATH, PROC_REF(on_poacher_death)) + RegisterSignal(poacher, COMSIG_QDELETING, PROC_REF(on_poacher_removed)) + squad += poacher + +/// A queued human poacher was built: it joins the squad. +/datum/mission_objective/field/wipe_camp/proc/on_human_poacher_built(mob/living/carbon/human/npc/poacher) + for(var/datum/human_npc_order/order as anything in pending_poachers) + if(order.spawned_ref?.resolve() == poacher) + pending_poachers -= order + break + if(!active || !mission || mission.failed || mission.completed) + return + enlist_poacher(poacher) + +/** + * A queued poacher that was never built (its spot became unusable) leaves the head count, so the wipe + * can still finish. Orders still waiting are looked at again later. + */ +/datum/mission_objective/field/wipe_camp/proc/check_pending_poachers() + if(!active || !mission || mission.failed || mission.completed) + return + for(var/datum/human_npc_order/order as anything in pending_poachers.Copy()) + if(QDELETED(order) || (order.done && !order.spawned_ref?.resolve())) + pending_poachers -= order + squad_size-- + if(length(pending_poachers)) + addtimer(CALLBACK(src, PROC_REF(check_pending_poachers)), 10 SECONDS) + return + if(squad_size > 0 && squad_down >= squad_size) + unlock_cache() + complete() + /** * A named poacher died: always counts toward the wipe (attribution only * flavors the line - dead is dead and the telemetry logs it, so a weather or diff --git a/voidcrew/modules/missions/missions/prospect_stake.dm b/voidcrew/modules/missions/missions/prospect_stake.dm index adcc1ff938b..bb847a7d425 100644 --- a/voidcrew/modules/missions/missions/prospect_stake.dm +++ b/voidcrew/modules/missions/missions/prospect_stake.dm @@ -1,16 +1,17 @@ /** * # Prospect Stake * - * "Plant our claim beacon at these red-zone coordinates and keep it alive - * for eight minutes. The filing goes out on Wideband, so expect... interest." + * "Plant our claim beacon on this rock and keep it standing until the claim + * matures. The filing goes out on Wideband, so expect... interest." * * The announced PvP flashpoint as a mission: a claim kit is dispensed at - * accept; deploying it (ship holding at the marked coordinates) files the - * claim galaxy-wide - the contested-cache broadcast pattern - and starts a - * defend clock that only runs on station. Boarding waves answer the filing; - * red space means anyone else can too. The beacon survives, it prints a - * bonded deed for the pad. Objectives: [fly there] -> [deploy & defend] -> - * [carry the deed home]. + * accept, and the crew lands on a contested or lawless planet to raise it on + * open ground, clear of the landing field and any parked hull. Deploying it + * files the claim galaxy-wide - the contested-cache broadcast pattern - and + * starts a defend clock that only runs while a crew member stands guard at + * the beacon. Waves answer the filing on the ground around it; red space + * means anyone else can too. The beacon survives, it prints a bonded deed for + * the pad. Objectives: [deploy & defend] -> [carry the deed home]. */ /datum/mission/prospect_stake name = "Prospect Stake" @@ -29,6 +30,7 @@ var/wave_theme /// The defense objective (kit dispensing at start) var/datum/mission_objective/claim_defense/defense + /datum/mission/prospect_stake/Destroy() defense = null return ..() @@ -36,26 +38,40 @@ /datum/mission/prospect_stake/get_archetype() return "claim" +/** + * Picks the planet to stake. An offer pinned to a band (the reserved top-band + * slots, the unarmed green bias) only tries that band, and a green pin finds + * nothing. Otherwise the band is rolled, falling back to the other band when + * the rolled one has no world to offer. + */ /datum/mission/prospect_stake/setup_target() - var/datum/mission_target/coords/coords = new(src) - // Yellow stakes are the on-ramp; red is the real thing - coords.zone_weights = list( - "[ZONE_YELLOW]" = 45, - "[ZONE_RED]" = 55, - ) - if(!coords.resolve()) - qdel(coords) - return FALSE - target = coords - return TRUE + var/list/bands = list() + if(!isnull(preferred_zone)) + bands += preferred_zone + else + // Yellow stakes are the on-ramp; red is the real thing + var/list/weights = list( + "[ZONE_YELLOW]" = 45, + "[ZONE_RED]" = 55, + ) + while(length(weights)) + var/band_key = pick_weight(weights) + weights -= band_key + bands += text2num(band_key) + for(var/band in bands) + var/datum/mission_target/planet/claim_site/site = new(src) + site.preferred_zone = band + if(site.resolve()) + target = site + return TRUE + qdel(site) + return FALSE /datum/mission/prospect_stake/generate_details() objective_name = "notarized claim deed" wave_theme = pick_weight(MISSION_WAVE_THEMES) + /datum/mission/prospect_stake/build_objectives() - var/datum/mission_objective/goto_coords/approach = new - approach.arrival_message = "On station over the claim." - add_objective(approach) defense = new defense.wave_theme = wave_theme add_objective(defense) @@ -65,18 +81,89 @@ defense?.dispense_kit() /datum/mission/prospect_stake/update_text() - name = "Prospect Stake: ([target.target_x], [target.target_y])" - desc = "File our mineral-rights claim at ([target.target_x], [target.target_y]) in the [target_zone_name]." + var/datum/mission_target/planet/site = target + var/site_name = istype(site) ? (site.planet?.name || "the planet") : "the planet" + name = "Prospect Stake: [site_name]" + desc = "File our mineral-rights claim on [site_name] at ([target.target_x], [target.target_y]) in the [target_zone_name]." /datum/mission/prospect_stake/waypoint_label() return "Prospect stake" +// ========================================================================= +// CLAIM SITE: a real world to stand on +// ========================================================================= + +/** + * # Claim Site + * + * The planet target narrowed to ground a claim can stand on: terrain worlds + * only (GLOB.overmap_planets also holds empty-space dock placeholders, crash + * sites and weak signals, none of which have any), and never neutral space. + * Picks a biome first and hands it to the planet target's wanted_planet + * filter, so every other rule (exclusivity, occupancy, field contacts, + * relocation tracking) is the planet target's own. + */ +/datum/mission_target/planet/claim_site + /// Biomes a field contact may be minted as when no terrain world is on offer + var/static/list/claim_biomes = list( + /datum/overmap/planet/lava, + /datum/overmap/planet/ice, + /datum/overmap/planet/jungle, + /datum/overmap/planet/beach, + /datum/overmap/planet/wasteland, + ) + +/// Claims are filed in contested and lawless space only +/datum/mission_target/planet/claim_site/zone_is_eligible(band) + return band != ZONE_GREEN && ..() + +/datum/mission_target/planet/claim_site/resolve() + var/list/biomes = list() + for(var/obj/structure/overmap/planet/candidate as anything in GLOB.overmap_planets) + if(QDELETED(candidate) || candidate == planet || candidate.mission_exclusive || !candidate.is_terrain_planet()) + continue + if(!istype(get_turf(candidate), /turf/open/overmap) || !zone_is_eligible(SSovermap_zones?.get_zone_type(get_turf(candidate)))) + continue + biomes |= candidate.planet + if(!length(biomes)) + // Never mint a contact in a band no claim can use (a green-pinned reserved slot + // would leave a stray planet on the chart for an offer that then fails) + if(!mission?.create_field_contact || !zone_is_eligible(preferred_zone)) + unhook() + return FALSE + // Nothing standing in the band: the planet target mints a contact of this biome + biomes = claim_biomes + wanted_planet = pick(biomes) + return ..() + +/** + * Why a claim beacon can't stand on this turf, as a balloon line, or null if + * it can. Open, solid ground on this world's own surface or caves - never a + * deck, a ruin, space, lava, water or a chasm - and clear of the landing + * berths and any parked hull, so no ship comes down on the claim and no wave + * lands on somebody's floor. + */ +/datum/mission_target/planet/claim_site/proc/claim_ground_refusal(turf/spot) + if(!spot || !isopenturf(spot) || isgroundlessturf(spot) || !contains_turf(spot)) + return "no claim ground here!" + if(!istype(get_area(spot), /area/overmap_encounter/planetoid) || spot.is_blocked_turf(exclude_mobs = TRUE)) + return "no claim ground here!" + var/datum/space_level/level = length(planet.mapzone?.z_levels) ? planet.mapzone.z_levels[1] : null + var/strip_top = planet.get_dock_strip_top_y(level) + if(!isnull(strip_top) && spot.y <= strip_top) + return "too close to the landing field!" + // One tile past where a wave can scatter, so no arrival lands on a deck + for(var/turf/nearby as anything in RANGE_TURFS(ZONE_MOBS_WAVE_SCATTER + 1, spot)) + if(istype(nearby.loc, /area/shuttle)) + return "too close to a ship!" + return null + /** * # Claim Beacon Kit * - * The packed beacon handed over at accept. Use in hand while the ship holds - * at the stake coordinates; the deployment channel raises the beacon on the - * spot you're standing. + * The packed beacon handed over at accept. Use in hand on open ground at the + * claim site; the deployment channel raises the beacon on the spot you're + * standing. */ /obj/item/claim_beacon_kit name = "claim beacon kit" @@ -93,29 +180,40 @@ if(.) return var/datum/mission_objective/claim_defense/objective = objective_ref?.resolve() - if(!objective?.mission || objective.mission.failed || objective.mission.completed) + if(!objective?.mission || objective.mission.failed || objective.mission.completed || !objective.active) balloon_alert(user, "contract expired!") return TRUE - if(!objective.active || !objective.ship_on_station()) - balloon_alert(user, "ship not holding at the stake coordinates!") + var/refusal = claim_refusal(objective, get_turf(user)) + if(refusal) + balloon_alert(user, refusal) return TRUE balloon_alert(user, "deploying beacon...") playsound(src, 'sound/items/tools/ratchet.ogg', 60, TRUE) if(!do_after(user, 8 SECONDS, target = user)) balloon_alert(user, "deployment interrupted!") return TRUE - if(!objective.ship_on_station()) - balloon_alert(user, "drifted off station!") + if(!objective.mission || objective.mission.failed || objective.mission.completed || !objective.active) + return TRUE + refusal = claim_refusal(objective, get_turf(user)) + if(refusal) + balloon_alert(user, refusal) return TRUE objective.deploy(user, get_turf(user)) return TRUE +/// Why the beacon can't go up here, or null if it can +/obj/item/claim_beacon_kit/proc/claim_refusal(datum/mission_objective/claim_defense/objective, turf/spot) + var/datum/mission_target/planet/claim_site/site = objective.mission?.target + if(!istype(site)) + return "no claim ground here!" + return site.claim_ground_refusal(spot) + /** * # Claim Beacon * * The deployed stake: destructible on purpose - defending it is the mission. - * Ticks the defend clock while the ship holds station, calls its own waves, - * and files every beat on Wideband like the contested cache does. + * Ticks the defend clock while a crew member stands guard, calls its own + * waves, and files every beat on Wideband like the contested cache does. */ /obj/structure/mission_claim_beacon name = "prospecting claim beacon" @@ -125,15 +223,13 @@ anchored = TRUE density = TRUE max_integrity = 400 + // Driven into the ground: wind, force pushes and gravity never shift it off the claim + move_resist = INFINITY /// Weakref to the claim_defense objective driving this beacon var/datum/weakref/objective_ref /// Internal wideband transmitter (the contested-cache broadcast pattern) var/obj/item/radio/headset/radio - /// world.time gate for the next defender wave - var/next_wave_at = 0 - /// Throttle for off-station warnings - COOLDOWN_DECLARE(offstation_warn_cooldown) /obj/structure/mission_claim_beacon/Initialize(mapload) . = ..() @@ -142,7 +238,6 @@ radio.canhear_range = 0 radio.set_listening(FALSE) radio.recalculateChannels() - next_wave_at = world.time + 1 MINUTES START_PROCESSING(SSobj, src) /obj/structure/mission_claim_beacon/Destroy() @@ -164,14 +259,6 @@ priority_announce(message, "Prospect Filing", sender_override = "Sector Claims Registry") radio?.talk_into(src, message, RADIO_CHANNEL_WIDEBAND) -/obj/structure/mission_claim_beacon/proc/show_offstation_warning() - if(!COOLDOWN_FINISHED(src, offstation_warn_cooldown)) - return - COOLDOWN_START(src, offstation_warn_cooldown, 15 SECONDS) - balloon_alert_to_viewers("off station!") - var/datum/mission_objective/claim_defense/objective = objective_ref?.resolve() - objective?.notify_crew("Claim beacon reports the ship off station.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') - /// The claim matured: stop being a mission object, stay as scenery /obj/structure/mission_claim_beacon/proc/retire() STOP_PROCESSING(SSobj, src) diff --git a/voidcrew/modules/missions/missions/recovery.dm b/voidcrew/modules/missions/missions/recovery.dm index a018e34a098..9973774908a 100644 --- a/voidcrew/modules/missions/missions/recovery.dm +++ b/voidcrew/modules/missions/missions/recovery.dm @@ -70,39 +70,40 @@ mission_limit = 2 gps_tag_prefix = "HUNT" - /// Mob type pools per difficulty (space-capable faction pirates). + /// Mob type pools per difficulty: faction pirates as human NPCs + /// (voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm), in space suits where the site is airless. /// Hard pool is the real faction bosses. Red-zone bounties are the /// named monsters of the sector, not just another captain. var/static/list/easy_targets = list( - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/grey/melee, + /mob/living/carbon/human/npc/faction_pirate/silverscale/melee, + /mob/living/carbon/human/npc/faction_pirate/skeleton/melee, + /mob/living/carbon/human/npc/faction_pirate/grey/melee, ) var/static/list/medium_targets = list( - /mob/living/basic/trooper/pirate/faction/silverscale/ranged, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged, - /mob/living/basic/trooper/pirate/faction/grey/captain, + /mob/living/carbon/human/npc/faction_pirate/silverscale/ranged, + /mob/living/carbon/human/npc/faction_pirate/skeleton/ranged, + /mob/living/carbon/human/npc/faction_pirate/lustrous/ranged, + /mob/living/carbon/human/npc/faction_pirate/grey/captain, ) var/static/list/hard_targets = list( - /mob/living/basic/trooper/pirate/faction/boss/silverscale, - /mob/living/basic/trooper/pirate/faction/boss/skeleton, - /mob/living/basic/trooper/pirate/faction/boss/grey, - /mob/living/basic/trooper/pirate/faction/boss/lustrous, - /mob/living/basic/trooper/pirate/faction/boss/interdyne, - /mob/living/basic/trooper/pirate/faction/boss/medieval, + /mob/living/carbon/human/npc/faction_pirate/silverscale/boss, + /mob/living/carbon/human/npc/faction_pirate/skeleton/boss, + /mob/living/carbon/human/npc/faction_pirate/grey/boss, + /mob/living/carbon/human/npc/faction_pirate/lustrous/boss, + /mob/living/carbon/human/npc/faction_pirate/interdyne/boss, + /mob/living/carbon/human/npc/faction_pirate/medieval/boss, ) /// Entourage pools: bounty targets above easy keep hired muscle around var/static/list/medium_guards = list( - /mob/living/basic/trooper/pirate/faction/grey/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/silverscale/melee, + /mob/living/carbon/human/npc/faction_pirate/grey/melee, + /mob/living/carbon/human/npc/faction_pirate/skeleton/melee, + /mob/living/carbon/human/npc/faction_pirate/silverscale/melee, ) var/static/list/hard_guards = list( - /mob/living/basic/trooper/pirate/faction/silverscale/ranged, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged, + /mob/living/carbon/human/npc/faction_pirate/silverscale/ranged, + /mob/living/carbon/human/npc/faction_pirate/skeleton/ranged, + /mob/living/carbon/human/npc/faction_pirate/lustrous/ranged, + /mob/living/carbon/human/npc/faction_pirate/interdyne/ranged, ) /datum/mission/recovery/kill/generate_details() diff --git a/voidcrew/modules/missions/missions/recovery_variants.dm b/voidcrew/modules/missions/missions/recovery_variants.dm index 7a38e0c38bc..411e32095fc 100644 --- a/voidcrew/modules/missions/missions/recovery_variants.dm +++ b/voidcrew/modules/missions/missions/recovery_variants.dm @@ -165,6 +165,7 @@ /datum/mission/recovery/extraction/build_objectives() var/datum/mission_objective/field/plant_quest/plant = new plant.quest_item_type = /obj/item/mission_recovery/anchored + plant.scene_type = /datum/mission_scene/cargo_wreck add_objective(plant) add_objective(new /datum/mission_objective/deliver/bound) @@ -189,7 +190,7 @@ return alarm_tripped = TRUE var/turf/site = get_turf(cargo) - new wave_theme(site, list(1, 2)) + new wave_theme(site, list(1, 2), TRUE) second_wave_timer = addtimer(CALLBACK(src, PROC_REF(second_wave), site), EXTRACTION_SECOND_WAVE_DELAY, TIMER_STOPPABLE) servant?.ship_notify("[name]: site security alarm triggered.", "MISSION UPDATE", SHIP_NOTIFY_WARNING, 'voidcrew/sound/notify2.ogg', 50) @@ -197,13 +198,13 @@ second_wave_timer = null if(failed || completed || !site) return - new wave_theme(site, list(1, 2)) + new wave_theme(site, list(1, 2), TRUE) /** * # Anchored Recovery Cargo * * The extraction objective: a mission_recovery item that starts bolted to - * the deck. Unbolting is a channel that trips the mission's alarm. + * the deck. Unbolting is a wrench channel that trips the mission's alarm. */ /obj/item/mission_recovery/anchored desc = "Contracted salvage, bolted to the deck." @@ -214,26 +215,32 @@ /obj/item/mission_recovery/anchored/examine(mob/user) . = ..() if(anchored) - . += span_warning("It is bolted down.") + . += span_warning("It is bolted to the floor.") +/// Bare hands get nowhere with the bolts /obj/item/mission_recovery/anchored/attack_hand(mob/living/user, list/modifiers) if(!anchored) return ..() + balloon_alert(user, "it's bolted down") + return TRUE + +/obj/item/mission_recovery/anchored/wrench_act(mob/living/user, obj/item/tool) + if(!anchored) + return NONE balloon_alert(user, "wrenching it free...") - playsound(src, 'sound/items/tools/ratchet.ogg', 60, TRUE) - if(!do_after(user, EXTRACTION_UNBOLT_TIME, target = src)) + if(!tool.use_tool(src, user, EXTRACTION_UNBOLT_TIME, volume = 60)) balloon_alert(user, "still bolted!") - return TRUE + return ITEM_INTERACT_BLOCKING if(!anchored) balloon_alert(user, "already free") - return TRUE + return ITEM_INTERACT_BLOCKING anchored = FALSE balloon_alert(user, "wrenched free") playsound(src, 'sound/machines/click.ogg', 60, TRUE) var/datum/mission/recovery/extraction/mission = mission_ref?.resolve() if(istype(mission)) mission.trigger_extraction_alarm(src) - return TRUE + return ITEM_INTERACT_SUCCESS #undef SURVEY_CALIBRATE_TIME #undef EXTRACTION_UNBOLT_TIME diff --git a/voidcrew/modules/missions/missions/rescue.dm b/voidcrew/modules/missions/missions/rescue.dm index 0a3e7b6527a..fbae8504522 100644 --- a/voidcrew/modules/missions/missions/rescue.dm +++ b/voidcrew/modules/missions/missions/rescue.dm @@ -3,11 +3,11 @@ * * "A survivor's beacon is still pinging inside that wreck. Bring them home alive." * - * Recovery variant where the objective breathes: a survivor NPC spawns in - * the target ruin, follows whoever asks (or rides a fireman carry), and the - * mission turns in while they're alive beside the ship's mission pad. - * Survivor death burns a retarget ("another beacon"). The non-gunner's - * recovery mission. + * Recovery variant where the objective breathes: a survivor NPC hides in the + * target ruin, follows whoever asks, and the mission turns in while they're + * alive beside the ship's mission pad. Survivor death burns a retarget + * ("another beacon"). The non-gunner's recovery mission. The survivor + * themselves are in rescue_survivor.dm. */ /datum/mission/rescue name = "Rescue Contract" @@ -50,80 +50,3 @@ /datum/mission/rescue/waypoint_label() return "Rescue: [objective_name]" - -/** - * # Mission Survivor - * - * The living objective: friendly, fragile, and clingy on request. An empty - * hand toggles follow; they never fight back. - * - * They are immune to the environment on purpose. The contract puts them inside - * wrecks, and a wreck is airless and near absolute zero: on tg's basic-mob - * defaults this mob takes 1 brute (no air to breathe) plus 2 burn (body temp - * under 250K, which it reaches in a single tick at -30K/s) every two-second - * Life tick from the moment it spawns. That is 60 HP of survivor in forty - * seconds, and the survivor spawns when the crew's ship docks - so they were - * dead before anyone could walk across the ruin to them, every single time, on - * every airless site. Their suit is what they have been surviving in; only - * violence gets to kill them. - */ -/mob/living/basic/mission_survivor - name = "survivor" - desc = "Somebody who has had a very long week and would love to see a ship interior again. Their emergency softsuit is patched in three places and still holding." - icon = 'icons/mob/simple/simple_human.dmi' - maxHealth = 60 - health = 60 - melee_damage_lower = 0 - melee_damage_upper = 0 - combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - speak_emote = list("croaks") - - /// Who the survivor is currently following, if anyone - var/datum/weakref/following_ref - -/mob/living/basic/mission_survivor/Initialize(mapload) - . = ..() - var/static/list/survivor_names = list("Adler", "Bex", "Cato", "Dova", "Emin", "Farrow", "Ines", "Jun", "Kest", "Loami") - name = "survivor ([pick(survivor_names)])" - real_name = name - apply_dynamic_human_appearance(src, outfit_path = /datum/outfit/job/assistant) - -/mob/living/basic/mission_survivor/examine(mob/user) - . = ..() - var/mob/following = following_ref?.resolve() - if(following) - . += span_notice("They're sticking close to [following].") - else - . += span_notice("They look lost.") - -/mob/living/basic/mission_survivor/attack_hand(mob/living/user, list/modifiers) - . = ..() - if(. || user.combat_mode) - return - var/mob/following = following_ref?.resolve() - if(following == user) - stop_following() - balloon_alert(user, "waiting here") - say("I'll... wait right here.") - else - start_following(user) - balloon_alert(user, "following you") - say("Right behind you. Please don't be wrong about the way out.") - return TRUE - -/mob/living/basic/mission_survivor/proc/start_following(mob/living/leader) - following_ref = WEAKREF(leader) - GLOB.move_manager.move_to(src, leader, 1, 4) - -/mob/living/basic/mission_survivor/proc/stop_following() - following_ref = null - GLOB.move_manager.stop_looping(src) - -/mob/living/basic/mission_survivor/death(gibbed) - stop_following() - return ..() diff --git a/voidcrew/modules/missions/missions/rescue_survivor.dm b/voidcrew/modules/missions/missions/rescue_survivor.dm new file mode 100644 index 00000000000..10683117d53 --- /dev/null +++ b/voidcrew/modules/missions/missions/rescue_survivor.dm @@ -0,0 +1,1092 @@ +/** + * # Rescue survivor + * + * The person a rescue contract sends a crew to fetch. They are an ambient NPC (the world population + * core in voidcrew/modules/ambient_npcs/), so they get its looks, its walking, its activities and + * its dialogue cooldowns, and a real human body (voidcrew/modules/human_npcs/): wounds, bleeding, + * crit, death and revival are a person's, and a crew can fireman-carry, drag, bag or treat them + * like anyone. They behave like a frightened person: + * + * - placed somewhere sheltered in the wreck (a locker, a corner, an alcove), out of sight of + * whatever is loose in there (mission_survivor_hiding_place()); + * - hiding: crouched and quiet, fidgeting now and then; inside a locker, silent; + * - before anyone has found them, their beacon crackles a distress call to the contract crew's + * radios when they are a few screens away; + * - when they see a person, they call out and wave (or bang on the locker), and come over if + * nothing is about; + * - a hostile close by, or one going for them: they run, away from it, and hide again where they + * stop; one further off: they keep still, and if there are people about they shout a warning; + * - an empty hand makes them follow (or wait); following, they stay at their rescuer's side, + * duck and yelp at danger instead of running, climb out of anything they are shut in, and wait + * where they are if their rescuer goes; + * - they never fight; + * - removed by the contract (delivered, timed out, abandoned, re-targeted), they are taken up from + * orbit in the transporter's column of light (beam_out()), or deleted at once if nobody sees. + * + * The contract puts them inside wrecks, which are airless and near absolute zero: they have been + * living in their emergency softsuit, helmet on and a tank on their back, and breathe from the tank + * when the air is bad, as a person does. Their body is the mission's: the corpse cleanup never takes + * it, since a dead survivor can still be brought back (the escort objective's revival window). + */ + +/// How far they see people and threats +#define SURVIVOR_SIGHT 7 +/// How often they look around +#define SURVIVOR_LOOK_INTERVAL (1 SECONDS) +/// A threat this close, or one going for them, sends them running (until they have a rescuer) +#define SURVIVOR_FLEE_DISTANCE 4 +/// How far they run at most, per run +#define SURVIVOR_FLEE_RANGE 6 +/// How long one flight lasts before they settle wherever they got to +#define SURVIVOR_FLEE_TIME (12 SECONDS) +/// A threat that keeps coming can send them running this many more times in one flight +#define SURVIVOR_FLEE_RUNS 3 +/// The contract crew hear the distress call within this many tiles +#define SURVIVOR_DISTRESS_RANGE 30 +/// Between two distress calls +#define SURVIVOR_DISTRESS_GAP_LOW (50 SECONDS) +#define SURVIVOR_DISTRESS_GAP_HIGH (90 SECONDS) +/// Somebody they spotted this close (or nearer) gets walked up to, if nothing else is about +#define SURVIVOR_APPROACH_RANGE 6 +/// Somebody this close counts as right there (they stand up, they plead) +#define SURVIVOR_NEAR_RANGE 3 +/// Inside a locker they hear people this close +#define SURVIVOR_HEARING_RANGE 3 +/// Following: a leader further than this, or gone, or dead, is out of reach +#define SURVIVOR_FOLLOW_RANGE 14 +/// Following: a leader out of reach this long, and they stop and wait where they are +#define SURVIVOR_LOST_TIME (45 SECONDS) +/// Following outranks every reaction: nothing makes them run off from their rescuer +#define SURVIVOR_FOLLOW_PRIORITY 60 +/// Running outranks calling out +#define SURVIVOR_FLEE_PRIORITY 55 +/// Following: how long they stay down when they duck, and how soon they may duck again +#define SURVIVOR_DUCK_TIME (3 SECONDS) +#define SURVIVOR_DUCK_COOLDOWN (8 SECONDS) +/// Hiding place search: how far from the site's spawn point it looks +#define SURVIVOR_SHELTER_RADIUS 20 +/// Hiding place search: nothing hostile within this many tiles of it +#define SURVIVOR_SHELTER_THREAT_GAP 6 +/// Hiding place search: blocked neighbours (of 8) a floor tile needs to count as a corner or alcove +#define SURVIVOR_SHELTER_MIN_COVER 5 +/// The beam: the column comes down, they come apart in it, and they are gone this long after it started +#define SURVIVOR_BEAM_TIME (3 SECONDS) +/// The beam: how long the column takes to come down before they start to come apart +#define SURVIVOR_BEAM_DROP (0.4 SECONDS) +/// The beam is only drawn for someone with a client within this many tiles; otherwise they are deleted at once +#define SURVIVOR_BEAM_WATCH_RANGE 10 +/// Trait source for what the survivor puts on themselves +#define SURVIVOR_TRAIT "mission_survivor" + +// ========================================================================= +// THE SURVIVOR +// ========================================================================= + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor + name = "survivor" + desc = "Somebody who has had a very long week and would love to see a ship interior again. Their emergency suit is patched in three places and still holding." + death_cash_low = 0 + death_cash_high = 0 + speech_pace = 1.5 + outfit = /datum/outfit/mission_survivor + // Their body is the contract's until it lets go of them (corpse_lifetime()) + npc_corpse_lifetime = 0 + routine = list(/datum/ambient_activity/survivor_hide = 1) + ai_controller = /datum/ai_controller/basic_controller/ambient_npc/mission_survivor + /// The contract they belong to (a /datum/mission), for the distress call and to know its crew + var/datum/weakref/contract_ref + /// Who they were before this ("salvage tech"), from the contract + var/story + /// Where they keep their head down when they are not doing anything else + var/turf/hide_spot + /// The locker they were put in to hide, while they are still in it + var/datum/weakref/hid_in_ref + /// Someone has made them follow at least once + var/rescued = FALSE + /// They have seen a person: no more distress calls + var/contacted = FALSE + /// They said something on first stepping aboard a ship + var/said_aboard = FALSE + /// Being taken up in the beam: nothing else happens to them + var/beaming_out = FALSE + /// REFs of the people they already called out to + var/list/greeted + /// world.time they next look around, call for help on the radio, or fidget + var/next_look = 0 + var/next_distress = 0 + var/next_fidget = 0 + /// world.time they last saw a threat + var/last_threat_at = 0 + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/Initialize(mapload, datum/human_npc_order/order) + . = ..() + hide_spot = get_turf(src) + next_fidget = world.time + rand(20 SECONDS, 40 SECONDS) + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/Destroy() + contract_ref = null + hide_spot = null + hid_in_ref = null + greeted = null + return ..() + +// A dead survivor can be brought back inside the contract's window: the corpse cleanup leaves them be +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/corpse_lifetime() + return 0 + +/// Ties them to `contract`: its crew hear their distress call, and they take its story +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/join_contract(datum/mission/contract) + if(!contract) + return + contract_ref = WEAKREF(contract) + story = contract.objective_name + if(story) + var/article = (LOWER_TEXT(copytext(story, 1, 2)) in list("a", "e", "i", "o", "u")) ? "An" : "A" + desc = "[article] [story] who has had a very long week. [p_Their()] emergency suit is patched in three places and still holding." + +/** + * Puts them in their hiding place: into `locker` (shut) if there is one, or down in a crouch where + * they stand. Where they stand is where they come back to. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/take_shelter(obj/structure/closet/locker) + hide_spot = get_turf(src) + if(QDELETED(locker)) + crouch() + return + if(locker.opened) + forceMove(locker.loc) + locker.close() + else + forceMove(locker) + if(loc == locker) + hid_in_ref = WEAKREF(locker) + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/examine(mob/user) + . = ..() + if(stat != CONSCIOUS) + return + var/datum/ambient_activity/survivor_follow/follow = activity + var/mob/leader = istype(follow) ? follow.anchor() : null + if(leader) + . += span_notice("[p_Theyre()] sticking close to [leader].") + else if(crouching) + . += span_notice("[p_Theyre()] shaking.") + +// ========================================================================= +// FOLLOWING +// ========================================================================= + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/talked_to(mob/living/user) + if(beaming_out || !talk_ready(user)) + return + if(!buckled) + face_atom(user) + var/datum/ambient_activity/survivor_follow/follow = activity + var/mob/living/leader = istype(follow) ? follow.anchor() : null + if(leader == user) + stop_following() + speak_context("wait", user, force = TRUE) + return + // Whoever came for them keeps them: a stranger can't walk off with them + if(leader && is_contract_crew(leader) && !is_contract_crew(user)) + speak_context("refuse", user, force = TRUE) + return + if(start_following(user)) + speak_context("follow", user, force = TRUE) + +/// They follow `leader` from now on. Returns the activity, or null. +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/start_following(mob/living/leader) + if(beaming_out || !isliving(leader)) + return null + rescued = TRUE + contacted = TRUE + return start_activity(new /datum/ambient_activity/survivor_follow(src, leader)) + +/// They stop following and wait where they are +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/stop_following() + hide_spot = get_turf(src) + if(is_following()) + end_activity() + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/is_following() + return istype(activity, /datum/ambient_activity/survivor_follow) + +/// Their rescuer is out of reach: they wait where they are. Called by the follow activity as it ends. +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/lose_leader() + hide_spot = get_turf(src) + +/// Their rescuer went down in front of them +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/leader_fell(mob/living/leader) + hide_spot = get_turf(src) + if(!buckled) + face_atom(leader) + speak_context("leader_down", leader, force = TRUE) + +/// Whether `person` is crew of the ship that took their contract +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/is_contract_crew(mob/living/person) + var/datum/mission/contract = contract_ref?.resolve() + if(!contract?.servant || !isliving(person)) + return FALSE + return ambient_crew_ship(person) == contract.servant + +/// Shut in something while following: they push it open, or bang on it if it won't +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/climb_out() + var/obj/structure/closet/container = loc + if(!istype(container) || !reaction_ready("climb_out", 3 SECONDS)) + return + if(container.open()) + return + if(reaction_ready("trapped", 12 SECONDS)) + bang_on(container) + speak_context("trapped", null, force = TRUE) + +/// Bangs on the inside of `container` +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/bang_on(obj/structure/closet/container) + if(QDELETED(container)) + return + container.Shake(2, 1, 0.5 SECONDS) + playsound(container, 'sound/effects/bang.ogg', 30, TRUE, -2) + +/// The first time they step aboard a crew's ship +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/check_aboard() + if(said_aboard || !istype(get_area(src), /area/shuttle/voidcrew)) + return + said_aboard = TRUE + speak_context("aboard", null, force = TRUE) + +// ========================================================================= +// LOOKING AROUND +// ========================================================================= + +/** + * About once a second while their AI is on: who and what can they see, and what do they do about + * it. Starts reactions (calling out, running) as activities; never sleeps. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/look_around() + if(beaming_out || !can_act() || world.time < next_look) + return + next_look = world.time + SURVIVOR_LOOK_INTERVAL + var/turf/here = get_turf(src) + if(!here) + return + var/obj/structure/closet/container = istype(loc, /obj/structure/closet) ? loc : null + // Out of the locker they hid in: whoever let them out gets thanked + if(hid_in_ref && !container) + hid_in_ref = null + var/mob/living/opener = nearest_person(here, 2) + if(opener) + mark_greeted(opener) + contacted = TRUE + face_atom(opener) + speak_context("freed", opener, force = TRUE) + var/mob/living/threat = container ? null : nearest_threat(here) + if(threat) + last_threat_at = world.time + if(is_following()) + if(threat && reaction_ready("duck", SURVIVOR_DUCK_COOLDOWN)) + duck(threat) + return + if(threat) + react_threat(threat, here) + return + var/mob/living/person = new_person_in_sight(here, container) + if(person) + contacted = TRUE + start_activity(new /datum/ambient_activity/survivor_call(src, person)) + return + if(!contacted) + distress_call(here) + +/** + * A threat in sight, while nobody is leading them out. Close, or after them: they run. Further off: + * they keep still, and warn anyone there. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/react_threat(mob/living/threat, turf/here) + var/after_us = threat.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == src + if(get_dist(here, threat) <= SURVIVOR_FLEE_DISTANCE || after_us) + if(!istype(activity, /datum/ambient_activity/survivor_flee)) + start_activity(new /datum/ambient_activity/survivor_flee(src, threat)) + return + if(istype(activity, /datum/ambient_activity/survivor_flee)) + return + // Walking up to someone with that about? No. + if(istype(activity, /datum/ambient_activity/survivor_call)) + end_activity() + if(isturf(loc) && !crouching) + crouch() + var/mob/living/person = nearest_person(here, SURVIVOR_SIGHT) + if(person) + contacted = TRUE + mark_greeted(person) + if(reaction_ready("warn", 15 SECONDS)) + speak_context("warn", person, force = TRUE) + return + if(!reaction_ready("cower", 30 SECONDS)) + return + if(prob(50)) + speak_context("cower", threat, force = TRUE) + else + manual_emote(pick("freezes.", "holds [p_their()] breath.", "presses [p_themselves()] against the wall.")) + +/// Following, and something is coming: they duck down and yelp, and stay with their rescuer +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/duck(mob/living/threat) + if(!isturf(loc)) + return + speak_context("duck", threat, force = TRUE) + if(crouching || buckled) + return + crouch() + addtimer(CALLBACK(src, PROC_REF(stop_ducking)), SURVIVOR_DUCK_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/stop_ducking() + if(crouching && is_following()) + stand_up() + +/// Whether `thing` is something they should be afraid of: awake, nobody's player, not a person, and out to hurt someone +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/is_threat(mob/living/thing) + if(thing == src || thing.stat != CONSCIOUS || thing.client || istype(thing, /mob/living/carbon/human/npc/ambient_npc)) + return FALSE + if(faction_check_atom(thing)) + return FALSE + return mission_survivor_dangerous(thing) + +/// The nearest threat they can see from `here`, or null +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/nearest_threat(turf/here) + var/mob/living/nearest + var/nearest_distance = INFINITY + for(var/mob/living/thing in hearers(SURVIVOR_SIGHT, here)) + if(!is_threat(thing)) + continue + var/distance = get_dist(here, thing) + if(distance < nearest_distance) + nearest = thing + nearest_distance = distance + return nearest + +/** + * The nearest awake person (someone played) within `range` of `here` that they can see, or null. + * From inside a locker they can't see out, so it goes by ear: anyone close enough counts. + * `skip_greeted` leaves out people they already called out to. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/nearest_person(turf/here, range, by_ear = FALSE, skip_greeted = FALSE) + var/mob/living/nearest + var/nearest_distance = INFINITY + for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, range)) + if(person == src || person.stat != CONSCIOUS) + continue + var/turf/there = get_turf(person) + if(!there || there.z != here.z) + continue + var/distance = get_dist(here, there) + if(distance > range || distance >= nearest_distance) + continue + if(skip_greeted && LAZYFIND(greeted, REF(person))) + continue + if(!by_ear && !can_see(here, there, range)) + continue + nearest = person + nearest_distance = distance + return nearest + +/// Someone they have not called out to yet, in sight (or in earshot from a locker), or null. They count as called out to from now. +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/new_person_in_sight(turf/here, obj/structure/closet/container) + var/mob/living/person = container ? nearest_person(here, SURVIVOR_HEARING_RANGE, by_ear = TRUE, skip_greeted = TRUE) : nearest_person(here, SURVIVOR_SIGHT, skip_greeted = TRUE) + if(person) + mark_greeted(person) + return person + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/mark_greeted(mob/living/person) + var/key = REF(person) + if(LAZYFIND(greeted, key)) + return + LAZYADD(greeted, key) + if(length(greeted) > 20) + greeted.Cut(1, 2) + +/** + * Nobody has found them yet: their beacon picks up the contract crew's ship and they talk into it. + * Heard on the radios of that crew within SURVIVOR_DISTRESS_RANGE, as a weak signal. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/distress_call(turf/here) + if(world.time < next_distress) + return + var/datum/mission/contract = contract_ref?.resolve() + var/obj/structure/overmap/ship/ship = contract?.servant + if(!ship) + return + var/list/listeners = list() + for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, SURVIVOR_DISTRESS_RANGE)) + if(person.stat == DEAD) + continue + var/turf/there = get_turf(person) + if(!there || there.z != here.z || get_dist(here, there) > SURVIVOR_DISTRESS_RANGE) + continue + if(ambient_crew_ship(person) != ship) + continue + listeners += person + if(!length(listeners)) + return + next_distress = world.time + rand(SURVIVOR_DISTRESS_GAP_LOW, SURVIVOR_DISTRESS_GAP_HIGH) + var/list/lines = get_lines("distress") + if(!length(lines)) + return + var/line = fill_line(pick(lines)) + for(var/mob/living/person as anything in listeners) + to_chat(person, span_radio("\[Distress\] [real_name] says, \"[line]\"")) + person.playsound_local(get_turf(person), 'sound/misc/interference.ogg', 20, FALSE) + +/** + * Their hiding activity's tick: up on their feet when someone is right there, down again when + * they are alone; a plea to whoever is close; otherwise a fidget now and then. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/hide_tick() + var/turf/here = get_turf(src) + if(!here) + return + if(!isturf(loc)) + return // shut in somewhere, keeping quiet + var/mob/living/near = nearest_person(here, SURVIVOR_NEAR_RANGE) + var/scared = world.time - last_threat_at < 5 SECONDS + if(near && !scared) + if(crouching) + stand_up() + if(!buckled) + face_atom(near) + if(reaction_ready("plead", 40 SECONDS)) + speak_context(rescued ? "wait_idle" : "plead", near, force = TRUE) + return + if(!near && !crouching && !buckled) + crouch() + if(world.time < next_fidget) + return + next_fidget = world.time + rand(40 SECONDS, 80 SECONDS) + if(prob(40)) + speak_context(rescued ? "wait_idle" : "hide", null, force = TRUE) + return + manual_emote(pick( + "hugs [p_their()] knees.", + "shivers.", + "taps at the beacon on [p_their()] wrist.", + "listens, very still.", + "rocks back and forth.", + )) + +/** + * Where to run from `threat`: a free tile within SURVIVOR_FLEE_RANGE, further from it than they are, + * on their own side of it (so the way there doesn't pass it), in sight from where they stand (so + * the way there is short), best out of its sight with a wall at their back. Null if nowhere. + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/flee_tile(mob/living/threat, list/avoid) + var/turf/here = get_turf(src) + var/turf/danger = get_turf(threat) + if(!here || !danger || here.z != danger.z) + return null + var/start_gap = get_dist(here, danger) + var/turf/best + var/best_score = -INFINITY + for(var/turf/tile as anything in RANGE_TURFS(SURVIVOR_FLEE_RANGE, here)) + var/gap = get_dist(tile, danger) + if(gap < start_gap + 2 || get_dist(tile, here) >= gap) + continue + if(!standable(tile, avoid) || !can_see(here, tile, SURVIVOR_FLEE_RANGE + 1)) + continue + var/score = gap + rand(0, 3) + if(!can_see(danger, tile, SURVIVOR_SIGHT + 2)) + score += 4 + if(ambient_backed_by_wall_or_table(tile)) + score += 1 + if(score > best_score) + best = tile + best_score = score + return best + +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/react_attacked(atom/attacker) + if(stat != CONSCIOUS || fading || beaming_out || !reaction_ready("attacked", 4 SECONDS)) + return + var/mob/living/hitter = isliving(attacker) ? attacker : null + speak_context(hitter?.client ? "hurt_by_person" : "hurt", attacker, force = TRUE) + if(is_following()) + if(hitter) + duck(hitter) + return + if(hitter) + start_activity(new /datum/ambient_activity/survivor_flee(src, hitter)) + +// ========================================================================= +// LEAVING: THE BEAM +// ========================================================================= + +/** + * Taken up from orbit: a column of light comes down on them, they lift and come apart in it, and + * they are gone. They are held still meanwhile. Somewhere nobody can see, they just go. + * `farewell` has them say goodbye first (delivered home). `force_visual` draws the beam even + * with nobody about (unit tests). + */ +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/beam_out(farewell = FALSE, force_visual = FALSE) + if(beaming_out || QDELETED(src)) + return + var/turf/spot = get_turf(src) + if(!spot || (!force_visual && !mission_survivor_watched(spot))) + beaming_out = TRUE + qdel(src) + return + var/was_awake = stat == CONSCIOUS && isturf(loc) + if(was_awake) + setDir(SOUTH) // face front, so the dissolve reads + if(farewell) + speak_context("goodbye", null, force = TRUE) + manual_emote("waves.") + else + manual_emote("looks up.") + beaming_out = TRUE + fading = TRUE + end_activity() + stand_up() + add_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED, TRAIT_NO_FLOATING_ANIM), SURVIVOR_TRAIT) + pulledby?.stop_pulling() + stop_pulling() + buckled?.unbuckle_mob(src, force = TRUE) + set_anchored(TRUE) + move_resist = INFINITY + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + playsound(spot, 'sound/effects/magic/teleport_diss.ogg', 40, TRUE) + new /obj/effect/temp_visual/transporter_beam(spot, SURVIVOR_BEAM_TIME + 0.5 SECONDS) + addtimer(CALLBACK(src, PROC_REF(beam_dissolve)), SURVIVOR_BEAM_DROP) + addtimer(CALLBACK(src, PROC_REF(beam_gone)), SURVIVOR_BEAM_TIME) + +/// The column is down: they lift a little and come apart in it, head first +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/beam_dissolve() + if(!isturf(loc)) + return // shut in a locker: the column takes them through it + var/dissolve_time = SURVIVOR_BEAM_TIME - SURVIVOR_BEAM_DROP + new /obj/effect/abstract/particle_holder(src, /particles/transporter_motes, PARTICLE_ATTACH_MOB) + transporter_dematerialise(src, dissolve_time) + animate(src, pixel_z = pixel_z + 8, time = dissolve_time, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + +/// Gone: the last flash where they stood, and the column lifts back up without them +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/proc/beam_gone() + var/turf/spot = get_turf(src) + if(spot) + new /obj/effect/temp_visual/transporter_flash/departure(spot) + playsound(spot, 'sound/effects/magic/teleport_app.ogg', 50, TRUE) + qdel(src) + +/// Whether anyone with a client is close enough to `spot` to see a beam come down there +/proc/mission_survivor_watched(turf/spot) + for(var/mob/viewer in SSspatial_grid.orthogonal_range_search(spot, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, SURVIVOR_BEAM_WATCH_RANGE)) + if(!viewer.client) + continue + var/turf/there = get_turf(viewer) + if(there?.z == spot.z && get_dist(there, spot) <= SURVIVOR_BEAM_WATCH_RANGE) + return TRUE + return FALSE + +// ========================================================================= +// WHAT THEY SAY +// ========================================================================= + +// Their own lines only: none of the ambient core's chatter fits someone in this state +/mob/living/carbon/human/npc/ambient_npc/mission_survivor/get_lines(context) + var/static/list/lines = list( + // Hiding alone, under their breath + "hide" = list( + "#Come on. Somebody heard it.", + "#Stay quiet. Stay quiet.", + "#Please, please, please.", + "#Beacon's still on. It's still on.", + ), + // A threat about and nobody there + "cower" = list( + "#Don't see me. Don't see me.", + "#Go away. Please go away.", + "#Oh no. Oh no.", + ), + // A threat about, and people there + "warn" = list( + "Look out!", + "There's more of them!", + "Watch it, they're coming!", + ), + // First sight of someone + "call" = list( + "Over here!", + "Hey! Hey, over here!", + "Is someone there? Please!", + "Here! I'm over here!", + "You came. You actually came.", + ), + // First sound of someone, from a locker + "call_locker" = list( + "In here! I'm in here!", + "Is someone out there? Let me out!", + "Don't go! I'm in here!", + ), + // Let out of the locker + "freed" = list( + "Oh, thank god.", + "Thank you. Thank you.", + "I thought you were one of them.", + ), + // Someone right there, not taking them yet + "plead" = list( + "Please, get me out of here.", + "Are you taking me out? Please say yes.", + "Can we go? Please?", + ), + "flee" = list( + "No no no!", + "Get away from me!", + "Help! Help!", + "Somebody help!", + ), + "hurt" = list( + "Agh!", + "Ow! Help!", + "Please! Stop!", + ), + "hurt_by_person" = list( + "Hey! I'm on your side!", + "Ow! What was that for?", + "Watch where you're shooting!", + ), + "follow" = list( + "Right behind you.", + "Okay. Okay. Let's go.", + "Don't leave me behind.", + "Right behind you. Please don't be wrong about the way out.", + ), + "wait" = list( + "I'll... wait right here.", + "Don't be long.", + "Okay. I'll be here.", + ), + "refuse" = list( + "I'm with them.", + "No. I'm going with them.", + ), + // Following, now and then + "follow_idle" = list( + "Is your ship far?", + "I thought nobody was coming.", + "How long was I in there?", + "Thank you. Really.", + "Do you have any water?", + ), + // Following, and something is coming + "duck" = list( + "What was that?", + "Something's moving!", + "Don't let it near me!", + "Get down!", + ), + "lost" = list( + "Hello? Where'd you go?", + "Don't leave me here!", + "Hello?", + ), + "leader_down" = list( + "No. No, no, no.", + "Get up. Please, get up.", + ), + "trapped" = list( + "Let me out!", + "Hey! Open this!", + ), + "aboard" = list( + "Is this your ship? Oh, thank god.", + "A real ship. Lights and everything.", + ), + // Waiting where they were told, or near someone after being rescued + "wait_idle" = list( + "Is it much further?", + "I'll stay right here.", + "Are we leaving soon?", + ), + "goodbye" = list( + "Thank you. I mean it.", + "I owe you. All of you.", + "Thank you. Really.", + ), + // Over the radio, before anyone has found them + "distress" = list( + "Is anyone out there? Please.", + "This is {name}. I'm still here.", + "I can hear a ship. Please don't leave.", + "Hello? Anyone? Please.", + "Still here. Still here. Please.", + ), + ) + var/list/found = lines[context] + return islist(found) ? found : list() + +// ========================================================================= +// THEIR LOOK +// ========================================================================= + +/// What they have been living in: an orange emergency suit over their work clothes, helmet on and a tank of air +/datum/outfit/mission_survivor + name = "Rescue Survivor" + uniform = /obj/item/clothing/under/color/grey + suit = /obj/item/clothing/suit/space/fragile + head = /obj/item/clothing/head/helmet/space/fragile + mask = /obj/item/clothing/mask/breath + back = /obj/item/tank/internals/oxygen + shoes = /obj/item/clothing/shoes/workboots + internals_slot = ITEM_SLOT_BACK + +// ========================================================================= +// THEIR BRAIN +// ========================================================================= + +/// The ambient routine runner, with their senses in front of it. Unlike the rest of the ambient +/// core they never resist being pulled, carried, buckled or shut in: that is how a crew gets them out. +/datum/ai_controller/basic_controller/ambient_npc/mission_survivor + planning_subtrees = list( + /datum/ai_planning_subtree/mission_survivor_senses, + /datum/ai_planning_subtree/ambient_routine, + ) + // Awake while a crew is still a few screens off, for the distress call + interesting_dist = 24 + +/// Looks around and reacts; the routine subtree after it does the walking +/datum/ai_planning_subtree/mission_survivor_senses + +/datum/ai_planning_subtree/mission_survivor_senses/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = controller.pawn + if(istype(survivor) && controller.ai_status == AI_STATUS_ON) + survivor.look_around() + +// ========================================================================= +// WHAT THEY DO +// ========================================================================= + +/// Keeping their head down where they are told to stay, or where they last stopped +/datum/ambient_activity/survivor_hide + name = "hiding" + +/datum/ambient_activity/survivor_hide/setup() + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + var/turf/hide_at = istype(survivor) ? survivor.hide_spot : null + if(isturf(doer.loc) && hide_at && hide_at != doer.loc && hide_at.z == doer.z && get_dist(doer, hide_at) <= SURVIVOR_FOLLOW_RANGE && doer.standable(hide_at, failed_spots)) + go_to(hide_at) + return TRUE + +/datum/ambient_activity/survivor_hide/act(seconds) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor)) + survivor.hide_tick() + return AMBIENT_STEP_CONTINUE + +/datum/ambient_activity/survivor_hide/spot_unreachable() + . = ..() + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor)) + survivor.hide_spot = get_turf(doer) + +/// Calling out to someone they spotted, waving, and coming over if it is safe; from a locker, banging on it +/datum/ambient_activity/survivor_call + name = "calling out" + priority = AMBIENT_PRIORITY_REACTION + duration_low = 15 SECONDS + duration_high = 20 SECONDS + +/datum/ambient_activity/survivor_call/setup() + var/mob/living/person = anchor() + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(!isliving(person) || !istype(survivor)) + return FALSE + set_duration() + if(istype(doer.loc, /obj/structure/closet)) + survivor.bang_on(doer.loc) + doer.speak_context("call_locker", person, force = TRUE) + return TRUE + doer.stand_up() + doer.face_atom(person) + doer.manual_emote(pick("waves both arms.", "waves.", "waves weakly.")) + doer.speak_context("call", person, force = TRUE) + var/distance = get_dist(doer, person) + if(distance > 2 && distance <= SURVIVOR_APPROACH_RANGE) + go_to(get_turf(person), 2) + return TRUE + +/datum/ambient_activity/survivor_call/act(seconds) + var/mob/living/person = anchor() + var/turf/there = get_turf(person) + if(!person || person.stat == DEAD || there?.z != doer.z) + return AMBIENT_STEP_DONE + var/obj/structure/closet/container = doer.loc + if(istype(container)) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + // Right outside: they push it open themselves + if(get_dist(container, person) <= 1 && container.open()) + return AMBIENT_STEP_DONE + if(istype(survivor) && survivor.reaction_ready("bang", 6 SECONDS)) + survivor.bang_on(container) + return AMBIENT_STEP_CONTINUE + if(spot && get_dist(doer, person) > 2) + if(LAZYACCESS(failed_spots, there)) + return AMBIENT_STEP_DONE + go_to(there, 2) + return AMBIENT_STEP_MOVE + if(!doer.buckled) + doer.face_atom(person) + return AMBIENT_STEP_DONE + +/datum/ambient_activity/survivor_call/finish() + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor) && isturf(survivor.loc)) + survivor.hide_spot = survivor.loc + return ..() + +/// Running from something, then down in a crouch wherever they got to +/datum/ambient_activity/survivor_flee + name = "running" + priority = SURVIVOR_FLEE_PRIORITY + /// How many more times a threat that keeps coming can send them on + var/runs_left = SURVIVOR_FLEE_RUNS + +/datum/ambient_activity/survivor_flee/setup() + var/mob/living/threat = anchor() + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(!isliving(threat) || !istype(survivor)) + return FALSE + ends_at = world.time + SURVIVOR_FLEE_TIME + doer.set_pace(running = TRUE) + if(survivor.reaction_ready("flee_line", 6 SECONDS)) + doer.speak_context("flee", threat, force = TRUE) + var/turf/away = isturf(doer.loc) ? survivor.flee_tile(threat, failed_spots) : null + if(away) + doer.stand_up() + go_to(away) + else + // Nowhere to go: down, arms over their head + doer.crouch() + return TRUE + +/datum/ambient_activity/survivor_flee/arrive() + doer.crouch() + +/datum/ambient_activity/survivor_flee/act(seconds) + var/mob/living/threat = anchor() + if(!threat || threat.stat != CONSCIOUS) + return AMBIENT_STEP_DONE + if(runs_left > 0 && get_dist(doer, threat) <= 2 && isturf(doer.loc)) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + var/turf/away = survivor.flee_tile(threat, failed_spots) + if(away) + runs_left-- + doer.stand_up() + go_to(away) + return AMBIENT_STEP_MOVE + return AMBIENT_STEP_CONTINUE + +/datum/ambient_activity/survivor_flee/finish() + if(!QDELETED(doer)) + doer.set_pace(running = FALSE) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor) && isturf(survivor.loc)) + survivor.hide_spot = survivor.loc + return ..() + +/// At their rescuer's side as they move, facing them when they stop; out of anything they get shut in +/datum/ambient_activity/survivor_follow + name = "following" + priority = SURVIVOR_FOLLOW_PRIORITY + spot_distance = 1 + /// world.time their rescuer went out of reach, or 0 + var/lost_since = 0 + /// They called out for their rescuer since losing them + var/called_lost = FALSE + +/datum/ambient_activity/survivor_follow/setup() + if(!isliving(anchor())) + return FALSE + next_line = world.time + rand(40 SECONDS, 80 SECONDS) + // They keep up with whoever is getting them out + doer.set_pace(running = TRUE) + return TRUE + +/datum/ambient_activity/survivor_follow/finish() + if(!QDELETED(doer)) + doer.set_pace(running = FALSE) + return ..() + +/// Whether `leader` can still be followed: alive, on this level, near enough +/datum/ambient_activity/survivor_follow/proc/in_reach(mob/living/leader) + var/turf/there = get_turf(leader) + var/turf/here = get_turf(doer) + return leader && leader.stat != DEAD && there && here && there.z == here.z && get_dist(here, there) <= SURVIVOR_FOLLOW_RANGE + +// Where they go is wherever their rescuer is now +/datum/ambient_activity/survivor_follow/at_spot() + if(!isturf(doer.loc)) + return TRUE // act() gets them out first + var/mob/living/leader = anchor() + if(!in_reach(leader) || get_dist(doer, leader) <= 1) + return TRUE + if(doer.crouching) + doer.stand_up() + var/turf/there = get_turf(leader) + if(spot != there) + go_to(there, 1) + return FALSE + +/datum/ambient_activity/survivor_follow/act(seconds) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/survivor = doer + if(!istype(survivor)) + return AMBIENT_STEP_DONE + if(!isturf(doer.loc)) + survivor.climb_out() + return AMBIENT_STEP_CONTINUE + var/mob/living/leader = anchor() + if(leader?.stat == DEAD && get_dist(doer, leader) <= SURVIVOR_SIGHT) + survivor.leader_fell(leader) + return AMBIENT_STEP_DONE + if(!in_reach(leader)) + if(!lost_since) + lost_since = world.time + if(!called_lost && world.time - lost_since > 8 SECONDS) + called_lost = TRUE + doer.speak_context("lost", null, force = TRUE) + if(world.time - lost_since >= SURVIVOR_LOST_TIME) + survivor.lose_leader() + return AMBIENT_STEP_DONE + return AMBIENT_STEP_CONTINUE + lost_since = 0 + called_lost = FALSE + if(get_dist(doer, leader) <= 2 && !doer.buckled) + doer.face_atom(leader) + survivor.check_aboard() + chatter("follow_idle", 60 SECONDS, 120 SECONDS) + return AMBIENT_STEP_CONTINUE + +// A spot they could not reach is just their rescuer's last one: they try again from where they are +/datum/ambient_activity/survivor_follow/spot_unreachable() + spot = null + arrived = FALSE + +// ========================================================================= +// WHERE THEY HIDE +// ========================================================================= + +/** + * Where a survivor hides at `target`, looking around `near` (the site's spawn point): list(turf, + * locker or null). Half the time a locker if there is one (they get in it), otherwise a corner or + * an alcove, never beside open space or on a docked ship, and never within + * SURVIVOR_SHELTER_THREAT_GAP of anything hostile already loose in there. Falls back to `near`. + * + * Bounded (a square of SURVIVOR_SHELTER_RADIUS around `near`) and never sleeps: it runs from the + * site's loaded signal. + */ +/proc/mission_survivor_hiding_place(turf/near, datum/mission_target/target) + . = list(near, null) + if(!near || !target) + return + var/list/dangers = list() + for(var/mob/living/thing as anything in GLOB.mob_living_list) + if(thing.z != near.z || thing.stat == DEAD || thing.client) + continue + if(get_dist(thing, near) > SURVIVOR_SHELTER_RADIUS + SURVIVOR_SHELTER_THREAT_GAP) + continue + if(FACTION_NEUTRAL in thing.faction) + continue + if(mission_survivor_dangerous(thing)) + dangers += get_turf(thing) + var/list/lockers = list() + var/list/nooks = list() + var/best_cover = 0 + for(var/turf/open/tile in RANGE_TURFS(SURVIVOR_SHELTER_RADIUS, near)) + if(isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) + continue + if(istype(tile.loc, /area/shuttle) || !target.contains_turf(tile)) + continue + if(length(dangers) && ambient_too_close(tile, dangers, SURVIVOR_SHELTER_THREAT_GAP)) + continue + var/obj/structure/closet/locker = locate() in tile + if(locker) + if(mission_survivor_locker_ok(locker)) + lockers += locker + continue + if(tile.is_blocked_turf(exclude_mobs = TRUE)) + continue + var/cover = mission_survivor_cover(tile) + if(cover < SURVIVOR_SHELTER_MIN_COVER) + continue + nooks[tile] = cover + best_cover = max(best_cover, cover) + if(length(lockers) && (prob(50) || !length(nooks))) + var/obj/structure/closet/locker = pick(lockers) + return list(get_turf(locker), locker) + if(!length(nooks)) + return + // The snuggest spots, not always the single snuggest one + var/list/best = list() + for(var/turf/tile as anything in nooks) + if(nooks[tile] >= best_cover - 1) + best += tile + return list(pick(best), null) + +/// Neighbours (of 8) of `tile` that are walls or blocked, or 0 if it opens onto space or has no free way in +/proc/mission_survivor_cover(turf/tile) + var/blocked = 0 + var/way_in = FALSE + for(var/direction in GLOB.alldirs) + var/turf/neighbour = get_step(tile, direction) + if(!neighbour || isspaceturf(neighbour) || isgroundlessturf(neighbour)) + return 0 + if(neighbour.is_blocked_turf(exclude_mobs = TRUE)) + blocked++ + else if(direction in GLOB.cardinals) + way_in = TRUE + return way_in ? blocked : 0 + +/// A locker a survivor can shut themselves in, and that someone can get to and open +/proc/mission_survivor_locker_ok(obj/structure/closet/locker) + if(locker.locked || locker.welded || locker.wall_mounted || locker.horizontal) + return FALSE + if(istype(locker, /obj/structure/closet/body_bag) || (locker.resistance_flags & INDESTRUCTIBLE)) + return FALSE + if(locker.max_mob_size < MOB_SIZE_HUMAN || locker.mob_storage_capacity < 1 || (locate(/mob/living) in locker)) + return FALSE + if(locker.opened && !locker.can_close()) + return FALSE + for(var/direction in GLOB.cardinals) + var/turf/neighbour = get_step(locker, direction) + if(neighbour && !isspaceturf(neighbour) && !neighbour.is_blocked_turf(exclude_mobs = TRUE)) + return TRUE + return FALSE + +/** + * Whether `thing` is out to hurt people: a hostile legacy animal, or a mob whose AI picks targets. + * Faction is the caller's business. + */ +/proc/mission_survivor_dangerous(mob/living/thing) + if(istype(thing, /mob/living/simple_animal/hostile)) + return TRUE + return !!thing.ai_controller?.blackboard[BB_TARGETING_STRATEGY] + +#undef SURVIVOR_SIGHT +#undef SURVIVOR_LOOK_INTERVAL +#undef SURVIVOR_FLEE_DISTANCE +#undef SURVIVOR_FLEE_RANGE +#undef SURVIVOR_FLEE_TIME +#undef SURVIVOR_FLEE_RUNS +#undef SURVIVOR_DISTRESS_RANGE +#undef SURVIVOR_DISTRESS_GAP_LOW +#undef SURVIVOR_DISTRESS_GAP_HIGH +#undef SURVIVOR_APPROACH_RANGE +#undef SURVIVOR_NEAR_RANGE +#undef SURVIVOR_HEARING_RANGE +#undef SURVIVOR_FOLLOW_RANGE +#undef SURVIVOR_LOST_TIME +#undef SURVIVOR_FOLLOW_PRIORITY +#undef SURVIVOR_FLEE_PRIORITY +#undef SURVIVOR_DUCK_TIME +#undef SURVIVOR_DUCK_COOLDOWN +#undef SURVIVOR_SHELTER_RADIUS +#undef SURVIVOR_SHELTER_THREAT_GAP +#undef SURVIVOR_SHELTER_MIN_COVER +#undef SURVIVOR_BEAM_TIME +#undef SURVIVOR_BEAM_DROP +#undef SURVIVOR_BEAM_WATCH_RANGE +#undef SURVIVOR_TRAIT diff --git a/voidcrew/modules/missions/objectives/combat.dm b/voidcrew/modules/missions/objectives/combat.dm index a9f08365fff..72cf2024bc2 100644 --- a/voidcrew/modules/missions/objectives/combat.dm +++ b/voidcrew/modules/missions/objectives/combat.dm @@ -158,8 +158,12 @@ var/proof_type = /obj/item/mission_recovery/proof /// The live target, while they're alive var/mob/living/target_mob + /// The queued order building a human NPC target (voidcrew/modules/human_npcs/), until it is built + var/datum/human_npc_order/quarry_order /datum/mission_objective/field/kill_named/deactivate() + // A target still waiting in the spawn queue is never built. + QDEL_NULL(quarry_order) if(target_mob) UnregisterSignal(target_mob, COMSIG_LIVING_DEATH) target_mob = null @@ -174,6 +178,9 @@ create_proof(spawn_turf) complete() return + if(ispath(target_mob_type, /mob/living/carbon/human/npc)) + spawn_human_quarry(spawn_turf) + return var/mob/living/target = spawn_field_npc(target_mob_type, spawn_turf, finalize = FALSE) target.name = mission.objective_name target.desc += " They look like they're worth something dead." @@ -186,6 +193,31 @@ guard.desc += " They're on somebody's payroll." guard.npc_spawn_context.finalize() +/** + * A human NPC target (voidcrew/modules/human_npcs/) and its guards are queued, a body a tick, not built + * on the spot. The target carries the contract's name and is tracked once it exists; the guards are + * untracked muscle, as above. + */ +/datum/mission_objective/field/kill_named/proc/spawn_human_quarry(turf/spawn_turf) + var/datum/npc_spawn_context/context = new(mission?.target || src, mission?.target?.get_zone_type(), "auto", source_turf = spawn_turf) + quarry_order = spawn_human_npc(target_mob_type, spawn_turf, CALLBACK(src, PROC_REF(on_quarry_built)), name = mission.objective_name, context = context) + for(var/guard_type in guard_types) + var/turf/guard_spot = get_nearby_open_turf(spawn_turf) + if(ispath(guard_type, /mob/living/carbon/human/npc)) + var/datum/npc_spawn_context/guard_context = new(mission?.target || src, mission?.target?.get_zone_type(), "auto", source_turf = guard_spot) + spawn_human_npc(guard_type, guard_spot, context = guard_context) + continue + var/mob/living/guard = spawn_field_npc(guard_type, guard_spot, finalize = FALSE) + guard.desc += " They're on somebody's payroll." + guard.npc_spawn_context.finalize() + +/// The queued target exists now: track it, unless the contract moved on while it waited. +/datum/mission_objective/field/kill_named/proc/on_quarry_built(mob/living/quarry) + quarry_order = null + if(!active || !mission || mission.failed || mission.completed || mission.named_kill_completed) + return + track_target(quarry) + /// Keep death tracking and the cash entitlement attached to the same mission. /datum/mission_objective/field/kill_named/proc/track_target(mob/living/target) target_mob = target @@ -193,6 +225,12 @@ if(istype(target, /mob/living/basic/trooper/pirate/faction)) var/mob/living/basic/trooper/pirate/faction/pirate = target pirate.mission_payout_ref = WEAKREF(mission) + else if(istype(target, /mob/living/carbon/human/npc/ruin)) + var/mob/living/carbon/human/npc/ruin/quarry = target + quarry.npc_payout_mission = WEAKREF(mission) + else if(istype(target, /mob/living/carbon/human/npc/faction_pirate)) + var/mob/living/carbon/human/npc/faction_pirate/pirate_quarry = target + pirate_quarry.npc_payout_mission = WEAKREF(mission) RegisterSignal(target, COMSIG_LIVING_DEATH, PROC_REF(on_target_death)) mission.register_quest_atom(target) @@ -239,8 +277,9 @@ * turn-in item - the mission auto-completes like a survey. */ /datum/mission_objective/kill_count - /// Base typepath of mobs that count - var/mob_base_type = /mob/living/basic/trooper/pirate/faction + /// Base typepaths of basic mobs that count (the basic faction pirates). Human NPCs count by the pirate + /// flag they sail under (counts_victim()): ruin pirates and NPC ship crews alike. + var/list/mob_base_types = list(/mob/living/basic/trooper/pirate/faction) /// Minimum zone the kill must happen in (design guard: no green farming) var/minimum_zone = ZONE_YELLOW /// Kills required / confirmed @@ -248,10 +287,13 @@ var/kill_count = 0 /// How close (tiles) a living crew member must be to confirm a kill var/confirm_range = 9 + /// Weakrefs to every body already counted: one revived and killed again counts once + var/list/datum/weakref/counted_kills = list() /datum/mission_objective/kill_count/reset() . = ..() kill_count = 0 + counted_kills.Cut() /datum/mission_objective/kill_count/activate() . = ..() @@ -263,16 +305,17 @@ /datum/mission_objective/kill_count/proc/on_any_death(datum/source, mob/living/victim, gibbed) SIGNAL_HANDLER - if(completed || !istype(victim, mob_base_type)) + if(completed || !counts_victim(victim)) return - var/turf/where = get_turf(victim) - if(!where) + var/datum/weakref/victim_ref = WEAKREF(victim) + if(victim_ref in counted_kills) return - var/zone_type = SSovermap_zones?.get_zone_type_anywhere(where) - if(isnull(zone_type) || zone_type < minimum_zone) + var/turf/where = get_turf(victim) + if(!where || !kill_zone_counts(where)) return if(!kill_attributable_to_crew(victim, where)) return + counted_kills += victim_ref kill_count++ if(kill_count >= required_kills) notify_crew("Suppression sweep complete.") @@ -280,6 +323,21 @@ return notify_crew("Kill confirmed: [kill_count]/[required_kills].", sound = 'voidcrew/sound/notify2.ogg') +/// Whether a kill at `where` is deep enough to count (design guard: no green farming). +/datum/mission_objective/kill_count/proc/kill_zone_counts(turf/where) + var/zone_type = SSovermap_zones?.get_zone_type_anywhere(where) + return !isnull(zone_type) && zone_type >= minimum_zone + +/** + * Whether `victim` is a mob this sweep counts: the basic faction pirates, and human NPCs + * (voidcrew/modules/human_npcs/) that sail under the pirate flag: ruin and mission pirates and the crews + * of NPC pirate ships. Each body counts once (counted_kills). + */ +/datum/mission_objective/kill_count/proc/counts_victim(mob/living/victim) + if(is_type_in_list(victim, mob_base_types)) + return TRUE + return ishumannpc(victim) && (FACTION_PIRATE in victim.faction) + /** * Whether this kill belongs to the servant crew: the last attacker's ckey is * a crew member's, or a living crew member is close enough to have done it diff --git a/voidcrew/modules/missions/objectives/site_ops.dm b/voidcrew/modules/missions/objectives/site_ops.dm index 0689c21430e..519e75acb19 100644 --- a/voidcrew/modules/missions/objectives/site_ops.dm +++ b/voidcrew/modules/missions/objectives/site_ops.dm @@ -18,12 +18,15 @@ /datum/mission_objective/field/plant_quest /// The quest item type planted at the site var/quest_item_type = /obj/item/mission_recovery + /// Set dressing laid around the item (a /datum/mission_scene path, null for none) + var/scene_type = /datum/mission_scene/dropped_case /datum/mission_objective/field/plant_quest/spawn_field_objects(turf/spawn_turf) var/obj/item/mission_recovery/quest_item = new quest_item_type(spawn_turf) quest_item.name = mission.objective_name mission.bind_item(quest_item) mission.register_quest_atom(quest_item) + dress_mission_scene(scene_type, spawn_turf, mission.target) complete() /datum/mission_objective/field/plant_quest/get_progress_string() @@ -91,23 +94,30 @@ /datum/mission_objective/field/pylon_chain/proc/pick_spawn_spots(turf/anchor) var/list/spots = list(anchor) var/datum/mission_target/target = mission?.target + // Out of sight first; ground a crew can see is only used if the rest can't hold the set + var/datum/spawn_cover/cover = new(anchor.z) var/list/candidates = list() + var/list/in_view = list() for(var/turf/open/tile in RANGE_TURFS(PYLON_SCATTER_RADIUS, anchor)) if(isspaceturf(tile) || tile.is_blocked_turf(exclude_mobs = TRUE)) continue if(target && !target.contains_turf(tile)) continue - candidates += tile - while(length(spots) < points_total && length(candidates)) - var/turf/candidate = pick_n_take(candidates) - var/too_close = FALSE - for(var/turf/taken as anything in spots) - if(get_dist(candidate, taken) < PYLON_MIN_SPACING) - too_close = TRUE - break - if(too_close) - continue - spots += candidate + if(cover.is_hidden(tile)) + candidates += tile + else + in_view += tile + for(var/list/pool as anything in list(candidates, in_view)) + while(length(spots) < points_total && length(pool)) + var/turf/candidate = pick_n_take(pool) + var/too_close = FALSE + for(var/turf/taken as anything in spots) + if(get_dist(candidate, taken) < PYLON_MIN_SPACING) + too_close = TRUE + break + if(too_close) + continue + spots += candidate // Cramped site: take whatever's open rather than hand back a short set while(length(spots) < points_total) spots += get_nearby_open_turf(anchor, 3) @@ -121,6 +131,7 @@ pylon.name = "survey pylon [length(pylons) + 1] of [points_total]" pylon.objective_ref = WEAKREF(src) pylons += pylon + dress_mission_scene(/datum/mission_scene/pylon_drop, spawn_turf, mission?.target) /// The first pylon still waiting on calibration /datum/mission_objective/field/pylon_chain/proc/next_pylon() @@ -163,7 +174,7 @@ points_done++ // Calibration is loud. Something heard it. if(wave_theme) - new wave_theme(get_turf(pylon), list(1, 2)) + new wave_theme(get_turf(pylon), list(1, 2), TRUE) // The finished pylon stays as a breadcrumb but stops being the objective mission.forget_quest_atom(pylon) @@ -299,7 +310,7 @@ */ /datum/mission_objective/field/escort /// The survivor mob type - var/survivor_type = /mob/living/basic/mission_survivor + var/survivor_type = /mob/living/carbon/human/npc/ambient_npc/mission_survivor /// The live survivor var/mob/living/survivor /// Health fraction at turn-in for the "still walking" pay bonus @@ -341,6 +352,9 @@ * * The successful turn-in has already nulled the ref by the time deactivation * runs, so a repatriated survivor never reaches this. + * + * Whoever is watching sees them taken up from orbit in a column of light + * (beam_out()); this is usually aboard the crew's own ship, mid-escort. */ /datum/mission_objective/field/escort/proc/release_survivor() clear_grace() @@ -349,11 +363,20 @@ if(QDELETED(leaving)) return UnregisterSignal(leaving, list(COMSIG_LIVING_DEATH, COMSIG_LIVING_REVIVE)) - leaving.visible_message(span_notice("[leaving]'s beacon chirps, and they are teleported away.")) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/person = leaving + if(istype(person)) + person.beam_out() + return qdel(leaving) +/// The survivor goes somewhere sheltered in the site (a locker, a corner), not just any open tile /datum/mission_objective/field/escort/spawn_field_objects(turf/spawn_turf) - survivor = new survivor_type(spawn_turf) + var/list/hiding_place = mission_survivor_hiding_place(spawn_turf, mission?.target) + survivor = new survivor_type(hiding_place[1] || spawn_turf) + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/person = survivor + if(istype(person)) + person.join_contract(mission) + person.take_shelter(hiding_place[2]) protect_field_mob(survivor) RegisterSignal(survivor, COMSIG_LIVING_DEATH, PROC_REF(on_survivor_death)) RegisterSignal(survivor, COMSIG_LIVING_REVIVE, PROC_REF(on_survivor_revived)) @@ -444,14 +467,17 @@ if(!was_revived && survivor.health >= survivor.maxHealth * unharmed_threshold) mission.value *= unharmed_bonus notify_crew("Survivor recovered unharmed.") - // Repatriation: they step onto the pad and ship out + // Repatriation: a goodbye, and the beam takes them home from beside the pad var/mob/living/leaving = survivor UnregisterSignal(leaving, list(COMSIG_LIVING_DEATH, COMSIG_LIVING_REVIVE)) survivor = null - leaving.visible_message(span_notice("[leaving] waves goodbye and is teleported out.")) if(istype(reward_anchor, /obj/machinery/mission_pad)) var/obj/machinery/mission_pad/pad = reward_anchor pad.do_teleport_effect() + var/mob/living/carbon/human/npc/ambient_npc/mission_survivor/person = leaving + if(istype(person)) + person.beam_out(farewell = TRUE) + return qdel(leaving) /datum/mission_objective/field/escort/get_progress_string() @@ -470,20 +496,28 @@ /** * The prospect-stake centerpiece: a claim beacon kit is dispensed at accept; - * deploying it (only with the ship holding at the target coordinates) files - * the claim on Wideband - everyone hears - and starts a defend clock that - * only runs while the ship stays on station. The beacon survives, it prints - * the deed; the beacon dies, the contract dies with it. + * deploying it on the site's open ground (the kit decides where) files the + * claim on Wideband - everyone hears - and starts a defend clock that only + * runs while a crew member stands guard at the beacon. Waves come on that + * clock, not the wall clock, so stepping away never calls extra company. The + * beacon survives, it prints the deed; the beacon dies, the contract dies + * with it. */ /datum/mission_objective/claim_defense /// How long the beacon must survive after deployment var/defend_duration = 8 MINUTES /// Seconds of defense remaining (set on deploy) var/defend_remaining = 0 - /// How far (overmap tiles) the ship may drift from the stake coordinates - var/hold_range = 2 - /// Interval between defender waves while the clock runs + /// How close (tiles) a crew member must stand to the beacon for the clock to run + var/guard_range = 7 + /// A guard idle at the keyboard this long stops counting + var/guard_afk_time = 3 MINUTES + /// Guarded time before the first defender wave + var/first_wave_delay = 1 MINUTES + /// Guarded time between defender waves var/wave_interval = 2 MINUTES + /// Seconds of guarded time left until the next wave + var/wave_countdown = 0 /// zone_mobs theme answering the claim (set by the mission) var/wave_theme /// The kit handed over at accept @@ -492,6 +526,10 @@ var/obj/structure/mission_claim_beacon/beacon /// Whether the deed has printed (beacon survived) var/deed_printed = FALSE + /// Whether the last tick found nobody standing guard + var/unguarded = FALSE + /// Throttle for the unattended-beacon notice + COOLDOWN_DECLARE(unguarded_notice_cooldown) /datum/mission_objective/claim_defense/deactivate() if(beacon) @@ -516,19 +554,33 @@ mission.fail("No mission pad to dispense the claim beacon to.") return FALSE kit = new(kit_turf) + mission_pad_delivery_effect(kit_turf) kit.objective_ref = WEAKREF(src) notify_crew("Claim beacon kit delivered.") return TRUE -/// Whether the ship is close enough to the stake coordinates to count -/datum/mission_objective/claim_defense/proc/ship_on_station() +/** + * Whether a crew member is standing guard: alive, awake, at the keyboard, out + * on the site's ground rather than inside a hull, and within guard_range of + * the beacon. Nobody there, no clock. + */ +/datum/mission_objective/claim_defense/proc/is_guarded() var/obj/structure/overmap/ship/ship = get_servant() - var/datum/mission_target/target = mission?.target - if(!ship || !target) + var/datum/team/crew_team = ship?.ship_team + var/turf/beacon_turf = get_turf(beacon) + if(!crew_team || !beacon_turf) return FALSE - var/rel_x = ship.x - var/rel_y = ship.y - OVERMAP_SOUTH_SIDE_COORD + 1 - return sqrt((rel_x - target.target_x) ** 2 + (rel_y - target.target_y) ** 2) <= hold_range + for(var/datum/mind/member as anything in crew_team.members) + var/mob/living/guard = member?.current + if(!istype(guard) || guard.stat >= UNCONSCIOUS || !guard.client || guard.client.is_afk(guard_afk_time)) + continue + var/turf/guard_turf = get_turf(guard) + if(!guard_turf || guard_turf.z != beacon_turf.z || get_dist(guard_turf, beacon_turf) > guard_range) + continue + if(istype(get_area(guard_turf), /area/shuttle) || !mission.target?.contains_turf(guard_turf)) + continue + return TRUE + return FALSE /// The kit was used in hand and the channel finished: raise the beacon /datum/mission_objective/claim_defense/proc/deploy(mob/living/user, turf/where) @@ -537,26 +589,38 @@ beacon = new(where) beacon.objective_ref = WEAKREF(src) QDEL_NULL(kit) + unguarded = FALSE + wave_countdown = first_wave_delay / 10 var/datum/mission_target/target = mission.target - beacon.file_claim("Prospect filing: mineral-rights claim staked at grid [target.target_x], [target.target_y]. Claim matures in [round(defend_duration / (1 MINUTES))] minutes. All counterclaims settled on-site.") + var/datum/mission_target/planet/site = target + var/site_name = istype(site) ? site.planet?.name : null + beacon.file_claim("Prospect filing: mineral-rights claim staked[site_name ? " on [site_name]" : ""] at grid [target.target_x], [target.target_y]. All counterclaims settled on-site.") notify_crew("Claim filed on Wideband.", type = SHIP_NOTIFY_WARNING) -/// One second of defending, ticked by the beacon's process +/// One tick of defending, from the beacon's process /datum/mission_objective/claim_defense/proc/tick_defense(seconds) if(deed_printed || !active || !beacon) return - if(!ship_on_station()) - beacon.show_offstation_warning() + if(!is_guarded()) + if(!unguarded && COOLDOWN_FINISHED(src, unguarded_notice_cooldown)) + COOLDOWN_START(src, unguarded_notice_cooldown, 1 MINUTES) + notify_crew("Claim beacon unattended.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + unguarded = TRUE return + unguarded = FALSE defend_remaining -= seconds if(defend_remaining <= 0) print_deed() return - // Periodic company - if(wave_theme && beacon.next_wave_at <= world.time) - beacon.next_wave_at = world.time + wave_interval - new wave_theme(get_turf(beacon), list(1, 2)) - notify_crew("Claim jumpers inbound on the beacon!", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + // Periodic company, counted in guarded time + if(!wave_theme) + return + wave_countdown -= seconds + if(wave_countdown > 0) + return + wave_countdown += wave_interval / 10 + new wave_theme(get_turf(beacon), list(1, 2), TRUE) + notify_crew("Claim jumpers inbound on the beacon!", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') /// The clock ran out with the beacon alive: print the deed /datum/mission_objective/claim_defense/proc/print_deed() @@ -578,14 +642,19 @@ beacon = null if(deed_printed || !mission || mission.failed || mission.completed) return + // Left alone long enough for the site to empty out and be torn down + var/datum/mission_target/planet/site = mission.target + if(istype(site) && site.planet?.unloading) + mission.fail("Claim abandoned - the filing is void.") + return mission.fail("Claim beacon destroyed - the filing is void.") /datum/mission_objective/claim_defense/get_progress_string() if(deed_printed) return "Deed printed" if(beacon) - if(!ship_on_station()) - return "OFF STATION - claim clock paused" + if(unguarded) + return "Beacon unattended" return "Defend the beacon ([round(defend_remaining / 60)]:[add_leading(num2text(round(defend_remaining) % 60), 2, "0")])" var/datum/mission_target/target = mission?.target - return target ? "Deploy the beacon at ([target.target_x], [target.target_y])" : "Deploy the beacon" + return target ? "Stake the claim at ([target.target_x], [target.target_y])" : "Stake the claim" diff --git a/voidcrew/modules/missions/site_dressing.dm b/voidcrew/modules/missions/site_dressing.dm new file mode 100644 index 00000000000..75e6363f01a --- /dev/null +++ b/voidcrew/modules/missions/site_dressing.dm @@ -0,0 +1,375 @@ +/** + * # Mission Site Dressing + * + * A recovery item on its own is one sprite on a random tile: nothing on the site + * says anything happened there, and nothing draws the eye to it. A scene lays a + * little set dressing around the objective's anchor turf (a crater and wreckage + * for a crash, a dropped flare and some litter for a lost case) so the spot can be + * found by looking and reads like the job's story. + * + * Everything placed here is an /obj/effect: it can't be picked up, taken apart, + * broken for parts or sold. Footprint is the anchor plus up to two tiles around + * it, only on open, unblocked ground inside the site, never on another objective. + * The only lasting cost is a smoke emitter and a couple of lights, which burn out + * on one timer each; nothing processes. + */ + +/// How long a crash keeps smoking and a dropped flare keeps burning +#define MISSION_SCENE_BURN_TIME (15 MINUTES) + +/// Lays a scene around an objective's anchor turf. Null or a bad path does nothing. +/proc/dress_mission_scene(scene_type, turf/anchor, datum/mission_target/target) + if(!anchor || !ispath(scene_type, /datum/mission_scene)) + return + var/datum/mission_scene/scene = new scene_type + scene.dress(anchor, target) + +/datum/mission_scene + +/// Places the scene. The anchor holds the objective; only flat ground marks go on it. +/datum/mission_scene/proc/dress(turf/anchor, datum/mission_target/target) + return + +/// Whether one piece of dressing may go on this turf +/datum/mission_scene/proc/can_use(turf/tile, datum/mission_target/target) + var/static/list/objective_types = typecacheof(list( + /obj/item/mission_recovery, + /obj/structure/mission_survey_pylon, + /obj/structure/mission_claim_beacon, + /obj/structure/ingredient_cache, + /obj/item/drug_ingredient, + /obj/effect/anomaly, + /obj/effect/decal/mission_scene, + )) + if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile)) + return FALSE + if(tile.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + // A landed ship's floor flies off with whatever is on it + if(istype(get_area(tile), /area/shuttle)) + return FALSE + if(target && !target.contains_turf(tile)) + return FALSE + for(var/atom/movable/thing as anything in tile) + if(objective_types[thing.type]) + return FALSE + return TRUE + +/// Usable turfs within range of the anchor, the anchor itself excluded +/datum/mission_scene/proc/spots_around(turf/anchor, datum/mission_target/target, range = 1) + var/list/spots = list() + for(var/turf/tile as anything in RANGE_TURFS(range, anchor)) + if(tile != anchor && can_use(tile, target)) + spots += tile + return spots + +/** + * Puts one piece on a spot from the list, preferring one no other piece of this + * scene took. Returns the piece, or null when there was nowhere to put it. + */ +/datum/mission_scene/proc/place(piece_type, list/spots, list/taken) + if(!length(spots)) + return null + var/list/free = spots - taken + var/turf/spot = pick(length(free) ? free : spots) + taken += spot + return new piece_type(spot) + +// ========================================================================= +// CRASH: something came down hard (Field Expedition) +// ========================================================================= + +/datum/mission_scene/crash + +/datum/mission_scene/crash/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch(anchor) + new /obj/effect/decal/mission_scene/scorch/core(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/far = spots_around(anchor, target, 2) - near + var/list/taken = list() + // The furrow it ploughed coming in, and the hull it shed at the end of it + var/skid_dir = pick_skid_dir(anchor, target) + if(skid_dir) + var/obj/effect/decal/mission_scene/skid/skid = new(anchor) + skid.point(skid_dir) + var/turf/beyond = get_step(anchor, REVERSE_DIR(skid_dir)) + if(beyond in near) + taken += beyond + new /obj/effect/decal/mission_scene/wreck(beyond) + else + place(/obj/effect/decal/mission_scene/wreck, near, taken) + else + place(/obj/effect/decal/mission_scene/wreck, near, taken) + place(/obj/effect/decal/mission_scene/wreck/dish, near, taken) + place(/obj/effect/decal/mission_scene/debris, near, taken) + place(/obj/effect/decal/mission_scene/debris/glass, near, taken) + place(/obj/effect/decal/mission_scene/debris, length(far) ? far : near, taken) + for(var/_ in 1 to 3) + place(/obj/effect/decal/mission_scene/debris/rubble, length(far) ? far : near, taken) + +/// A direction with two clear tiles for the furrow, or null +/datum/mission_scene/crash/proc/pick_skid_dir(turf/anchor, datum/mission_target/target) + for(var/direction in shuffle(GLOB.alldirs)) + var/turf/first = get_step(anchor, direction) + var/turf/second = first && get_step(first, direction) + if(second && can_use(first, target) && can_use(second, target)) + return direction + return null + +// ========================================================================= +// DROPPED CASE: somebody lost it here and lit a flare (recovery) +// ========================================================================= + +/datum/mission_scene/dropped_case + +/datum/mission_scene/dropped_case/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch/scuff(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/taken = list() + place(/obj/effect/decal/mission_scene/flare, near, taken) + place(/obj/effect/decal/mission_scene/debris/glass, near, taken) + place(/obj/effect/decal/mission_scene/debris, near, taken) + +// ========================================================================= +// CARGO WRECK: the crate it came in, broken open (extraction) +// ========================================================================= + +/datum/mission_scene/cargo_wreck + +/datum/mission_scene/cargo_wreck/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch/scuff(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/taken = list() + place(/obj/effect/decal/mission_scene/crate, near, taken) + place(/obj/effect/decal/mission_scene/flare, near, taken) + place(/obj/effect/decal/mission_scene/debris, near, taken) + place(/obj/effect/decal/mission_scene/debris/glass, near, taken) + +// ========================================================================= +// PYLON DROP: a survey pylon landed from orbit (survey, core sample) +// ========================================================================= + +/datum/mission_scene/pylon_drop + +/datum/mission_scene/pylon_drop/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch/pylon(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/taken = list() + for(var/_ in 1 to 2) + place(/obj/effect/decal/mission_scene/debris/rubble, near, taken) + +// ========================================================================= +// THE PIECES +// ========================================================================= + +/// Set dressing. An effect, so it gives nothing when attacked, wrenched or blown up. +/obj/effect/decal/mission_scene + name = "wreckage" + desc = "Torn plating and burnt wiring." + icon = 'icons/mob/silicon/robots.dmi' + icon_state = "gib1" + plane = GAME_PLANE + layer = LOW_OBJ_LAYER + /// Icon states picked from at random, if any + var/list/random_states + /// Largest random pixel nudge either way + var/jitter = 6 + +/obj/effect/decal/mission_scene/Initialize(mapload) + . = ..() + if(length(random_states)) + icon_state = pick(random_states) + if(jitter) + pixel_x = base_pixel_x + rand(-jitter, jitter) + pixel_y = base_pixel_y + rand(-jitter, jitter) + +/// Scraps of hull and wiring +/obj/effect/decal/mission_scene/debris + random_states = list("gib1", "gib2", "gib3", "gib4", "gib5", "gib6") + +/obj/effect/decal/mission_scene/debris/glass + name = "broken glass" + desc = "Glittering shards from a shattered panel." + icon = 'icons/obj/debris.dmi' + icon_state = "tiny" + random_states = list("tiny", "small") + +/obj/effect/decal/mission_scene/debris/glass/Initialize(mapload) + . = ..() + setDir(pick(GLOB.cardinals)) + +/obj/effect/decal/mission_scene/debris/rubble + name = "rubble" + desc = "Loose chunks thrown clear by the impact." + icon = 'icons/obj/debris.dmi' + icon_state = "rubble" + random_states = null + +/// The burn at the center. Wide and soft; the core darkens the middle off-center. +/obj/effect/decal/mission_scene/scorch + name = "scorched ground" + desc = "Blackened and still warm." + icon = 'icons/effects/light_overlays/light_96.dmi' + icon_state = "light" + color = "#1e1610" + alpha = 170 + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + blocks_emissive = EMISSIVE_BLOCK_NONE + jitter = 4 + SET_BASE_PIXEL(-32, -32) + /// Horizontal and vertical stretch of the sprite + var/spread_x = 0.95 + var/spread_y = 0.8 + +/obj/effect/decal/mission_scene/scorch/Initialize(mapload) + . = ..() + var/matrix/shape = matrix() + shape.Scale(spread_x, spread_y) + shape.Turn(rand(0, 179)) + transform = shape + +/obj/effect/decal/mission_scene/scorch/core + icon = 'icons/effects/light_overlays/light_64.dmi' + color = "#120c08" + alpha = 150 + jitter = 6 + SET_BASE_PIXEL(-16, -16) + spread_x = 0.6 + spread_y = 0.7 + +/// A light scuff where something heavy hit the deck +/obj/effect/decal/mission_scene/scorch/scuff + name = "scuff marks" + color = "#2a2420" + alpha = 110 + jitter = 0 + spread_x = 0.6 + spread_y = 0.6 + +/obj/effect/decal/mission_scene/scorch/pylon + alpha = 160 + jitter = 0 + spread_x = 0.7 + spread_y = 0.7 + +/// The furrow a crash ploughs: a long soft smudge pointed away from the anchor +/obj/effect/decal/mission_scene/skid + name = "furrow" + desc = "A long gouge in the ground." + icon = 'icons/effects/light_overlays/light_96.dmi' + icon_state = "light" + color = "#1a130d" + alpha = 150 + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + blocks_emissive = EMISSIVE_BLOCK_NONE + jitter = 0 + SET_BASE_PIXEL(-32, -32) + +/// Lays the furrow out from the anchor toward direction +/obj/effect/decal/mission_scene/skid/proc/point(direction) + var/matrix/shape = matrix() + shape.Scale(ISDIAGONALDIR(direction) ? 1.2 : 0.9, 0.38) + shape.Turn(dir2angle(direction) - 90) + transform = shape + var/dx = (direction & EAST) ? 1 : ((direction & WEST) ? -1 : 0) + var/dy = (direction & NORTH) ? 1 : ((direction & SOUTH) ? -1 : 0) + pixel_x = base_pixel_x + dx * 44 + pixel_y = base_pixel_y + dy * 44 + +/// The biggest piece of what came down, smoking until it burns out +/obj/effect/decal/mission_scene/wreck + name = "smoking wreckage" + desc = "A buckled hull section, still smoking." + icon = 'icons/obj/fluff/general.dmi' + icon_state = "broken_canister" + color = "#8a8078" + layer = BELOW_OBJ_LAYER + jitter = 4 + light_range = 2 + light_power = 0.8 + light_color = LIGHT_COLOR_FIRE + /// Whether this piece puts up the smoke column + var/smokes = TRUE + +/obj/effect/decal/mission_scene/wreck/Initialize(mapload) + . = ..() + if(smokes) + particles = new /particles/smoke/mission_plume + addtimer(CALLBACK(src, PROC_REF(burn_out)), MISSION_SCENE_BURN_TIME, TIMER_DELETE_ME) + +/obj/effect/decal/mission_scene/wreck/Destroy(force) + QDEL_NULL(particles) + return ..() + +/obj/effect/decal/mission_scene/wreck/proc/burn_out() + QDEL_NULL(particles) + set_light(0) + name = "wreckage" + desc = "A buckled hull section, scorched black." + +/obj/effect/decal/mission_scene/wreck/dish + name = "bent antenna dish" + desc = "Snapped off its mount on the way down." + icon_state = "sat_dish" + color = "#a09890" + light_range = 0 + smokes = FALSE + +/obj/effect/decal/mission_scene/wreck/dish/Initialize(mapload) + . = ..() + setDir(pick(GLOB.cardinals)) + transform = transform.Turn(pick(-70, 70, 110, -110)) + +/// The crate the cargo came in +/obj/effect/decal/mission_scene/crate + name = "smashed crate" + desc = "Split open and empty." + icon = 'icons/obj/storage/crates.dmi' + icon_state = "crateopen" + color = "#8a8078" + layer = BELOW_OBJ_LAYER + jitter = 3 + +/obj/effect/decal/mission_scene/crate/Initialize(mapload) + . = ..() + transform = transform.Turn(rand(-20, 20)) + +/// Somebody lit it to mark the spot +/obj/effect/decal/mission_scene/flare + name = "flare" + desc = "A road flare, burning low." + icon = 'icons/obj/lighting.dmi' + icon_state = "flare-on" + light_range = 3 + light_power = 1 + light_color = LIGHT_COLOR_FLARE + +/obj/effect/decal/mission_scene/flare/Initialize(mapload) + . = ..() + transform = transform.Turn(rand(0, 359)) + addtimer(CALLBACK(src, PROC_REF(burn_out)), MISSION_SCENE_BURN_TIME, TIMER_DELETE_ME) + +/obj/effect/decal/mission_scene/flare/proc/burn_out() + set_light(0) + icon_state = "flare-empty" + name = "spent flare" + desc = "Burnt down to the stub." + +/// A tall, slow column a crew can spot from across the site. Client-side only. +/particles/smoke/mission_plume + width = 192 + height = 320 + count = 150 + spawning = 2 + lifespan = 4 SECONDS + fade = 2 SECONDS + position = generator(GEN_SPHERE, 0, 4, NORMAL_RAND) + scale = list(1.5, 1.5) + grow = list(0.05, 0.05) + gravity = list(0.08, 0.95) + +#undef MISSION_SCENE_BURN_TIME diff --git a/voidcrew/modules/missions/spawn_cover.dm b/voidcrew/modules/missions/spawn_cover.dm new file mode 100644 index 00000000000..f1b2ccde122 --- /dev/null +++ b/voidcrew/modules/missions/spawn_cover.dm @@ -0,0 +1,103 @@ +/** + * # Mission Spawn Cover + * + * Field objectives are placed on whatever turf the site sampler rolls, and the + * site can already be loaded with a crew standing in it: accept a contract while + * landed and the survivor, the bounty, the anomaly or the crash site would wink + * into existence in front of them. The pickers here sample the same candidates, + * skip any turf a living player could see right now, and when nowhere is hidden + * fall back to the candidate farthest from everyone watching. + * + * "Could see" is the watcher's screen rectangle (their own view size, so a scope + * or zoom counts), widened by a few tiles so whatever scatters around the anchor + * (guards, dressing, the rest of a pylon set) doesn't land on the edge of the + * screen, then a straight line of sight that walls and other opaque turfs block. + * Darkness is ignored: a dark tile in view still counts as seen. + */ + +/// Tiles past the edge of a watcher's screen still treated as in view +#define MISSION_SPAWN_COVER_MARGIN 3 +/// Random samples a hidden pick takes from a candidate list before falling back +#define MISSION_SPAWN_COVER_TRIES 40 + +/** + * Everyone who could be looking at one z-level, taken once per pick so each + * candidate check is a few subtractions and, only for turfs on somebody's screen, + * one line-of-sight walk. + */ +/datum/spawn_cover + /// One entry per watcher: list(eye turf, half view width, half view height), margin included + var/list/watchers = list() + /// The seen candidate farthest from its nearest watcher, for when nothing is hidden + var/turf/fallback + /// Chebyshev distance from the fallback to its nearest watcher + var/fallback_distance = -1 + +/datum/spawn_cover/New(z) + . = ..() + if(!z) + return + for(var/mob/living/player as anything in GLOB.alive_player_list) + var/client/viewer = player.client + if(!viewer) + continue + // The client's eye, not the body: an AI looks through its camera eye + var/turf/eye_turf = get_turf(viewer.eye || player) + if(!eye_turf || eye_turf.z != z) + continue + var/list/view_size = getviewsize(viewer.view) + add_watcher(eye_turf, CEILING((view_size[1] - 1) / 2, 1), CEILING((view_size[2] - 1) / 2, 1)) + +/// Registers one pair of eyes. Half sizes are the screen's, before the margin. +/datum/spawn_cover/proc/add_watcher(turf/eye_turf, half_width, half_height) + watchers += list(list(eye_turf, half_width + MISSION_SPAWN_COVER_MARGIN, half_height + MISSION_SPAWN_COVER_MARGIN)) + +/datum/spawn_cover/proc/has_watchers() + return length(watchers) > 0 + +/** + * TRUE when no watcher could see the candidate. A seen candidate is remembered + * as the fallback if it sits farther from its nearest watcher than the last one. + */ +/datum/spawn_cover/proc/is_hidden(turf/candidate) + if(!length(watchers)) + return TRUE + var/nearest = INFINITY + var/seen = FALSE + for(var/list/watcher as anything in watchers) + var/turf/eye_turf = watcher[1] + var/dx = abs(candidate.x - eye_turf.x) + var/dy = abs(candidate.y - eye_turf.y) + nearest = min(nearest, max(dx, dy)) + if(seen || dx > watcher[2] || dy > watcher[3]) + continue + if(inLineOfSight(eye_turf.x, eye_turf.y, candidate.x, candidate.y, candidate.z)) + seen = TRUE + if(!seen) + return TRUE + if(nearest > fallback_distance) + fallback = candidate + fallback_distance = nearest + return FALSE + +/** + * A random turf from candidates that nobody can see, or the sampled candidate + * farthest from any watcher. Candidates must already be valid spawn turfs on one + * z-level; this only decides between them. With nobody on the level it is a + * plain pick(). + */ +/proc/pick_unwatched_turf(list/candidates, tries = MISSION_SPAWN_COVER_TRIES) + if(!length(candidates)) + return null + var/turf/first = candidates[1] + var/datum/spawn_cover/cover = new(first.z) + if(!cover.has_watchers()) + return pick(candidates) + for(var/_ in 1 to tries) + var/turf/candidate = pick(candidates) + if(cover.is_hidden(candidate)) + return candidate + return cover.fallback + +#undef MISSION_SPAWN_COVER_MARGIN +#undef MISSION_SPAWN_COVER_TRIES diff --git a/voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm b/voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm deleted file mode 100644 index c44867a13e0..00000000000 --- a/voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm +++ /dev/null @@ -1,23 +0,0 @@ -/mob/living/basic/trooper/pirate/melee/beach - faction = list(FACTION_BEACH) - -/mob/living/basic/trooper/pirate/ranged/beach - faction = list(FACTION_BEACH) - -/mob/living/basic/trooper/pirate/melee/beach/boat - icon = 'voidcrew/icons/mob/boat_pirates.dmi' - icon_state = "piratemelee" - icon_living = "piratemelee" - use_dynamic_appearance = FALSE - faction = list(FACTION_BEACH) - unsuitable_atmos_damage = 0 - minimum_survivable_temperature = 0 - -/mob/living/basic/trooper/pirate/ranged/beach/boat - icon = 'voidcrew/icons/mob/boat_pirates.dmi' - icon_state = "pirateranged" - icon_living = "pirateranged" - use_dynamic_appearance = FALSE - faction = list(FACTION_BEACH) - unsuitable_atmos_damage = 0 - minimum_survivable_temperature = 0 diff --git a/voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm b/voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm deleted file mode 100644 index e59848796f3..00000000000 --- a/voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm +++ /dev/null @@ -1,92 +0,0 @@ -/mob/living/simple_animal/hostile/asteroid/hermit - name = "Wasteland Inhabitant" - desc = "If you can read this, yell at a coder!" - icon = 'voidcrew/icons/mob/simple_human.dmi' - icon_state = "survivor_base" - icon_living = "survivor_base" - icon_dead = null - icon_gib = null - mob_biotypes = MOB_ORGANIC - minbodytemp = 180 - unsuitable_atmos_damage = 15 - atmos_requirements = list("min_oxy" = 1, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0) - speak_chance = 20 - turns_per_move = 5 - response_help_continuous = "pushes" - response_help_simple = "push" - speed = 0 - maxHealth = 100 - health = 100 - harm_intent_damage = 5 - melee_damage_lower = 10 - melee_damage_upper = 10 - attack_verb_continuous = "punches" - attack_verb_simple = "punch" - attack_sound = 'sound/items/weapons/punch1.ogg' - combat_mode = TRUE - unsuitable_atmos_damage = 15 - speak_emote = list( - "The sun... The sun...", - "You'll do...", - "All mine...", - "Gg..free..", - "Drink.. need drink...", - "I can smell... you..", - "G-g...", - "Taste..the salt.", - ) - loot = list( - /obj/effect/mob_spawn/corpse/human/hermit/survivor - ) - del_on_death = 1 - faction = list("hermit", "wasteland") - -/mob/living/simple_animal/hostile/asteroid/hermit/death(gibbed) - move_force = MOVE_FORCE_DEFAULT - move_resist = MOVE_RESIST_DEFAULT - pull_force = PULL_FORCE_DEFAULT - return ..() - -/mob/living/simple_animal/hostile/asteroid/hermit/survivor - name = "Hermit Wanderer" - desc =" A wild-eyed figure, wearing tattered mining equipment and boasting a malformed body, twisted by the heavy metals and high background radiation of the sandworlds." - -/mob/living/simple_animal/hostile/asteroid/hermit/survivor/random/Initialize() - . = ..() - if(prob(10)) - spawn_npc_in_zone(/mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger, loc, get_npc_spawn_context(), FALSE) - return INITIALIZE_HINT_QDEL - if(prob(35)) - spawn_npc_in_zone(/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, loc, get_npc_spawn_context(), FALSE) - return INITIALIZE_HINT_QDEL - -/mob/living/simple_animal/hostile/asteroid/hermit/ranged - icon_state = "survivor_hunter" - icon_living = "survivor_hunter" - casingtype = /obj/item/ammo_casing/c38 - projectilesound = 'sound/items/weapons/gun/rifle/shot.ogg' - ranged = 1 - rapid_fire_delay = 6 - retreat_distance = 5 - minimum_distance = 5 - -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - name = "Hermit Hunter" - desc ="A wild-eyed figure. Watch out- he has a gun, and he remembers just enough of his old life to use it!" - loot = list( - /obj/effect/mob_spawn/corpse/human/hermit/survivor/hunter - ) - -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger - name = "Hermit Soldier" - desc = "The miner's rebellion, though mostly underground, recieved a few good weapon shipments from an off-sector source. You should probably start running." - icon_state = "survivor_gunslinger" - icon_living = "survivor_gunslinger" - casingtype = /obj/item/ammo_casing/c46x30mm - projectilesound = 'sound/items/weapons/gun/smg/shot.ogg' - speed = 1 - rapid = 4 - rapid_fire_delay = 3 - loot = list( - /obj/effect/mob_spawn/corpse/human/hermit/survivor/gunslinger - ) diff --git a/voidcrew/modules/nova_guns/attribution.txt b/voidcrew/modules/nova_guns/attribution.txt new file mode 100644 index 00000000000..b2abbd50db2 --- /dev/null +++ b/voidcrew/modules/nova_guns/attribution.txt @@ -0,0 +1,31 @@ +The art and sounds in this module come from Nova Sector (https://github.com/NovaSector/NovaSector), +taken on 2026-11-08 from the paths below. Nova licenses its assets CC BY-SA 3.0 +(https://creativecommons.org/licenses/by-sa/3.0/) unless a file says otherwise, and none of these +files says otherwise; they are used here under that licence. Each DMI holds only the states these +guns use, copied pixel for pixel, apart from the changes listed. + +Icons (Nova paths are under modular_nova/modules/modular_weapons/icons/) +icons/rapier.dmi - states rapier, rapier_mag, rapier_bolt, rapier_bolt_locked from + obj/company_and_or_faction_based/aethon_arms/wide_guns.dmi. + rapier_suppressor is new: the saber_suppressor state from /tg/station + icons/obj/weapons/guns/ballistic.dmi (CC BY-SA 3.0), moved into a 48x32 frame. +icons/zashch.dmi - states zashch, zashch_bolt, zashch_bolt_locked, zashch_mag from + obj/company_and_or_faction_based/szot_dynamica/guns_32.dmi. +icons/zashch_lefthand.dmi - state zashch from mob/company_and_or_faction_based/szot_dynamica/guns_lefthand.dmi. +icons/zashch_righthand.dmi - state zashch from mob/company_and_or_faction_based/szot_dynamica/guns_righthand.dmi. +icons/nt_m96.dmi - states mauser, mauser_bolt and mauser_bolt_open from + obj/company_and_or_faction_based/nanotrasen_armories/ballistic.dmi. + mauser_bolt_open is renamed mauser_bolt_locked, the state our gun code draws + for an open bolt. +icons/magazines.dmi - states rapier, rapier-full, rapier-empty from + obj/company_and_or_faction_based/aethon_arms/ammo.dmi, and zashch_mag, + zashch_mag-full, zashch_mag-empty from + obj/company_and_or_faction_based/szot_dynamica/ammo.dmi. + +Sounds (Nova paths are under modular_nova/modules/modular_weapons/sounds/, taken unmodified) +sound/smg_light.ogg - sounds/smg_light.ogg +sound/pistol_heavy.ogg - sounds/pistol_heavy.ogg + Byte-identical to TerraGov Marine Corps sound/weapons/guns/fire/gun_p23.ogg + (TGMC PR #13409), licensed CC BY-NC-SA 3.0. Non-commercial use only. +sound/bay_gunshot_magnum.ogg - sounds/bay_gunshot_magnum.ogg. Nova's sounds/attributions.txt says it was + taken from Baystation12 (https://github.com/Baystation12/Baystation12). diff --git a/voidcrew/modules/nova_guns/icons/magazines.dmi b/voidcrew/modules/nova_guns/icons/magazines.dmi new file mode 100644 index 00000000000..50c72d556db Binary files /dev/null and b/voidcrew/modules/nova_guns/icons/magazines.dmi differ diff --git a/voidcrew/modules/nova_guns/icons/nt_m96.dmi b/voidcrew/modules/nova_guns/icons/nt_m96.dmi new file mode 100644 index 00000000000..3489ff7d615 Binary files /dev/null and b/voidcrew/modules/nova_guns/icons/nt_m96.dmi differ diff --git a/voidcrew/modules/nova_guns/icons/rapier.dmi b/voidcrew/modules/nova_guns/icons/rapier.dmi new file mode 100644 index 00000000000..6756b2223f4 Binary files /dev/null and b/voidcrew/modules/nova_guns/icons/rapier.dmi differ diff --git a/voidcrew/modules/nova_guns/icons/zashch.dmi b/voidcrew/modules/nova_guns/icons/zashch.dmi new file mode 100644 index 00000000000..aa87961128d Binary files /dev/null and b/voidcrew/modules/nova_guns/icons/zashch.dmi differ diff --git a/voidcrew/modules/nova_guns/icons/zashch_lefthand.dmi b/voidcrew/modules/nova_guns/icons/zashch_lefthand.dmi new file mode 100644 index 00000000000..1ecc3e555a3 Binary files /dev/null and b/voidcrew/modules/nova_guns/icons/zashch_lefthand.dmi differ diff --git a/voidcrew/modules/nova_guns/icons/zashch_righthand.dmi b/voidcrew/modules/nova_guns/icons/zashch_righthand.dmi new file mode 100644 index 00000000000..b87710e4d9e Binary files /dev/null and b/voidcrew/modules/nova_guns/icons/zashch_righthand.dmi differ diff --git a/voidcrew/modules/nova_guns/nt_m96.dm b/voidcrew/modules/nova_guns/nt_m96.dm new file mode 100644 index 00000000000..fce8a50b3b1 --- /dev/null +++ b/voidcrew/modules/nova_guns/nt_m96.dm @@ -0,0 +1,34 @@ +// The NT M-96, ported from Nova Sector (NovaSector/NovaSector), where it is called the c96. +// Art and sound credits are in attribution.txt next to icons/ and sound/. +// A self-loading 9mm with a ten round internal magazine. It is a rifle subtype to get the bolt and +// internal magazine handling: the bolt locks open on an empty gun, and rounds go in with it open. + +/obj/item/gun/ballistic/rifle/nt_m96 + name = "\improper NT M-96" + desc = "A reproduction of an antique pistol, built under a long-expired patent for collectors. Cheaper than the original, and still not cheap." + icon = 'voidcrew/modules/nova_guns/icons/nt_m96.dmi' + icon_state = "mauser" + inhand_icon_state = "gun" + worn_icon_state = "gun" + fire_sound = 'voidcrew/modules/nova_guns/sound/bay_gunshot_magnum.ogg' + fire_sound_volume = 80 + w_class = WEIGHT_CLASS_NORMAL + slot_flags = ITEM_SLOT_BELT + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/nt_m96 + can_suppress = FALSE + need_bolt_lock_to_interact = TRUE + weapon_weight = WEAPON_MEDIUM + // A self-loader: each spent case is ejected as it fires and the next round is chambered. + semi_auto = TRUE + fire_delay = 0.45 SECONDS + projectile_damage_multiplier = 0.75 + spread = 3.5 + // A /rifle subtype would pick the marksman kick. This handles like a pistol. + gun_feel_class = GUN_FEEL_CLASS_PISTOL + +/obj/item/ammo_box/magazine/internal/nt_m96 + name = "\improper NT M-96 internal magazine" + desc = "Oh god, this shouldn't be here" + ammo_type = /obj/item/ammo_casing/c9mm + caliber = CALIBER_9MM + max_ammo = 10 diff --git a/voidcrew/modules/nova_guns/rapier.dm b/voidcrew/modules/nova_guns/rapier.dm new file mode 100644 index 00000000000..c3bc31f10c1 --- /dev/null +++ b/voidcrew/modules/nova_guns/rapier.dm @@ -0,0 +1,43 @@ +// The M-94 'Rapier' submachinegun and its magazine, ported from Nova Sector (NovaSector/NovaSector). +// Art and sound credits are in attribution.txt next to icons/ and sound/. +// A lighter-hitting WT-550 in the same 4.6x30mm, with 30 round magazines and a threaded barrel. + +/obj/item/gun/ballistic/automatic/rapier + name = "\improper M-94 'Rapier' submachinegun" + desc = "An elegant automatic submachinegun, popular with the roguish and unsavory sort. Uses 4.6x30mm rounds." + icon = 'voidcrew/modules/nova_guns/icons/rapier.dmi' + icon_state = "rapier" + inhand_icon_state = "arg" + fire_sound = 'voidcrew/modules/nova_guns/sound/smg_light.ogg' + bolt_type = BOLT_TYPE_LOCKING + w_class = WEIGHT_CLASS_BULKY + weapon_weight = WEAPON_MEDIUM + burst_size = 1 + fire_delay = 0.3 SECONDS + projectile_damage_multiplier = 0.8 + projectile_speed_multiplier = 1.1 + accepted_magazine_type = /obj/item/ammo_box/magazine/rapier + // One shot per pull, so no fire selector. + actions_types = list() + // The suppressor art sits at the left of its frame; these carry it to the end of the barrel. + suppressor_x_offset = 20 + suppressor_y_offset = 2 + // Bulky and automatic would pick the rifle kick and sounds. This is an SMG. + gun_feel_class = GUN_FEEL_CLASS_SMG + +/obj/item/gun/ballistic/automatic/rapier/Initialize(mapload) + . = ..() + AddComponent(/datum/component/automatic_fire, fire_delay) + +/obj/item/ammo_box/magazine/rapier + name = "\improper M-94 'Rapier' magazine" + desc = "A 4.6x30mm magazine for the M-94 'Rapier'." + icon = 'voidcrew/modules/nova_guns/icons/magazines.dmi' + icon_state = "rapier" + ammo_type = /obj/item/ammo_casing/c46x30mm + caliber = CALIBER_46X30MM + max_ammo = 30 + multiple_sprites = AMMO_BOX_FULL_EMPTY + +/obj/item/ammo_box/magazine/rapier/empty + start_empty = TRUE diff --git a/voidcrew/modules/nova_guns/sound/bay_gunshot_magnum.ogg b/voidcrew/modules/nova_guns/sound/bay_gunshot_magnum.ogg new file mode 100644 index 00000000000..6794bfa08ec Binary files /dev/null and b/voidcrew/modules/nova_guns/sound/bay_gunshot_magnum.ogg differ diff --git a/voidcrew/modules/nova_guns/sound/pistol_heavy.ogg b/voidcrew/modules/nova_guns/sound/pistol_heavy.ogg new file mode 100644 index 00000000000..34e0412f823 Binary files /dev/null and b/voidcrew/modules/nova_guns/sound/pistol_heavy.ogg differ diff --git a/voidcrew/modules/nova_guns/sound/smg_light.ogg b/voidcrew/modules/nova_guns/sound/smg_light.ogg new file mode 100644 index 00000000000..d34cb9440ca Binary files /dev/null and b/voidcrew/modules/nova_guns/sound/smg_light.ogg differ diff --git a/voidcrew/modules/nova_guns/zashch.dm b/voidcrew/modules/nova_guns/zashch.dm new file mode 100644 index 00000000000..bc9500b3232 --- /dev/null +++ b/voidcrew/modules/nova_guns/zashch.dm @@ -0,0 +1,41 @@ +// The 'Zashch' heavy pistol and its magazine, ported from Nova Sector (NovaSector/NovaSector). +// Art and sound credits are in attribution.txt next to icons/ and sound/. +// A slow 10mm pistol with a deep box magazine and a seclite that cannot be taken off. + +/obj/item/gun/ballistic/automatic/pistol/zashch + name = "\improper 'Zashch' heavy pistol" + desc = "A hulking, self-loading 10mm handgun, designated 'Zashchitnik'. Feeds from large box magazines." + icon = 'voidcrew/modules/nova_guns/icons/zashch.dmi' + icon_state = "zashch" + lefthand_file = 'voidcrew/modules/nova_guns/icons/zashch_lefthand.dmi' + righthand_file = 'voidcrew/modules/nova_guns/icons/zashch_righthand.dmi' + inhand_icon_state = "zashch" + w_class = WEIGHT_CLASS_NORMAL + accepted_magazine_type = /obj/item/ammo_box/magazine/zashch + can_suppress = FALSE + fire_delay = 1 SECONDS + fire_sound = 'voidcrew/modules/nova_guns/sound/pistol_heavy.ogg' + fire_sound_volume = 80 + rack_sound = 'sound/items/weapons/gun/pistol/rack.ogg' + lock_back_sound = 'sound/items/weapons/gun/pistol/slide_lock.ogg' + bolt_drop_sound = 'sound/items/weapons/gun/pistol/slide_drop.ogg' + +/obj/item/gun/ballistic/automatic/pistol/zashch/add_seclight_point() + AddComponent(/datum/component/seclite_attachable, \ + starting_light = new /obj/item/flashlight/seclite(src), \ + is_light_removable = FALSE, \ + ) + +/obj/item/ammo_box/magazine/zashch + name = "\improper Zashch pistol magazine" + desc = "A large 10mm box magazine for the 'Zashch' heavy pistol." + icon = 'voidcrew/modules/nova_guns/icons/magazines.dmi' + icon_state = "zashch_mag" + base_icon_state = "zashch_mag" + ammo_type = /obj/item/ammo_casing/c10mm + caliber = CALIBER_10MM + max_ammo = 20 + multiple_sprites = AMMO_BOX_FULL_EMPTY + +/obj/item/ammo_box/magazine/zashch/empty + start_empty = TRUE diff --git a/voidcrew/modules/npc_scaling/zone_profiles.dm b/voidcrew/modules/npc_scaling/zone_profiles.dm index 59385ef4d83..1397933e795 100644 --- a/voidcrew/modules/npc_scaling/zone_profiles.dm +++ b/voidcrew/modules/npc_scaling/zone_profiles.dm @@ -101,8 +101,8 @@ /mob/living/simple_animal/hostile/asteroid/elite, /mob/living/basic/boss, /mob/living/basic/trooper/pirate/faction/boss, - /mob/living/basic/bounty_criminal/boss, - /mob/living/basic/bounty_criminal/kingpin, + /mob/living/carbon/human/npc/bounty_criminal/boss, + /mob/living/carbon/human/npc/bounty_criminal/kingpin, /mob/living/basic/bounty_lair_boss, /mob/living/basic/lich, /mob/living/basic/hoarfrost_matriarch, @@ -186,13 +186,21 @@ RegisterSignal(src, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(npc_zone_projectile)) /// Use for raw max-health changes (posse size, phases). Retains the selected factor. +/// Never on a carbon: a person's crit line stays where tg puts it (human NPCs, defaults PI-1). /mob/living/proc/set_npc_zone_health(raw_max_health) + if(iscarbon(src)) + stack_trace("set_npc_zone_health() was called on [src] ([type]); carbons keep tg's health. Toughen human NPCs with armour and npc_damage_taken.") + return npc_zone_original_max_health = raw_max_health resize_npc_zone_health(raw_max_health * npc_zone_health_factor) /// Basic and legacy simple animals both store ALL physical damage in bruteloss. /// Scale the damage store too, so updatehealth/healing/revival cannot undo the resize. +/// Never on a carbon (see set_npc_zone_health()). /mob/living/proc/resize_npc_zone_health(new_max_health) + if(iscarbon(src)) + stack_trace("resize_npc_zone_health() was called on [src] ([type]); carbons keep tg's health. Toughen human NPCs with armour and npc_damage_taken.") + return if(maxHealth <= 0 || new_max_health <= 0) return var/ratio = new_max_health / maxHealth diff --git a/voidcrew/modules/npc_ships/code/_defines.dm b/voidcrew/modules/npc_ships/code/_defines.dm index 42a56952e19..f6d0946fb93 100644 --- a/voidcrew/modules/npc_ships/code/_defines.dm +++ b/voidcrew/modules/npc_ships/code/_defines.dm @@ -307,6 +307,10 @@ GLOBAL_LIST_EMPTY(door_to_rooms) // ship_ref -> list(door_ref -> list(room_id_ #define BB_PIRATE_POST_TURF "pirate_post_turf" /// Where a mob that strayed is walking back to #define BB_PIRATE_RETURN_TURF "pirate_return_turf" +/// Where a pirate with no crew was put (a ruin's, a planet's, a contract's): it keeps near it +#define BB_PIRATE_CREWLESS_HOME "pirate_crewless_home" +/// A pirate with no crew and nothing to fight walks home once it is this many tiles from it +#define PIRATE_CREWLESS_LEASH 10 /// The door (or wrecked door frame) this mob is breaking through #define BB_PIRATE_BREACH_TARGET "pirate_breach_target" /// The turf it breaks the door from @@ -390,3 +394,38 @@ GLOBAL_LIST_EMPTY(door_to_rooms) // ship_ref -> list(door_ref -> list(room_id_ #define PIRATE_CREW_CROSS_BARK_CREW (30 SECONDS) /// How often a pirate with no target looks around for one #define PIRATE_CREW_SCAN_COOLDOWN (1 SECONDS) + +// ========== HUMAN SHIP CREWS (faction_pirate_humans.dm, pirate_crew_human.dm) ========== +// NPC ship crews and their boarders are real human NPCs (voidcrew/modules/human_npcs/). Unit tests +// can't see these; they use the literals. + +/// A crewman who fights hand to hand (ship_crew/var/crew_role); its faction table dresses it from the melee outfits +#define SHIP_CREW_ROLE_MELEE "melee" +/// A crewman who fights with a gun; its faction table dresses it from the gunner outfits +#define SHIP_CREW_ROLE_GUNNER "gunner" + +/// Pulls of the trigger left in the volley a human gunner is firing +#define BB_PIRATE_VOLLEY_LEFT "pirate_volley_left" +/// world.time a gunner's spin-up or aim ends; a hit before then spoils it +#define BB_PIRATE_WINDUP_UNTIL "pirate_windup_until" +/// world.time the door being broken last lost integrity, so a breach that gets somewhere isn't given up on +#define BB_PIRATE_BREACH_PROGRESS_AT "pirate_breach_progress_at" +/// The door's integrity when it was last looked at +#define BB_PIRATE_BREACH_INTEGRITY "pirate_breach_integrity" +/// The item an elite or boss is using on itself (its stimulant, a flask) +#define BB_PIRATE_SELF_ITEM "pirate_self_item" +/// world.time a boss may next drink from its flask +#define BB_PIRATE_NEXT_DRINK "pirate_next_drink" +/// world.time a boss gunner may next put its gun to a downed hunter's head +#define BB_PIRATE_NEXT_EXECUTION "pirate_next_execution" + +/// A crew member adrift in space, out of sight of every player, this long is cleaned up (pirate_crew.dm) +#define PIRATE_CREW_ADRIFT_TIME (2 MINUTES) +/// A boss gunner waits this long between executions +#define PIRATE_CREW_EXECUTION_COOLDOWN (60 SECONDS) +/// A boss with a flask drinks from it no more often than this, and only once it is this hurt +#define PIRATE_CREW_DRINK_COOLDOWN (20 SECONDS) +#define PIRATE_CREW_DRINK_HEALTH_SHARE 0.7 +/// Elites' and bosses' toughness by rank (npc_damage_taken). Starting values for the balance pass. +#define SHIP_CREW_ELITE_DAMAGE_TAKEN 0.8 +#define SHIP_CREW_BOSS_DAMAGE_TAKEN 0.6 diff --git a/voidcrew/modules/npc_ships/code/admin_verbs.dm b/voidcrew/modules/npc_ships/code/admin_verbs.dm index dd3bd2d8a1b..c62568887a6 100644 --- a/voidcrew/modules/npc_ships/code/admin_verbs.dm +++ b/voidcrew/modules/npc_ships/code/admin_verbs.dm @@ -2,49 +2,49 @@ * Admin verbs for NPC pirate mob spawning */ -/// Mapping of faction names to their mob types +/// Mapping of faction names to their mob types: the human NPC ship crews (faction_pirate_humans.dm) GLOBAL_LIST_INIT(npc_pirate_factions, list( "Silverscale" = list( - "Melee (Duelist)" = /mob/living/basic/trooper/pirate/faction/silverscale/melee, - "Ranged (Marksman)" = /mob/living/basic/trooper/pirate/faction/silverscale/ranged, - "Captain (Noble)" = /mob/living/basic/trooper/pirate/faction/silverscale/captain, - "Boss (Highlord)" = /mob/living/basic/trooper/pirate/faction/boss/silverscale, + "Melee (Duelist)" = /mob/living/carbon/human/npc/faction_pirate/silverscale/melee, + "Ranged (Marksman)" = /mob/living/carbon/human/npc/faction_pirate/silverscale/ranged, + "Captain (Noble)" = /mob/living/carbon/human/npc/faction_pirate/silverscale/captain, + "Boss (Highlord)" = /mob/living/carbon/human/npc/faction_pirate/silverscale/boss, ), "Skeleton" = list( - "Melee (Swashbuckler)" = /mob/living/basic/trooper/pirate/faction/skeleton/melee, - "Ranged (Gunner)" = /mob/living/basic/trooper/pirate/faction/skeleton/ranged, - "Captain" = /mob/living/basic/trooper/pirate/faction/skeleton/captain, - "Boss (Davy Jones)" = /mob/living/basic/trooper/pirate/faction/boss/skeleton, + "Melee (Swashbuckler)" = /mob/living/carbon/human/npc/faction_pirate/skeleton/melee, + "Ranged (Gunner)" = /mob/living/carbon/human/npc/faction_pirate/skeleton/ranged, + "Captain" = /mob/living/carbon/human/npc/faction_pirate/skeleton/captain, + "Boss (Davy Jones)" = /mob/living/carbon/human/npc/faction_pirate/skeleton/boss, ), "Grey Tide" = list( - "Melee (Grey Tider)" = /mob/living/basic/trooper/pirate/faction/grey/melee, - "Ranged (Gunner)" = /mob/living/basic/trooper/pirate/faction/grey/ranged, - "Captain (Tidemaster)" = /mob/living/basic/trooper/pirate/faction/grey/captain, - "Boss (The Robust One)" = /mob/living/basic/trooper/pirate/faction/boss/grey, + "Melee (Grey Tider)" = /mob/living/carbon/human/npc/faction_pirate/grey/melee, + "Ranged (Gunner)" = /mob/living/carbon/human/npc/faction_pirate/grey/ranged, + "Captain (Tidemaster)" = /mob/living/carbon/human/npc/faction_pirate/grey/captain, + "Boss (The Robust One)" = /mob/living/carbon/human/npc/faction_pirate/grey/boss, ), "Lustrous" = list( - "Melee (Scintillant)" = /mob/living/basic/trooper/pirate/faction/lustrous/melee, - "Ranged (Coruscant)" = /mob/living/basic/trooper/pirate/faction/lustrous/ranged, - "Captain (Radiant)" = /mob/living/basic/trooper/pirate/faction/lustrous/captain, - "Boss (The Radiant One)" = /mob/living/basic/trooper/pirate/faction/boss/lustrous, + "Melee (Scintillant)" = /mob/living/carbon/human/npc/faction_pirate/lustrous/melee, + "Ranged (Coruscant)" = /mob/living/carbon/human/npc/faction_pirate/lustrous/ranged, + "Captain (Radiant)" = /mob/living/carbon/human/npc/faction_pirate/lustrous/captain, + "Boss (The Radiant One)" = /mob/living/carbon/human/npc/faction_pirate/lustrous/boss, ), "Interdyne" = list( - "Melee (Enforcer)" = /mob/living/basic/trooper/pirate/faction/interdyne/melee, - "Ranged (Pharmacist)" = /mob/living/basic/trooper/pirate/faction/interdyne/ranged, - "Captain (Director)" = /mob/living/basic/trooper/pirate/faction/interdyne/captain, - "Boss (Director Prime)" = /mob/living/basic/trooper/pirate/faction/boss/interdyne, + "Melee (Enforcer)" = /mob/living/carbon/human/npc/faction_pirate/interdyne/melee, + "Ranged (Pharmacist)" = /mob/living/carbon/human/npc/faction_pirate/interdyne/ranged, + "Captain (Director)" = /mob/living/carbon/human/npc/faction_pirate/interdyne/captain, + "Boss (Director Prime)" = /mob/living/carbon/human/npc/faction_pirate/interdyne/boss, ), "IRS" = list( - "Melee (Enforcer)" = /mob/living/basic/trooper/pirate/faction/irs/melee, - "Ranged (Agent)" = /mob/living/basic/trooper/pirate/faction/irs/ranged, - "Captain (Head Auditor)" = /mob/living/basic/trooper/pirate/faction/irs/captain, - "Boss (Chief Auditor)" = /mob/living/basic/trooper/pirate/faction/boss/irs, + "Melee (Enforcer)" = /mob/living/carbon/human/npc/faction_pirate/irs/melee, + "Ranged (Agent)" = /mob/living/carbon/human/npc/faction_pirate/irs/ranged, + "Captain (Head Auditor)" = /mob/living/carbon/human/npc/faction_pirate/irs/captain, + "Boss (Chief Auditor)" = /mob/living/carbon/human/npc/faction_pirate/irs/boss, ), "Medieval" = list( - "Melee (Footsoldier)" = /mob/living/basic/trooper/pirate/faction/medieval/melee, - "Ranged (Crossbowman)" = /mob/living/basic/trooper/pirate/faction/medieval/ranged, - "Captain (Warlord)" = /mob/living/basic/trooper/pirate/faction/medieval/captain, - "Boss (The Black Knight)" = /mob/living/basic/trooper/pirate/faction/boss/medieval, + "Melee (Footsoldier)" = /mob/living/carbon/human/npc/faction_pirate/medieval/melee, + "Ranged (Crossbowman)" = /mob/living/carbon/human/npc/faction_pirate/medieval/ranged, + "Captain (Warlord)" = /mob/living/carbon/human/npc/faction_pirate/medieval/captain, + "Boss (The Black Knight)" = /mob/living/carbon/human/npc/faction_pirate/medieval/boss, ), )) @@ -90,9 +90,16 @@ ADMIN_VERB(spawn_npc_pirate, R_SPAWN|R_DEBUG, "Spawn NPC Pirate", "Spawn an NPC // Step 3: Ask whether it boards the ship we're standing on var/setup_raid = tgui_alert(user, "Send it after the crew of the ship you're standing on, as a boarder?", "Boarder AI", list("Yes", "No")) - // Spawn the pirate + // Spawn the pirate: a human NPC is built at once, dressed as a boarder if it is boarding var/datum/npc_spawn_context/context = new(spawn_turf, null, "ordinary") - var/mob/living/basic/trooper/pirate/spawned_pirate = spawn_npc_in_zone(mob_path, spawn_turf, context, FALSE) + var/mob/living/spawned_pirate + if(ispath(mob_path, /mob/living/carbon/human/npc)) + var/datum/human_npc_order/order = spawn_human_npc(mob_path, spawn_turf, outfit = (setup_raid == "Yes" ? boarding_outfit_for(mob_path) : null), context = context, immediate = TRUE) + spawned_pirate = order?.spawned_ref?.resolve() + if(spawned_pirate) + spawned_pirate.flags_1 |= ADMIN_SPAWNED_1 + else + spawned_pirate = spawn_npc_in_zone(mob_path, spawn_turf, context, FALSE) if(!spawned_pirate) to_chat(user, span_warning("Failed to spawn pirate.")) return @@ -118,7 +125,8 @@ ADMIN_VERB(spawn_npc_pirate, R_SPAWN|R_DEBUG, "Spawn NPC Pirate", "Spawn an NPC else to_chat(user, span_notice("Spawned [spawned_pirate.name] ([selected_faction] - [selected_type]) with its own AI.")) - context.finalize() + if(!ispath(mob_path, /mob/living/carbon/human/npc)) + context.finalize() message_admins("[key_name_admin(user)] spawned NPC pirate: [spawned_pirate.name] ([selected_faction] - [selected_type]) at [AREACOORD(spawn_turf)]") log_admin("[key_name(user)] spawned NPC pirate: [spawned_pirate.name] ([selected_faction] - [selected_type]) at [AREACOORD(spawn_turf)]") BLACKBOX_LOG_ADMIN_VERB("Spawn NPC Pirate") diff --git a/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm b/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm index 0b5c1916e1c..fcdb81f058b 100644 --- a/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm +++ b/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm @@ -704,18 +704,20 @@ return AI_BEHAVIOR_DELAY /** - * Count hostile mobs (pirate troopers) currently on a target ship. - * Used to enforce the max boarder cap during ship combat. + * Count the boarders on a target ship still in the fight: the raid's members aboard it (pirate_crew.dm), + * human or basic, whatever their faction. Used to enforce the max boarder cap during ship combat. */ /datum/ai_behavior/npc_ship/fire_boarding_pods/proc/count_hostile_mobs_on_ship(obj/structure/overmap/ship/target) - if(!target?.shuttle?.shuttle_areas) + var/list/shuttle_areas = target?.shuttle?.shuttle_areas + var/datum/pirate_crew/raid = pirate_crew_of(target, TRUE) + if(!length(shuttle_areas) || !raid) return 0 - var/count = 0 - for(var/area/shuttle_area as anything in target.shuttle.shuttle_areas) - for(var/mob/living/basic/trooper/pirate/boarder in shuttle_area) - if(boarder.stat != DEAD) - count++ + for(var/mob/living/boarder as anything in raid.members) + if(human_npc_out_of_fight(boarder)) + continue + if(shuttle_areas[get_area(boarder)]) + count++ return count // ========== USE INTERDICTOR ========== diff --git a/voidcrew/modules/npc_ships/code/boarding_pod.dm b/voidcrew/modules/npc_ships/code/boarding_pod.dm index e54b2177e0e..d737e88b670 100644 --- a/voidcrew/modules/npc_ships/code/boarding_pod.dm +++ b/voidcrew/modules/npc_ships/code/boarding_pod.dm @@ -3,15 +3,24 @@ * * Uses the existing supplypod/landing zone system for the dramatic drop-from-above effect. * Pod lands, opens to reveal the boarder, then vanishes. + * + * Human NPC boarders (the NPC ship crews, faction_pirate_humans.dm) are built through the spawn queue + * (spawn_human_npc(), a body at a time) aboard the pirate's own hull, dressed from their faction's + * boarding table (a sealed suit and air), and only then put in their pod and launched + * (boarding_pod_launch()). A basic-mob boarder is built straight into its pod as before. Either way the + * caller hears about the boarder through `on_boarder`, called with it once it is in its pod. */ /// Tracks spawn index for distributing boarders along patrol path GLOBAL_VAR_INIT(boarding_spawn_index, 0) GLOBAL_VAR_INIT(boarding_spawn_total, 1) -/// Global proc to create a boarding pod drop at a target location -/// Returns the boarder mob so the caller can register signals (e.g. death tracking) -/proc/create_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type, datum/npc_spawn_context/encounter, budget_reserved = FALSE) +/** + * Launches a boarding pod at `target_turf` carrying a `mob_type`. Returns the boarder (a basic mob, built + * now), the /datum/human_npc_order of a human boarder still in the spawn queue, or null if nothing was + * sent. `on_boarder` is called with the boarder once it is in its pod. + */ +/proc/create_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type, datum/npc_spawn_context/encounter, budget_reserved = FALSE, datum/callback/on_boarder) if(!target_turf || !mob_type) return null // Combat volleys call this on a timer; the target may have docked since firing. @@ -28,6 +37,12 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) return null var/datum/npc_spawn_context/context = new(target_ship, controller.boarding_context.zone_type, "ordinary", source_turf = controller.boarding_context.source_turf) + if(ispath(mob_type, /mob/living/carbon/human/npc)) + var/datum/callback/launch = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(boarding_pod_launch), /obj/structure/closet/supplypod/boarding, target_turf, target_ship, source_ship, on_boarder) + var/datum/human_npc_order/order = spawn_human_npc(mob_type, boarding_pod_build_turf(source_ship, target_turf), launch, outfit = boarding_outfit_for(mob_type), context = context) + controller.track_boarding_order(order) + return order + // Create the boarding pod with the mob inside var/obj/structure/closet/supplypod/boarding/pod = new() @@ -37,17 +52,52 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) qdel(pod) return null - // Store references for the pod + boarding_pod_send(pod, boarder, target_turf, target_ship, source_ship) + on_boarder?.Invoke(boarder) + return boarder + +/// Where a human boarder is built before it goes in its pod: aboard the pirate's own hull, where it +/// climbs into the pod, else (a hull with no dock) the landing spot itself. It is in its pod the same tick. +/proc/boarding_pod_build_turf(obj/structure/overmap/ship/npc/source_ship, turf/target_turf) + return get_turf(source_ship?.shuttle) || target_turf + +/// The boarding outfit table for a human ship crewman (its faction's table sealed for vacuum), or null +/// for anything else. +/proc/boarding_outfit_for(mob_type) + if(!ispath(mob_type, /mob/living/carbon/human/npc/faction_pirate)) + return null + var/mob/living/carbon/human/npc/faction_pirate/crew_type = mob_type + return initial(crew_type.boarding_outfit_table) + +/** + * A human boarder is built (the spawn queue's callback): if the raid it was sent on still stands, it goes + * in its pod and the pod launches; if the target docked or the raid ended meanwhile, the delivery is + * cancelled and the boarder deleted before anyone saw it. A boss learns which hull it boarded for. + */ +/proc/boarding_pod_launch(pod_type, turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, datum/callback/on_boarder, mob/living/boarder) + if(QDELETED(boarder)) + return + var/datum/ai_controller/npc_ship/controller = source_ship?.ai_controller + if(QDELETED(target_ship) || QDELETED(source_ship) || target_ship.state != OVERMAP_SHIP_FLYING || source_ship.state != OVERMAP_SHIP_FLYING || !istype(controller) || controller.get_target() != target_ship) + qdel(boarder) + return + var/obj/structure/closet/supplypod/boarding/pod = new pod_type() + boarder.forceMove(pod) + var/mob/living/carbon/human/npc/faction_pirate/crewman = boarder + if(istype(crewman) && ispath(pod_type, /obj/structure/closet/supplypod/boarding/boss)) + crewman.parent_ship = source_ship + boarding_pod_send(pod, boarder, target_turf, target_ship, source_ship) + on_boarder?.Invoke(boarder) + +/// Sends `pod`, with `boarder` already inside, falling onto `target_turf`. +/proc/boarding_pod_send(obj/structure/closet/supplypod/boarding/pod, mob/living/boarder, turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship) pod.target_ship = target_ship pod.source_ship = source_ship pod.boarder_ref = WEAKREF(boarder) pod.spawn_index = GLOB.boarding_spawn_index++ - // Create the landing zone - this handles the whole falling animation new /obj/effect/pod_landingzone/boarding(target_turf, pod, target_ship, source_ship) - return boarder - /** * Boarding Pod - A supplypod variant for delivering hostile boarders * @@ -162,9 +212,12 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) // ========== BOSS BOARDING POD ========== -/// Global proc to create a boss boarding pod drop at a target location -/// Returns the boss mob so the caller can register signals and track it -/proc/create_boss_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type) +/** + * Launches the boss's heavy boarding pod at `target_turf`. Returns the boss (a basic mob), the order of a + * human boss still in the spawn queue, or null. `on_boss` is called with the boss once it is in its pod. + * A human boss keeps its own look (it is dressed from its faction's ordinary table, with a mask and air). + */ +/proc/create_boss_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type, datum/callback/on_boss) if(!target_turf || !mob_type) return null if(QDELETED(target_ship) || QDELETED(source_ship) || target_ship.state != OVERMAP_SHIP_FLYING || source_ship.state != OVERMAP_SHIP_FLYING) @@ -179,6 +232,12 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) controller.boarding_boss_sent = TRUE var/datum/npc_spawn_context/context = new(target_ship, controller.boarding_context.zone_type, "boss", source_turf = controller.boarding_context.source_turf) + if(ispath(mob_type, /mob/living/carbon/human/npc)) + var/datum/callback/launch = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(boarding_pod_launch), /obj/structure/closet/supplypod/boarding/boss, target_turf, target_ship, source_ship, on_boss) + var/datum/human_npc_order/order = spawn_human_npc(mob_type, boarding_pod_build_turf(source_ship, target_turf), launch, context = context) + controller.track_boarding_order(order) + return order + // Create the boss boarding pod var/obj/structure/closet/supplypod/boarding/boss/pod = new() @@ -192,14 +251,8 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) boss.parent_ship = source_ship context.finalize() - // Store references for the pod - pod.target_ship = target_ship - pod.source_ship = source_ship - pod.boarder_ref = WEAKREF(boss) - - // Create the landing zone - this handles the whole falling animation - new /obj/effect/pod_landingzone/boarding(target_turf, pod, target_ship, source_ship) - + boarding_pod_send(pod, boss, target_turf, target_ship, source_ship) + on_boss?.Invoke(boss) return boss /** diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm index 441ce006666..8cb174b72ea 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm @@ -168,7 +168,9 @@ outfit = /datum/outfit/piratecorpse/faction/lustrous /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous/special(mob/living/carbon/human/spawned_human) - // Prevent the ethereal heart's crystal revival mechanic from reviving NPC pirate corpses + // Only for bodies that start dead: the corpse a basic lustrous pirate leaves, and appearance dummies. A + // clientless prop revived by its crystal would be nobody. Living lustrous pirates are human NPCs that + // take only this spawner's look, never this proc, and crystallise as players do (defaults PI-6). ADD_TRAIT(spawned_human, TRAIT_CANNOT_CRYSTALIZE, TRAIT_GENERIC) return ..() diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm new file mode 100644 index 00000000000..060544beed2 --- /dev/null +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm @@ -0,0 +1,834 @@ +/** + * # Faction pirates as human NPCs + * + * The seven pirate factions (Silverscale, the skeletons, the grey tide, the Lustrous, Interdyne, the IRS + * and the medieval knights) as real human NPCs (voidcrew/modules/human_npcs/): real bodies with real + * crit, stamina, wounds, cuffs, bleeding and revival, holding the real weapons they fight with. One type + * per faction and rank, /mob/living/carbon/human/npc/faction_pirate//, + * used wherever a faction pirate turns up: + * - aboard NPC pirate ships, as the hull's crew and the boarders it sends (phase B). The ship enlists them + * in a /datum/pirate_crew (pirate_crew.dm); boarders are dressed from the faction's boarding table; + * a boarding boss learns its hull (parent_ship); + * - in ruins and on planets, scattered by the pirate zone markers (voidcrew/modules/loot/themes/plunder.dm), + * and as kill contract targets and their guards (voidcrew/modules/missions/missions/recovery.dm) + * (phase C). There they belong to the planet's alliance like the fauna, and a contract target pays its + * cash once per contract (npc_payout_mission). + * Their AI is PR #500's pirate crew on the human base (pirate_crew_human.dm), crew or no crew: one with no + * crew fights what it sees and calls out who it spotted. + * + * Every faction keeps its look, lines and guns: + * - the look (species, hair, skin) comes from the faction corpse spawner (npc_look), the clothes and + * weapons from the faction's outfit table (human_npc_outfits_pirates.dm), picked by crew_role and rank; + * in vacuum the faction's own space suit, whatever outfit it wears; + * - toughness comes from what they wear plus npc_damage_taken by rank (captains 0.8, bosses 0.6), never + * from a health pool; + * - captains and bosses carry a real stimulant and inject it when their fight starts (the old stun + * immunity), Davy Jones drinks from a flask of cursed grog (his old regeneration), the Black Knight has + * tg's hulk and gigantism (his size and his shrugging off batons), the Radiant One is a real ethereal; + * - medieval footsoldiers, the warlord and the Black Knight refuse guns (TRAIT_NOGUNS), as before; + * - the designed loot rolls (the zone ladder, the red-zone 25% piece, boss uniques, the plunder of 25, 300, + * 1000 or 3000 credits) drop once at death, by the zone it belongs to (npc_home_zone()). What they wear + * and hold is locked to the body (D3) and never loot. A ship captain's key is not gear: it drops when he + * dies. + * + * Their ID card carries their name, so one whose helmet hides its face is still "Silverscale Duelist" and + * not "Unknown". It is locked gear like the rest and opens nothing. + */ + +/// What a faction pirate says when things happen to its body (human_npc_barks.dm). "spot" is said when one +/// with no crew picks someone new to fight; a crew calls out its own (pirate_crew.dm). +/datum/human_npc_voice/faction_pirate + lines = list( + PIRATE_BARK_SPOT = list("There!", "Over here!", "Found one!", "Contact!"), + HUMAN_NPC_BARK_DOWNED = list("I'm hit!", "Agh! Man down!", "Not like this..."), + HUMAN_NPC_BARK_PLEAD = list("Don't... don't.", "Quarter... give quarter..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathing.", "Back on my feet."), + HUMAN_NPC_BARK_CUFFED = list("The captain will hear of this.", "Get these off me!", "You'll hang for this."), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("Alright! Alright, I'm done!", "Don't shoot! I yield!", "Quarter! I'm out!"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + "stimulant" = list("jabs a needle into their neck."), + "drink" = list("takes a long pull from a battered flask."), + ) + chances = list(PIRATE_BARK_SPOT = 60) + +/datum/human_npc_voice/faction_pirate/skeleton + lines = list( + PIRATE_BARK_SPOT = list("There ye are!", "I see ye!", "Over here, lads!"), + HUMAN_NPC_BARK_DOWNED = list("Back to the deep..."), + HUMAN_NPC_BARK_PLEAD = list("Mercy, matey..."), + HUMAN_NPC_BARK_RECOVER = list("The dead don't stay down."), + HUMAN_NPC_BARK_CUFFED = list("Irons won't hold the dead."), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Unhand me!"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("clatters to the floor."), + "stimulant" = list("jabs a needle between their ribs."), + "drink" = list("takes a long pull from a battered flask."), + ) + +/datum/human_npc_voice/faction_pirate/grey + lines = list( + PIRATE_BARK_SPOT = list("Get 'em!", "That one, there!", "Over here!"), + HUMAN_NPC_BARK_DOWNED = list("I want my toolbox..."), + HUMAN_NPC_BARK_PLEAD = list("I didn't do nothing!"), + HUMAN_NPC_BARK_RECOVER = list("Robust!"), + HUMAN_NPC_BARK_CUFFED = list("I didn't take nothing!"), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("Okay! Okay! I give!", "Not the face! I'm done!", "I didn't do nothing! I give up!"), + ) + +/datum/human_npc_voice/faction_pirate/irs + lines = list( + PIRATE_BARK_SPOT = list("There's the taxpayer!", "Stop right there.", "You're overdue."), + HUMAN_NPC_BARK_DOWNED = list("This will be... noted..."), + HUMAN_NPC_BARK_PLEAD = list("I'm only an auditor..."), + HUMAN_NPC_BARK_RECOVER = list("Where were we."), + HUMAN_NPC_BARK_CUFFED = list("Obstruction of an audit. Noted."), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("I'm unarmed! I'm an accountant!", "Fine! The audit is suspended!", "I yield! Pending review!"), + ) + +/datum/human_npc_voice/faction_pirate/medieval + lines = list( + PIRATE_BARK_SPOT = list("Have at thee!", "There, the coward!", "Halt!"), + HUMAN_NPC_BARK_DOWNED = list("I am slain!", "'Tis but a scratch..."), + HUMAN_NPC_BARK_PLEAD = list("Quarter! I beg quarter!"), + HUMAN_NPC_BARK_RECOVER = list("I yet stand!"), + HUMAN_NPC_BARK_CUFFED = list("Thou shalt pay for this dishonour."), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Unhand me, cur!"), + HUMAN_NPC_BARK_SURRENDER = list("I yield! I yield!", "Quarter! I beg quarter!"), + ) + +/** + * When a pirate with no crew gives up (human_npc_surrender.dm): a ruin's, a planet's or a contract's. A crew + * aboard a ship fights on together, and its ship decides what is left of it (pirate_crew.dm). Bosses never + * give up; captains seldom. + */ +/datum/human_npc_surrender/faction_pirate + chance = 50 + retry_chance = 20 + +/datum/human_npc_surrender/faction_pirate/hurt_enough(mob/living/carbon/human/npc/npc) + if(npc.npc_rank >= HUMAN_NPC_RANK_BOSS) + return FALSE + return ..() + +/datum/human_npc_surrender/faction_pirate/odds(mob/living/carbon/human/npc/npc, list/hostiles, list/allies, first) + . = ..() + if(npc.npc_rank >= HUMAN_NPC_RANK_ELITE) + . = round(. / 2) + +/// Knights and their kind: they beg quarter less often, and never run. +/datum/human_npc_surrender/faction_pirate/stubborn + chance = 30 + retry_chance = 10 + flees = FALSE + +/// Auditors and tiders: one gun on them is enough, and they run when they can. +/datum/human_npc_surrender/faction_pirate/meek + chance = 70 + retry_chance = 35 + hostiles_needed = 1 + health_share = 0.5 + +/mob/living/carbon/human/npc/faction_pirate + name = "pirate" + faction = list(FACTION_PIRATE) + ai_controller = /datum/ai_controller/basic_controller/pirate_crew/human + npc_voice = /datum/human_npc_voice/faction_pirate + npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + // Read only while it has no crew (pirate_crew_human.dm) + npc_surrender = /datum/human_npc_surrender/faction_pirate + /// SHIP_CREW_ROLE_MELEE or SHIP_CREW_ROLE_GUNNER: which of its faction's outfits a regular wears + var/crew_role + /// Its faction's table with a sealed suit added, for boarders (an outfit table typepath) + var/datum/human_npc_outfit_table/boarding_outfit_table + /// One line about it on examine, in character + var/crew_desc + /// What this faction shouts when its ship's crew goes on alert (pirate_crew.dm). Null: the generic lines. + var/list/crew_alert_lines + /// What it shouts when it spots someone. Null: the generic lines. + var/list/crew_spot_lines + /// The ZONE_* its spawn order dressed it for, or null if it was made without one + var/npc_dressed_zone + + // ===== Designed loot, as the basic faction pirates roll it (faction_pirate_mobs.dm) ===== + /// Always dropped + var/list/loot_pool + /// Each rolled once at spawn against npc_ladder_red_drop() + var/list/rare_loot_pool + /// What the rare roll gave + var/list/rolled_rare_loot + /// With any of these set, it drops one zone-laddered weapon (pirate_roll_ladder_weapon()) + var/list/random_loot = list( + /obj/item/knife/combat/survival, + /obj/item/spear, + /obj/item/melee/baseball_bat, + ) + /// One of these is always dropped + var/list/random_loot_2 = list( + /obj/item/storage/medkit/regular, + /obj/item/storage/toolbox/mechanical, + /obj/item/flashlight/seclite, + /obj/item/reagent_containers/hypospray/medipen, + /obj/item/stack/telecrystal, + ) + /// A captain whose zone ladder drops a melee weapon (Silverscale, Lustrous and Interdyne captains) + var/captain_ladder_melee = FALSE + /// Weakref to the /datum/mission whose named target this is; its death cash pays only if that contract + /// can still claim it (/datum/mission/proc/claim_boss_cash()) + var/datum/weakref/npc_payout_mission + + // ===== Elites and bosses ===== + /// The medipen it injects when its fight starts, or null. Elites and bosses get one by rank. + var/obj/item/reagent_containers/hypospray/medipen/stimulant_type + /// The NPC hull this is the boarding boss of; killing or taking it disables the hull + var/obj/structure/overmap/ship/npc/pirate/parent_ship + +/mob/living/carbon/human/npc/faction_pirate/Initialize(mapload, datum/human_npc_order/order) + switch(npc_rank) + if(HUMAN_NPC_RANK_ELITE) + // A type that sets its own toughness keeps it + if(npc_damage_taken == 1) + npc_damage_taken = SHIP_CREW_ELITE_DAMAGE_TAKEN + stimulant_type ||= /obj/item/reagent_containers/hypospray/medipen/stimpack/traitor + if(HUMAN_NPC_RANK_BOSS) + if(npc_damage_taken == 1) + npc_damage_taken = SHIP_CREW_BOSS_DAMAGE_TAKEN + . = ..() + npc_dressed_zone = order?.resolve_zone() + // Where it was put: a pirate with no crew keeps near it (pirate_crew_human.dm) + if(istype(ai_controller)) + ai_controller.set_blackboard_key(BB_PIRATE_CREWLESS_HOME, get_turf(src)) + // tg's Initialize gave it the planet's alliance before it was dressed; an ordered faction list may + // have replaced its factions since, so it rejoins here. Off a planet this does nothing. + inherit_planetary_faction(src) + rolled_rare_loot = list() + for(var/item_type in rare_loot_pool) + if(npc_ladder_red_drop()) + rolled_rare_loot += item_type + crew_issue_id() + if(stimulant_type) + var/obj/item/stimulant = new stimulant_type(src) + if(!crew_give(stimulant)) + qdel(stimulant) + +/mob/living/carbon/human/npc/faction_pirate/Destroy() + parent_ship = null + npc_payout_mission = null + return ..() + +// Natives of the planet they are found on, like the fauna they share it with. +/mob/living/carbon/human/npc/faction_pirate/should_inherit_planetary_faction() + return TRUE + +// In vacuum, the faction's own space suit, even over an outfit it was handed directly. +/mob/living/carbon/human/npc/faction_pirate/npc_fit_outfit_to_site(outfit, datum/human_npc_outfit_table/table) + if(isnull(table) && ispath(npc_outfit_table, /datum/human_npc_outfit_table)) + table = human_npc_singleton(npc_outfit_table) + return ..(outfit, table) + +/mob/living/carbon/human/npc/faction_pirate/examine(mob/user) + . = ..() + if(crew_desc && stat != DEAD) + . += span_notice(crew_desc) + +/// An ID card with its name on it, so a helmet that hides its face doesn't make it "Unknown". +/mob/living/carbon/human/npc/faction_pirate/proc/crew_issue_id() + if(wear_id) + return + var/obj/item/card/id/advanced/black/card = new(src) + card.registered_name = real_name + card.assignment = real_name + card.update_label() + if(!equip_to_slot_if_possible(card, ITEM_SLOT_ID, disable_warning = TRUE)) + qdel(card) + return + npc_lock_item(card) + +/** + * Puts `thing` somewhere on its body it can take it from: a free pocket, else its backpack or belt. + * Locks or issues it by the gear rules (a ship key isn't gear, a medipen is a consumable). Returns TRUE + * if it is on the body; FALSE leaves it where it was, for the caller to deal with. + */ +/mob/living/carbon/human/npc/faction_pirate/proc/crew_give(obj/item/thing) + if(QDELETED(thing)) + return FALSE + if(equip_to_slot_if_possible(thing, ITEM_SLOT_LPOCKET, disable_warning = TRUE) || equip_to_slot_if_possible(thing, ITEM_SLOT_RPOCKET, disable_warning = TRUE)) + npc_lock_item(thing) + return TRUE + for(var/obj/item/container in list(back, belt)) + if(container.atom_storage?.attempt_insert(thing, src, override = TRUE, messages = FALSE)) + npc_lock_item(thing) + return TRUE + return FALSE + +/** + * Puts something that isn't gear (its ship's key) in a pocket, where a crew that has it cuffed or down can + * pick it out (the strip menu, human_npc.dm). A full pocket is emptied into its bag or belt first. Falls + * back to crew_give() if neither pocket can be had. + */ +/mob/living/carbon/human/npc/faction_pirate/proc/crew_pocket(obj/item/thing) + if(QDELETED(thing)) + return FALSE + for(var/slot in list(ITEM_SLOT_LPOCKET, ITEM_SLOT_RPOCKET)) + if(equip_to_slot_if_possible(thing, slot, disable_warning = TRUE)) + npc_lock_item(thing) + return TRUE + for(var/slot in list(ITEM_SLOT_LPOCKET, ITEM_SLOT_RPOCKET)) + var/obj/item/in_pocket = get_item_by_slot(slot) + if(isnull(in_pocket)) + continue + for(var/obj/item/container in list(back, belt)) + if(!container.atom_storage?.attempt_insert(in_pocket, src, override = TRUE, messages = FALSE)) + continue + if(equip_to_slot_if_possible(thing, slot, disable_warning = TRUE)) + npc_lock_item(thing) + return TRUE + break + return crew_give(thing) + +/** + * The zone it belongs to, for zone-laddered loot: its spawn context's, which may have resolved after it + * was made (a ruin loading before its planet registered), else the zone it was dressed for, else where + * it stands. + */ +/mob/living/carbon/human/npc/faction_pirate/proc/npc_home_zone() + return npc_spawn_context?.zone_type || npc_dressed_zone || npc_spawn_zone() + +// ===== Death and its designed drops ===== + +/mob/living/carbon/human/npc/faction_pirate/npc_roll_death_loot() + var/zone = npc_home_zone() + . = list() + if(length(loot_pool)) + . += loot_pool + if(length(rolled_rare_loot)) + . += rolled_rare_loot + if(length(random_loot)) + . += pirate_roll_ladder_weapon(zone) + if(captain_ladder_melee) + if(zone == ZONE_YELLOW) + . += /obj/item/claymore/shortsword + else if(zone == ZONE_RED) + . += pick(npc_ladder_yellow_melee()) + if(length(random_loot_2)) + . += pick(random_loot_2) + +// A named contract target's cash goes to the contract once: if the contract is gone or has already paid, +// the body carries none. +/mob/living/carbon/human/npc/faction_pirate/npc_drop_death_loot() + if(!npc_loot_dropped && npc_death_cash > 0 && npc_payout_mission) + var/datum/mission/mission = npc_payout_mission.resolve() + if(!mission?.claim_boss_cash()) + npc_death_cash = 0 + return ..() + +/mob/living/carbon/human/npc/faction_pirate/death(gibbed) + var/was_alive = stat != DEAD + . = ..() + if(was_alive && stat == DEAD) + crew_report_boss_down() + +/// The hull this boarded for hears its boss is down (PI-22: the boss's fall disables the hull). +/mob/living/carbon/human/npc/faction_pirate/proc/crew_report_boss_down() + var/datum/ai_controller/npc_ship/controller = parent_ship?.ai_controller + if(istype(controller)) + SEND_SIGNAL(controller, COMSIG_BOARDING_BOSS_KILLED, src) + +// ===== The factions ===== + +// ----- Silverscale (aristocratic lizards) ----- + +/mob/living/carbon/human/npc/faction_pirate/silverscale + npc_name = "Silverscale Pirate" + crew_desc = "An aristocratic lizard pirate demanding tribute from lesser beings." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/silverscale + npc_outfit_table = /datum/human_npc_outfit_table/pirate/silverscale + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/silverscale/boarding + +/mob/living/carbon/human/npc/faction_pirate/silverscale/Initialize(mapload, datum/human_npc_order/order) + // The rank and file may be carrying a fish (as the basic duelists and marksmen do) + if(crew_role) + random_loot_2 = list(/obj/item/storage/medkit/regular, /obj/item/storage/toolbox/mechanical, random_fish_type()) + return ..() + +/mob/living/carbon/human/npc/faction_pirate/silverscale/melee + npc_name = "Silverscale Duelist" + crew_desc = "A noble lizard trained in the art of the blade." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/silverscale/ranged + npc_name = "Silverscale Marksman" + crew_desc = "A noble lizard with impeccable aim." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/faction_pirate/silverscale/captain + npc_name = "Silverscale Noble" + crew_desc = "A high-born lizard of impeccable breeding and ruthless ambition." + npc_rank = HUMAN_NPC_RANK_ELITE + random_loot = null + captain_ladder_melee = TRUE + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/faction_pirate/silverscale/boss + npc_name = "Silverscale Highlord" + crew_desc = "The supreme ruler of the Silverscale noble house. A master duelist who has slain countless challengers." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/clothing/suit/hooded/cloak/drake, /obj/item/clothing/head/hooded/cloakhood/drake) + npc_death_cash = 3000 + +// ----- Skeleton (the Flying Dutchman) ----- + +/mob/living/carbon/human/npc/faction_pirate/skeleton + npc_name = "Skeleton Pirate" + crew_desc = "Yarr! An undead scourge of the space lanes." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/skeleton + npc_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton/boarding + npc_voice = /datum/human_npc_voice/faction_pirate/skeleton + // The dead don't give up + npc_surrender = null + crew_alert_lines = list( + "All hands! Boarders!", + "Fresh crew coming aboard!", + "They'll fit right in. Eventually.", + ) + crew_spot_lines = list( + "There ye are!", + "I see ye!", + "Over here, lads!", + ) + +/mob/living/carbon/human/npc/faction_pirate/skeleton/melee + npc_name = "Skeleton Swashbuckler" + crew_desc = "A skeletal swordsman that refuses to stay dead." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/skeleton/ranged + npc_name = "Skeleton Gunner" + crew_desc = "A skeletal marksman with hollow eyes." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/faction_pirate/skeleton/captain + npc_name = "Skeleton Captain" + crew_desc = "The terrifying undead commander of the Flying Dutchman." + npc_rank = HUMAN_NPC_RANK_ELITE + loot_pool = list(/obj/item/gun/energy/laser) + random_loot = null + npc_death_cash = 1000 + +/// Davy Jones drinks cursed grog from the flask in his pocket when he is hurt (his old regeneration, +/// defaults PI-5). +/mob/living/carbon/human/npc/faction_pirate/skeleton/boss + npc_name = "Davy Jones" + crew_desc = "Captain of the Flying Dutchman. His cursed compass always points to the greatest treasure... or the nearest victim." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/gun/ballistic/shotgun/hook) + npc_death_cash = 3000 + +// ----- Grey Tide (rogue assistants) ----- + +/mob/living/carbon/human/npc/faction_pirate/grey + npc_name = "Grey Tider" + crew_desc = "A former assistant turned space pirate. Robust." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/grey + npc_outfit_table = /datum/human_npc_outfit_table/pirate/grey + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/grey/boarding + npc_voice = /datum/human_npc_voice/faction_pirate/grey + npc_surrender = /datum/human_npc_surrender/faction_pirate/meek + crew_alert_lines = list( + "Someone's on the ship!", + "Who let them in?", + "They're after our stuff!", + ) + crew_spot_lines = list( + "Get 'em!", + "That one, there!", + "Over here!", + ) + +/mob/living/carbon/human/npc/faction_pirate/grey/melee + crew_desc = "Armed with a toolbox and bad intentions." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/grey/ranged + npc_name = "Grey Tider Gunner" + crew_desc = "An assistant with a gun. Uh oh." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/faction_pirate/grey/captain + npc_name = "Tidemaster" + crew_desc = "If he's on your shift, the Captain does not have his spare." + npc_rank = HUMAN_NPC_RANK_ELITE + loot_pool = list(/obj/item/storage/toolbox/syndicate) + random_loot = null + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/faction_pirate/grey/boss + npc_name = "The Robust One" + crew_desc = "The ultimate assistant. The greyest tider to ever live. Their toolbox has seen things." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/stack/telecrystal/five) + rare_loot_pool = list(/obj/item/storage/toolbox/mechanical/old/clean) + npc_death_cash = 3000 + +/mob/living/carbon/human/npc/faction_pirate/grey/boss/Initialize(mapload, datum/human_npc_order/order) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/robust_one) + +// ----- Lustrous (mutated ethereals) ----- + +// Real ethereals: they glow by their species, and a dead one's heart crystallises and revives it as a +// player's does, unless the crystal is broken (defaults PI-6). +/mob/living/carbon/human/npc/faction_pirate/lustrous + npc_name = "Lustrous Pirate" + crew_desc = "A mutated ethereal obsessed with bluespace crystals." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous + npc_outfit_table = /datum/human_npc_outfit_table/pirate/lustrous + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/lustrous/boarding + +/mob/living/carbon/human/npc/faction_pirate/lustrous/melee + npc_name = "Lustrous Scintillant" + crew_desc = "A crystalline being with a sharp blade." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/lustrous/ranged + npc_name = "Lustrous Coruscant" + crew_desc = "A crystalline being channeling energy through strange weapons." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/faction_pirate/lustrous/captain + npc_name = "Lustrous Radiant" + crew_desc = "A blindingly bright ethereal, the leader of their crystalline kind." + npc_rank = HUMAN_NPC_RANK_ELITE + random_loot = null + captain_ladder_melee = TRUE + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/faction_pirate/lustrous/boss + npc_name = "The Radiant One" + crew_desc = "A blindingly luminous ethereal. The crystalline matrix of their body phases in and out of realspace." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/grenade/gas_crystal/nitrous_oxide_crystal) + rare_loot_pool = list(/obj/item/cain_and_abel) + npc_death_cash = 3000 + +// ----- Interdyne (ex-pharmacists) ----- + +/mob/living/carbon/human/npc/faction_pirate/interdyne + npc_name = "Interdyne Pirate" + crew_desc = "A former pharmaceutical employee turned bioterrorist." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/interdyne + npc_outfit_table = /datum/human_npc_outfit_table/pirate/interdyne + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/interdyne/boarding + +/mob/living/carbon/human/npc/faction_pirate/interdyne/melee + npc_name = "Interdyne Enforcer" + crew_desc = "Enforces pharmaceutical compliance with surgical precision." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/interdyne/ranged + npc_name = "Interdyne Pharmacist" + crew_desc = "Dispenses death instead of medicine." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/faction_pirate/interdyne/captain + npc_name = "Interdyne Director" + crew_desc = "The head of this rogue pharmaceutical operation." + npc_rank = HUMAN_NPC_RANK_ELITE + random_loot = null + captain_ladder_melee = TRUE + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/faction_pirate/interdyne/boss + npc_name = "Director Prime" + crew_desc = "The head of all Interdyne black site operations. Their hypospray contains experimental compounds." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/reagent_containers/hypospray/combat, /obj/item/gun/syringe/syndicate, /obj/item/grenade/gluon) + npc_death_cash = 3000 + +// ----- IRS (tax collectors) ----- + +/mob/living/carbon/human/npc/faction_pirate/irs + npc_name = "IRS Agent" + crew_desc = "The only thing certain in life is death and taxes. They're here for both." + faction = list(FACTION_PIRATE, FACTION_IRS) + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/irs + npc_outfit_table = /datum/human_npc_outfit_table/pirate/irs + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/irs/boarding + npc_voice = /datum/human_npc_voice/faction_pirate/irs + npc_surrender = /datum/human_npc_surrender/faction_pirate/meek + crew_alert_lines = list( + "Unscheduled visitors. Begin the audit.", + "Everyone aboard is now under review.", + "Intruders on deck. Find them.", + ) + crew_spot_lines = list( + "There's the taxpayer!", + "Stop right there.", + "You're overdue.", + ) + +/mob/living/carbon/human/npc/faction_pirate/irs/melee + npc_name = "IRS Enforcer" + crew_desc = "Collects taxes the hard way." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/irs/ranged + crew_desc = "Armed and ready to collect." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/faction_pirate/irs/captain + npc_name = "IRS Head Auditor" + crew_desc = "The head of tax enforcement. Nobody escapes their audits." + npc_rank = HUMAN_NPC_RANK_ELITE + loot_pool = list(/obj/item/gun/energy/laser) + random_loot = null + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/faction_pirate/irs/boss + npc_name = "Chief Auditor" + crew_desc = "The supreme authority of the Space IRS. No one escapes their audits. NO ONE." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + // Carried in the outfit: the chief has no bag to put one in + stimulant_type = null + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/gun/energy/laser, /obj/item/storage/bag/money/dutchmen) + npc_death_cash = 3000 + +// ----- Medieval (space warmongers) ----- + +/mob/living/carbon/human/npc/faction_pirate/medieval + npc_name = "Medieval Pirate" + crew_desc = "A space-faring knight who somehow operates a starship." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/medieval + npc_outfit_table = /datum/human_npc_outfit_table/pirate/medieval + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/medieval/boarding + npc_voice = /datum/human_npc_voice/faction_pirate/medieval + npc_surrender = /datum/human_npc_surrender/faction_pirate/stubborn + crew_alert_lines = list( + "To arms! Intruders!", + "Who dares board us?", + "Defend the ship!", + ) + crew_spot_lines = list( + "Have at thee!", + "There, the coward!", + "Halt!", + ) + /// Hates guns and throws them hard (footsoldiers, the warlord and the Black Knight; not the crossbowmen) + var/refuses_guns = FALSE + +/mob/living/carbon/human/npc/faction_pirate/medieval/Initialize(mapload, datum/human_npc_order/order) + . = ..() + if(refuses_guns) + add_traits(list(TRAIT_NOGUNS, TRAIT_TOSS_GUN_HARD), INNATE_TRAIT) + +/mob/living/carbon/human/npc/faction_pirate/medieval/melee + npc_name = "Medieval Footsoldier" + crew_desc = "A knight who refuses to use guns. Honorable." + crew_role = SHIP_CREW_ROLE_MELEE + refuses_guns = TRUE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/faction_pirate/medieval/ranged + npc_name = "Medieval Crossbowman" + crew_desc = "At least crossbows are period-appropriate. Sort of." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/// The warlord: plate armour and a stimulant where the basic mob had stun immunity (defaults PI-4). +/mob/living/carbon/human/npc/faction_pirate/medieval/captain + npc_name = "Medieval Warlord" + crew_desc = "A hulking brute of a knight. Massive, and very angry." + npc_rank = HUMAN_NPC_RANK_ELITE + refuses_guns = TRUE + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + loot_pool = list(/obj/item/fireaxe) + random_loot = null + npc_death_cash = 1000 + +/// The Black Knight: tg's hulk (stun and shove immune until he drops into crit) and gigantism (his size). +/mob/living/carbon/human/npc/faction_pirate/medieval/boss + npc_name = "The Black Knight" + crew_desc = "A massive armored warrior. 'Tis but a scratch!' No really, they refuse to die." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = 0.5 + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + refuses_guns = TRUE + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/claymore/shortsword, /obj/item/shield/kite) + npc_death_cash = 3000 + +/mob/living/carbon/human/npc/faction_pirate/medieval/boss/Initialize(mapload, datum/human_npc_order/order) + . = ..() + dna.add_mutation(/datum/mutation/hulk, MUTATION_SOURCE_MUTATOR) + dna.add_mutation(/datum/mutation/gigantism, MUTATION_SOURCE_MUTATOR) + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/black_knight) + +// ===== Boss kit ===== + +/// Davy Jones's flask. Locked to him like the rest of his kit (D3). +/obj/item/reagent_containers/cup/glass/flask/davy_jones + name = "battered flask" + desc = "A dented pewter flask. Whatever is in it smells of the deep." + list_reagents = list(/datum/reagent/medicine/omnizine/cursed_grog = 40) + +/** + * Cursed grog: knits the flesh of the undead while it is in their blood, with a cold blue glow. To the + * living it is brine and bad rum, so blood drawn from Davy Jones heals nobody. Never synthesised, never + * overdosed. Like any reagent it can be purged, and the flask runs dry. + */ +/datum/reagent/medicine/omnizine/cursed_grog + name = "Cursed Grog" + description = "Sea water and something older. It keeps the dead from staying dead." + color = "#2a4a7a" + taste_description = "brine and old rum" + healing = 3 + overdose_threshold = 0 + chemical_flags = NONE + +/datum/reagent/medicine/omnizine/cursed_grog/on_mob_life(mob/living/carbon/affected_mob, seconds_per_tick, times_fired) + healing = (affected_mob.mob_biotypes & MOB_UNDEAD) ? initial(healing) : 0 + return ..() + +/datum/reagent/medicine/omnizine/cursed_grog/on_mob_metabolize(mob/living/affected_mob) + . = ..() + affected_mob.add_filter("cursed_grog", 2, outline_filter(1, "#4488FF")) + +/datum/reagent/medicine/omnizine/cursed_grog/on_mob_end_metabolize(mob/living/affected_mob) + . = ..() + affected_mob.remove_filter("cursed_grog") + +/// The Robust One's toolbox: the old blue toolbox, with the telecrystals inside that make it hit harder. +/obj/item/storage/toolbox/mechanical/old/clean/robust_one + attack_verb_continuous = list("ROBUSTS") + attack_verb_simple = list("ROBUST") + +/obj/item/storage/toolbox/mechanical/old/clean/robust_one/PopulateContents() + . = ..() + new /obj/item/stack/telecrystal(src, 6) + +/// Davy Jones's cutlass. +/obj/item/claymore/cutlass/davy_jones + attack_verb_continuous = list("curses", "slashes", "cuts") + attack_verb_simple = list("curse", "slash", "cut") + +/// The Black Knight's claymore. +/obj/item/claymore/black_knight + attack_verb_continuous = list("smites", "cleaves", "hews") + attack_verb_simple = list("smite", "cleave", "hew") + +/// The warlord's boarding axe. +/obj/item/fireaxe/boardingaxe/warlord + attack_verb_continuous = list("crushes", "cleaves", "hacks") + attack_verb_simple = list("crush", "cleave", "hack") + +/// An energy cutlass with a boss's own attack verbs. +/obj/item/melee/energy/sword/pirate/boss + /// Verbs while lit + var/list/boss_verbs_continuous = list("eviscerates") + var/list/boss_verbs_simple = list("eviscerate") + +/obj/item/melee/energy/sword/pirate/boss/make_transformable() + AddComponent( \ + /datum/component/transforming, \ + force_on = active_force, \ + throwforce_on = active_throwforce, \ + throw_speed_on = 4, \ + sharpness_on = active_sharpness, \ + hitsound_on = active_hitsound, \ + w_class_on = active_w_class, \ + attack_verb_continuous_on = boss_verbs_continuous, \ + attack_verb_simple_on = boss_verbs_simple, \ + ) + RegisterSignal(src, COMSIG_TRANSFORMING_ON_TRANSFORM, PROC_REF(on_transform)) + +/// The Radiant One's cutlass. +/obj/item/melee/energy/sword/pirate/boss/radiant + boss_verbs_continuous = list("phases through", "slashes") + boss_verbs_simple = list("phase through", "slash") + +// ===== Dressing by role, and for boarding ===== + +// A crewman of the rank and file wears its role's outfits from its faction's table +// (voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm): a duelist the faction's blades, a marksman +// its guns in PR #500's gun weights. Captains and bosses wear their own. +/datum/human_npc_outfit_table/pirate/outfits_for(mob/living/carbon/human/npc/npc, zone) + var/mob/living/carbon/human/npc/faction_pirate/crewman = npc + if(istype(crewman) && crewman.npc_rank == HUMAN_NPC_RANK_REGULAR) + switch(crewman.crew_role) + if(SHIP_CREW_ROLE_MELEE) + if(length(melee) && !gunners_only) + return melee + if(SHIP_CREW_ROLE_GUNNER) + if(length(gunners)) + return gunners + return ..() + +// Boarders cross space and may land in a vented room, so they wear a sealed suit and carry air +// (always_site, human_npc_outfits.dm). Skeletons need neither and are left as they are. +/datum/human_npc_outfit_table/pirate/silverscale/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/skeleton/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/grey/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/lustrous/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/interdyne/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/irs/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/medieval/boarding + always_site = HUMAN_NPC_SITE_VACUUM diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm index 4622793770b..081ecec1352 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm @@ -1,4 +1,5 @@ -// Faction-specific pirate NPC mobs for Voidcrew NPC ships +// Faction-specific pirate NPC basic mobs. NPC ship crews and their boarders are human NPCs now +// (faction_pirate_humans.dm); these stay for ruins, planets and missions until those move (phase C). // Each faction has melee, ranged, and captain variants // All are space-capable (no atmos damage, spacewalk trait) @@ -25,10 +26,6 @@ var/list/crew_alert_lines /// What it shouts when it spots someone. Null uses the generic lines. var/list/crew_spot_lines - /// Weights of the guns a gunner of this faction rolls when it joins a crew (pirate_guns.dm). Null: the default mix. - var/list/crew_gun_weights - /// Keeps its own ranged weapon instead of rolling a gun - var/crew_keeps_gun = FALSE /// Random combat loot - one item is picked at random and added to death drops. var/list/random_loot = list(\ /obj/item/knife/combat/survival,\ @@ -83,6 +80,10 @@ AddElement(/datum/element/death_drops, string_list(death_loot)) /mob/living/basic/trooper/pirate/faction/proc/roll_ladder_weapon(zone, roll = rand(1, 100)) + return pirate_roll_ladder_weapon(zone, roll) + +/// The weapon a faction pirate drops by the zone it fought in, basic or human (faction_pirate_humans.dm). +/proc/pirate_roll_ladder_weapon(zone, roll = rand(1, 100)) switch(zone) if(ZONE_YELLOW) return roll <= 5 ? /obj/item/gun/energy/laser : pick(npc_ladder_yellow_melee()) @@ -636,56 +637,3 @@ ADD_TRAIT(src, TRAIT_PUSHIMMUNE, INNATE_TRAIT) ADD_TRAIT(src, TRAIT_NOGUNS, INNATE_TRAIT) ADD_TRAIT(src, TRAIT_TOSS_GUN_HARD, INNATE_TRAIT) - -// ==================== CREW GUNS ==================== -// What each faction's gunners roll when they join a crew (pirate_guns.dm) - -/mob/living/basic/trooper/pirate/faction/silverscale - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 3, - /datum/pirate_gun_loadout/laser_rifle = 2, - /datum/pirate_gun_loadout/pistol = 3, - /datum/pirate_gun_loadout/shotgun = 1, - ) - -/mob/living/basic/trooper/pirate/faction/skeleton - crew_gun_weights = list( - /datum/pirate_gun_loadout/laser_rifle = 1, - /datum/pirate_gun_loadout/pistol = 2, - /datum/pirate_gun_loadout/smg = 2, - /datum/pirate_gun_loadout/shotgun = 3, - ) - -/mob/living/basic/trooper/pirate/faction/grey - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 3, - /datum/pirate_gun_loadout/laser_rifle = 3, - /datum/pirate_gun_loadout/smg = 1, - ) - -/mob/living/basic/trooper/pirate/faction/lustrous - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 3, - /datum/pirate_gun_loadout/laser_rifle = 3, - /datum/pirate_gun_loadout/pistol = 1, - ) - -/mob/living/basic/trooper/pirate/faction/interdyne - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 2, - /datum/pirate_gun_loadout/pistol = 2, - /datum/pirate_gun_loadout/smg = 3, - /datum/pirate_gun_loadout/shotgun = 1, - ) - -/mob/living/basic/trooper/pirate/faction/irs - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 1, - /datum/pirate_gun_loadout/pistol = 3, - /datum/pirate_gun_loadout/smg = 2, - /datum/pirate_gun_loadout/shotgun = 2, - ) - -// Crossbows suit them -/mob/living/basic/trooper/pirate/faction/medieval - crew_keeps_gun = TRUE diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm index 8d743ce5b2f..1cffb46b6b3 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm @@ -1,5 +1,6 @@ // Faction-specific NPC pirate ship types -// Each faction has unique colors, crew types, and behaviors +// Each faction has unique colors, crew types, and behaviors. Crews, boarders and bosses are human NPCs +// (faction_pirate_humans.dm). // ==================== FACTION COLORS ==================== @@ -29,11 +30,11 @@ crew_min = 2 crew_max = 4 - captain_type = /mob/living/basic/trooper/pirate/faction/silverscale/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/silverscale/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - /mob/living/basic/trooper/pirate/faction/silverscale/ranged, + /mob/living/carbon/human/npc/faction_pirate/silverscale/melee, + /mob/living/carbon/human/npc/faction_pirate/silverscale/melee, + /mob/living/carbon/human/npc/faction_pirate/silverscale/ranged, ) // Boarding pods - aristocrats send their duelists @@ -52,7 +53,7 @@ pirate_faction = "silverscale" // Phased combat - aristocratic duelists - boss_type = /mob/living/basic/trooper/pirate/faction/boss/silverscale + boss_type = /mob/living/carbon/human/npc/faction_pirate/silverscale/boss broke_lines = list( "Destitute. How disappointing. My retainers will see what else your hold contains.", "You have nothing to offer but cargo, then. And perhaps your lives.", @@ -90,11 +91,11 @@ crew_min = 3 crew_max = 6 - captain_type = /mob/living/basic/trooper/pirate/faction/skeleton/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/skeleton/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged, + /mob/living/carbon/human/npc/faction_pirate/skeleton/melee, + /mob/living/carbon/human/npc/faction_pirate/skeleton/melee, + /mob/living/carbon/human/npc/faction_pirate/skeleton/ranged, ) // Boarding pods - undead are relentless boarders @@ -112,7 +113,7 @@ pirate_faction = "skeleton" // Phased combat - undead hordes - boss_type = /mob/living/basic/trooper/pirate/faction/boss/skeleton + boss_type = /mob/living/carbon/human/npc/faction_pirate/skeleton/boss broke_lines = list( "No coin on this wreck. No matter - we take flesh as readily as gold.", "Empty holds, empty pockets. We will settle for your crew.", @@ -150,11 +151,11 @@ crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/pirate/faction/grey/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/grey/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/grey/melee, - /mob/living/basic/trooper/pirate/faction/grey/melee, - /mob/living/basic/trooper/pirate/faction/grey/ranged, + /mob/living/carbon/human/npc/faction_pirate/grey/melee, + /mob/living/carbon/human/npc/faction_pirate/grey/melee, + /mob/living/carbon/human/npc/faction_pirate/grey/ranged, ) // Boarding pods - grey tiders love swarming @@ -172,7 +173,7 @@ hold_credits_max = 1600 // Phased combat - greytide swarm - boss_type = /mob/living/basic/trooper/pirate/faction/boss/grey + boss_type = /mob/living/carbon/human/npc/faction_pirate/grey/boss broke_lines = list( "lmao ur broke? w/e. boys go grab whatever isnt bolted down", "no creds? fine. WE TAKE THE TOOLBOXES", @@ -206,10 +207,10 @@ crew_min = 2 crew_max = 4 - captain_type = /mob/living/basic/trooper/pirate/faction/lustrous/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/lustrous/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/lustrous/melee, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged, + /mob/living/carbon/human/npc/faction_pirate/lustrous/melee, + /mob/living/carbon/human/npc/faction_pirate/lustrous/ranged, ) // Boarding pods - ethereals prefer ranged combat, fewer boarders @@ -227,7 +228,7 @@ hold_credits_max = 3000 // Phased combat - crystalline beings - boss_type = /mob/living/basic/trooper/pirate/faction/boss/lustrous + boss_type = /mob/living/carbon/human/npc/faction_pirate/lustrous/boss broke_lines = list( "You carry no wealth. We will take what matter you do carry.", "Nothing of value in your accounts. We will harvest from your hold instead.", @@ -266,11 +267,11 @@ crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/pirate/faction/interdyne/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/interdyne/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/interdyne/melee, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged, + /mob/living/carbon/human/npc/faction_pirate/interdyne/melee, + /mob/living/carbon/human/npc/faction_pirate/interdyne/ranged, + /mob/living/carbon/human/npc/faction_pirate/interdyne/ranged, ) // Boarding pods - interdyne sends surgical strike teams @@ -288,7 +289,7 @@ hold_credits_max = 3800 // Phased combat - surgical strike teams - boss_type = /mob/living/basic/trooper/pirate/faction/boss/interdyne + boss_type = /mob/living/carbon/human/npc/faction_pirate/interdyne/boss broke_lines = list( "Accounts empty. Switching to physical asset recovery. Deploying collection team.", "No liquid funds. Your cargo and your crew will serve as compensation.", @@ -331,11 +332,11 @@ crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/pirate/faction/irs/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/irs/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/irs/melee, - /mob/living/basic/trooper/pirate/faction/irs/ranged, - /mob/living/basic/trooper/pirate/faction/irs/ranged, + /mob/living/carbon/human/npc/faction_pirate/irs/melee, + /mob/living/carbon/human/npc/faction_pirate/irs/ranged, + /mob/living/carbon/human/npc/faction_pirate/irs/ranged, ) // Boarding pods - IRS sends auditors to seize assets @@ -354,7 +355,7 @@ hold_credits_max = 6000 // Phased combat - tax enforcement - boss_type = /mob/living/basic/trooper/pirate/faction/boss/irs + boss_type = /mob/living/carbon/human/npc/faction_pirate/irs/boss broke_lines = list( "Insufficient funds for settlement. Proceeding directly to asset seizure.", "You cannot pay. Then we collect in kind. Agents are boarding now.", @@ -396,11 +397,11 @@ crew_min = 3 crew_max = 6 - captain_type = /mob/living/basic/trooper/pirate/faction/medieval/captain + captain_type = /mob/living/carbon/human/npc/faction_pirate/medieval/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/medieval/melee, - /mob/living/basic/trooper/pirate/faction/medieval/melee, - /mob/living/basic/trooper/pirate/faction/medieval/ranged, + /mob/living/carbon/human/npc/faction_pirate/medieval/melee, + /mob/living/carbon/human/npc/faction_pirate/medieval/melee, + /mob/living/carbon/human/npc/faction_pirate/medieval/ranged, ) // Boarding pods - medieval warriors LOVE boarding actions @@ -418,7 +419,7 @@ hold_credits_max = 3200 // Phased combat - knights and men-at-arms - boss_type = /mob/living/basic/trooper/pirate/faction/boss/medieval + boss_type = /mob/living/carbon/human/npc/faction_pirate/medieval/boss broke_lines = list( "No coin in thy coffers? Then we shall take thy goods by force of arms!", "A pauper's ship! Very well - the spoils shall be whatever thou carriest!", diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/nt_patrol.dm b/voidcrew/modules/npc_ships/code/faction_pirates/nt_patrol.dm index 20fe1ccaac3..0be6558a3d8 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/nt_patrol.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/nt_patrol.dm @@ -61,11 +61,11 @@ // Crew configuration - NT customs enforcement detail crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/nanotrasen/ranged/assault/customs + captain_type = /mob/living/carbon/human/npc/faction_pirate/nt_customs/sergeant crew_types = list( - /mob/living/basic/trooper/nanotrasen/customs, - /mob/living/basic/trooper/nanotrasen/ranged/smg/customs, - /mob/living/basic/trooper/nanotrasen/ranged/smg/customs, + /mob/living/carbon/human/npc/faction_pirate/nt_customs/officer, + /mob/living/carbon/human/npc/faction_pirate/nt_customs/inspector, + /mob/living/carbon/human/npc/faction_pirate/nt_customs/inspector, ) // Negotiation - the "fine". Dispatch overwrites both bounds with the exact @@ -88,8 +88,8 @@ boarding_pods_max = 2 boarding_pod_cooldown_time = 30 SECONDS boarding_pod_mob_types = list( - /mob/living/basic/trooper/nanotrasen/customs, - /mob/living/basic/trooper/nanotrasen/ranged/smg/customs, + /mob/living/carbon/human/npc/faction_pirate/nt_customs/officer, + /mob/living/carbon/human/npc/faction_pirate/nt_customs/inspector, ) // Phased combat - compliance action waves, then the Director @@ -99,7 +99,7 @@ list(3, 4), // Wave 2 list(3, 5), // Wave 3 ) - boss_type = /mob/living/basic/trooper/pirate/faction/boss/nt_patrol + boss_type = /mob/living/carbon/human/npc/faction_pirate/nt_customs/director wave_taunts = list( list( // Wave 1 -> 2 cooldown "Compliance teams are inbound. This is a billable action.", @@ -185,10 +185,11 @@ // faction_pirate_equipment.dm, but keyed to the NT crew's faction so they // don't gun down their own officers. // -// req_access is deliberately an access nobody aboard carries. The crew are -// basic mobs with no ID cards, so the swipe lock only ever gates boarders - -// and the inherited ACCESS_SYNDICATE gated it the wrong way round, since -// voidcrew's syndicate job outfits hand players that access at roundstart. +// req_access is CentCom access. The crew carry CentCom ID cards, but those are +// locked to their bodies (human NPC gear lock, voidcrew/modules/human_npcs/) and +// the crew never work a turret's panel, so the swipe lock only ever gates +// boarders - and the inherited ACCESS_SYNDICATE gated it the wrong way round, +// since voidcrew's syndicate job outfits hand players that access at roundstart. // The counterplay is the turretid console, which ship_nt_patrol.dmm maps // unlocked on purpose. @@ -204,100 +205,177 @@ faction = list(ROLE_DEATHSQUAD) req_access = list(ACCESS_CENT_GENERAL) -// ==================== CREW MOBS ==================== -// Space-capable customs variants of the upstream NT troopers -// (code/modules/mob/living/basic/trooper/nanotrasen.dm). Mirrors what -// /mob/living/basic/trooper/pirate/faction does for pirate crews: atmos -// immunity + TRAIT_SPACEWALK so ship interiors and boarding actions work. - -/// Customs guard - melee escort with a stun baton -/mob/living/basic/trooper/nanotrasen/customs - name = "\improper Nanotrasen Customs Officer" - desc = "A Nanotrasen customs officer, here to search your ship. The baton is standard issue." - maxHealth = 110 - health = 110 - melee_damage_lower = 12 - melee_damage_upper = 18 - attack_verb_continuous = "batons" - attack_verb_simple = "baton" - attack_sound = 'sound/items/weapons/genhit1.ogg' - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - minimum_survivable_temperature = 0 - r_hand = /obj/item/melee/baton/security - -/mob/living/basic/trooper/nanotrasen/customs/Initialize(mapload) - . = ..() - ADD_TRAIT(src, TRAIT_SPACEWALK, INNATE_TRAIT) - -/// Customs inspector - SMG-armed ranged officer -/mob/living/basic/trooper/nanotrasen/ranged/smg/customs - name = "\improper Nanotrasen Customs Inspector" - desc = "A Nanotrasen customs inspector. Authorized to conduct searches, seizures, and suppressive fire." - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - minimum_survivable_temperature = 0 - -/mob/living/basic/trooper/nanotrasen/ranged/smg/customs/Initialize(mapload) - . = ..() - ADD_TRAIT(src, TRAIT_SPACEWALK, INNATE_TRAIT) - -/// Customs sergeant - assault-rifle captain of the patrol (carries the ship key) -/mob/living/basic/trooper/nanotrasen/ranged/assault/customs - name = "\improper Nanotrasen Customs Sergeant" - desc = "The ranking customs officer aboard. Personally responsible for this quarter's seizure figures." - maxHealth = 120 - health = 120 - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - minimum_survivable_temperature = 0 - -/mob/living/basic/trooper/nanotrasen/ranged/assault/customs/Initialize(mapload) - . = ..() - ADD_TRAIT(src, TRAIT_SPACEWALK, INNATE_TRAIT) - -// ==================== THE BOSS ==================== -// Subtypes /mob/living/basic/trooper/pirate/faction/boss because -// create_boss_boarding_pod() (boarding_pod.dm) writes boss.parent_ship and the -// boss death hook lives on that type - an upstream NT trooper would runtime. -// Flavor and loadout are pure Nanotrasen. - -/mob/living/basic/trooper/pirate/faction/boss/nt_patrol - name = "NT Compliance Director" - desc = "A senior Nanotrasen compliance executive in body armor, sent out when the paperwork stops working." - maxHealth = 350 - health = 350 - melee_damage_lower = 20 - melee_damage_upper = 25 - armour_penetration = 25 - attack_verb_continuous = "restructures" - attack_verb_simple = "restructure" - attack_sound = 'sound/items/weapons/genhit1.ogg' - attack_vis_effect = ATTACK_EFFECT_PUNCH +// ==================== THE CREW ==================== +// Customs officers, inspectors, their sergeant and the Compliance Director as human NPCs +// (voidcrew/modules/human_npcs/), crewing the corvette and boarding by pod the way the pirate factions do +// (faction_pirate_humans.dm, whose crew AI, boarding, captain's key and boss hooks they share). They look +// like tg's Nanotrasen troopers (the corpse spawners' species, hair and clothes) and hold the guns those +// troopers fired, by the zone ladder the basic customs mobs used (voidcrew/edits/mobs/weapon_drops.dm): +// inspectors and the sergeant carry an M1911 outside the red zone. Their CentCom ID cards are locked gear +// like the rest of their kit, so a dead officer's card opens nothing for anyone. Boarders wear a sealed suit +// and carry air. They drop nothing (a customs vessel is no prize); the Director drops his e-gun, his baton +// and 2,000 credits of collected fines, as the basic boss did. + +/// What customs says when things happen to its body (human_npc_barks.dm) +/datum/human_npc_voice/nt_customs + lines = list( + PIRATE_BARK_SPOT = list("Customs! Stay where you are!", "There. Detain them.", "Contact. Compliance action authorised."), + HUMAN_NPC_BARK_DOWNED = list("Officer down!", "I need a medic. Bill them for it."), + HUMAN_NPC_BARK_PLEAD = list("Assaulting an officer... is a crime...", "This is going on your file..."), + HUMAN_NPC_BARK_RECOVER = list("Back on duty.", "Where was I."), + HUMAN_NPC_BARK_CUFFED = list("You're cuffing a customs officer. That's on file.", "This is obstruction."), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off, citizen!"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + "stimulant" = list("jabs a needle into their neck."), + ) + chances = list(PIRATE_BARK_SPOT = 60) + +/mob/living/carbon/human/npc/faction_pirate/nt_customs + name = "customs officer" + npc_name = "Nanotrasen Customs Officer" + crew_desc = "A Nanotrasen customs officer, here to search your ship." + // The corvette's turrets and doors are keyed to it (above); never counted as a pirate faction = list(ROLE_DEATHSQUAD) - mob_spawner = /obj/effect/mob_spawn/corpse/human/nanotrasenelitesoldier - corpse = /obj/effect/mob_spawn/corpse/human/nanotrasenelitesoldier - r_hand = /obj/item/gun/energy/e_gun - loot_pool = list(/obj/item/gun/energy/e_gun, /obj/item/melee/baton/security/loaded) - plunder_credits = 2000 - ai_controller = /datum/ai_controller/basic_controller/trooper/ranged - var/projectiletype = /obj/projectile/beam/laser - var/projectilesound = 'sound/items/weapons/laser.ogg' - var/burst_shots = 3 - var/ranged_cooldown = 5 SECONDS - -/mob/living/basic/trooper/pirate/faction/boss/nt_patrol/Initialize(mapload) - . = ..() - AddComponent(\ - /datum/component/ranged_attacks,\ - projectile_type = projectiletype,\ - projectile_sound = projectilesound,\ - cooldown_time = ranged_cooldown,\ - burst_shots = burst_shots,\ + npc_look = /obj/effect/mob_spawn/corpse/human/nanotrasensoldier + npc_outfit_table = /datum/human_npc_outfit_table/pirate/nt_customs + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/nt_customs/boarding + npc_voice = /datum/human_npc_voice/nt_customs + // Always aboard their corvette or with its boarders: the crew decides, as for pirates + npc_surrender = null + crew_alert_lines = list( + "Unauthorised boarders. Detain them.", + "Intruders on a customs vessel. Weapons free.", + "All officers, we have a breach.", ) + crew_spot_lines = list( + "Customs! Stay where you are!", + "There. Detain them.", + "Contact. Compliance action authorised.", + ) + // A customs vessel is no prize: the crew drop nothing + random_loot = null + random_loot_2 = null + +// Corporate staff, not natives of any planet +/mob/living/carbon/human/npc/faction_pirate/nt_customs/should_inherit_planetary_faction() + return FALSE + +// The corpse look's own CentCom ID (tg's trooper corpses) already names its rank; keep it and put the +// NPC's own name on it, so a masked officer isn't "Unknown". +/mob/living/carbon/human/npc/faction_pirate/nt_customs/crew_issue_id() + var/obj/item/card/id/card = wear_id + if(!istype(card)) + return ..() + card.registered_name = real_name + card.update_label() + +/// Customs guard: a stun baton and cuffs' worth of authority +/mob/living/carbon/human/npc/faction_pirate/nt_customs/officer + npc_name = "Nanotrasen Customs Officer" + crew_desc = "A Nanotrasen customs officer, here to search your ship. The baton is standard issue." + crew_role = SHIP_CREW_ROLE_MELEE + +/// Customs inspector: an M1911, or a WT-550 in the red zone +/mob/living/carbon/human/npc/faction_pirate/nt_customs/inspector + npc_name = "Nanotrasen Customs Inspector" + crew_desc = "A Nanotrasen customs inspector. Authorised to conduct searches, seizures, and suppressive fire." + crew_role = SHIP_CREW_ROLE_GUNNER + +/// Customs sergeant: the corvette's captain, who carries its key +/mob/living/carbon/human/npc/faction_pirate/nt_customs/sergeant + npc_name = "Nanotrasen Customs Sergeant" + crew_desc = "The ranking customs officer aboard. Personally responsible for this quarter's seizure figures." + npc_look = /obj/effect/mob_spawn/corpse/human/nanotrasenassaultsoldier + npc_rank = HUMAN_NPC_RANK_ELITE + +/// The Compliance Director: the boss the corvette sends last. Killing or taking him disables it. +/mob/living/carbon/human/npc/faction_pirate/nt_customs/director + npc_name = "NT Compliance Director" + crew_desc = "A senior Nanotrasen compliance executive in body armour, sent out when the paperwork stops working." + npc_look = /obj/effect/mob_spawn/corpse/human/nanotrasenelitesoldier + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + loot_pool = list(/obj/item/gun/energy/e_gun, /obj/item/melee/baton/security/loaded) + npc_death_cash = 2000 + +// ===== Outfits: tg's trooper corpse outfits, with the real weapons the basic mobs fired ===== + +/// Rank and file by role and zone; the sergeant and the Director wear their own. +/datum/human_npc_outfit_table/pirate/nt_customs + vacuum_layer = /datum/human_npc_layer/eva/nt_customs + melee = list(/datum/outfit/nanotrasensoldiercorpse/nt_customs_officer = 1) + gunners = list(/datum/outfit/nanotrasensoldiercorpse/nt_customs_inspector = 1) + elite = list(/datum/outfit/nanotrasenassaultsoldiercorpse/nt_customs_sergeant = 1) + boss = list(/datum/outfit/nt_customs_director = 1) + /// In the red zone the gunners and the sergeant carry the guns the basic mobs fired there + var/list/red_gunners = list(/datum/outfit/nanotrasensoldiercorpse/nt_customs_inspector/smg = 1) + var/list/red_elite = list(/datum/outfit/nanotrasenassaultsoldiercorpse/nt_customs_sergeant/rifle = 1) + +/datum/human_npc_outfit_table/pirate/nt_customs/outfits_for(mob/living/carbon/human/npc/npc, zone) + if(zone == ZONE_RED) + var/mob/living/carbon/human/npc/faction_pirate/crewman = npc + if(npc?.npc_rank == HUMAN_NPC_RANK_ELITE) + return red_elite + if(istype(crewman) && crewman.npc_rank == HUMAN_NPC_RANK_REGULAR && crewman.crew_role == SHIP_CREW_ROLE_GUNNER) + return red_gunners + return ..() + +// Boarders cross space: a sealed suit and air +/datum/human_npc_outfit_table/pirate/nt_customs/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_layer/eva/nt_customs + suit = /obj/item/clothing/suit/space/syndicate/black/blue + head = /obj/item/clothing/head/helmet/space/syndicate/black/blue + +/datum/outfit/nanotrasensoldiercorpse/nt_customs_officer + name = "Human NPC: NT Customs Officer" + r_hand = /obj/item/melee/baton/security/loaded + l_pocket = /obj/item/crowbar + +/datum/outfit/nanotrasensoldiercorpse/nt_customs_inspector + name = "Human NPC: NT Customs Inspector" + r_hand = /obj/item/gun/ballistic/automatic/pistol/m1911 + l_pocket = /obj/item/ammo_box/magazine/m45 + r_pocket = /obj/item/ammo_box/magazine/m45 + +/datum/outfit/nanotrasensoldiercorpse/nt_customs_inspector/smg + name = "Human NPC: NT Customs Inspector (WT-550)" + r_hand = /obj/item/gun/ballistic/automatic/wt550 + l_pocket = /obj/item/ammo_box/magazine/wt550m9 + r_pocket = /obj/item/ammo_box/magazine/wt550m9 + +/datum/outfit/nanotrasenassaultsoldiercorpse/nt_customs_sergeant + name = "Human NPC: NT Customs Sergeant" + r_hand = /obj/item/gun/ballistic/automatic/pistol/m1911 + l_pocket = /obj/item/ammo_box/magazine/m45 + r_pocket = /obj/item/ammo_box/magazine/m45 + +/datum/outfit/nanotrasenassaultsoldiercorpse/nt_customs_sergeant/rifle + name = "Human NPC: NT Customs Sergeant (rifle)" + r_hand = /obj/item/gun/ballistic/automatic/ar + l_pocket = /obj/item/ammo_box/magazine/m223 + r_pocket = /obj/item/ammo_box/magazine/m223 + +/// The Director: tg's elite trooper in body armour instead of the modsuit, a lethal e-gun and a baton +/datum/outfit/nt_customs_director + name = "Human NPC: NT Compliance Director" + uniform = /obj/item/clothing/under/rank/centcom/military + suit = /obj/item/clothing/suit/armor/bulletproof + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + mask = /obj/item/clothing/mask/gas/sechailer/swat + head = /obj/item/clothing/head/helmet/swat/nanotrasen + back = /obj/item/storage/backpack/security + belt = /obj/item/melee/baton/security/loaded + id = /obj/item/card/id/advanced/centcom + id_trim = /datum/id_trim/centcom/ert/security + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival // ==================== NEGOTIATION DIALOG ==================== diff --git a/voidcrew/modules/npc_ships/code/mob_patrol.dm b/voidcrew/modules/npc_ships/code/mob_patrol.dm index 6865b6ce6ad..058ed5851a3 100644 --- a/voidcrew/modules/npc_ships/code/mob_patrol.dm +++ b/voidcrew/modules/npc_ships/code/mob_patrol.dm @@ -1431,7 +1431,7 @@ PATROL_LOG("[pawn] not adjacent to assembly [assembly.name] (dist=[assembly_dist]), cannot attack") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED - pawn.melee_attack(assembly) + patrol_melee_attack(controller, assembly) PATROL_LOG("[pawn] smashing door assembly [assembly.name]") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED @@ -1464,7 +1464,7 @@ PATROL_LOG("[pawn] not adjacent to obstacle [obstacle.name], cannot attack") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED - pawn.melee_attack(obstacle) + patrol_melee_attack(controller, obstacle) PATROL_LOG("[pawn] smashing blocking obstacle [obstacle.name]") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED @@ -1504,7 +1504,7 @@ return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED // Keep running until adjacent // Adjacent - attack it - pawn.melee_attack(assembly) + patrol_melee_attack(controller, assembly) PATROL_LOG("[pawn] smashing patrol target assembly [assembly.name]") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED @@ -1610,7 +1610,7 @@ // Always use melee attack for door breaking (like blobbernauts/standard troopers) // This is more reliable than ranged attacks and saves ammo for combat - pawn.melee_attack(door) + patrol_melee_attack(controller, door) PATROL_LOG("[pawn] smashing blocking door [door.name]") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED @@ -1789,7 +1789,7 @@ // Always use melee attack for door breaking (like blobbernauts/standard troopers) // This is more reliable than ranged attacks and saves ammo for combat - pawn.melee_attack(door) + patrol_melee_attack(controller, door) PATROL_LOG("[pawn] smashing door [door.name]") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED @@ -1931,7 +1931,7 @@ // If locked or welded, attack it to break it open if(closet.locked || closet.welded) - pawn.melee_attack(closet) + patrol_melee_attack(controller, closet) PATROL_LOG("[pawn] attacking locked/welded closet [closet.name]") return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED // Keep attacking until it opens @@ -1946,7 +1946,11 @@ // Scan for crew hiding inside - they'll be on/near the closet's turf now var/turf/closet_turf = get_turf(closet) if(closet_turf) + var/datum/targeting_strategy/targeting = GET_TARGETING_STRATEGY(controller.blackboard[BB_TARGETING_STRATEGY]) for(var/mob/living/carbon/hiding_crew in range(1, closet_turf)) + // Not the patroller itself, a person breaking out of an outpost prison, or anyone it won't fight + if(hiding_crew == pawn || (targeting && !targeting.can_attack(pawn, hiding_crew))) + continue if(hiding_crew.stat != DEAD) // Found a live crew member! Target them immediately controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hiding_crew) @@ -2136,3 +2140,14 @@ /datum/ai_planning_subtree/try_open_door_in_path, /datum/ai_planning_subtree/attack_patrol_door, ) + +/** + * One blow from a patroller at `target`: a basic mob's own melee attack, or, for a person on a patrol (a + * prisoner breaking out of an outpost prison is a real human body), a click with combat mode on through + * their own hands. + */ +/proc/patrol_melee_attack(datum/ai_controller/controller, atom/target) + var/mob/living/basic/basic_pawn = controller?.pawn + if(isbasicmob(basic_pawn)) + return basic_pawn.melee_attack(target) + return controller?.ai_interact(target = target, combat_mode = TRUE) diff --git a/voidcrew/modules/npc_ships/code/npc_ship.dm b/voidcrew/modules/npc_ships/code/npc_ship.dm index b639e0a06a5..0be9b40ff7c 100644 --- a/voidcrew/modules/npc_ships/code/npc_ship.dm +++ b/voidcrew/modules/npc_ships/code/npc_ship.dm @@ -147,6 +147,11 @@ /// treat an empty roster as a crew wipe unless this is set - otherwise a hull whose /// crew spawn silently failed would resolve at birth and churn the pool in a loop. var/crew_ever_spawned = FALSE + /// Human NPC crew still waiting in the spawn queue (spawn_human_npc()); the hull's roster isn't + /// complete, and can't be wiped, until they are built + var/list/datum/human_npc_order/crew_orders = list() + /// Set once the crew wipe has been handled, so a second path to it does nothing + var/crew_wipe_handled = FALSE // ========== MASS CACHING (Performance optimization) ========== // Instead of iterating all turfs every second, we cache mass and only @@ -239,8 +244,9 @@ if(abandonment_timer) deltimer(abandonment_timer) abandonment_timer = null - // Clear crew tracking + // Clear crew tracking; crew still in the spawn queue are never built tracked_crew.Cut() + QDEL_LIST(crew_orders) return ..() /** @@ -375,16 +381,13 @@ if(captain_type && length(valid_turfs)) var/turf/captain_loc = pick_n_take(valid_turfs) var/datum/npc_spawn_context/context = new(src, pool_zone_type, "boss") - var/mob/living/basic/captain = spawn_npc_in_zone(captain_type, captain_loc, context, FALSE) - // Give captain the ship key - stored in contents, drops on death - var/obj/item/ship_key/key = new(null, src) - key.forceMove(captain) - context.finalize() - // Track captain and register death signal - tracked_crew += captain - RegisterSignal(captain, COMSIG_LIVING_DEATH, PROC_REF(on_crew_death)) - // The captain holds the helm with one guard (pirate_crew.dm) - pirate_crew_enlist_defender(captain, src, PIRATE_ROLE_CAPTAIN) + if(ispath(captain_type, /mob/living/carbon/human/npc)) + // Human NPCs are built a body at a time (spawn_human_npc()); the captain joins when built + crew_orders += spawn_human_npc(captain_type, captain_loc, CALLBACK(src, PROC_REF(on_captain_built)), context = context) + else + var/mob/living/basic/captain = spawn_npc_in_zone(captain_type, captain_loc, context, FALSE) + context.finalize() + on_captain_built(captain) crew_count-- // Captain counts toward crew count // Spawn rest of crew from configured types @@ -392,15 +395,53 @@ var/turf/spawn_loc = pick_n_take(valid_turfs) var/mob_type = pick(crew_types) var/datum/npc_spawn_context/context = new(src, pool_zone_type, "ordinary") - var/mob/living/crewmember = spawn_npc_in_zone(mob_type, spawn_loc, context) - // Track crew and register death signal - tracked_crew += crewmember - RegisterSignal(crewmember, COMSIG_LIVING_DEATH, PROC_REF(on_crew_death)) - pirate_crew_enlist_defender(crewmember, src) + if(ispath(mob_type, /mob/living/carbon/human/npc)) + crew_orders += spawn_human_npc(mob_type, spawn_loc, CALLBACK(src, PROC_REF(on_crewman_built)), context = context) + else + on_crewman_built(spawn_npc_in_zone(mob_type, spawn_loc, context)) + +/// The captain is built: it carries the ship key and holds the helm with one guard (pirate_crew.dm). +/obj/structure/overmap/ship/npc/proc/on_captain_built(mob/living/captain) + if(QDELETED(captain)) + return + // The key is not gear: a human captain keeps it in a pocket, where a crew that cuffs or downs him can + // take it (the strip menu), and it drops when he dies (on_crew_death()) + var/obj/item/ship_key/key = new(null, src) + var/mob/living/carbon/human/npc/faction_pirate/human_captain = captain + if(!istype(human_captain) || !human_captain.crew_pocket(key)) + key.forceMove(captain) + track_crewman(captain, PIRATE_ROLE_CAPTAIN) +/// A crewman is built: tracked for the crew wipe and enlisted in the hull's crew. +/obj/structure/overmap/ship/npc/proc/on_crewman_built(mob/living/crewmember) + if(QDELETED(crewmember)) + return + track_crewman(crewmember, PIRATE_ROLE_HUNTER) + +/obj/structure/overmap/ship/npc/proc/track_crewman(mob/living/crewmember, role) + prune_crew_orders() + if(QDELETED(src)) + qdel(crewmember) + return + tracked_crew += crewmember + RegisterSignal(crewmember, COMSIG_LIVING_DEATH, PROC_REF(on_crew_death)) + // Out of the fight counts as gone for the crew wipe too (owner ruling OC-7): down or cuffed + if(ishumannpc(crewmember)) + RegisterSignals(crewmember, list(COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_crew_out_of_fight)) + pirate_crew_enlist_defender(crewmember, src, role) // A roster exists; the pool may now treat this roster emptying as a crew wipe - if(length(tracked_crew)) - crew_ever_spawned = TRUE + crew_ever_spawned = TRUE + +/// Drops crew orders that have been built or cancelled. +/obj/structure/overmap/ship/npc/proc/prune_crew_orders() + for(var/datum/human_npc_order/order as anything in crew_orders.Copy()) + if(QDELETED(order) || order.done) + crew_orders -= order + +/// Whether crew are still waiting to be built. +/obj/structure/overmap/ship/npc/proc/crew_pending() + prune_crew_orders() + return length(crew_orders) > 0 /** * Signal handler for when any crew member dies. @@ -408,27 +449,45 @@ */ /obj/structure/overmap/ship/npc/proc/on_crew_death(mob/living/victim, gibbed) SIGNAL_HANDLER - UnregisterSignal(victim, COMSIG_LIVING_DEATH) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) - // Drop ship key if the victim has one (captain) + // Drop ship key if the victim has one (captain). A human captain's drops from his pocket with + // whatever else he carries that isn't gear (human_npc_gear.dm); this catches one held loose. var/turf/drop_loc = get_turf(victim) if(drop_loc) for(var/obj/item/ship_key/key in victim.contents) - key.forceMove(drop_loc) + if(!victim.get_slot_by_item(key)) + key.forceMove(drop_loc) // Remove from tracked crew list tracked_crew -= victim log_shuttle("NPC_SHIP: [name] crew member [victim] died ([length(tracked_crew)] tracked crew remain)") + check_crew_wipe() + +/// A human crewman went into crit or was cuffed: if nobody aboard is left fighting, the crew is wiped. +/obj/structure/overmap/ship/npc/proc/on_crew_out_of_fight(mob/living/source) + SIGNAL_HANDLER + check_crew_wipe() - // Check if all crew are dead - if(!length(tracked_crew)) - npc_metric_crew_wiped(src) - // The pool slot frees the instant the crew is wiped. The abandonment timer below - // is a separate concern - it holds the hull claimable for a while - and must not - // delay the replacement spawn. - if(!player_controlled) - notify_spawner_resolved("crew wiped") - start_abandonment_timer() +/** + * The crew is wiped once every tracked crewman is dead, or out of the fight (in crit or cuffed, owner + * ruling OC-7: a crew the players took down or captured is beaten, and a body frozen in crit on a hull + * nobody is near must not hold the pool slot). Crew still waiting to be built keep it from wiping. + */ +/obj/structure/overmap/ship/npc/proc/check_crew_wipe() + if(crew_wipe_handled || crew_pending()) + return + for(var/mob/living/crewmember as anything in tracked_crew) + if(!human_npc_out_of_fight(crewmember)) + return + crew_wipe_handled = TRUE + npc_metric_crew_wiped(src) + // The pool slot frees the instant the crew is wiped. The abandonment timer below + // is a separate concern - it holds the hull claimable for a while - and must not + // delay the replacement spawn. + if(!player_controlled) + notify_spawner_resolved("crew wiped") + start_abandonment_timer() /** * Starts a timer to abandon the ship after all crew die. @@ -522,21 +581,20 @@ return despawn_derelict() /** - * How many of this hull's own tracked crew are alive and actually aboard. + * How many of this hull's own tracked crew are still fighting and actually aboard. * * Derived from live state rather than roster bookkeeping on purpose: a missed death * signal, a hard-deleted mob, or a pirate spaced or dragged off the hull must all read - * as "not aboard", or the pool slot this crew holds never frees. The roster is 2-6 - * entries, so this is cheap enough for the reconcile's slow tick. + * as "not aboard", or the pool slot this crew holds never frees. A crewman in crit or + * cuffed is out of the fight and doesn't count either (owner ruling OC-7). The roster is + * 2-6 entries, so this is cheap enough for the reconcile's slow tick. */ /obj/structure/overmap/ship/npc/proc/count_live_crew_aboard() if(!shuttle?.shuttle_areas) return 0 var/count = 0 for(var/mob/living/crew as anything in tracked_crew) - if(QDELETED(crew)) - continue - if(crew.stat == DEAD) + if(human_npc_out_of_fight(crew)) continue var/area/crew_area = get_area(crew) if(!crew_area || !shuttle.shuttle_areas[crew_area]) @@ -544,25 +602,6 @@ count++ return count -/** - * Kills tracked crew that ended up floating in open space (hull breach blowout). - * The same cleanup boarding waves already get via check_boarders_in_space(); without it - * a spaced pirate drifts alive forever. Run from the pool reconcile. - */ -/obj/structure/overmap/ship/npc/proc/sweep_spaced_crew() - var/list/spaced = list() - for(var/mob/living/crew as anything in tracked_crew) - if(QDELETED(crew) || crew.stat == DEAD) - continue - var/turf/crew_turf = get_turf(crew) - if(!crew_turf) - continue - if(isspaceturf(crew_turf) || istype(get_area(crew_turf), /area/space)) - spaced += crew - // death() fires on_crew_death, which edits tracked_crew - never kill mid-iteration - for(var/mob/living/lost as anything in spaced) - lost.death() - /** * Signal handler for ship integrity changes. * Updates boarding state when ship is damaged enough. @@ -916,10 +955,10 @@ // Crew configuration crew_min = 3 crew_max = 6 - captain_type = /mob/living/basic/trooper/pirate/ranged/space + captain_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/space/gunner crew_types = list( - /mob/living/basic/trooper/pirate/melee/space, - /mob/living/basic/trooper/pirate/ranged/space, + /mob/living/carbon/human/npc/ruin/trooper/pirate/space, + /mob/living/carbon/human/npc/ruin/trooper/pirate/space/gunner, ) // Faction diff --git a/voidcrew/modules/npc_ships/code/npc_ship_controller.dm b/voidcrew/modules/npc_ships/code/npc_ship_controller.dm index bb5a1744aab..f26d546ccf8 100644 --- a/voidcrew/modules/npc_ships/code/npc_ship_controller.dm +++ b/voidcrew/modules/npc_ships/code/npc_ship_controller.dm @@ -32,6 +32,10 @@ var/boarding_committed = 0 var/boarding_pending = 0 var/boarding_boss_sent = FALSE + /// Human boarders (and a human boss) of this encounter still waiting in the spawn queue + var/list/datum/human_npc_order/boarding_orders = list() + /// Of those, the ones the scripted wave sent: the wave isn't over while any of them is still coming + var/list/datum/human_npc_order/wave_orders = list() /datum/ai_controller/npc_ship/New(atom/new_pawn) // Initialize combat blackboard @@ -101,6 +105,7 @@ /// Override to avoid ai_movement access in parent Destroy /datum/ai_controller/npc_ship/Destroy(force) cleanup_boarding_signals() + cancel_boarding_orders() UnpossessPawn(FALSE) if(ai_status) GLOB.ai_controllers_by_status[ai_status] -= src @@ -439,6 +444,8 @@ // half-elapsed wipe timer over onto whoever we point at next. if(old_target != target) cleanup_boarding_signals() + // Boarders not yet built for the old target are never sent + cancel_boarding_orders() boarding_context = null boarding_budget = 0 boarding_committed = 0 @@ -835,27 +842,26 @@ GLOB.boarding_spawn_index = 0 GLOB.boarding_spawn_total = boarder_count - // Spawn the boarders via drop pods + // Spawn the boarders via drop pods. Human boarders are built through the spawn queue first, so they + // join the wave (track_wave_boarder()) as each one goes in its pod. var/list/wave_boarders = list() + blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] = wave_boarders + wave_orders.Cut() + var/launched = 0 for(var/i in 1 to boarder_count) if(!length(valid_turfs)) break var/turf/spawn_loc = pick(valid_turfs) var/mob_type = pick(mob_types) - // Create boarding pod with the mob inside - returns the boarder for tracking - var/mob/living/boarder = create_boarding_pod(spawn_loc, target, ship, mob_type) - - if(boarder) - // Track this boarder and register death signal - wave_boarders += boarder - RegisterSignal(boarder, COMSIG_LIVING_DEATH, PROC_REF(on_boarder_death)) + var/sent = create_boarding_pod(spawn_loc, target, ship, mob_type, on_boarder = CALLBACK(src, PROC_REF(track_wave_boarder), wave_boarders)) + if(istype(sent, /datum/human_npc_order)) + wave_orders += sent + if(sent) + launched++ // Note: Patrol setup is handled by the boarding pod's open_pod() proc - log_shuttle("PATROL: Wave [wave_number] launched [length(wave_boarders)] boarders via drop pods") - - // Store the wave boarders for tracking - blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] = wave_boarders + log_shuttle("PATROL: Wave [wave_number] launched [launched] boarders via drop pods") // Track wave start time and target position (for time limit and movement detection) set_blackboard_key(BB_NPC_BOARDING_WAVE_START_TIME, world.time) @@ -865,10 +871,43 @@ // Notify target ship target.ship_notify("Hostiles have boarded!", "SECURITY", SHIP_NOTIFY_DANGER, 'voidcrew/sound/warn3.ogg', 25) - npc_metric_boarding_wave(src, wave_number, length(wave_boarders)) + npc_metric_boarding_wave(src, wave_number, launched) return TRUE +/// A wave boarder is in its pod: it counts toward `wave` (the list it was sent with) until it is out of +/// the fight. Dead, in crit or cuffed is out (owner ruling OC-7). +/datum/ai_controller/npc_ship/proc/track_wave_boarder(list/wave, mob/living/boarder) + prune_boarding_orders() + if(QDELETED(boarder) || wave != blackboard[BB_NPC_BOARDING_WAVE_BOARDERS]) + return + wave += boarder + RegisterSignal(boarder, COMSIG_LIVING_DEATH, PROC_REF(on_boarder_death)) + if(ishumannpc(boarder)) + RegisterSignals(boarder, list(COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_boarder_out_of_fight)) + +/// Remembers a human boarder still waiting to be built, so the encounter can call it off. +/datum/ai_controller/npc_ship/proc/track_boarding_order(datum/human_npc_order/order) + if(!QDELETED(order) && !order.done) + boarding_orders += order + +/// Drops orders that have been built or cancelled. +/datum/ai_controller/npc_ship/proc/prune_boarding_orders() + for(var/datum/human_npc_order/order as anything in boarding_orders.Copy()) + if(QDELETED(order) || order.done) + boarding_orders -= order + for(var/datum/human_npc_order/order as anything in wave_orders.Copy()) + if(QDELETED(order) || order.done) + wave_orders -= order + +/// Calls off every boarder of this encounter that hasn't been built yet. +/datum/ai_controller/npc_ship/proc/cancel_boarding_orders() + for(var/datum/human_npc_order/order as anything in boarding_orders) + if(!QDELETED(order)) + qdel(order) + boarding_orders.Cut() + wave_orders.Cut() + /** * Find valid turfs on the target ship for spawning boarders. */ @@ -904,7 +943,7 @@ */ /datum/ai_controller/npc_ship/proc/on_boarder_death(mob/living/victim, gibbed) SIGNAL_HANDLER - UnregisterSignal(victim, COMSIG_LIVING_DEATH) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] if(wave_boarders) @@ -913,8 +952,14 @@ // Check if all boarders are dead INVOKE_ASYNC(src, PROC_REF(check_wave_complete)) +/// A human boarder went into crit or was cuffed: out of the fight, so the wave may be over. +/datum/ai_controller/npc_ship/proc/on_boarder_out_of_fight(mob/living/source) + SIGNAL_HANDLER + INVOKE_ASYNC(src, PROC_REF(check_wave_complete)) + /** - * Check if the current wave is complete (all boarders dead). + * Check if the current wave is complete: every boarder is out of the fight (dead, in crit or cuffed, + * owner ruling OC-7) and none is still waiting to be built. */ /datum/ai_controller/npc_ship/proc/check_wave_complete() // Guard against multiple calls - only process if we're in BOARDING state @@ -922,12 +967,16 @@ if(combat_state != NPC_COMBAT_BOARDING || !validate_boarding_target()) return + prune_boarding_orders() + if(length(wave_orders)) + return // Boarders still on their way + var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] - // Count living boarders + // Count boarders still fighting var/living_count = 0 for(var/mob/living/boarder as anything in wave_boarders) - if(!QDELETED(boarder) && boarder.stat != DEAD) + if(!human_npc_out_of_fight(boarder)) living_count++ if(living_count > 0) @@ -981,7 +1030,7 @@ // Notify about cooldown var/next_wave = completed_wave + 1 - target.ship_notify("Wave [completed_wave] repelled! Next wave in 30 seconds...", "COMBAT", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) + target.ship_notify("Boarders repelled!", "COMBAT", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) // Schedule next wave boarding_timers += addtimer(CALLBACK(src, PROC_REF(end_wave_cooldown), next_wave), NPC_BOARDING_WAVE_COOLDOWN, TIMER_STOPPABLE) @@ -1071,24 +1120,32 @@ if(!boss_type) return - // Create boss boarding pod - returns the boss for tracking - // Pod handles parent_ship, AI controller swap, and patrol assignment - var/mob/living/basic/trooper/pirate/faction/boss/boss = create_boss_boarding_pod(spawn_loc, target, ship, boss_type) - if(boss) - set_blackboard_key(BB_NPC_BOARDING_BOSS, boss) - RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(on_boss_death)) - npc_metric_boss_deployed(src, boss) + // Create boss boarding pod. The pod gives the boss its parent_ship; track_boss() follows it from + // the moment it is in its pod (a human boss is built through the spawn queue first). + create_boss_boarding_pod(spawn_loc, target, ship, boss_type, CALLBACK(src, PROC_REF(track_boss))) // Announce boss arrival target.ship_notify("WARNING: Enemy Commander incoming via drop pod!", "SECURITY", SHIP_NOTIFY_DANGER, 'voidcrew/sound/warn3.ogg', 25) +/// The boss is in its pod: watched until it is out of the fight. +/datum/ai_controller/npc_ship/proc/track_boss(mob/living/boss) + prune_boarding_orders() + if(QDELETED(boss)) + return + set_blackboard_key(BB_NPC_BOARDING_BOSS, boss) + RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(on_boss_death)) + // A human boss in crit or cuffed is beaten as surely as a dead one (owner ruling OC-7) + if(ishumannpc(boss)) + RegisterSignals(boss, list(COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_boss_death)) + npc_metric_boss_deployed(src, boss) + /** - * Signal handler for when the boss is killed. + * Signal handler for when the boss is killed, or a human boss is downed or cuffed. * This disables the pirate ship and allows players to board it. */ /datum/ai_controller/npc_ship/proc/on_boss_death(mob/living/victim, gibbed) SIGNAL_HANDLER - UnregisterSignal(victim, COMSIG_LIVING_DEATH) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) INVOKE_ASYNC(src, PROC_REF(handle_boss_killed)) @@ -1333,6 +1390,8 @@ boarding_timers.Cut() boarding_committed -= boarding_pending boarding_pending = 0 + // Boarders still being built land as part of the raid, outside any wave + wave_orders.Cut() // Unregister from player crew deaths var/list/tracked_crew = blackboard[BB_NPC_BOARDING_PLAYER_CREW] @@ -1345,11 +1404,11 @@ var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] for(var/mob/living/boarder as anything in wave_boarders) if(!QDELETED(boarder)) - UnregisterSignal(boarder, COMSIG_LIVING_DEATH) + UnregisterSignal(boarder, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) var/mob/living/boss = blackboard[BB_NPC_BOARDING_BOSS] if(!QDELETED(boss)) - UnregisterSignal(boss, COMSIG_LIVING_DEATH) + UnregisterSignal(boss, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) // Clear boarding blackboard keys clear_blackboard_key(BB_NPC_BOARDING_WAVE) @@ -1397,13 +1456,14 @@ return npc_metric_wave_ended(src, current_wave, "timed_out") - // Clean up current wave boarders + // Clean up current wave boarders; any still on their way land as part of the raid, outside the wave var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] if(wave_boarders) for(var/mob/living/boarder as anything in wave_boarders) if(!QDELETED(boarder)) - UnregisterSignal(boarder, COMSIG_LIVING_DEATH) + UnregisterSignal(boarder, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) clear_blackboard_key(BB_NPC_BOARDING_WAVE_BOARDERS) + wave_orders.Cut() if(is_red_zone_raid()) start_boss_cooldown() @@ -1411,8 +1471,9 @@ end_yellow_raid() /** - * Check if any boarders have fallen into space and clean them up. - * This prevents boarders from being "stuck" floating in space after being spaced. + * Boarders that have fallen into space are off the ship and out of this wave. Nothing kills them: space + * does that to an unsuited body, and a suited one drifts until its crew gives it up (pirate_crew.dm, + * check_adrift()). A wave whose last boarders were spaced is over. */ /datum/ai_controller/npc_ship/proc/check_boarders_in_space() var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] @@ -1421,21 +1482,17 @@ var/list/spaced_boarders = list() for(var/mob/living/boarder as anything in wave_boarders) - if(QDELETED(boarder)) + if(QDELETED(boarder) || boarder.stat == DEAD) continue - var/turf/boarder_turf = get_turf(boarder) - if(!boarder_turf) - continue - // Check if boarder is in space (not on a ship) - if(isspaceturf(boarder_turf) || istype(get_area(boarder_turf), /area/space)) + if(pirate_crew_member_adrift(boarder)) spaced_boarders += boarder - // Kill spaced boarders (they suffocate in space anyway) + if(!length(spaced_boarders)) + return for(var/mob/living/spaced as anything in spaced_boarders) - if(!QDELETED(spaced) && spaced.stat != DEAD) - spaced.death() - wave_boarders -= spaced - UnregisterSignal(spaced, COMSIG_LIVING_DEATH) + wave_boarders -= spaced + UnregisterSignal(spaced, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) + check_wave_complete() /// Snapshot the target band and connected crew exactly once, including combat-only raids. /datum/ai_controller/npc_ship/proc/capture_boarding_encounter(obj/structure/overmap/ship/target) @@ -1451,6 +1508,7 @@ return reserved /// Timer callbacks must still belong to this encounter after docking or target changes. +/// Returns the boarder, or the order of a human one still being built. /datum/ai_controller/npc_ship/proc/deploy_combat_boarder(turf/spot, obj/structure/overmap/ship/target, mob_type, datum/npc_spawn_context/encounter) if(get_target() != target || boarding_context != encounter) return @@ -1464,10 +1522,6 @@ var/list/spots = get_target_spawn_turfs(target) if(!istype(ship) || !ship.boss_type || !length(spots)) return FALSE - var/mob/living/boss = create_boss_boarding_pod(pick(spots), target, ship, ship.boss_type) - if(!boss) + if(!create_boss_boarding_pod(pick(spots), target, ship, ship.boss_type, CALLBACK(src, PROC_REF(track_boss)))) return FALSE - set_blackboard_key(BB_NPC_BOARDING_BOSS, boss) - RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(on_boss_death)) - npc_metric_boss_deployed(src, boss) return TRUE diff --git a/voidcrew/modules/npc_ships/code/outpost_patrol.dm b/voidcrew/modules/npc_ships/code/outpost_patrol.dm index ad94f58fb8d..fe3ab37194a 100644 --- a/voidcrew/modules/npc_ships/code/outpost_patrol.dm +++ b/voidcrew/modules/npc_ships/code/outpost_patrol.dm @@ -163,7 +163,7 @@ GLOBAL_LIST_EMPTY(outpost_patrol_caches) * was doing is cancelled first; the old controller and its blackboard are deleted. Returns the new * controller, or null if it could not take the mob. */ -/proc/swap_basic_ai_controller(mob/living/basic/pawn, controller_type) +/proc/swap_basic_ai_controller(mob/living/pawn, controller_type) if(QDELETED(pawn) || !ispath(controller_type, /datum/ai_controller)) return null var/datum/ai_controller/old_controller = pawn.ai_controller @@ -185,7 +185,7 @@ GLOBAL_LIST_EMPTY(outpost_patrol_caches) /// Whether a breakout controller leaves `target` alone: prisoners, and anyone else breaking out /proc/is_outpost_breakout_ally(mob/living/target) - if(istype(target, /mob/living/basic/outpost_prisoner)) + if(istype(target, /mob/living/carbon/human/npc/outpost_prisoner)) return TRUE return istype(target?.ai_controller, /datum/ai_controller/basic_controller/outpost_breakout) @@ -571,16 +571,16 @@ GLOBAL_LIST_EMPTY(outpost_patrol_caches) // ===== BREAKOUT AI ===== /** - * Someone who broke out of an outpost prison. Walks the outpost's patrol path, opens or smashes the - * doors on the way, searches lockers, and fights anyone who is not a prisoner with its own melee. + * Someone who broke out of an outpost prison. Walks the outpost's patrol path, opens the doors on the + * way that open for them, searches lockers, and fights anyone who is not a prisoner with their fists. * Swapped onto a prisoner with swap_basic_ai_controller(), then given a path with * assign_mob_to_outpost_patrol(). Without a path it wanders and still fights. */ /datum/ai_controller/basic_controller/outpost_breakout blackboard = list( BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/outpost_breakout, - // People are beaten down, not finished off - BB_TARGET_MINIMUM_STAT = SOFT_CRIT, + // People are beaten down, not finished off: nobody in crit is hit (defaults 1.13) + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, ) ai_movement = /datum/ai_movement/jps idle_behavior = /datum/idle_behavior/idle_random_walk @@ -664,19 +664,16 @@ GLOBAL_LIST_EMPTY(outpost_patrol_caches) /datum/ai_behavior/attack_obstructions/trooper/include_mobs/outpost_breakout -/datum/ai_behavior/attack_obstructions/trooper/include_mobs/outpost_breakout/attack_in_direction(datum/ai_controller/controller, mob/living/basic/basic_mob, direction) - var/turf/next_step = get_step(basic_mob, direction) +// A runner is a real human body (outpost_prison_prisoner.dm): it throws punches at whoever is in its way +// through its own hands, and walks round what fists can't move. +/datum/ai_behavior/attack_obstructions/trooper/include_mobs/outpost_breakout/attack_in_direction(datum/ai_controller/controller, mob/living/runner, direction) + var/turf/next_step = get_step(runner, direction) if(!next_step?.is_blocked_turf(exclude_mobs = FALSE, source_atom = controller.pawn)) return FALSE for(var/mob/living/blocking_mob in next_step) - if(blocking_mob == basic_mob || is_outpost_breakout_ally(blocking_mob)) + if(blocking_mob == runner || is_outpost_breakout_ally(blocking_mob)) continue - basic_mob.melee_attack(blocking_mob) - return TRUE - for(var/obj/object as anything in next_step.contents) - if(!can_smash_object(basic_mob, object)) - continue - basic_mob.melee_attack(object) + controller.ai_interact(blocking_mob, combat_mode = TRUE) return TRUE return FALSE diff --git a/voidcrew/modules/npc_ships/code/pirate_crew.dm b/voidcrew/modules/npc_ships/code/pirate_crew.dm index 127b17cbc4b..37ce19f9618 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew.dm @@ -8,8 +8,15 @@ * * Once alerted, a crew knows where every hostile on its battlefield is, refreshed every few * seconds, and sends its hunters after them. Players can dodge but can't hide. The captain - * and one guard hold the helm until most of the crew is dead. The per-mob behaviour lives in - * pirate_crew_ai.dm. + * and one guard hold the helm until most of the crew is out of the fight. The per-mob behaviour + * lives in pirate_crew_ai.dm, and for the human NPC crews every NPC ship now has, in + * pirate_crew_human.dm. + * + * A member counts as out of the fight once it is dead, in crit or cuffed (human_npc_out_of_fight(), + * owner ruling OC-7): it no longer hunts or holds the helm. A member adrift in space with no player + * near enough to see it is cleaned up after PIRATE_CREW_ADRIFT_TIME (check_adrift()): it can't get + * back aboard, and a body drifting alive forever would hold its crew open. That is bookkeeping, not a + * death: one drifting where a player can see it stays. */ /// Defending crews, by REF() of the NPC hull they crew @@ -38,9 +45,14 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( "Fine. I'll do it myself.", )) +/// Whether `recruit` is a body a pirate crew can take on: a basic mob, or a human NPC +/// (voidcrew/modules/human_npcs/). Human members still need their own ranged and breach behaviours. +/proc/pirate_crew_can_enlist(mob/living/recruit) + return isbasicmob(recruit) || ishumannpc(recruit) + /// Enlists `recruit` in the crew defending `hull`, making the crew if it has none yet /proc/pirate_crew_enlist_defender(mob/living/recruit, obj/structure/overmap/ship/hull, role = PIRATE_ROLE_HUNTER) - if(QDELETED(hull) || !isbasicmob(recruit) || QDELETED(recruit)) + if(QDELETED(hull) || !pirate_crew_can_enlist(recruit) || QDELETED(recruit)) return FALSE var/datum/pirate_crew/crew = pirate_crew_of(hull, FALSE) if(!crew) @@ -49,7 +61,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( /// Enlists `recruit` in the raid on `target`, making the raid if there is none yet /proc/pirate_crew_enlist_raider(mob/living/recruit, obj/structure/overmap/ship/target) - if(QDELETED(target) || !isbasicmob(recruit) || QDELETED(recruit)) + if(QDELETED(target) || !pirate_crew_can_enlist(recruit) || QDELETED(recruit)) return FALSE var/datum/pirate_crew/crew = pirate_crew_of(target, TRUE) if(!crew) @@ -64,8 +76,10 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( var/datum/pirate_crew/crew = registry[REF(ship)] return QDELETED(crew) ? null : crew -/// The controller a pirate gets when it joins a crew: ranged if it was a ranged trooper -/proc/pirate_crew_controller_type(mob/living/basic/recruit) +/// The controller a pirate gets when it joins a crew: the human one for a human NPC, else ranged if it was a ranged trooper +/proc/pirate_crew_controller_type(mob/living/recruit) + if(ishumannpc(recruit)) + return /datum/ai_controller/basic_controller/pirate_crew/human var/datum/ai_controller/current = recruit?.ai_controller if(istype(current, /datum/ai_controller/basic_controller/trooper/ranged) || istype(current, /datum/ai_controller/basic_controller/pirate_crew/ranged)) return /datum/ai_controller/basic_controller/pirate_crew/ranged @@ -73,15 +87,23 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( /// What `speaker` says for a PIRATE_BARK_* moment. Faction pirates may carry their own lines. /proc/pirate_crew_bark_lines(mob/living/speaker, kind) + var/list/alert_lines + var/list/spot_lines var/mob/living/basic/trooper/pirate/faction/faction_pirate = speaker + var/mob/living/carbon/human/npc/faction_pirate/crewman = speaker if(istype(faction_pirate)) - switch(kind) - if(PIRATE_BARK_ALERT) - if(length(faction_pirate.crew_alert_lines)) - return faction_pirate.crew_alert_lines - if(PIRATE_BARK_SPOT) - if(length(faction_pirate.crew_spot_lines)) - return faction_pirate.crew_spot_lines + alert_lines = faction_pirate.crew_alert_lines + spot_lines = faction_pirate.crew_spot_lines + else if(istype(crewman)) + alert_lines = crewman.crew_alert_lines + spot_lines = crewman.crew_spot_lines + switch(kind) + if(PIRATE_BARK_ALERT) + if(length(alert_lines)) + return alert_lines + if(PIRATE_BARK_SPOT) + if(length(spot_lines)) + return spot_lines switch(kind) if(PIRATE_BARK_ALERT) return GLOB.pirate_crew_alert_lines @@ -148,6 +170,8 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( var/battlefield_dirty = TRUE /// Whether the raiding crew has had its arrival line var/arrival_barked = FALSE + /// Members adrift in space, mob -> world.time they were first found adrift + var/list/adrift_since = list() COOLDOWN_DECLARE(intel_cooldown) COOLDOWN_DECLARE(battlefield_cooldown) COOLDOWN_DECLARE(spot_bark_cooldown) @@ -191,6 +215,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( controller.reset_ai_status() members.Cut() home_turfs.Cut() + adrift_since.Cut() for(var/mob/living/hostile in known_hostiles) UnregisterSignal(hostile, COMSIG_QDELETING) known_hostiles.Cut() @@ -207,7 +232,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( * role. A defending crew's first non-captain becomes the guard; a melee recruit takes the * job off a ranged guard while the crew is still calm. */ -/datum/pirate_crew/proc/enlist(mob/living/basic/recruit, role = PIRATE_ROLE_HUNTER) +/datum/pirate_crew/proc/enlist(mob/living/recruit, role = PIRATE_ROLE_HUNTER) if(QDELETED(recruit) || recruit.stat == DEAD) return FALSE if(raiding || isnull(role)) @@ -220,7 +245,6 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( home_turfs -= recruit return FALSE if(!already_member) - pirate_crew_arm(recruit) RegisterSignal(recruit, COMSIG_LIVING_DEATH, PROC_REF(on_member_lost)) RegisterSignal(recruit, COMSIG_QDELETING, PROC_REF(on_member_lost)) if(!raiding && role != PIRATE_ROLE_CAPTAIN) @@ -236,10 +260,15 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( return TRUE /// Puts `member` on a pirate crew controller wired to this crew. Returns the controller. -/datum/pirate_crew/proc/attach_controller(mob/living/basic/member, role) +/datum/pirate_crew/proc/attach_controller(mob/living/member, role) var/datum/ai_controller/basic_controller/pirate_crew/controller = swap_basic_ai_controller(member, pirate_crew_controller_type(member)) if(!istype(controller)) return null + // A human NPC's family decides who it keeps attacking (pirates: until hard crit); a fresh controller + // starts on its type's default + if(ishumannpc(member)) + var/mob/living/carbon/human/npc/human_member = member + controller.set_blackboard_key(BB_TARGET_MINIMUM_STAT, human_member.npc_target_stat) // A member handed over from another crew starts this crew's bookkeeping afresh if(controller.blackboard[BB_PIRATE_CREW] != src) controller.last_battlefield_turf = null @@ -277,8 +306,10 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( set_role(guard, PIRATE_ROLE_HUNTER) set_role(recruit, PIRATE_ROLE_GUARD) -/// Whether `member` fights at range +/// Whether `member` fights at range: a human carrying a gun it may use, or a basic mob on the ranged controller /proc/pirate_crew_is_ranged(mob/living/member) + if(ishuman(member)) + return pirate_crew_human_gunner(member) return istype(member?.ai_controller, /datum/ai_controller/basic_controller/pirate_crew/ranged) /datum/pirate_crew/proc/on_member_lost(mob/living/source) @@ -291,6 +322,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( UnregisterSignal(member, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) members -= member home_turfs -= member + adrift_since -= member if(!length(members)) qdel(src) @@ -484,7 +516,8 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( /datum/pirate_crew/proc/assign_hunts() var/list/hunters = list() for(var/mob/living/member in members) - if(QDELETED(member) || member.stat == DEAD || !member.ai_controller) + // Down or cuffed: not hunting anyone + if(QDELETED(member) || human_npc_out_of_fight(member) || !member.ai_controller) continue if(holds_post(member)) member.ai_controller.clear_blackboard_key(BB_PIRATE_HUNT_TARGET) @@ -568,13 +601,13 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( controller.can_idle = TRUE controller.reset_ai_status() -/// The captain and the guard join the hunt once the rest of the crew is mostly dead +/// The captain and the guard join the hunt once the rest of the crew is mostly out of the fight /datum/pirate_crew/proc/check_promotion() if(promoted || raiding) return var/living = 0 for(var/mob/living/member in members) - if(members[member] != PIRATE_ROLE_CAPTAIN && member.stat != DEAD) + if(members[member] != PIRATE_ROLE_CAPTAIN && !human_npc_out_of_fight(member)) living++ if(living > min(starting_crew / 3, 1)) return @@ -584,13 +617,14 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( // ===== BARKS ===== +/// A crew shout from `speaker`. Ordinary speech (defaults PI-18): a gagged or muted pirate says nothing. /datum/pirate_crew/proc/bark(mob/living/speaker, kind) - if(QDELETED(speaker) || speaker.stat != CONSCIOUS) + if(QDELETED(speaker) || speaker.stat != CONSCIOUS || !speaker.can_speak()) return FALSE var/list/lines = pirate_crew_bark_lines(speaker, kind) if(!length(lines)) return FALSE - INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), message = pick(lines), forced = "pirate crew") + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), message = pick(lines)) return TRUE /// `member` saw `target`: tell the crew, and maybe shout about it @@ -625,6 +659,9 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( if(battlefield_dirty || COOLDOWN_FINISHED(src, battlefield_cooldown)) refresh_battlefield() check_members() + if(QDELETED(src)) + return PROCESS_KILL + check_adrift() if(QDELETED(src)) return PROCESS_KILL if(!alerted) @@ -646,7 +683,8 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( */ /datum/pirate_crew/proc/check_members() var/list/stranded = list() - for(var/mob/living/basic/member in members) + // Every member, human NPCs included: a loop typed to basic mobs skipped them silently. + for(var/mob/living/member in members) if(QDELETED(member) || member.stat == DEAD || GET_CLIENT(member)) continue var/datum/ai_controller/controller = member.ai_controller @@ -660,13 +698,47 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( var/obj/structure/overmap/ship/other = pirate_crew_ship_at(where) if(other && other != home_ship) stranded[member] = other - for(var/mob/living/basic/member as anything in stranded) + for(var/mob/living/member as anything in stranded) var/obj/structure/overmap/ship/other = stranded[member] remove_member(member) pirate_crew_enlist_raider(member, other) if(QDELETED(src)) return +/** + * Cleans up members adrift in space that no player can see, once they have drifted for + * PIRATE_CREW_ADRIFT_TIME. Space kills a pirate without a suit on its own; a suited one would drift + * alive forever, off its battlefield, holding its crew and its hull's pool slot open. A human NPC goes + * the way one that walks out does (npc_vanish(): what it carries that isn't gear, a ship key, drops where + * it was). See the top of this file. + */ +/datum/pirate_crew/proc/check_adrift() + for(var/mob/living/member as anything in members.Copy()) + if(QDELETED(member) || member.stat == DEAD || GET_CLIENT(member) || !pirate_crew_member_adrift(member)) + adrift_since -= member + continue + if(isnull(adrift_since[member])) + adrift_since[member] = world.time + continue + if(world.time < adrift_since[member] + PIRATE_CREW_ADRIFT_TIME || human_npc_watched(member)) + continue + log_shuttle("PIRATE_CREW: [member] was lost adrift at [AREACOORD(member)]") + remove_member(member) + if(ishumannpc(member)) + var/mob/living/carbon/human/npc/lost = member + lost.npc_vanish() + else + qdel(member) + if(QDELETED(src)) + return + +/// Whether `member` is floating in space, off any ship's deck +/proc/pirate_crew_member_adrift(mob/living/member) + var/turf/where = get_turf(member) + if(isnull(where) || !isturf(member.loc)) + return FALSE + return isspaceturf(where) || istype(get_area(where), /area/space) + // ===== HOME SHIP SIGNALS ===== /datum/pirate_crew/proc/on_home_ship_deleted(datum/source) @@ -710,9 +782,12 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( behavior_names += replacetext("[behavior.type]", "/datum/ai_behavior/", "") text += ": [controller.ai_status][controller.can_idle ? " (may idle)" : ""][controller.able_to_plan ? "" : " (planning blocked)"][controller.on_failed_planning_timeout ? " (planning timeout)" : ""]" text += ", doing [length(behavior_names) ? jointext(behavior_names, " + ") : "nothing"]" - var/datum/component/pirate_gun/gun = pirate_gun_of(member) - if(gun) - text += ", [gun.status_text()]" + if(ishuman(member)) + var/obj/item/gun/gun = human_npc_held_gun(member) + if(gun) + text += ", holding [gun][gun.can_shoot() ? "" : " (empty)"]" + if(human_npc_out_of_fight(member)) + text += " (out of the fight)" var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] text += ", target [target ? "[target]" : "none"]" var/mob/hunt = controller.blackboard[BB_PIRATE_HUNT_TARGET] diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm b/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm index b2dbe66ce31..f0fb4024e87 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm @@ -4,6 +4,10 @@ * Fights whoever it can see, breaks through doors that won't open for it, holds the helm if it * is the captain or the guard, and otherwise walks to wherever the crew last knew a hostile to * be. Doors it may open are walked through, never hit. Allies in a corridor swap places. + * + * NPC ship crews and boarders are human NPCs and run the human controller (pirate_crew_human.dm), + * which fights and breaks doors with the body's own hands. The basic-mob controllers below are what + * a basic pirate gets if it is enlisted (an admin spawn, a basic boarding type). */ // ===== CONTROLLERS ===== @@ -67,8 +71,6 @@ * or away from the helm, since nothing would get to check. */ /datum/ai_planning_subtree/basic_ranged_attack_subtree/trooper/pirate_crew - // Stock gunfire, or a rolled pirate gun (pirate_guns.dm) - operational_datums = list(/datum/component/ranged_attacks, /datum/component/pirate_gun) ranged_attack_behavior = /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew /datum/ai_planning_subtree/basic_ranged_attack_subtree/trooper/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) @@ -78,20 +80,12 @@ var/behavior = ranged_attack_behavior if(istype(pirate_controller) && pirate_controller.target_hold_ground) behavior = /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/hold_ground - else - var/datum/component/pirate_gun/gun = pirate_gun_of(controller.pawn) - if(gun?.loadout.close_range) - behavior = /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/close controller.queue_behavior(behavior, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) return SUBTREE_RETURN_FINISH_PLANNING /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION -/// A shotgunner walks in close before it fires -/datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/close - required_distance = 3 - /// Returns fire at someone it may not go after (off the battlefield, or past the helm's leash) without taking a step towards them /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/hold_ground required_distance = PIRATE_CREW_SIGHT_RANGE @@ -129,8 +123,7 @@ if(QDELETED(target)) return FALSE if(istype(src, /datum/ai_controller/basic_controller/pirate_crew/ranged)) - var/datum/component/pirate_gun/gun = pirate_gun_of(pawn) - return can_see(pawn, target, gun?.loadout.close_range || 5) + return can_see(pawn, target, 5) return get_dist(pawn, target) <= 1 // ----- Door bookkeeping ----- @@ -155,12 +148,16 @@ set_blackboard_key(BB_PIRATE_BREACH_STAND, stand || get_turf(pawn)) set_blackboard_key(BB_PIRATE_BREACH_TURF, get_turf(target)) set_blackboard_key(BB_PIRATE_BREACH_STARTED, world.time) + clear_blackboard_key(BB_PIRATE_BREACH_INTEGRITY) + clear_blackboard_key(BB_PIRATE_BREACH_PROGRESS_AT) /datum/ai_controller/basic_controller/pirate_crew/proc/clear_breach() clear_blackboard_key(BB_PIRATE_BREACH_TARGET) clear_blackboard_key(BB_PIRATE_BREACH_STAND) clear_blackboard_key(BB_PIRATE_BREACH_TURF) clear_blackboard_key(BB_PIRATE_BREACH_STARTED) + clear_blackboard_key(BB_PIRATE_BREACH_INTEGRITY) + clear_blackboard_key(BB_PIRATE_BREACH_PROGRESS_AT) // ----- Pawn signals ----- @@ -428,8 +425,15 @@ if(isliving(requester) && istype(requester.ai_controller, /datum/ai_controller/basic_controller/pirate_crew)) return TRUE -/// Whether breaking `door` would open the ship to space: an external airlock or blast door with nothing beyond it +/** + * Whether breaking `door` would open the ship to space: an external airlock or blast door with nothing + * beyond it, or a firelock holding back a room that has lost its air. + */ /proc/pirate_door_vents_space(obj/machinery/door/door, turf/from) + if(istype(door, /obj/machinery/door/firedoor)) + var/turf/firelock_turf = get_turf(door) + var/turf/beyond = (from && firelock_turf) ? get_turf_through_door(from, firelock_turf) : null + return beyond && !is_safe_turf(beyond, TRUE, FALSE) if(!istype(door, /obj/machinery/door/airlock/external) && !istype(door, /obj/machinery/door/poddoor)) return FALSE var/turf/door_turf = get_turf(door) @@ -582,7 +586,12 @@ return pirate_controller.target_hold_ground = (target_reach(pirate_controller, crew, found) == PIRATE_TARGET_HOLD) controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, found) - crew?.on_member_spotted(pawn, found) + if(crew) + crew.on_member_spotted(pawn, found) + else if(ishumannpc(pawn)) + // A faction pirate met in a ruin or a contract, with no crew to tell + var/mob/living/carbon/human/npc/loner = pawn + loner.npc_bark(PIRATE_BARK_SPOT) /datum/ai_planning_subtree/pirate_crew_find_target/proc/scan_for_target(datum/ai_controller/basic_controller/pirate_crew/controller, datum/pirate_crew/crew, datum/targeting_strategy/strategy, mob/living/basic/pawn, turf/pawn_turf) var/atom/best_player @@ -628,7 +637,7 @@ * battlefield or only just off it on solid ground, and within PIRATE_CREW_POST_LEASH of the helm * for the captain and the guard. Past that a gunner standing on the battlefield still shoots back * at a player from where it stands (PIRATE_TARGET_HOLD); anything else is let go (PIRATE_TARGET_OUT). - * Nobody follows a target into space, where the spaced-crew sweeps would kill them. + * Nobody follows a target into space: a crewman adrift is lost to the crew (pirate_crew.dm). */ /datum/ai_planning_subtree/pirate_crew_find_target/proc/target_reach(datum/ai_controller/basic_controller/pirate_crew/controller, datum/pirate_crew/crew, atom/target) if(!crew) @@ -638,7 +647,7 @@ return PIRATE_TARGET_OUT var/mob/living/pawn = controller.pawn var/fallback = PIRATE_TARGET_OUT - if(istype(controller, /datum/ai_controller/basic_controller/pirate_crew/ranged) && crew.turf_on_battlefield(get_turf(pawn)) && pirate_crew_player_target(target)) + if(pirate_crew_is_ranged(pawn) && crew.turf_on_battlefield(get_turf(pawn)) && pirate_crew_player_target(target)) fallback = PIRATE_TARGET_HOLD if(crew.holds_post(pawn)) var/turf/post = crew.post_for(pawn) @@ -740,8 +749,14 @@ if(!pirate_door_breachable(blocker, stand)) controller.skip_door(blocker) return FALSE + // Given up once a stretch goes by with the door taking no damage at all: a human shooting a door + // open takes longer than a basic mob smashing it, but gets somewhere. var/started = controller.blackboard[BB_PIRATE_BREACH_STARTED] || world.time - if(world.time > started + PIRATE_CREW_BREACH_GIVE_UP) + if(blocker.atom_integrity < (controller.blackboard[BB_PIRATE_BREACH_INTEGRITY] || INFINITY)) + controller.set_blackboard_key(BB_PIRATE_BREACH_INTEGRITY, blocker.atom_integrity) + controller.set_blackboard_key(BB_PIRATE_BREACH_PROGRESS_AT, world.time) + var/progress_at = max(started, controller.blackboard[BB_PIRATE_BREACH_PROGRESS_AT]) + if(world.time > progress_at + PIRATE_CREW_BREACH_GIVE_UP) log_shuttle("PIRATE_CREW: [pawn] gave up breaking [blocker] at [AREACOORD(blocker)]") controller.skip_door(blocker) return FALSE @@ -911,12 +926,28 @@ /datum/ai_behavior/pirate_crew_breach/perform(seconds_per_tick, datum/ai_controller/controller, target_key, stand_key) var/obj/blocker = controller.blackboard[target_key] - var/mob/living/basic/pawn = controller.pawn + var/mob/living/pawn = controller.pawn if(QDELETED(blocker) || !blocker.density) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED if(istype(blocker, /obj/machinery/door) && pirate_can_open_door(pawn, blocker)) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED - if(!isbasicmob(pawn) || !pawn.Adjacent(blocker)) + if(ishuman(pawn)) + // A person's hands: pry it, shoot it from a step back, or hit it (pirate_crew_human.dm) + var/turf/stand = controller.blackboard[stand_key] || get_turf(pawn) + var/turf/spot = pirate_crew_human_breach_spot(pawn, blocker, stand) + if(controller.current_movement_target != spot) + set_movement_target(controller, spot) + if(get_turf(pawn) != spot && !(spot == stand && pawn.Adjacent(blocker))) + return AI_BEHAVIOR_DELAY + if(!pirate_crew_human_breach(controller, pawn, blocker)) + // Nothing it carries can hurt this door: leave it to someone who can + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + pirate_controller.skip_door(blocker) + pirate_controller.clear_breach() + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + var/mob/living/basic/basic_pawn = pawn + if(!isbasicmob(basic_pawn) || !basic_pawn.Adjacent(blocker)) return AI_BEHAVIOR_DELAY - pawn.melee_attack(blocker) + basic_pawn.melee_attack(blocker) return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_combat_sense.dm b/voidcrew/modules/npc_ships/code/pirate_crew_combat_sense.dm new file mode 100644 index 00000000000..d3c42984a75 --- /dev/null +++ b/voidcrew/modules/npc_ships/code/pirate_crew_combat_sense.dm @@ -0,0 +1,47 @@ +/** + * Combat sense for faction pirates (voidcrew/modules/human_npcs/human_npc_combat_sense.dm): NPC ship crews, + * boarders, NT customs and the crews squatting in ruins, every faction and rank. + * + * What they carry is #509's and unchanged: the rank and file no stimulant; captains a combat stimpack and + * bosses a stimulant pen, which they inject as their fight starts (pirate_crew_human_self_care). So the + * stimulant half only ever fires for a pirate still holding an unused stimulant. Under stun fire: + * - a gunner runs for cover, but only on its crew's battlefield, a captain or helm guard only within + * PIRATE_CREW_POST_LEASH of its post, and a pirate with no crew only within PIRATE_CREWLESS_LEASH of + * where it was put; never onto open space; + * - a gunner told to hold its ground against someone off the battlefield (target_hold_ground) stays put + * and shoots back, as before; + * - a melee pirate, or a shotgunner (its gun handling's close_range), charges the shooter. + */ +/datum/npc_combat_sense/pirate_crew + +/mob/living/carbon/human/npc/faction_pirate + npc_combat_sense = /datum/npc_combat_sense/pirate_crew + +/datum/npc_combat_sense/pirate_crew/may_move(mob/living/carbon/human/npc, datum/ai_controller/controller) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + return !(istype(pirate_controller) && pirate_controller.target_hold_ground) + +/datum/npc_combat_sense/pirate_crew/reach(mob/living/carbon/human/npc, datum/ai_controller/controller) + if(!pirate_crew_human_gunner(npc)) + return 1 + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(gun) + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + if(handling?.close_range) + return handling.close_range + return controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + +/datum/npc_combat_sense/pirate_crew/tile_ok(mob/living/carbon/human/npc, datum/ai_controller/controller, turf/tile) + if(!..() || pirate_turf_is_void(tile)) + return FALSE + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + var/datum/pirate_crew/crew = istype(pirate_controller) ? pirate_controller.get_crew() : null + if(crew) + if(!crew.turf_on_battlefield(tile)) + return FALSE + if(!crew.holds_post(npc)) + return TRUE + var/turf/post = crew.post_for(npc) + return isnull(post) || (post.z == tile.z && get_dist(post, tile) <= PIRATE_CREW_POST_LEASH) + var/turf/home = controller.blackboard[BB_PIRATE_CREWLESS_HOME] + return isnull(home) || home.z != tile.z || get_dist(home, tile) <= PIRATE_CREWLESS_LEASH diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_human.dm b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm new file mode 100644 index 00000000000..276364b569f --- /dev/null +++ b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm @@ -0,0 +1,503 @@ +/** + * Pirate crew AI on human NPC bodies (human NPC migration, phase B). + * + * The crew AI itself is PR #500's (pirate_crew.dm, pirate_crew_ai.dm): the crew's intel, the captain and + * guard at the helm, the hunt, door breaking that never vents a ship, swapping places in corridors. This + * file gives it a human body's hands, through the human NPC base (voidcrew/modules/human_npcs/): + * - it runs in soft crit and while cuffed (human_npc_ai_able_to_run()), with the soft-crit branch first, + * so a downed pirate crawls, pleads and, if it is an elite or a boss, fires wild, and a cuffed one + * works at its cuffs; + * - gunners fire the real gun in their hands in volleys (pirate_guns.dm), reload from the magazines and + * shells they carry the way a player does, and fight with a knife or their fists when they run dry; + * - doors that won't open are pried with a crowbar or a fireaxe it carries (an unpowered airlock, a + * firelock), else shot, else hit with a blade heavy enough to dent them; + * - captains and bosses inject the stimulant they carry when their fight starts; a boss with a flask + * drinks from it when hurt; a boss gunner puts its gun to the head of a hunter who is down (the + * hyperviolence execution, with its wind-up the crew can interrupt), once a minute at most. + * Everything goes through ai_interact(), a player's click. + */ + +/datum/ai_controller/basic_controller/pirate_crew/human + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + BB_AGGRO_RANGE = PIRATE_CREW_SIGHT_RANGE, + BB_HUMAN_NPC_SHOT_DELAY = 3 SECONDS, + BB_HUMAN_NPC_FIRE_RANGE = 7, + BB_HUMAN_NPC_KEEP_AWAY = 2, + ) + // Holds still through its own do_afters: a cuff breakout, a gun to someone's head + ai_traits = PAUSE_DURING_DO_AFTER + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/human_npc_surrender/crewless, + /datum/ai_planning_subtree/pirate_crew_find_target, + /datum/ai_planning_subtree/pirate_crew_human_self_care, + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/pirate_crew_doors, + /datum/ai_planning_subtree/human_npc_arm/pirate_crew, + /datum/ai_planning_subtree/pirate_crew_human_shoot, + /datum/ai_planning_subtree/human_npc_melee/pirate_crew, + /datum/ai_planning_subtree/pirate_crew_hold_post, + /datum/ai_planning_subtree/pirate_crew_hunt, + /datum/ai_planning_subtree/pirate_crew_leash, + /datum/ai_planning_subtree/pirate_crewless_home, + ) + +/datum/ai_controller/basic_controller/pirate_crew/human/TryPossessPawn(atom/new_pawn) + if(!ishuman(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + return ..() + +// The human NPC base's run check: cuffs and soft crit don't stop it (human_npc_soft_crit.dm). +/datum/ai_controller/basic_controller/pirate_crew/human/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/pirate_crew/human/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + +// A hit during an SMG's spin-up or a shotgun's aim spoils the volley. +/datum/ai_controller/basic_controller/pirate_crew/human/on_pawn_damaged(mob/living/source, damage_dealt) + . = ..() + if(damage_dealt <= 0 || blackboard[BB_PIRATE_WINDUP_UNTIL] <= world.time) + return + clear_blackboard_key(BB_PIRATE_WINDUP_UNTIL) + set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, 0) + set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + 1 SECONDS) + +/datum/ai_controller/basic_controller/pirate_crew/human/can_hit_now(atom/target) + if(QDELETED(target)) + return FALSE + var/mob/living/carbon/npc = pawn + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(gun) + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/reach = handling.close_range || blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + return get_dist(npc, target) <= reach && can_see(npc, target, reach) + return npc.Adjacent(target) + +// ===== WITH NO CREW: ruins, planets, contracts ===== + +/// A pirate with no crew gives up by its family's rules (npc_surrender, human_npc_surrender.dm). One in a +/// crew fights on with it. +/datum/ai_planning_subtree/human_npc_surrender/crewless + +/datum/ai_planning_subtree/human_npc_surrender/crewless/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + if(istype(pirate_controller) && pirate_controller.get_crew()) + return + return ..() + +/// A pirate with no crew and nobody to fight walks back to where it was put once it has strayed more +/// than PIRATE_CREWLESS_LEASH tiles (a chase, a flight from its surrender). +/datum/ai_planning_subtree/pirate_crewless_home + +/datum/ai_planning_subtree/pirate_crewless_home/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + if(!istype(pirate_controller) || pirate_controller.get_crew() || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/turf/home = controller.blackboard[BB_PIRATE_CREWLESS_HOME] + var/turf/here = get_turf(controller.pawn) + if(isnull(home) || isnull(here) || home.z != here.z) + return + if(get_dist(here, home) <= PIRATE_CREWLESS_LEASH) + return + controller.set_blackboard_key(BB_PIRATE_RETURN_TURF, home) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_travel/leash, BB_PIRATE_RETURN_TURF) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Whether a human crewman fights at range: it holds or carries a gun it can fire, and may use guns. +/proc/pirate_crew_human_gunner(mob/living/carbon/human/npc) + if(HAS_TRAIT(npc, TRAIT_NOGUNS)) + return FALSE + if(locate(/obj/item/gun) in npc.held_items) + return TRUE + return length(npc.get_all_contents_type(/obj/item/gun)) > 0 + +// ===== ARMING: the base's reload and draw, with the crew's reload shouts ===== + +/datum/ai_planning_subtree/human_npc_arm/pirate_crew + +/datum/ai_planning_subtree/human_npc_arm/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/gun = human_npc_held_gun(npc) + var/was_dry = gun && !gun.can_shoot() + // A pump racked between shells is no reload worth shouting about + var/just_racking = istype(gun, /obj/item/gun/ballistic) && human_npc_gun_needs_rack(gun) + . = ..() + if(. != SUBTREE_RETURN_FINISH_PLANNING || !was_dry || just_racking || !prob(PIRATE_CREW_RELOAD_BARK_CHANCE)) + return + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/list/lines = human_npc_can_reload(npc, gun) ? handling.reload_lines : handling.dry_lines + if(length(lines) && human_npc_can_talk(npc)) + INVOKE_ASYNC(npc, TYPE_PROC_REF(/atom/movable, say), pick(lines)) + +// ===== SHOOTING ===== + +/** + * With a loaded gun in hand: fires its volleys at the target from up to the gun's range (a shotgunner + * walks in to three tiles first), stepping back from anyone too close, and closing in when it can't see + * them. A gunner told to hold its ground (target_reach()) shoots back from where it stands and never + * takes a step after them. A boss gunner whose target is down puts the gun to their head instead. + */ +/datum/ai_planning_subtree/pirate_crew_human_shoot + +/datum/ai_planning_subtree/pirate_crew_human_shoot/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(QDELETED(target)) + return + var/mob/living/carbon/human/npc/npc = controller.pawn + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + return + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + return + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + var/hold = istype(pirate_controller) && pirate_controller.target_hold_ground + if(!hold && pirate_crew_human_wants_execution(controller, npc, target)) + if(npc.Adjacent(target)) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_human_execute, BB_BASIC_MOB_CURRENT_TARGET) + else + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, 1) + return SUBTREE_RETURN_FINISH_PLANNING + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/fire_range = hold ? PIRATE_CREW_SIGHT_RANGE : (handling.close_range || controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7) + var/distance = get_dist(npc, target) + if(distance <= fire_range && can_see(npc, target, fire_range)) + var/keep_away = controller.blackboard[BB_HUMAN_NPC_KEEP_AWAY] + if(!hold && !handling.close_range && keep_away && distance < keep_away) + controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_human_shoot, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + if(hold) + return + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, fire_range) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * One pull of the trigger per step of a volley (pirate_guns.dm). A volley that needs a spin-up or a + * moment's aim waits for it first; a hit in that moment spoils it (the controller's on_pawn_damaged()). + * Doesn't hold up planning. + */ +/datum/ai_behavior/pirate_crew_human_shoot + action_cooldown = 0.1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/pirate_crew_human_shoot/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(QDELETED(target) || !strategy?.can_attack(npc, target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + // Empty now: the next plan reloads or changes weapon + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, 0) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/windup_until = controller.blackboard[BB_PIRATE_WINDUP_UNTIL] + if(windup_until) + if(world.time < windup_until) + return AI_BEHAVIOR_DELAY + controller.clear_blackboard_key(BB_PIRATE_WINDUP_UNTIL) + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, handling.shots) + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_SHOT]) + return AI_BEHAVIOR_DELAY + if(controller.blackboard[BB_PIRATE_VOLLEY_LEFT] <= 0) + if(handling.windup) + npc.face_atom(target) + if(handling.windup_sound) + playsound(npc, handling.windup_sound, 50, TRUE) + controller.set_blackboard_key(BB_PIRATE_WINDUP_UNTIL, world.time + handling.windup) + return AI_BEHAVIOR_DELAY + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, handling.shots) + var/atom/hiding_target = strategy.find_hidden_mobs(npc, target) + controller.set_blackboard_key(hiding_location_key, hiding_target) + var/atom/final_target = hiding_target || target + if(human_npc_ally_in_line(npc, final_target)) + human_npc_sidestep(npc, final_target) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + npc.zone_selected = pirate_crew_human_aim_zone(npc, final_target) + controller.ai_interact(target = final_target, combat_mode = TRUE) + var/volley_left = controller.blackboard[BB_PIRATE_VOLLEY_LEFT] - 1 + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, volley_left) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + (volley_left > 0 ? handling.shot_gap : handling.volley_gap)) + return AI_BEHAVIOR_DELAY + +/// Where an ordinary shot is aimed. Never the head of someone helpless at point blank: that would be an +/// execution, which only a boss starts, and only on purpose (pirate_crew_human_execute). +/proc/pirate_crew_human_aim_zone(mob/living/shooter, atom/target) + var/zone = human_npc_pick_aim_zone() + if(zone == BODY_ZONE_HEAD && isliving(target) && shooter.Adjacent(target)) + var/mob/living/victim = target + if(victim.hv_can_be_executed()) + return BODY_ZONE_CHEST + return zone + +/// Whether a boss gunner should walk up to `target` and put its gun to their head: it is a boss (OC-9), +/// its execution isn't cooling down, and the target is someone it may execute. +/proc/pirate_crew_human_wants_execution(datum/ai_controller/controller, mob/living/carbon/human/npc/npc, atom/target) + if(!ishumannpc(npc) || npc.npc_rank < HUMAN_NPC_RANK_BOSS || !isliving(target)) + return FALSE + if(world.time < controller.blackboard[BB_PIRATE_NEXT_EXECUTION]) + return FALSE + var/mob/living/victim = target + if(victim.stat == DEAD || !victim.hv_can_be_executed()) + return FALSE + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + return gun?.hv_execution_possible(npc, victim) || (gun && !npc.Adjacent(victim)) + +/** + * A boss puts the barrel of its gun to the head of someone down beside it: the hyperviolence execution + * (executions.dm), a click aimed at the head. Its 1.5 second wind-up is a do_after, so it runs apart + * from the AI's tick, and the crew can break it by moving either of them. + */ +/datum/ai_behavior/pirate_crew_human_execute + action_cooldown = 1 SECONDS + +/datum/ai_behavior/pirate_crew_human_execute/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/npc = controller.pawn + var/mob/living/victim = controller.blackboard[target_key] + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(!isliving(victim) || isnull(gun) || !gun.hv_execution_possible(npc, victim) || !human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.set_blackboard_key(BB_PIRATE_NEXT_EXECUTION, world.time + PIRATE_CREW_EXECUTION_COOLDOWN) + npc.zone_selected = BODY_ZONE_HEAD + INVOKE_ASYNC(controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), victim, TRUE) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/// tg melee with what it holds, except against someone it was told to hold its ground against: a gunner +/// whose gun ran dry doesn't run off the battlefield or away from the helm after them. +/datum/ai_planning_subtree/human_npc_melee/pirate_crew + +/datum/ai_planning_subtree/human_npc_melee/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + if(istype(pirate_controller) && pirate_controller.target_hold_ground) + return + return ..() + +// ===== STIMULANTS AND THE FLASK ===== + +/** + * Captains and bosses inject the stimulant they carry once their fight has started (they have someone + * to fight); a boss carrying a flask drinks from it when it is hurt. Real items used the way a player + * uses them: the medipen in hand, then used; the flask clicked on itself. + */ +/datum/ai_planning_subtree/pirate_crew_human_self_care + +/datum/ai_planning_subtree/pirate_crew_human_self_care/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/npc = controller.pawn + if(!ishumannpc(npc) || npc.npc_rank < HUMAN_NPC_RANK_ELITE || npc.stat != CONSCIOUS) + return + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + return + if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/obj/item/to_use = pirate_crew_human_stimulant(npc) + if(isnull(to_use) && npc.health < npc.maxHealth * PIRATE_CREW_DRINK_HEALTH_SHARE && world.time >= controller.blackboard[BB_PIRATE_NEXT_DRINK]) + to_use = pirate_crew_human_flask(npc) + if(isnull(to_use)) + return + controller.set_blackboard_key(BB_PIRATE_SELF_ITEM, to_use) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_human_use_on_self, BB_PIRATE_SELF_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// A stimulant medipen it carries and hasn't used, unless one is already in its blood. +/proc/pirate_crew_human_stimulant(mob/living/carbon/npc) + if(npc.reagents?.has_reagent(/datum/reagent/medicine/stimulants)) + return null + for(var/obj/item/reagent_containers/hypospray/medipen/pen in npc.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(pen.used_up || !pen.reagents?.has_reagent(/datum/reagent/medicine/stimulants)) + continue + if(npc.is_holding(pen) || human_npc_can_draw(npc, pen)) + return pen + return null + +/// A flask with something left in it that it can take out. +/proc/pirate_crew_human_flask(mob/living/carbon/npc) + for(var/obj/item/reagent_containers/cup/glass/flask/flask in npc.get_all_contents_type(/obj/item/reagent_containers/cup/glass/flask)) + if(!flask.reagents?.total_volume) + continue + if(npc.is_holding(flask) || human_npc_can_draw(npc, flask)) + return flask + return null + +/** + * Takes the item in a free hand (letting go of a two-handed grip, or holstering a spent gun, if both are + * full), uses it on itself, puts a flask back and takes up its weapon again. + */ +/datum/ai_behavior/pirate_crew_human_use_on_self + action_cooldown = 0.8 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/pirate_crew_human_use_on_self/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/human/npc/npc = controller.pawn + var/obj/item/thing = controller.blackboard[item_key] + if(QDELETED(thing) || human_npc_holding(thing) != npc || npc.stat != CONSCIOUS) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(thing)) + if(!length(npc.get_empty_held_indexes()) && !pirate_crew_human_free_a_hand(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!human_npc_take_in_hand(npc, thing)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, thing) || npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + if(istype(thing, /obj/item/reagent_containers/hypospray/medipen)) + npc.npc_bark("stimulant", force = TRUE) + npc.execute_mode() + var/obj/item/reagent_containers/hypospray/medipen/pen = thing + if(pen.used_up && npc.is_holding(pen)) + npc.dropItemToGround(pen) + else + npc.npc_bark("drink", force = TRUE) + controller.ai_interact(target = npc, combat_mode = FALSE) + controller.set_blackboard_key(BB_PIRATE_NEXT_DRINK, world.time + PIRATE_CREW_DRINK_COOLDOWN) + if(npc.is_holding(thing)) + thing.equip_to_best_slot(npc) + for(var/obj/item/held in npc.held_items) + human_npc_ready_weapon(npc, held) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/datum/ai_behavior/pirate_crew_human_use_on_self/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + if(!succeeded) + // Couldn't get at it; it won't keep trying every tick + controller.set_blackboard_key(BB_PIRATE_NEXT_DRINK, world.time + PIRATE_CREW_DRINK_COOLDOWN) + +/// Frees a hand: lets go of a two-handed grip, or holsters a gun that can't fire. Returns TRUE if a hand is free. +/proc/pirate_crew_human_free_a_hand(mob/living/carbon/npc) + for(var/obj/item/held in npc.held_items) + if(held.GetComponent(/datum/component/two_handed) && HAS_TRAIT(held, TRAIT_WIELDED)) + held.attack_self(npc) + break + if(length(npc.get_empty_held_indexes())) + return TRUE + var/obj/item/gun/spent = human_npc_held_gun(npc) + if(spent && !spent.can_shoot()) + spent.equip_to_best_slot(npc) + return length(npc.get_empty_held_indexes()) > 0 + +// ===== DOORS ===== + +/** + * A human crewman breaking through `blocker` (a door or the frame a broken door left), the ways a person + * can: prying an unpowered airlock or a firelock with a crowbar or fireaxe it carries, shooting the door + * from a step back, or hitting it with something heavy enough to dent it. Returns FALSE when it has no + * way to hurt this door, so the crew's breach gives up on it and another member can try. + */ +/proc/pirate_crew_human_breach(datum/ai_controller/controller, mob/living/carbon/human/npc, obj/blocker) + if(LAZYLEN(npc.do_afters) || HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || npc.next_move > world.time) + return TRUE + // A pry bar on a door that will give: an airlock with no power that isn't bolted or welded, or a firelock + var/obj/item/pry_tool = pirate_crew_human_pry_tool(npc) + if(pry_tool && pirate_crew_human_can_pry(blocker)) + if(npc.Adjacent(blocker) && pirate_crew_human_ready_tool(npc, pry_tool)) + // A crowbar's right click forces a firelock fully open; its left click only holds it + var/list/modifiers = (istype(blocker, /obj/machinery/door/firedoor) && pry_tool.tool_behaviour == TOOL_CROWBAR) ? list(RIGHT_CLICK = TRUE) : null + controller.ai_interact(target = blocker, combat_mode = FALSE, modifiers = modifiers) + return TRUE + // A gun, from a step back so the shot isn't a bash (pirate_crew_human_breach_spot() puts it there) + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(gun && !npc.Adjacent(blocker) && get_dist(npc, blocker) <= 3 && can_see(npc, blocker, 3)) + if(human_npc_ready_item(npc, gun)) + npc.face_atom(blocker) + controller.ai_interact(target = blocker, combat_mode = TRUE) + return TRUE + // A blade or a club that dents it + var/obj/item/weapon = pirate_crew_human_door_weapon(npc, blocker) + if(weapon) + if(npc.Adjacent(blocker) && human_npc_ready_item(npc, weapon)) + controller.ai_interact(target = blocker, combat_mode = TRUE) + return TRUE + return FALSE + +/** + * Where a crewman works on `blocker` from: the floor beside it (`stand`) to pry or hit it, or for a gunner + * with nothing to pry it, the floor a step further back, so the shot isn't a bash with the gun. + */ +/proc/pirate_crew_human_breach_spot(mob/living/carbon/human/npc, obj/blocker, turf/stand) + if(!isturf(stand)) + stand = get_turf(npc) + if(isnull(human_npc_loaded_gun(npc))) + return stand + if(pirate_crew_human_can_pry(blocker) && pirate_crew_human_pry_tool(npc)) + return stand + var/turf/back = get_step(stand, get_dir(blocker, stand)) + if(back && pirate_turf_standable(back) && can_see(back, blocker, 3)) + return back + return stand + +/// Whether a crowbar or a fireaxe in a person's hands would force `blocker`. +/proc/pirate_crew_human_can_pry(obj/blocker) + if(istype(blocker, /obj/machinery/door/firedoor)) + var/obj/machinery/door/firedoor/firelock = blocker + return firelock.density && !firelock.welded && !firelock.operating + if(!istype(blocker, /obj/machinery/door/airlock)) + return FALSE + var/obj/machinery/door/airlock/airlock = blocker + return airlock.density && !airlock.locked && !airlock.welded && !airlock.seal && !airlock.operating && !airlock.hasPower() + +/// A crowbar or a fireaxe it holds or can draw. +/proc/pirate_crew_human_pry_tool(mob/living/carbon/npc) + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(!(thing.tool_behaviour == TOOL_CROWBAR || istype(thing, /obj/item/fireaxe))) + continue + if(npc.is_holding(thing) || human_npc_can_draw(npc, thing)) + return thing + return null + +/// Gets `tool` into the active hand and ready (a fireaxe wielded), putting away what's in the way. +/// Returns TRUE once it can be used. +/proc/pirate_crew_human_ready_tool(mob/living/carbon/npc, obj/item/tool) + if(!npc.is_holding(tool)) + if(!length(npc.get_empty_held_indexes()) && !pirate_crew_human_free_a_hand(npc)) + // Both hands still full: put the weaker of what it holds away + var/obj/item/stowed = npc.get_inactive_held_item() || npc.get_active_held_item() + if(isnull(stowed) || !stowed.equip_to_best_slot(npc)) + return FALSE + if(!human_npc_take_in_hand(npc, tool)) + return FALSE + if(istype(tool, /obj/item/fireaxe)) + human_npc_ready_weapon(npc, tool) + if(!HAS_TRAIT(tool, TRAIT_WIELDED)) + return FALSE + return human_npc_ready_item(npc, tool) + +/// A melee weapon it holds or can draw that hits `blocker` hard enough to hurt it (its damage +/// deflection), or null. +/proc/pirate_crew_human_door_weapon(mob/living/carbon/npc, obj/blocker) + var/obj/item/best + var/best_force = 0 + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(!human_npc_is_melee_weapon(thing)) + continue + if(!npc.is_holding(thing) && !human_npc_can_draw(npc, thing)) + continue + var/blow = pirate_crew_human_door_blow(thing) + if(blow > best_force) + best = thing + best_force = blow + if(isnull(best) || best_force < blocker.damage_deflection) + return null + if(!npc.is_holding(best)) + if(!length(npc.get_empty_held_indexes()) || !human_npc_take_in_hand(npc, best)) + return null + human_npc_ready_weapon(npc, best) + return best + +/// What `weapon` hits a door for, as it would be swung: lit, wielded, and with its demolition modifier. +/proc/pirate_crew_human_door_blow(obj/item/weapon) + var/force = weapon.force + var/datum/component/transforming/transforming = weapon.GetComponent(/datum/component/transforming) + if(transforming && !HAS_TRAIT(weapon, TRAIT_TRANSFORM_ACTIVE)) + force = max(force, transforming.force_on) + var/datum/component/two_handed/two_handed = weapon.GetComponent(/datum/component/two_handed) + if(two_handed && !HAS_TRAIT(weapon, TRAIT_WIELDED)) + force = max(force, two_handed.force_wielded) + return force * weapon.demolition_mod diff --git a/voidcrew/modules/npc_ships/code/pirate_guns.dm b/voidcrew/modules/npc_ships/code/pirate_guns.dm index 9c98550f815..80ba300a4c7 100644 --- a/voidcrew/modules/npc_ships/code/pirate_guns.dm +++ b/voidcrew/modules/npc_ships/code/pirate_guns.dm @@ -1,359 +1,104 @@ /** - * Pirate crew guns. + * Pirate crew guns: how a human pirate handles the real gun in its hands. * - * A ranged pirate rolls a gun when it joins a crew (pirate_crew_arm()), weighted by its faction: an - * energy gun, a laser rifle, a pistol, an SMG or a shotgun. The gun shows in its hand and fires the - * real gun's rounds: a ballistic gun's are read off the item by bounty_real_weapon() (bounties - * module), with its magazine, pellets, spread and damage multiplier; an energy gun fires its - * casing's beam. Nothing is spawned for the fight and nothing drops. - * - * Each loadout has a magazine and a reload, and the SMG and shotgun wind up before they fire (a - * hit during the wind-up spoils it). The shotgun walks in close before it fires. Every loadout - * deals about what the old two-laser volley every 6 seconds did, so a crew hits as hard as before. + * PR #500 rolled a drawn gun for each basic-mob gunner and fired its rounds from the body. Human + * pirates (faction_pirate_humans.dm) hold the real thing, chosen by their faction's outfit table + * (voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm, weighted as PR #500's crew_gun_weights), + * with real spare magazines or shells, and fire it through the real fire path. What is left of PR #500's + * loadouts is how each kind of gun is used: how many pulls of the trigger make a volley and how long the + * pirate waits between volleys (tuned, as before, to about the old two-laser volley every six seconds), + * the SMG's spin-up and the shotgun's moment of aim that a hit spoils, the shotgunner walking in close, + * and what it shouts when it reloads. The pump, the magazine change and their sounds are the gun's own. */ -/// One of each loadout, by type -GLOBAL_LIST_INIT(pirate_gun_loadouts, init_pirate_gun_loadouts()) +/// One of each handling, most specific gun type first +GLOBAL_LIST_INIT(pirate_gun_handlings, init_pirate_gun_handlings()) -/proc/init_pirate_gun_loadouts() +/proc/init_pirate_gun_handlings() . = list() - for(var/loadout_type in subtypesof(/datum/pirate_gun_loadout)) - .[loadout_type] = new loadout_type - -/// What a gunner rolls when its faction says nothing: an even mix of energy and ballistic -GLOBAL_LIST_INIT(pirate_gun_default_weights, list( - /datum/pirate_gun_loadout/energy_gun = 2, - /datum/pirate_gun_loadout/laser_rifle = 1, - /datum/pirate_gun_loadout/pistol = 2, - /datum/pirate_gun_loadout/smg = 2, - /datum/pirate_gun_loadout/shotgun = 2, -)) + // Most specific first: pistols are automatic guns too, and every gun type is a /obj/item/gun. + for(var/handling_type in list( + /datum/pirate_gun_handling/shotgun, + /datum/pirate_gun_handling/pistol, + /datum/pirate_gun_handling/smg, + /datum/pirate_gun_handling/ballistic, + /datum/pirate_gun_handling/crossbow, + /datum/pirate_gun_handling/energy, + /datum/pirate_gun_handling, + )) + . += new handling_type + +/// How a pirate uses `gun`: the first handling whose gun type it is. +/proc/pirate_gun_handling_for(obj/item/gun/gun) + for(var/datum/pirate_gun_handling/handling as anything in GLOB.pirate_gun_handlings) + if(istype(gun, handling.gun_type)) + return handling + return GLOB.pirate_gun_handlings[length(GLOB.pirate_gun_handlings)] /** - * A pirate gun: the item in the hand, its rounds, and how it is fired. Read-only, shared by every - * pirate carrying it; the ammunition and timers live on the pirate's /datum/component/pirate_gun. + * How a pirate fires one kind of gun. Read-only and shared; the timers live on the pirate's AI + * blackboard (BB_PIRATE_VOLLEY_LEFT, BB_PIRATE_WINDUP_UNTIL, BB_HUMAN_NPC_NEXT_SHOT). */ -/datum/pirate_gun_loadout - /// The gun in the hand - var/gun_type - /// For a ballistic gun, what it is loaded with (null: the magazine's own rounds) - var/ammo_type - /// For an energy gun, the casing it fires (its beam and its sound) - var/obj/item/ammo_casing/energy/energy_casing - /// For an energy gun, shots in a cell - var/energy_shots = 0 - /// Shots in one burst, and the time between them - var/burst = 1 - var/burst_delay = 0.2 SECONDS - /// From the start of one burst (or wind-up) to the next - var/cooldown = 6 SECONDS - var/reload_time = 2.5 SECONDS - /// The spin-up or pump before a burst, if any; a hit during it spoils the burst +/datum/pirate_gun_handling + /// The guns it covers + var/gun_type = /obj/item/gun + /// Pulls of the trigger in one volley, and the time between them + var/shots = 1 + var/shot_gap = 0.3 SECONDS + /// From the last pull of a volley to the first of the next + var/volley_gap = 3 SECONDS + /// The spin-up or aim before a volley, if any: a hit during it spoils the volley var/windup = 0 var/windup_sound - var/fire_sound - var/reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' /// Tiles: it walks in to this before it fires (0: the usual gunner range) var/close_range = 0 - /// What its carrier may shout on reloading + /// What it may shout as it changes magazines or loads shells var/list/reload_lines = list("Reloading!", "Changing mags!", "Cover me, I'm reloading!") - /// The real gun's numbers, for a ballistic gun (/datum/bounty_real_weapon) - var/datum/bounty_real_weapon/weapon - -/datum/pirate_gun_loadout/New() - . = ..() - if(ispath(gun_type, /obj/item/gun/ballistic)) - weapon = bounty_real_weapon(gun_type, ammo_type) - if(!fire_sound) - fire_sound = weapon.fire_sound - else if(energy_casing && !fire_sound) - fire_sound = initial(energy_casing.fire_sound) - -/// Rounds in a full magazine, or shots in a full cell -/datum/pirate_gun_loadout/proc/magazine_size() - if(weapon) - return max(1, weapon.magazine) - return max(1, energy_shots) - -/// Projectiles one round fires -/datum/pirate_gun_loadout/proc/pellets() - if(weapon) - return max(1, weapon.pellets) - return max(1, initial(energy_casing.pellets)) - -/// A new projectile of one round (one pellet of a shell) at `start`, carrying the real round's numbers -/datum/pirate_gun_loadout/proc/make_projectile(turf/start) - if(weapon) - if(!weapon.round_type) - return null - var/obj/projectile/bullet = new weapon.round_type(start) - weapon.load(bullet) - return bullet - var/projectile_type = initial(energy_casing.projectile_type) - return projectile_type ? new projectile_type(start) : null - -/// Degrees one projectile strays -/datum/pirate_gun_loadout/proc/shot_spread() - if(weapon) - return weapon.shot_spread() - var/variance = initial(energy_casing.variance) - return variance ? round((rand() - 0.5) * variance) : 0 - -/// Energy gun (lethal): two beams every six seconds, twelve shots to a cell -/datum/pirate_gun_loadout/energy_gun - gun_type = /obj/item/gun/energy/e_gun/lethal - energy_casing = /obj/item/ammo_casing/energy/laser - energy_shots = 12 - burst = 2 - cooldown = 6 SECONDS - reload_time = 3 SECONDS - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_empty.ogg' - reload_lines = list("Swapping cells!", "Cell's dry!", "Cover me!") - -/// Laser rifle: as the energy gun, with a bigger cell -/datum/pirate_gun_loadout/laser_rifle - gun_type = /obj/item/gun/energy/laser - energy_casing = /obj/item/ammo_casing/energy/lasergun - energy_shots = 16 - burst = 2 - cooldown = 6 SECONDS - reload_time = 3 SECONDS - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_empty.ogg' - reload_lines = list("Swapping cells!", "Cell's dry!", "Cover me!") - -/// Makarov: a quick pair of 9mm every seven seconds, a reload every six pairs -/datum/pirate_gun_loadout/pistol + /// What it may shout when the gun runs dry and can't be reloaded + var/list/dry_lines + +/// Energy guns: two beams every six seconds. A cell can't be changed by hand, so a dry one is put away. +/datum/pirate_gun_handling/energy + gun_type = /obj/item/gun/energy + shots = 2 + shot_gap = 0.2 SECONDS + volley_gap = 6 SECONDS + reload_lines = null + dry_lines = list("Cell's dry!", "Cover me!") + +/// The medieval crossbow: one bolt every six seconds. It draws itself again. +/datum/pirate_gun_handling/crossbow + gun_type = /obj/item/gun/energy/recharge + shots = 1 + volley_gap = 6 SECONDS + reload_lines = null + +/// Any other bullet gun: one shot every three seconds. +/datum/pirate_gun_handling/ballistic + gun_type = /obj/item/gun/ballistic + +/// Pistols: a quick pair every seven seconds. +/datum/pirate_gun_handling/pistol gun_type = /obj/item/gun/ballistic/automatic/pistol - burst = 2 - burst_delay = 0.3 SECONDS - cooldown = 7 SECONDS - reload_time = 2 SECONDS - reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' - -/// Uzi: a five-round string after a short wind-up, every seven seconds -/datum/pirate_gun_loadout/smg - gun_type = /obj/item/gun/ballistic/automatic/mini_uzi - burst = 5 - burst_delay = 0.1 SECONDS - cooldown = 7 SECONDS - reload_time = 2.5 SECONDS + shots = 2 + shot_gap = 0.3 SECONDS + volley_gap = 7 SECONDS + +/// SMGs: spins up, then three bursts, every seven seconds. +/datum/pirate_gun_handling/smg + gun_type = /obj/item/gun/ballistic/automatic + shots = 3 + shot_gap = 0.3 SECONDS + volley_gap = 7 SECONDS windup = 0.4 SECONDS windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' -/// Pump shotgun with buckshot: walks in close, pumps, and fires a shell every four seconds -/datum/pirate_gun_loadout/shotgun +/// Pump shotguns: walks in close, aims a moment and fires a shell every four seconds. The pump after +/// each shot is the gun's own (the AI racks it as a player does). +/datum/pirate_gun_handling/shotgun gun_type = /obj/item/gun/ballistic/shotgun - ammo_type = /obj/item/ammo_casing/shotgun/buckshot - burst = 1 - cooldown = 4 SECONDS - reload_time = 3 SECONDS + shots = 1 + volley_gap = 4 SECONDS windup = 0.4 SECONDS - windup_sound = 'sound/items/weapons/gun/shotgun/rack.ogg' - reload_sound = 'sound/items/weapons/gun/shotgun/insert_shell.ogg' close_range = 3 reload_lines = list("Reloading!", "Loading shells!", "Cover me!") - -// ===== ARMING ===== - -/// The loadout weights for `gunner`'s faction: type => weight, or null to keep its own gun -/proc/pirate_gun_weights_for(mob/living/basic/gunner) - var/mob/living/basic/trooper/pirate/faction/faction_pirate = gunner - if(istype(faction_pirate)) - if(faction_pirate.crew_keeps_gun) - return null - return faction_pirate.crew_gun_weights || GLOB.pirate_gun_default_weights - if(istype(gunner, /mob/living/basic/trooper/pirate)) - return GLOB.pirate_gun_default_weights - return null - -/** - * Hands `gunner` a rolled pirate gun in place of its stock ranged attack: the gun shows in its hand - * and fires real rounds. Only a gunner whose faction has loadouts, and only once. Returns the loadout. - */ -/proc/pirate_crew_arm(mob/living/basic/gunner, loadout_type) - if(QDELETED(gunner) || gunner.GetComponent(/datum/component/pirate_gun)) - return null - if(!gunner.GetComponent(/datum/component/ranged_attacks)) - return null - if(!loadout_type) - var/list/weights = pirate_gun_weights_for(gunner) - if(!length(weights)) - return null - loadout_type = pick_weight(weights) - var/datum/pirate_gun_loadout/loadout = GLOB.pirate_gun_loadouts[loadout_type] - if(!loadout) - return null - qdel(gunner.GetComponent(/datum/component/ranged_attacks)) - gunner.AddComponent(/datum/component/pirate_gun, loadout) - var/mob/living/basic/trooper/trooper = gunner - if(istype(trooper)) - trooper.r_hand = loadout.gun_type - if(trooper.use_dynamic_appearance) - apply_dynamic_human_appearance(trooper, mob_spawn_path = trooper.mob_spawner, r_hand = trooper.r_hand, l_hand = trooper.l_hand) - return loadout - -/// The pirate gun `gunner` carries, if any -/proc/pirate_gun_of(mob/living/gunner) - return gunner?.GetComponent(/datum/component/pirate_gun) - -// ===== THE GUN IN HAND ===== - -/** - * A pirate's gun in use: its ammunition and timers. The AI's ranged attack (RangedAttack()) asks it - * to fire; it fires a burst when it's ready, winds up first if it has to, and reloads when empty. - * Its timers check `serial`, so a spoiled or cancelled burst does nothing. - */ -/datum/component/pirate_gun - var/datum/pirate_gun_loadout/loadout - /// Rounds left in the magazine - var/ammo = 0 - /// world.time it may start the next burst or wind-up - var/next_fire_at = 0 - /// world.time the reload is done - var/reloading_until = 0 - /// Winding up now - var/winding = FALSE - /// Goes up with every burst, wind-up and fumble, so stale timers do nothing - var/serial = 0 - /// What it's aimed at - var/datum/weakref/aim_ref - -/datum/component/pirate_gun/Initialize(datum/pirate_gun_loadout/loadout) - if(!isbasicmob(parent) || !istype(loadout)) - return COMPONENT_INCOMPATIBLE - src.loadout = loadout - ammo = loadout.magazine_size() - -/datum/component/pirate_gun/Destroy(force) - serial++ - loadout = null - aim_ref = null - return ..() - -/datum/component/pirate_gun/RegisterWithParent() - . = ..() - ADD_TRAIT(parent, TRAIT_SUBTREE_REQUIRED_OPERATIONAL_DATUM, type) - RegisterSignal(parent, COMSIG_MOB_ATTACK_RANGED, PROC_REF(on_ranged_attack)) - RegisterSignal(parent, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) - -/datum/component/pirate_gun/UnregisterFromParent() - . = ..() - UnregisterSignal(parent, list(COMSIG_MOB_ATTACK_RANGED, COMSIG_MOB_AFTER_APPLY_DAMAGE)) - REMOVE_TRAIT(parent, TRAIT_SUBTREE_REQUIRED_OPERATIONAL_DATUM, type) - -/datum/component/pirate_gun/proc/on_ranged_attack(mob/living/basic/source, atom/target, modifiers) - SIGNAL_HANDLER - try_fire(target) - -/// A hit during the wind-up spoils it: nothing fires, and the next try waits a moment -/datum/component/pirate_gun/proc/on_damaged(mob/living/source, damage_dealt) - SIGNAL_HANDLER - if(!winding || damage_dealt <= 0) - return - serial++ - winding = FALSE - next_fire_at = max(next_fire_at, world.time + 1 SECONDS) - -/datum/component/pirate_gun/proc/ready() - return !winding && world.time >= next_fire_at - -/datum/component/pirate_gun/proc/is_reloading() - return world.time < reloading_until - -/datum/component/pirate_gun/proc/can_fire(mob/living/owner) - return owner && owner.stat == CONSCIOUS && isturf(owner.loc) && !HAS_TRAIT(owner, TRAIT_INCAPACITATED) && !HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED) - -/// Starts a burst at `target`, after the wind-up if it has one. TRUE if it started. -/datum/component/pirate_gun/proc/try_fire(atom/target) - var/mob/living/owner = parent - if(!can_fire(owner) || !ready() || QDELETED(target)) - return FALSE - if(loadout.close_range && get_dist(owner, target) > loadout.close_range) - return FALSE - aim_ref = WEAKREF(target) - serial++ - owner.face_atom(target) - if(loadout.windup) - winding = TRUE - next_fire_at = world.time + loadout.windup + loadout.cooldown - if(loadout.windup_sound) - playsound(owner, loadout.windup_sound, 50, TRUE) - addtimer(CALLBACK(src, PROC_REF(finish_windup), serial), loadout.windup, TIMER_DELETE_ME) - return TRUE - fire_burst() - return TRUE - -/datum/component/pirate_gun/proc/finish_windup(fire_serial) - if(fire_serial != serial || !winding) - return - winding = FALSE - if(can_fire(parent)) - fire_burst() - -/// A burst: as many shots as the magazine has left, then a reload if it's spent -/datum/component/pirate_gun/proc/fire_burst() - var/shots = clamp(loadout.burst, 1, ammo) - ammo -= shots - next_fire_at = max(next_fire_at, world.time + loadout.cooldown) - var/fire_serial = serial - shoot_once(fire_serial) - for(var/i in 2 to shots) - addtimer(CALLBACK(src, PROC_REF(shoot_once), fire_serial), (i - 1) * loadout.burst_delay, TIMER_DELETE_ME) - if(ammo <= 0) - start_reload((shots - 1) * loadout.burst_delay) - -/// One round of the burst under `fire_serial`: every pellet of it -/datum/component/pirate_gun/proc/shoot_once(fire_serial) - if(fire_serial != serial) - return null - var/mob/living/owner = parent - var/atom/target = aim_ref?.resolve() - if(!can_fire(owner) || QDELETED(target)) - return null - if(isliving(target)) - var/mob/living/living_target = target - if(living_target.stat == DEAD) - return null - owner.face_atom(target) - var/turf/start = get_turf(owner) - if(!start || start == get_turf(target)) - return null - var/obj/projectile/first - for(var/pellet in 1 to loadout.pellets()) - var/obj/projectile/shot = loadout.make_projectile(start) - if(!shot) - break - shot.firer = owner - shot.fired_from = owner - shot.ignored_factions = owner.faction?.Copy() - shot.aim_projectile(target, owner, null, loadout.shot_spread()) - if(QDELETED(shot)) - continue - shot.fire() - if(!first && !QDELETED(shot)) - first = shot - if(loadout.fire_sound) - playsound(owner, loadout.fire_sound, 60, TRUE) - SEND_SIGNAL(owner, COMSIG_BASICMOB_POST_ATTACK_RANGED, target, null) - return first - -/// A fresh magazine, ready `delay` + the reload time from now -/datum/component/pirate_gun/proc/start_reload(delay = 0) - ammo = loadout.magazine_size() - reloading_until = world.time + delay + loadout.reload_time - next_fire_at = max(next_fire_at, reloading_until) - addtimer(CALLBACK(src, PROC_REF(reload_effects)), delay, TIMER_DELETE_ME) - -/datum/component/pirate_gun/proc/reload_effects() - var/mob/living/owner = parent - if(QDELETED(owner) || owner.stat != CONSCIOUS) - return - if(loadout.reload_sound) - playsound(owner, loadout.reload_sound, 40, TRUE) - if(length(loadout.reload_lines) && prob(PIRATE_CREW_RELOAD_BARK_CHANCE)) - INVOKE_ASYNC(owner, TYPE_PROC_REF(/atom/movable, say), message = pick(loadout.reload_lines), forced = "pirate crew") - -/// For the admin status readout -/datum/component/pirate_gun/proc/status_text() - var/obj/item/gun_path = loadout.gun_type - return "[initial(gun_path.name)] [ammo]/[loadout.magazine_size()][is_reloading() ? " reloading" : ""][winding ? " winding up" : ""]" diff --git a/voidcrew/modules/npc_ships/code/spawner_subsystem.dm b/voidcrew/modules/npc_ships/code/spawner_subsystem.dm index 2391f08dc8b..f74882420fa 100644 --- a/voidcrew/modules/npc_ships/code/spawner_subsystem.dm +++ b/voidcrew/modules/npc_ships/code/spawner_subsystem.dm @@ -258,12 +258,18 @@ SUBSYSTEM_DEF(npc_ships) if(ship.spawner_resolved || ship.abandoned) continue if(!ship.crew_ever_spawned) + // Human crews are built a body at a time (spawn_human_npc()); still queued is not malformed + if(ship.crew_pending()) + continue if(!(REF(ship) in logged_malformed)) logged_malformed += REF(ship) log_world("SSnpc_ships: [ship.name] has no crew roster (spawn_crew failed?) - it holds its pool slot and will never auto-resolve") continue - // Put spaced survivors out of their misery before the census reads them - ship.sweep_spaced_crew() + // A crewman spaced off the hull no longer counts as aboard; the space kills him or it doesn't + // (no death is forced, defaults PI-3), and one adrift with nobody to see him is cleaned up by + // his crew (pirate_crew.dm). Crew still queued to be built keep the hull's slot. + if(ship.crew_pending()) + continue if(ship.count_live_crew_aboard() <= 0) ship.notify_spawner_resolved("crew wiped (reconcile)") continue diff --git a/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm b/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm index 2a88700fb42..a6a43e9ced1 100644 --- a/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm +++ b/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm @@ -649,8 +649,12 @@ GLOBAL_LIST_EMPTY(space_ruin_signals) /** * Picks a random non-dense, non-space turf inside the loaded ruin's template footprint. * Used by recovery missions to place objectives. Returns null if the ruin isn't loaded. + * + * unwatched: mission field spawns. Prefer solid ground nothing dense is standing on, and + * ones no living player can see right now (pick_unwatched_turf()), so a crew already + * aboard doesn't watch the objective appear. */ -/obj/structure/overmap/space_ruin/proc/get_random_interior_turf() +/obj/structure/overmap/space_ruin/proc/get_random_interior_turf(unwatched = FALSE) if(!loaded || !ruin_bottom_left || !ruin_template?.width || !ruin_template?.height) return null var/turf/top_right = locate( @@ -679,6 +683,12 @@ GLOBAL_LIST_EMPTY(space_ruin_signals) candidates += interior_turf if(!length(candidates)) return null + if(unwatched) + var/list/clear = list() + for(var/turf/candidate as anything in candidates) + if(!candidate.is_blocked_turf(exclude_mobs = TRUE) && !isgroundlessturf(candidate)) + clear += candidate + return pick_unwatched_turf(length(clear) ? clear : candidates) return pick(candidates) /obj/structure/overmap/space_ruin/proc/remove_docks() diff --git a/voidcrew/modules/player_outposts/outpost_admin_prison.dm b/voidcrew/modules/player_outposts/outpost_admin_prison.dm index 11ebecc5d59..841fb028771 100644 --- a/voidcrew/modules/player_outposts/outpost_admin_prison.dm +++ b/voidcrew/modules/player_outposts/outpost_admin_prison.dm @@ -94,7 +94,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T record(user, home, "[open ? "open" : "close"] prison intake") if("prison_spawn") // Forced: an admin spawn ignores the cell's ready time and a running experiment - var/mob/living/basic/outpost_prisoner/arrival = prison.admit_next(TRUE) + var/mob/living/carbon/human/npc/outpost_prisoner/arrival = prison.admit_next(TRUE) if(!arrival) error = "No free cell." return @@ -108,7 +108,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return record(user, home, "fill [count] prison cell\s") if("prison_release", "prison_kill", "prison_remove", "prison_set", "prison_fight", "prison_breakout", "prison_wreck") - var/mob/living/basic/outpost_prisoner/prisoner = locate(params["ref"]) in prison.prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = locate(params["ref"]) in prison.prisoners if(QDELETED(prisoner)) error = "That prisoner is gone." return @@ -126,7 +126,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T if(!prisoner.can_join_riot() || prisoner.trouble) error = "That prisoner can't fight now." return - var/mob/living/basic/outpost_prisoner/partner = prison.nearest_fight_partner(prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/partner = prison.nearest_fight_partner(prisoner) if(!partner) error = "No other prisoner can fight." return @@ -284,7 +284,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T if(!istext(form) || !(form in list("hulk", "fly", "nightmare", "changeling"))) error = "Invalid experiment form." return - var/mob/living/basic/outpost_prisoner/subject = locate(params["ref"]) in prison.prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/subject = locate(params["ref"]) in prison.prisoners if(QDELETED(subject)) error = "That prisoner is gone." return @@ -418,7 +418,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T for(var/datum/outpost_prison_cell/cell as anything in cells) cell_rows += list(list("number" = cell.number, "occupant_ref" = cell.occupant ? REF(cell.occupant) : null)) var/list/prisoner_rows = list() - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in roster_order()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in roster_order()) prisoner_rows += list(list( "ref" = REF(prisoner), "name" = prisoner.real_name, @@ -440,7 +440,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T "mood" = round(prisoner.mood), "state" = prisoner.trouble_state(), "loose_left" = prisoner.loose_seconds_shown(), - "cuffed" = !!prisoner.cuffs, + "cuffed" = !!prisoner.handcuffed, "lockdown_left" = max(0, round(prisoner.lockdown_left)), )) var/list/trouble_block = trouble_payload() @@ -519,11 +519,11 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return null /// Where their care falls between no pay and full pay, 0 to 1 (section 1's G(care)) -/mob/living/basic/outpost_prisoner/proc/admin_care_grade() +/mob/living/carbon/human/npc/outpost_prisoner/proc/admin_care_grade() return clamp((care() - OUTPOST_PRISON_GRADE_FLOOR) / (OUTPOST_PRISON_GRADE_FULL - OUTPOST_PRISON_GRADE_FLOOR), 0, 1) /// What they are up to, in a few words -/mob/living/basic/outpost_prisoner/proc/admin_activity_text() +/mob/living/carbon/human/npc/outpost_prisoner/proc/admin_activity_text() if(stat == DEAD) return "dead" if(phase == PRISONER_ARRIVING) @@ -532,9 +532,9 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return "beaming out" if(pulledby) return "being dragged" - if(beaten_left > 0) - return "beaten ([round(beaten_left)] s)" - if(cuffs) + if(beaten) + return HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION) ? "beaten, in crit" : "beaten" + if(handcuffed) if(trouble == PRISONER_TROUBLE_LOOSE) return "cuffed, loose" return is_rioting() ? "cuffed, rioting" : "cuffed" @@ -559,18 +559,17 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return text /// Sets one of a prisoner's values. Returns FALSE if it can't take it. -/datum/outpost_prison/proc/admin_set(mob/living/basic/outpost_prisoner/prisoner, field, value) +/datum/outpost_prison/proc/admin_set(mob/living/carbon/human/npc/outpost_prisoner/prisoner, field, value) switch(field) if("hunger") prisoner.set_hunger(clamp(value, 0, 100)) if("grime") prisoner.set_uniform_grime(clamp(value, 0, 100)) if("health") - // 1 to 100 percent; killing has its own button. + // 1 to 100 percent; killing has its own button. Bruising over the limbs, measured as it lands. if(prisoner.stat == DEAD) return FALSE - var/target_loss = prisoner.maxHealth * (100 - clamp(value, 1, 100)) / 100 - prisoner.adjustBruteLoss(target_loss - prisoner.getBruteLoss(), forced = TRUE) + outpost_prison_set_health(prisoner, prisoner.maxHealth * clamp(value, 1, 100) / 100) if("sentence") if(prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT) return FALSE @@ -598,7 +597,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T /// Applies one change to every living prisoner. Returns how many. /datum/outpost_prison/proc/admin_all(what) var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == DEAD || prisoner.phase == PRISONER_LEAVING) continue count++ @@ -612,13 +611,11 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T if("clean") prisoner.set_uniform_grime(0) if("hurt") - // A real hit, so they bleed, never a killing one. - var/damage = min(30, prisoner.health - 1) - if(damage > 0) - prisoner.apply_damage(damage, BRUTE) + // A real hit on one limb, so they bruise and may bleed (defaults PR-27). + prisoner.apply_damage(30, BRUTE, pick(BODY_ZONE_CHEST, BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) if("heal") - prisoner.adjustBruteLoss(-prisoner.getBruteLoss(), forced = TRUE) - prisoner.setStaminaLoss(0) + // tg's own full heal of the body (damage, wounds, blood), cuffs and the rest left as they are. + prisoner.fully_heal(HEAL_DAMAGE | HEAL_WOUNDS | HEAL_BLOOD | HEAL_LIMBS | HEAL_ORGANS) if("enrage") // Low enough to threaten staff, fight, and riot once it has held for half a minute. prisoner.set_mood(10) @@ -636,7 +633,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T end_fight(brawl) if(riot_active) end_riot() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == DEAD || prisoner.phase == PRISONER_LEAVING) continue prisoner.cancel_threat() @@ -651,10 +648,10 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return count /// The nearest other prisoner who could fight, for the admin fight button -/datum/outpost_prison/proc/nearest_fight_partner(mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/nearest +/datum/outpost_prison/proc/nearest_fight_partner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/nearest var/nearest_distance = INFINITY - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == prisoner || !other.can_join_riot() || other.trouble) continue var/distance = get_dist(prisoner, other) @@ -664,10 +661,10 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return nearest /// What the admin panel shows as their state -/mob/living/basic/outpost_prisoner/proc/trouble_state() +/mob/living/carbon/human/npc/outpost_prisoner/proc/trouble_state() if(trouble == PRISONER_TROUBLE_LOOSE) return "loose" - if(beaten_left > 0) + if(beaten) return "beaten" // is_rioting() covers a breakout too; the panel tells them apart, as admin_activity_text() does if(trouble == PRISONER_TROUBLE_BREAKOUT) @@ -680,7 +677,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return trouble || "normal" /// Seconds left loose, shown only while they are out of the cell block -/mob/living/basic/outpost_prisoner/proc/loose_seconds_shown() +/mob/living/carbon/human/npc/outpost_prisoner/proc/loose_seconds_shown() if(trouble != PRISONER_TROUBLE_LOOSE || stat == DEAD || prison?.in_cell_block(src)) return null return max(0, round(loose_left)) diff --git a/voidcrew/modules/player_outposts/outpost_npc_looks.dm b/voidcrew/modules/player_outposts/outpost_npc_looks.dm index d31bce7a67e..288938ffd8a 100644 --- a/voidcrew/modules/player_outposts/outpost_npc_looks.dm +++ b/voidcrew/modules/player_outposts/outpost_npc_looks.dm @@ -1,8 +1,13 @@ /** * # Outpost NPC looks * - * Human-looking basic mobs on outposts (prisoners, guards, and ambient NPCs) wear an outfit on - * a human body. tg's set_dynamic_human_appearance() builds that body on a fixed, bald, pale dummy + * Two kinds of look live here. + * + * Faces for real bodies (/datum/outpost_npc_look, at the bottom of the file): ambient NPCs are real + * human bodies and wear one randomly rolled face for life; what they hold is a real item in hand. + * + * Dressed appearances for the basic mobs that still copy a picture of a person onto themselves + * (the rest of the file): they wear an outfit on a human body. tg's set_dynamic_human_appearance() builds that body on a fixed, bald, pale dummy * and caches one appearance per outfit, so everyone in the same outfit has the same face and a * male body whatever their gender. * @@ -193,6 +198,83 @@ GLOBAL_LIST_EMPTY(outpost_npc_looks) return style_name return null +// ========================================================================= +// FACES FOR REAL BODIES +// ========================================================================= + +/** + * One person's face, kept as the features a real human body needs to look like them: gender and + * physique, skin tone, hair and facial hair (styles and colours) and eye colour. Ambient NPCs are + * human bodies (voidcrew/modules/ambient_npcs/); each rolls one of these when it is made and wears + * it for life, so the same person shows idle, holding a mop, welding, and dead. Strays keep theirs + * across visits (stray_identity()), so the same person comes back. The appearance cache above is + * for the basic mobs that still copy a dressed look onto themselves (bounty companions and goons). + */ +/datum/outpost_npc_look + /// MALE or FEMALE (also the body's physique) + var/gender = MALE + var/skin_tone = "caucasian1" + var/hairstyle = "Bald" + var/hair_color = "#000000" + var/facial_hairstyle = "Shaved" + var/facial_hair_color = "#000000" + var/eye_color = "#000000" + +/// A random person of `gender` (MALE or FEMALE; anything else is male), rolled the way randomize_human_normie() rolls one +/proc/new_outpost_npc_person(gender = MALE) + var/datum/outpost_npc_look/person = new + person.gender = gender == FEMALE ? FEMALE : MALE + person.skin_tone = pick(GLOB.skin_tones) + person.hairstyle = outpost_npc_hairstyle(person.gender) || "Bald" + if(person.gender == MALE && prob(OUTPOST_NPC_FACIAL_HAIR_CHANCE)) + person.facial_hairstyle = outpost_npc_facial_hairstyle() || "Shaved" + person.hair_color = random_hair_color() + person.facial_hair_color = person.hair_color + person.eye_color = random_eye_color() + return person + +/// The face `body` wears now, as a look that can be put on another body +/proc/outpost_npc_look_of(mob/living/carbon/human/body) + var/datum/outpost_npc_look/person = new + person.gender = body.gender == FEMALE ? FEMALE : MALE + person.skin_tone = body.skin_tone + person.hairstyle = body.hairstyle + person.hair_color = body.hair_color + person.facial_hairstyle = body.facial_hairstyle + person.facial_hair_color = body.facial_hair_color + person.eye_color = body.eye_color_left + return person + +/// A copy, so two bodies never share one look datum +/datum/outpost_npc_look/proc/copy() + var/datum/outpost_npc_look/twin = new + twin.gender = gender + twin.skin_tone = skin_tone + twin.hairstyle = hairstyle + twin.hair_color = hair_color + twin.facial_hairstyle = facial_hairstyle + twin.facial_hair_color = facial_hair_color + twin.eye_color = eye_color + return twin + +/// Puts this face on `body`, undressed parts bare (no underwear), and redraws it +/datum/outpost_npc_look/proc/apply_to(mob/living/carbon/human/body) + if(QDELETED(body)) + return + body.gender = gender + body.physique = gender + body.skin_tone = skin_tone + body.set_hairstyle(hairstyle, update = FALSE) + body.set_haircolor(hair_color, update = FALSE) + body.set_facial_hairstyle(facial_hairstyle, update = FALSE) + body.set_facial_haircolor(facial_hair_color, update = FALSE) + body.set_eye_color(eye_color) + body.underwear = "Nude" + body.undershirt = "Nude" + body.socks = "Nude" + // Without is_creating the limbs keep the skin tone they were made with + body.update_body(is_creating = TRUE) + #undef OUTPOST_NPC_LOOK_COUNT #undef OUTPOST_NPC_FACIAL_HAIR_CHANCE #undef OUTPOST_NPC_HELD_LOOKS_MAX diff --git a/voidcrew/modules/player_outposts/outpost_prison_ambience.dm b/voidcrew/modules/player_outposts/outpost_prison_ambience.dm index b85a6cd7f75..d6756ea9640 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_ambience.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_ambience.dm @@ -23,7 +23,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", // ===== THE PRISONER ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Picks their examine line within a mood band, so looking twice reads the same var/ambience_examine_seed = 0 /// Running after they turned to look at staff coming in, after humming, and after a whisper in a huddle @@ -32,14 +32,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", COOLDOWN_DECLARE(ambience_huddle_cooldown) /// Hooks up the prisoner's side of the ambience; called from setup_extras(). Nothing needs hooking: the prison drives it from its tick, and examine is the override below. -/mob/living/basic/outpost_prisoner/proc/setup_ambience() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_ambience() return /** * Their mood in words, by band and personality, and one line on how they are kept when something * shows (hurt, a dirty jumpsuit, shut in, due out), then every package's own lines. Never a number. */ -/mob/living/basic/outpost_prisoner/examine(mob/user) +/mob/living/carbon/human/npc/outpost_prisoner/examine(mob/user) . = ..() if(stat == DEAD || phase != PRISONER_PRESENT) return @@ -54,7 +54,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", . += span_notice(line) /// A closer look: what they are in for, their cell, roughly how long they have left, and their birthday until the yard has had the cake -/mob/living/basic/outpost_prisoner/examine_more(mob/user) +/mob/living/carbon/human/npc/outpost_prisoner/examine_more(mob/user) . = ..() if(stat == DEAD || !prison) return @@ -69,14 +69,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", . += span_notice("It's [p_their()] birthday today.") /// "a few minutes" or "about 25 minutes": their sentence left, rounded to OUTPOST_PRISONER_EXAMINE_ROUND_MINUTES -/mob/living/basic/outpost_prisoner/proc/sentence_examine_text() +/mob/living/carbon/human/npc/outpost_prisoner/proc/sentence_examine_text() var/minutes = sentence_left / 60 if(minutes < OUTPOST_PRISONER_EXAMINE_ROUND_MINUTES) return "a few minutes" return "about [round(minutes, OUTPOST_PRISONER_EXAMINE_ROUND_MINUTES)] minutes" /// The "examine" key for their mood: the bands match the angry, threat, climb and tidy lines -/mob/living/basic/outpost_prisoner/proc/mood_examine_band() +/mob/living/carbon/human/npc/outpost_prisoner/proc/mood_examine_band() if(mood < PRISONER_THREAT_ANGRY_MOOD) return "mood_0" if(mood < PRISONER_THREAT_MOOD) @@ -88,7 +88,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return "mood_75" /// A line on their mood, filled in, or null. Their own personality's lines come up more often than the shared ones. -/mob/living/basic/outpost_prisoner/proc/mood_examine_line() +/mob/living/carbon/human/npc/outpost_prisoner/proc/mood_examine_line() var/list/bands = outpost_prisoner_extra_dialogue("outpost_prison_security.json", "examine") var/list/band = bands[mood_examine_band()] if(!islist(band)) @@ -105,7 +105,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return fill_examine_line(pool[(ambience_examine_seed % length(pool)) + 1]) /// Fills an examine line's pronouns ({They}, {their}, ...) and verb endings ({s}, {es}) for them -/mob/living/basic/outpost_prisoner/proc/fill_examine_line(line) +/mob/living/carbon/human/npc/outpost_prisoner/proc/fill_examine_line(line) if(!istext(line)) return null line = replacetextEx(line, "{They}", p_They()) @@ -118,7 +118,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return line /// One line on how they are kept, the most pressing first, or null when nothing shows -/mob/living/basic/outpost_prisoner/proc/state_examine_line() +/mob/living/carbon/human/npc/outpost_prisoner/proc/state_examine_line() var/health_percent = health_factor() if(health_percent < PRISONER_BLEED_BELOW) return "[p_They()] look[p_s()] badly hurt." @@ -190,7 +190,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", /// Whether another prisoner is too close to `tile` to sulk there /datum/prisoner_activity/sulk/proc/sulk_company_near(turf/tile) - for(var/mob/living/basic/outpost_prisoner/other in prisoner.prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoner.prison.prisoners) if(other != prisoner && get_dist(other, tile) <= OUTPOST_PRISONER_SULK_ALONE_RANGE) return TRUE return FALSE @@ -242,7 +242,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", * wing); TRUE drops it. Small talk, remarks to staff, conversations and chatter about what they * are doing go quiet; complaints and threats about the wing still get said. */ -/datum/outpost_prison/proc/speech_hushed(mob/living/basic/outpost_prisoner/speaker, context) +/datum/outpost_prison/proc/speech_hushed(mob/living/carbon/human/npc/outpost_prisoner/speaker, context) if(stage != PRISON_STAGE_RESTLESS) return FALSE var/static/list/always_hushed = list("idle", "staff_near", "conversation") @@ -289,7 +289,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", var/spoken = FALSE var/emotes = 0 var/reacted = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in shuffle(prisoners)) if(!prisoner.ai_running() || !prisoner.routine_allowed() || prisoner.activity?.sleeping) continue if(!COOLDOWN_FINISHED(prisoner, ambience_notice_cooldown)) @@ -322,12 +322,12 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return reacted /// A chat they are in waits a moment while they look over -/datum/outpost_prison/proc/pause_chat_for_notice(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/pause_chat_for_notice(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/prisoner_activity/chat/talk = prisoner.activity if(!istype(talk)) return if(istype(talk, /datum/prisoner_activity/chat/listen)) - var/mob/living/basic/outpost_prisoner/talker = talk.chat_partner() + var/mob/living/carbon/human/npc/outpost_prisoner/talker = talk.chat_partner() talk = talker?.activity if(!istype(talk) || istype(talk, /datum/prisoner_activity/chat/listen)) return @@ -338,7 +338,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", /// Content prisoners now and then hum; while the wing is restless, the ones gathered in the yard whisper among themselves /datum/outpost_prison/proc/yard_sounds_tick(seconds) var/huddling = stage == PRISON_STAGE_RESTLESS && !riot_imminent && !riot_active - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS || !prisoner.ai_running()) continue if(huddling && istype(prisoner.activity, /datum/prisoner_activity/gather)) @@ -352,7 +352,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", * A content cheerful, chatty or quiet prisoner, awake and free, hums to themself, at most once per * OUTPOST_PRISONER_HUM_COOLDOWN. Returns TRUE if they did. */ -/datum/outpost_prison/proc/try_hum(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/try_hum(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/static/list/hummers = list("cheerful", "chatty", "quiet") if(prisoner.mood < OUTPOST_PRISONER_HUM_MOOD || !(prisoner.personality in hummers)) return FALSE @@ -364,7 +364,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return TRUE /// A gathered prisoner whispers a huddle line, at most once per OUTPOST_PRISONER_HUDDLE_GAP. Returns TRUE if they did. -/datum/outpost_prison/proc/try_huddle(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/try_huddle(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!COOLDOWN_FINISHED(prisoner, ambience_huddle_cooldown)) return FALSE var/line = prisoner.pick_line("huddle") @@ -404,7 +404,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", ambience_chant_beats++ var/list/gathered = list() var/list/slammers = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in shuffle(prisoners)) var/datum/prisoner_activity/gather/gathering = prisoner.activity if(!istype(gathering) || !gathering.started || gathering.spot || !prisoner.ai_running() || !prisoner.routine_allowed()) continue @@ -421,12 +421,12 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", if(!(ambience_chant_beats % OUTPOST_PRISON_CHANT_LINE_EVERY)) var/list/voices = length(slammers) ? slammers : gathered if(length(voices)) - var/mob/living/basic/outpost_prisoner/voice = pick(voices) + var/mob/living/carbon/human/npc/outpost_prisoner/voice = pick(voices) voice.say_context("riot_chant") return slammers /// A mess table or a door beside `prisoner` to slam on, or null -/datum/outpost_prison/proc/chant_surface(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/chant_surface(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/turf/here = get_turf(prisoner) for(var/direction in GLOB.cardinals) var/turf/beside = get_step(here, direction) @@ -450,7 +450,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return 0 ambience_roared = TRUE var/roared = 0 - for(var/mob/living/basic/outpost_prisoner/rioter in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter in prisoners) if(!rioter.is_rioting() || rioter.stat != CONSCIOUS || rioter.phase != PRISONER_PRESENT || !rioter.ai_running()) continue if(rioter.say_context("riot_roar")) diff --git a/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm b/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm index 3e259912edc..af42e46864b 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm @@ -49,11 +49,11 @@ /// Weakrefs to guards the kingpin paid off, until they are back at work var/list/boss_bribed_guards = list() -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Their riot move, made the first time a riot needs it (boss_move_datum()), or null var/datum/prison_boss_move/boss_move -/mob/living/basic/outpost_prison_guard +/mob/living/carbon/human/npc/outpost_prison_guard /// world.time until which they stay out of a riot, paid off by the kingpin var/prison_bribed_until = 0 @@ -78,6 +78,9 @@ return /datum/prison_boss_move/slip_cuffs if(BOUNTY_KIT_HEAVY) return /datum/prison_boss_move/barricade + // Its chrome stays out of prison (decision 7): the Juggernaut's charge + if(BOUNTY_KIT_CYBERPSYCHO) + return /datum/prison_boss_move/charge return null /** @@ -88,7 +91,7 @@ boss_bribes_tick() if(!trouble_enabled || !riot_active) return - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) var/datum/prison_boss_move/move = prisoner.boss_move_datum() if(!move) continue @@ -96,7 +99,7 @@ move.try_start() /// A prisoner is leaving the roster (extras_prisoner_leaving()): their move stops -/datum/outpost_prison/proc/boss_moves_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/boss_moves_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) QDEL_NULL(prisoner.boss_move) /// The prison is being deleted (extras_destroy()) @@ -108,25 +111,25 @@ * cell, past the riot's wind-up, awake and on their feet, not held, climbing or batoned, with a member * of the wing home and someone on the level. Uncuffed, or cuffed if `cuffed`. */ -/datum/outpost_prison/proc/boss_riot_ok(mob/living/basic/outpost_prisoner/prisoner, cuffed = FALSE) +/datum/outpost_prison/proc/boss_riot_ok(mob/living/carbon/human/npc/outpost_prisoner/prisoner, cuffed = FALSE) if(!trouble_enabled || !riot_active || riot_windup_left > 0 || !crew_home()) return FALSE if(QDELETED(prisoner) || prisoner.prison != src || prisoner.stat != CONSCIOUS || !prisoner.riot_at_large()) return FALSE if(!in_cell_block(prisoner) || !isturf(prisoner.loc) || !prisoner.ai_running()) return FALSE - if(prisoner.is_down() || prisoner.pulledby || prisoner.climb_ref || prisoner.beaten_left > 0 || prisoner.surrendered_to_turret() || world.time < prisoner.baton_stop_until) + if(prisoner.is_down() || prisoner.pulledby || prisoner.climb_ref || prisoner.is_beaten() || prisoner.surrendered_to_turret() || world.time < prisoner.baton_stop_until) return FALSE - return cuffed ? !!prisoner.cuffs : !prisoner.cuffs + return cuffed ? !!prisoner.handcuffed : !prisoner.handcuffed /// Whether a move of `prisoner`'s may land on `person`: awake staff the riot goes for, in the cell block where `prisoner` can get at them -/datum/outpost_prison/proc/boss_may_hit(mob/living/basic/outpost_prisoner/prisoner, mob/living/person) +/datum/outpost_prison/proc/boss_may_hit(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/person) if(QDELETED(person) || person == prisoner || !is_outpost_prison_staff(person)) return FALSE return in_cell_block(person) && !!prisoner.reachable?[get_turf(person)] /// The nearest staff a move of `prisoner`'s may land on (boss_may_hit()) within `range` and in sight, or null -/datum/outpost_prison/proc/boss_nearest_staff(mob/living/basic/outpost_prisoner/prisoner, range = PRISON_BOSS_SIGHT) +/datum/outpost_prison/proc/boss_nearest_staff(mob/living/carbon/human/npc/outpost_prisoner/prisoner, range = PRISON_BOSS_SIGHT) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(range, prisoner)) @@ -151,7 +154,7 @@ // ===== THE PRISONER'S SIDE ===== /// Their riot move, made the first time it is asked for; null for anyone without one (bounty_boss_move_type()) -/mob/living/basic/outpost_prisoner/proc/boss_move_datum() +/mob/living/carbon/human/npc/outpost_prisoner/proc/boss_move_datum() if(boss_move) return boss_move var/move_type = bounty_boss_move_type(bounty_record) @@ -161,7 +164,7 @@ return boss_move /// Whether they are winding up or in the middle of their move (or reeling from it), so they hold still (trouble_target()) -/mob/living/basic/outpost_prisoner/proc/boss_move_busy() +/mob/living/carbon/human/npc/outpost_prisoner/proc/boss_move_busy() return !!boss_move && boss_move.busy_until > world.time // ===== A MOVE ===== @@ -174,7 +177,7 @@ * call finish_windup(pending_serial) instead of waiting. */ /datum/prison_boss_move - /// The prisoner whose move it is (/mob/living/basic/outpost_prisoner) + /// The prisoner whose move it is (/mob/living/carbon/human/npc/outpost_prisoner) var/datum/weakref/prisoner_ref /// How long it winds up, telegraphing, before anything happens var/windup = 1 SECONDS @@ -196,7 +199,7 @@ /// Weakrefs to the wind-up's marks still showing var/list/telegraphs -/datum/prison_boss_move/New(mob/living/basic/outpost_prisoner/owner) +/datum/prison_boss_move/New(mob/living/carbon/human/npc/outpost_prisoner/owner) . = ..() prisoner_ref = WEAKREF(owner) @@ -207,18 +210,18 @@ /// The prisoner, while they exist /datum/prison_boss_move/proc/owner() - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() return QDELETED(prisoner) ? null : prisoner /// Every tick of a riot, before try_start(): anything the move keeps track of -/datum/prison_boss_move/proc/observe(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/observe(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return /// Starts the move if it is ready, the boss may use it and there is something to use it on. Returns TRUE if it started. /datum/prison_boss_move/proc/try_start() if(pending_serial || world.time < busy_until || world.time < ready_at) return FALSE - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() var/datum/outpost_prison/prison = prisoner?.prison if(!prison || (once_per_riot && used_in_riot == prison.boss_riot_serial)) return FALSE @@ -243,14 +246,14 @@ return TRUE /// Whether the boss is still free to finish the wind-up under way -/datum/prison_boss_move/proc/may_finish(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/may_finish(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return !!prisoner?.prison?.boss_riot_ok(prisoner, needs_cuffs) /// During the wind-up: stopped if the boss is no longer free to finish it (knocked down, dragged, cuffed, out of the riot) /datum/prison_boss_move/proc/watch_windup(serial) if(!serial || serial != pending_serial) return FALSE - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() if(!may_finish(prisoner)) cancel() interrupted(prisoner) @@ -266,7 +269,7 @@ if(!serial || serial != pending_serial) return FALSE var/atom/target = pending_target?.resolve() - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() if(!may_finish(prisoner)) cancel() interrupted(prisoner) @@ -288,24 +291,24 @@ pending_target = null busy_until = 0 clear_telegraphs() - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() if(prisoner) REMOVE_TRAIT(prisoner, TRAIT_IMMOBILIZED, PRISON_BOSS_MOVE_TRAIT) /// What the move goes for now, or null: a member of staff, or a thing near them -/datum/prison_boss_move/proc/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return null /// Shows everyone what is coming, with a sound and a line -/datum/prison_boss_move/proc/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/proc/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) return /// Does it. `target` may be gone by now. Returns TRUE if anything happened. -/datum/prison_boss_move/proc/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/proc/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) return FALSE /// The wind-up was stopped before it finished -/datum/prison_boss_move/proc/interrupted(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/interrupted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return /// Marks `turfs` for the wind-up, with the field kits' floor marks @@ -340,7 +343,7 @@ return ..() /// The nearest staff in sight, PRISON_BOSS_CHARGE_MIN_RANGE to _RANGE tiles off, on a line he could run clear to them -/datum/prison_boss_move/charge/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/charge/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison var/mob/living/best var/best_distance = INFINITY @@ -359,7 +362,7 @@ * The line from the boss toward `target`, PRISON_BOSS_CHARGE_RANGE tiles long: it stops at the first * wall, and never runs past the cell block's edge. */ -/datum/prison_boss_move/charge/proc/charge_line(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/charge/proc/charge_line(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) . = list() var/datum/outpost_prison/prison = prisoner.prison var/turf/origin = get_turf(prisoner) @@ -382,7 +385,7 @@ break /// Whether the line toward `person` reaches them with nothing solid and nobody else before them -/datum/prison_boss_move/charge/proc/clear_run_to(mob/living/basic/outpost_prisoner/prisoner, mob/living/person) +/datum/prison_boss_move/charge/proc/clear_run_to(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/person) var/turf/aim = get_turf(person) for(var/turf/spot as anything in charge_line(prisoner, person)) if(spot == aim) @@ -392,14 +395,14 @@ return FALSE /// Whether a charge can run onto `spot`: in the cell block, ground the boss could walk, nothing solid on it -/datum/outpost_prison/proc/boss_run_tile_ok(mob/living/basic/outpost_prisoner/prisoner, turf/spot) +/datum/outpost_prison/proc/boss_run_tile_ok(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/spot) if(!spot || spot.z != prisoner.z || isclosedturf(spot) || !in_cell_block(spot) || !prisoner.walkable?[spot]) return FALSE if(isgroundlessturf(spot) || islava(spot) || ischasm(spot)) return FALSE return !spot.is_blocked_turf(exclude_mobs = TRUE) -/datum/prison_boss_move/charge/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/charge/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) path = charge_line(prisoner, target) prisoner.visible_message(span_boldwarning("[prisoner] drops [prisoner.p_their()] shoulder and lowers [prisoner.p_their()] head!")) playsound(prisoner, 'sound/mobs/non-humanoids/gorilla/gorilla.ogg', 60, TRUE) @@ -407,7 +410,7 @@ mark_turfs(path, "#c8a060") prisoner.say_context("boss_move_charge") -/datum/prison_boss_move/charge/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/charge/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) if(!length(path) || get_dist(prisoner, path[1]) > 1) path = null return FALSE @@ -427,7 +430,7 @@ * slams into it and reels. Nothing is broken, and he never leaves the cell block. */ /datum/prison_boss_move/charge/proc/charge_step(serial) - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() var/datum/outpost_prison/prison = prisoner?.prison if(!serial || serial != charge_serial || !prison || !prison.boss_riot_ok(prisoner)) end_charge() @@ -452,7 +455,7 @@ addtimer(CALLBACK(src, PROC_REF(charge_step), serial), PRISON_BOSS_CHARGE_STEP, TIMER_DELETE_ME) /// Knocks `person` off their feet: brute through melee armour and a knockdown (a guard, who can't be knocked down, takes the blow) -/datum/prison_boss_move/charge/proc/slam_into(mob/living/basic/outpost_prisoner/prisoner, mob/living/person) +/datum/prison_boss_move/charge/proc/slam_into(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/person) prisoner.face_atom(person) prisoner.do_attack_animation(person, ATTACK_EFFECT_SMASH) person.visible_message(span_danger("[prisoner] slams into [person]!"), span_userdanger("[prisoner] slams into you!")) @@ -463,7 +466,7 @@ log_combat(prisoner, person, "shoulder-charged") /// Ran into something solid on `spot`: stopped dead, reeling for PRISON_BOSS_CHARGE_REEL. It takes no harm. -/datum/prison_boss_move/charge/proc/bounce(mob/living/basic/outpost_prisoner/prisoner, turf/spot) +/datum/prison_boss_move/charge/proc/bounce(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/spot) end_charge() if(prisoner.stat != CONSCIOUS) return @@ -502,7 +505,7 @@ return ..() /// A bunk within PRISON_BOSS_FIRE_RANGE tiles, in sight with nothing in the way, with staff standing where it would burn -/datum/prison_boss_move/bed_fire/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/bed_fire/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(fire_ref?.resolve()) return null var/datum/outpost_prison/prison = prisoner.prison @@ -533,14 +536,14 @@ continue . += beside -/datum/prison_boss_move/bed_fire/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bed_fire/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) patch = prisoner.prison.boss_fire_patch(target) prisoner.visible_message(span_boldwarning("[prisoner] strikes a match and eyes [target].")) playsound(prisoner, 'sound/items/match_strike.ogg', 60, TRUE) mark_turfs(patch, "#ff5a1f", "target_circle") prisoner.say_context("boss_move_fire") -/datum/prison_boss_move/bed_fire/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bed_fire/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/obj/structure/bed/bunk = target var/list/burning = patch patch = null @@ -618,7 +621,7 @@ * The way out of the cell block he is breaking now, beside him, once the riot's blows have been at it * (note_exit_attacked()): a staff door, a serving hatch not yet open, or a window. Never anything else. */ -/datum/prison_boss_move/rigged_charge/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/rigged_charge/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison var/obj/target = prisoner.riot_target_ref?.resolve() if(!isobj(target) || !prison.is_exit_blocker(target) || !prisoner.within_reach(target)) @@ -630,13 +633,13 @@ return null return target -/datum/prison_boss_move/rigged_charge/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/rigged_charge/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) prisoner.visible_message(span_boldwarning("[prisoner] crouches by [target] and packs something against it.")) playsound(prisoner, 'sound/machines/click.ogg', 60, TRUE) mark_turfs(list(get_turf(target)), COLOR_RED) prisoner.say_context("boss_move_rig") -/datum/prison_boss_move/rigged_charge/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/rigged_charge/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/datum/outpost_prison/prison = prisoner.prison if(QDELETED(target) || !prison.is_exit_blocker(target)) return FALSE @@ -746,48 +749,56 @@ /// world.time they were first seen cuffed this time, 0 while they are not var/cuffed_at = 0 -/datum/prison_boss_move/slip_cuffs/observe(mob/living/basic/outpost_prisoner/prisoner) - if(!prisoner.cuffs) +/datum/prison_boss_move/slip_cuffs/observe(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + if(!prisoner.handcuffed) cuffed_at = 0 else if(!cuffed_at) cuffed_at = world.time /// Their cuffs, once they have worn them PRISON_BOSS_SLIP_DELAY, with staff in sight to get away from -/datum/prison_boss_move/slip_cuffs/find_target(mob/living/basic/outpost_prisoner/prisoner) - if(!prisoner.cuffs || !cuffed_at || world.time - cuffed_at < PRISON_BOSS_SLIP_DELAY) +/datum/prison_boss_move/slip_cuffs/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + if(!prisoner.handcuffed || !cuffed_at || world.time - cuffed_at < PRISON_BOSS_SLIP_DELAY) return null if(!prisoner.prison.boss_nearest_staff(prisoner)) return null - return prisoner.cuffs + return prisoner.handcuffed -/datum/prison_boss_move/slip_cuffs/may_finish(mob/living/basic/outpost_prisoner/prisoner) - return ..() && prisoner.cuffs == pending_target?.resolve() +/datum/prison_boss_move/slip_cuffs/may_finish(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + return ..() && prisoner.handcuffed == pending_target?.resolve() -/datum/prison_boss_move/slip_cuffs/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/slip_cuffs/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) prisoner.visible_message(span_boldwarning("[prisoner] starts twisting at [target], working a hand loose.")) playsound(prisoner, 'sound/items/weapons/handcuffs.ogg', 40, TRUE) prisoner.Shake(1, 0, windup) prisoner.say_context("boss_move_slip") -/datum/prison_boss_move/slip_cuffs/interrupted(mob/living/basic/outpost_prisoner/prisoner) - if(prisoner?.stat == CONSCIOUS && prisoner.cuffs) - prisoner.visible_message(span_notice("[prisoner] loses [prisoner.p_their()] grip on [prisoner.cuffs].")) +/datum/prison_boss_move/slip_cuffs/interrupted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + if(prisoner?.stat == CONSCIOUS && prisoner.handcuffed) + prisoner.visible_message(span_notice("[prisoner] loses [prisoner.p_their()] grip on [prisoner.handcuffed].")) -/datum/prison_boss_move/slip_cuffs/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) - var/obj/item/restraints/handcuffs/worn = prisoner.cuffs +/** + * After the telegraph, they work at the cuffs the way anyone does (tg's cuff_resist(): the cuffs' own + * breakout time, broken by being moved or knocked down). Out of them, they slip away in a shimmer. + */ +/datum/prison_boss_move/slip_cuffs/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) + var/obj/item/restraints/handcuffs/worn = prisoner.handcuffed if(!worn || worn != target) return FALSE - // Without a user they drop where they stand: the same cuffs, on the floor - prisoner.remove_cuffs() + INVOKE_ASYNC(src, PROC_REF(work_free), prisoner, worn) + return TRUE + +/datum/prison_boss_move/slip_cuffs/proc/work_free(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/restraints/handcuffs/worn) + prisoner.cuff_resist(worn) + if(QDELETED(prisoner) || prisoner.handcuffed == worn || prisoner.stat != CONSCIOUS) + return prisoner.visible_message(span_danger("[prisoner] slips out of [worn]!")) playsound(prisoner, 'sound/effects/magic/smoke.ogg', 40, TRUE) prisoner.apply_status_effect(/datum/status_effect/prison_boss_shimmer) - return TRUE /** - * The shimmer after a Ghost slips their cuffs, as the field cloak: faint and blurred for - * PRISON_BOSS_SHIMMER_TIME, and PRISON_BOSS_SHIMMER_MISS percent of blows, throws and shots miss. A hit - * that lands or a flash ends it. + * The shimmer after a Ghost slips their cuffs: faint and blurred for PRISON_BOSS_SHIMMER_TIME, a look + * and nothing more. A Ghost's dodging is their real chrome (a Slipwire shunt, outpost_prison_bounty.dm; + * owner ruling OC-6), which works with the shimmer or without it. A hit that lands or a flash ends it. */ /datum/status_effect/prison_boss_shimmer id = "prison_boss_shimmer" @@ -797,48 +808,24 @@ alert_type = null /// Broken by a hit or a flash, which says so itself var/broken = FALSE - /// The miss message shows at most every second, so a burst of fire doesn't flood chat - COOLDOWN_DECLARE(miss_message_cooldown) /datum/status_effect/prison_boss_shimmer/on_apply() if(owner.stat != CONSCIOUS) return FALSE animate(owner, alpha = PRISON_BOSS_SHIMMER_ALPHA, time = 0.3 SECONDS) owner.add_filter("prison_boss_shimmer", 2, list("type" = "blur", "size" = 1)) - RegisterSignal(owner, COMSIG_LIVING_CHECK_BLOCK, PROC_REF(on_check_block)) - RegisterSignal(owner, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_prehit)) RegisterSignal(owner, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) RegisterSignal(owner, COMSIG_MOB_FLASHED, PROC_REF(on_flashed)) owner.visible_message(span_warning("[owner] fades into a faint shimmer!")) return TRUE /datum/status_effect/prison_boss_shimmer/on_remove() - UnregisterSignal(owner, list(COMSIG_LIVING_CHECK_BLOCK, COMSIG_PROJECTILE_PREHIT, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_MOB_FLASHED)) + UnregisterSignal(owner, list(COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_MOB_FLASHED)) animate(owner, alpha = 255, time = 0.3 SECONDS) owner.remove_filter("prison_boss_shimmer") if(!broken && owner.stat != DEAD) owner.visible_message(span_notice("The shimmer around [owner] fades.")) -/datum/status_effect/prison_boss_shimmer/proc/miss_message(message) - if(!COOLDOWN_FINISHED(src, miss_message_cooldown)) - return - COOLDOWN_START(src, miss_message_cooldown, 1 SECONDS) - owner.visible_message(message) - -/datum/status_effect/prison_boss_shimmer/proc/on_check_block(mob/living/source, atom/hit_by, damage, attack_text, attack_type, armour_penetration, damage_type) - SIGNAL_HANDLER - if(owner.stat != CONSCIOUS || !prob(PRISON_BOSS_SHIMMER_MISS)) - return NONE - miss_message(span_warning("[attack_text] passes through the shimmer where [owner] was!")) - return SUCCESSFUL_BLOCK - -/datum/status_effect/prison_boss_shimmer/proc/on_prehit(mob/living/source, obj/projectile/shot) - SIGNAL_HANDLER - if(shot.firer == owner || owner.stat != CONSCIOUS || !prob(PRISON_BOSS_SHIMMER_MISS)) - return NONE - miss_message(span_warning("[shot] passes through the shimmer around [owner]!")) - return PROJECTILE_INTERRUPT_HIT_PHASE - /datum/status_effect/prison_boss_shimmer/proc/on_damaged(mob/living/source, damage, damagetype) SIGNAL_HANDLER if(damage <= 0 || broken) @@ -879,7 +866,7 @@ * The nearest table beside him that tg lets be flipped, to go over toward the nearest staff in sight; * failing that, a locker beside him to shove into the doorway of a staff door staff are coming through. */ -/datum/prison_boss_move/barricade/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/barricade/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison var/mob/living/nearest = prison.boss_staff_about(prisoner, PRISON_BOSS_BARRICADE_SIGHT) if(!nearest) @@ -905,7 +892,7 @@ return null /// The nearest awake staff in sight within `range` anywhere in the wing, the office included: who a barricade is against -/datum/outpost_prison/proc/boss_staff_about(mob/living/basic/outpost_prisoner/prisoner, range) +/datum/outpost_prison/proc/boss_staff_about(mob/living/carbon/human/npc/outpost_prisoner/prisoner, range) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(range, prisoner)) @@ -941,7 +928,7 @@ return FALSE /// The tile straight in front of the staff door on `door_tile`, on the yard side, that `prisoner` can walk and nothing stands on, or null -/datum/outpost_prison/proc/boss_doorway_front(turf/door_tile, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/boss_doorway_front(turf/door_tile, mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/direction in GLOB.cardinals) var/turf/front = get_step(door_tile, direction) if(!front || !cell_block[front] || isclosedturf(front) || !prisoner.walkable?[front] || front.is_blocked_turf()) @@ -950,7 +937,7 @@ return null /// Whether a heavy may shove `locker`: a shut locker or crate, loose on the floor, in the cell block where `prisoner` can get at it, and not outpost property -/datum/outpost_prison/proc/boss_locker_shovable(obj/structure/closet/locker, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/boss_locker_shovable(obj/structure/closet/locker, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(locker) || locker.anchored || !locker.density || !isturf(locker.loc)) return FALSE if((locker.resistance_flags & INDESTRUCTIBLE) || HAS_TRAIT(locker, TRAIT_OUTPOST_PROPERTY)) @@ -961,7 +948,7 @@ /datum/outpost_prison/proc/boss_shove_tile_ok(turf/spot) return spot && cell_block[spot] && !isclosedturf(spot) && prisoner_can_stand(spot) && !spot.is_blocked_turf() -/datum/prison_boss_move/barricade/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/barricade/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) if(shove_to) shove_ref = WEAKREF(target) prisoner.visible_message(span_boldwarning("[prisoner] puts [prisoner.p_their()] shoulder to [target].")) @@ -973,7 +960,7 @@ mark_turfs(list(get_turf(target)), "#c0c0c0") prisoner.say_context("boss_move_barricade") -/datum/prison_boss_move/barricade/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/barricade/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/datum/outpost_prison/prison = prisoner.prison if(shove_to) var/obj/structure/closet/locker = target @@ -1000,7 +987,7 @@ * It stops in the doorway, at anything or anyone in the way, or when the heavy can't follow. */ /datum/prison_boss_move/barricade/proc/shove_step(serial) - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() var/datum/outpost_prison/prison = prisoner?.prison var/obj/structure/closet/locker = shove_ref?.resolve() if(!serial || serial != shove_serial || !prison || !prison.boss_riot_ok(prisoner) || QDELETED(locker) || !shove_to) @@ -1042,9 +1029,9 @@ boss_riot_serial++ if(forced_riot || !crew_home()) return joining - var/mob/living/basic/outpost_prisoner/kingpin - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in joining) - if(prisoner.bounty_record?.archetype != BOUNTY_ARCHETYPE_KINGPIN || prisoner.stat != CONSCIOUS || prisoner.cuffs) + var/mob/living/carbon/human/npc/outpost_prisoner/kingpin + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in joining) + if(prisoner.bounty_record?.archetype != BOUNTY_ARCHETYPE_KINGPIN || prisoner.stat != CONSCIOUS || prisoner.handcuffed) continue if(!in_cell_block(prisoner) || prisoner.is_confined() || !prisoner.ai_running()) continue @@ -1053,7 +1040,7 @@ if(!kingpin) return joining var/list/swayed = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if((prisoner in joining) || !prisoner.can_join_riot()) continue if(prisoner.mood < prisoner.riot_join_mood() + PRISON_BOSS_WORD_BONUS) @@ -1070,11 +1057,11 @@ once_per_riot = TRUE /// The nearest of the wing's own NPC guards in sight and on duty, never a player -/datum/prison_boss_move/bribe/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/bribe/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison - var/mob/living/basic/outpost_prison_guard/best + var/mob/living/carbon/human/npc/outpost_prison_guard/best var/best_distance = INFINITY - for(var/mob/living/basic/outpost_prison_guard/guard in view(PRISON_BOSS_BRIBE_RANGE, prisoner)) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in view(PRISON_BOSS_BRIBE_RANGE, prisoner)) var/distance = get_dist(prisoner, guard) if(distance < best_distance && prison.boss_bribable(guard)) best = guard @@ -1082,20 +1069,20 @@ return best /// Whether the kingpin may pay off `guard`: one of this wing's guards, on duty, not already paid, and nobody's playing them -/datum/outpost_prison/proc/boss_bribable(mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/boss_bribable(mob/living/carbon/human/npc/outpost_prison_guard/guard) if(!istype(guard) || QDELETED(guard) || guard.prison != src || !guard.on_duty()) return FALSE return isnull(guard.mind) && isnull(guard.client) -/datum/prison_boss_move/bribe/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bribe/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) prisoner.visible_message(span_boldwarning("[prisoner] catches [target]'s eye and taps [prisoner.p_their()] pocket.")) playsound(prisoner, 'sound/items/coinflip.ogg', 50, TRUE) mark_turfs(list(get_turf(target)), "#e0c050", "target_circle") prisoner.say_context("boss_move_bribe") -/datum/prison_boss_move/bribe/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bribe/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/datum/outpost_prison/prison = prisoner.prison - var/mob/living/basic/outpost_prison_guard/guard = target + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = target if(!prison.boss_bribable(guard) || get_dist(prisoner, guard) > PRISON_BOSS_BRIBE_RANGE || !(guard in view(PRISON_BOSS_BRIBE_RANGE, prisoner))) return FALSE prison.boss_bribe_guard(guard) @@ -1106,7 +1093,7 @@ * no riot response, no baton, and rioters leave them be), standing back in the office's far corner. * boss_bribes_tick() sends them back to work. */ -/datum/outpost_prison/proc/boss_bribe_guard(mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/boss_bribe_guard(mob/living/carbon/human/npc/outpost_prison_guard/guard) guard.prison_bribed_until = world.time + PRISON_BOSS_BRIBE_TIME guard.end_response() guard.set_baton(FALSE) @@ -1121,7 +1108,7 @@ /// Paid-off guards whose time is up go back to work /datum/outpost_prison/proc/boss_bribes_tick() for(var/datum/weakref/ref as anything in boss_bribed_guards.Copy()) - var/mob/living/basic/outpost_prison_guard/guard = ref.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = ref.resolve() if(QDELETED(guard)) boss_bribed_guards -= ref continue @@ -1134,7 +1121,7 @@ guard.visible_message(span_notice("[guard] squares [guard.p_their()] shoulders and gets back to work.")) /// Whether the kingpin has them standing back from the riot (on_duty()) -/mob/living/basic/outpost_prison_guard/proc/boss_bribed() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/boss_bribed() return prison_bribed_until > world.time /** diff --git a/voidcrew/modules/player_outposts/outpost_prison_bounty.dm b/voidcrew/modules/player_outposts/outpost_prison_bounty.dm index e631f2e1154..3c62ec816ee 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_bounty.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_bounty.dm @@ -81,7 +81,7 @@ /// For tests: whether a meek prisoner watching the staff door darts when it opens (TRUE or FALSE) instead of rolling for it var/prison_bounty_forced_dart -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Running after a bounty prisoner called out someone off the ship that caught them (bounty_extra_speech()) COOLDOWN_DECLARE(prison_captor_line_cooldown) @@ -333,7 +333,7 @@ GLOBAL_VAR(bounty_pool_timer) /// Living bounty prisoners on the roster who are not leaving, of `tier` when given /datum/outpost_prison/proc/bounty_prisoner_count(tier) . = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.bounty_record || prisoner.stat == DEAD || prisoner.phase == PRISONER_LEAVING) continue if(tier && prisoner.bounty_record.tier != tier) @@ -445,7 +445,7 @@ GLOBAL_VAR(bounty_pool_timer) continue bounty_pool_remove(record) record.status = BOUNTY_RECORD_IMPRISONED - var/mob/living/basic/outpost_prisoner/prisoner = new(spot, record) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = new(spot, record) admit(prisoner, cell) prisoner.beam_in() start_arrival_gap(soonest_lane()) @@ -456,17 +456,17 @@ GLOBAL_VAR(bounty_pool_timer) // ===== A PRISONER FROM A RECORD ===== /** - * Before the prisoner's Initialize() calls ..(): the body their record says (gender, and health by - * tier), so tg sizes max_stamina from the right maxHealth. A record still in the pool leaves it now, - * before they exist. + * Before the prisoner's Initialize() calls ..(): a record still in the pool leaves it now, before they + * exist, and a tougher criminal takes less from a blow (npc_damage_taken by tier, through physiology, + * defaults PR-16). Their face and gender come from the record through the human NPC base. */ -/mob/living/basic/outpost_prisoner/proc/apply_bounty_body(datum/bounty_record/record) +/mob/living/carbon/human/npc/outpost_prisoner/proc/apply_bounty_body(datum/bounty_record/record) if(record in GLOB.bounty_prisoner_pool) bounty_pool_remove(record) - if(record.gender == MALE || record.gender == FEMALE) - gender = record.gender - maxHealth = bounty_by_tier(record.tier, BOUNTY_PRISONER_HEALTH_PETTY, BOUNTY_PRISONER_HEALTH_WANTED, BOUNTY_PRISONER_HEALTH_MOST_WANTED) - health = maxHealth + npc_damage_taken = bounty_by_tier(record.tier, BOUNTY_PRISONER_DAMAGE_TAKEN_PETTY, BOUNTY_PRISONER_DAMAGE_TAKEN_WANTED, BOUNTY_PRISONER_DAMAGE_TAKEN_MOST_WANTED) + // The Ghost's dodging is real chrome (owner ruling OC-6): a Slipwire shunt, which burns out to scrap if they die. + if(record.archetype == BOUNTY_ARCHETYPE_BOSS && record.kit == BOUNTY_KIT_GHOST) + npc_cyberware = list(/obj/item/organ/cyberimp/cyberware/slipwire) /** * At the end of the prisoner's Initialize(), before their look is built: who their record says @@ -478,12 +478,11 @@ GLOBAL_VAR(bounty_pool_timer) * yard hears BOUNTY_PRISON_MOST_WANTED_YARD_CRIME, as a Most Wanted criminal never names theirs in * its own lines (BUG-12); the console and examine still read the record's. */ -/mob/living/basic/outpost_prisoner/proc/apply_bounty_record(datum/bounty_record/record) +/mob/living/carbon/human/npc/outpost_prisoner/proc/apply_bounty_record(datum/bounty_record/record) if(record.gender == MALE || record.gender == FEMALE) gender = record.gender if(length(record.name)) - real_name = record.name - name = real_name + fully_replace_character_name(real_name, record.name) var/plain_crime = bounty_plain_crime(record.crime) if(record.tier == BOUNTY_TIER_MOST_WANTED) crime = BOUNTY_PRISON_MOST_WANTED_YARD_CRIME @@ -492,9 +491,8 @@ GLOBAL_VAR(bounty_pool_timer) var/list/personalities = bounty_archetype_personalities(record.archetype) if(length(personalities)) personality = pick(personalities) - // The plain jumpsuit: the glasses and beanie variants would hide their hair or fake their eyewear - outfit_path = /datum/outfit/outpost_prisoner - // As hurt as they were caught, but never below BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH percent + // As hurt as they were caught, but never below BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH percent: real + // damage spread over their limbs when they beam in (beam_in()) arrival_brute = round(maxHealth * clamp(record.hurt_fraction || 0, 0, 1 - BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH / 100)) record.status = BOUNTY_RECORD_IMPRISONED @@ -535,7 +533,7 @@ GLOBAL_VAR(bounty_pool_timer) * nothing (a release is followed by forget(), for one). A closed record never returns to the pool * (bounty_pool_add() refuses it). Returns TRUE if this call closed it. */ -/proc/close_bounty_record(mob/living/basic/outpost_prisoner/prisoner, status) +/proc/close_bounty_record(mob/living/carbon/human/npc/outpost_prisoner/prisoner, status) var/datum/bounty_record/record = prisoner?.bounty_record if(!record || record.status != BOUNTY_RECORD_IMPRISONED) return FALSE @@ -544,13 +542,13 @@ GLOBAL_VAR(bounty_pool_timer) return TRUE /// Why Kessler's experiments refuse `prisoner`, or null: a bounty prisoner's identity would be lost -/proc/bounty_experiment_refusal(mob/living/basic/outpost_prisoner/prisoner) +/proc/bounty_experiment_refusal(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner?.bounty_record ? "Kessler won't touch a bounty prisoner." : null // ===== PAY ===== /// What `prisoner` earns the treasury (stipend, pay rate and release bonus) as a multiple of an ordinary prisoner's: x1.5, x2 or x2.5 by tier -/datum/outpost_prison/proc/bounty_pay_mult(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/bounty_pay_mult(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/bounty_record/record = prisoner?.bounty_record if(!record) return 1 @@ -558,54 +556,40 @@ GLOBAL_VAR(bounty_pool_timer) // ===== DANGER ===== -/// Multiplier on their punches and shiv blows (update_melee() and strike()), by tier and by what they hit with -/mob/living/basic/outpost_prisoner/proc/bounty_damage_mult() - if(!bounty_record) - return 1 - if(has_shiv()) - return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_SHIV_MULT_PETTY, BOUNTY_PRISON_SHIV_MULT_WANTED, BOUNTY_PRISON_SHIV_MULT_MOST_WANTED) - return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_PUNCH_MULT_PETTY, BOUNTY_PRISON_PUNCH_MULT_WANTED, BOUNTY_PRISON_PUNCH_MULT_MOST_WANTED) - /// Multiplier on their mood losses (mood_scale()) -/mob/living/basic/outpost_prisoner/proc/bounty_mood_scale() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_mood_scale() if(!bounty_record) return 1 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_MOOD_SCALE_PETTY, BOUNTY_PRISON_MOOD_SCALE_WANTED, BOUNTY_PRISON_MOOD_SCALE_MOST_WANTED) /// Mood points added to the line below which they square up to staff (threat_mood_for() and threat_mood_ceiling()) -/mob/living/basic/outpost_prisoner/proc/bounty_threat_bonus() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_threat_bonus() if(!bounty_record) return 0 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_THREAT_BONUS_PETTY, BOUNTY_PRISON_THREAT_BONUS_WANTED, BOUNTY_PRISON_THREAT_BONUS_MOST_WANTED) /// Mood points added to the line below which they join a riot (riot_join_bonus()) -/mob/living/basic/outpost_prisoner/proc/bounty_riot_bonus() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_riot_bonus() if(!bounty_record) return 0 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_RIOT_BONUS_PETTY, BOUNTY_PRISON_RIOT_BONUS_WANTED, BOUNTY_PRISON_RIOT_BONUS_MOST_WANTED) /// Multiplier on the damage their blows do to the wing's ways out (hit_exit()) -/mob/living/basic/outpost_prisoner/proc/bounty_breakout_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_breakout_mult() if(!bounty_record) return 1 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_BREAKOUT_MULT_PETTY, BOUNTY_PRISON_BREAKOUT_MULT_WANTED, BOUNTY_PRISON_BREAKOUT_MULT_MOST_WANTED) -/// Multiplier on how long cuffing them takes (cuff_time()) -/mob/living/basic/outpost_prisoner/proc/bounty_cuff_mult() - if(!bounty_record) - return 1 - return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_CUFF_MULT_PETTY, BOUNTY_PRISON_CUFF_MULT_WANTED, BOUNTY_PRISON_CUFF_MULT_MOST_WANTED) - /// Whether they were a meek criminal: they back off, go over hatches and slip out of doors -/mob/living/basic/outpost_prisoner/proc/bounty_is_meek() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_is_meek() return bounty_record?.archetype == BOUNTY_ARCHETYPE_MEEK /// Multiplier on how likely they are to back off when a player hits them: twice as likely for a meek bounty prisoner (hit_reaction(), through bounty_hit_reaction_weights()) -/mob/living/basic/outpost_prisoner/proc/bounty_back_off_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_back_off_mult() return bounty_is_meek() ? BOUNTY_PRISON_MEEK_BACK_OFF_MULT : 1 /// Multiplier on how likely they are to hit back when a player hits them: a Wanted or Most Wanted prisoner who is not meek hits back like a grumpy one (hit_reaction(), through bounty_hit_reaction_weights()) -/mob/living/basic/outpost_prisoner/proc/bounty_hit_back_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_hit_back_mult() if(!bounty_record || bounty_is_meek()) return 1 return bounty_by_tier(bounty_record.tier, 1, BOUNTY_PRISON_HIT_BACK_MULT_WANTED, BOUNTY_PRISON_HIT_BACK_MULT_MOST_WANTED) @@ -614,38 +598,30 @@ GLOBAL_VAR(bounty_pool_timer) * A calm prisoner's hit-back and back-off weights (outpost_prisoner_hit_reaction_weights()'s list, * which is theirs to change), scaled for a bounty prisoner. Returns the same list. hit_reaction() rolls on it. */ -/mob/living/basic/outpost_prisoner/proc/bounty_hit_reaction_weights(list/weights) +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_hit_reaction_weights(list/weights) if(!bounty_record || !islist(weights)) return weights weights[PRISONER_HIT_FIGHT] = max(1, round(weights[PRISONER_HIT_FIGHT] * bounty_hit_back_mult())) weights[PRISONER_HIT_COWER] = max(1, round(weights[PRISONER_HIT_COWER] * bounty_back_off_mult())) return weights -/// Their speed while loose, or null for the usual: a meek bounty prisoner runs faster than the others (go_loose()) -/mob/living/basic/outpost_prisoner/proc/bounty_loose_speed() - return bounty_is_meek() ? BOUNTY_PRISON_MEEK_LOOSE_SPEED : null - -/// The speed they go back to when not running from anything: a loose meek bounty prisoner keeps their loose speed (update_flee_speed(), back_in_custody()) -/mob/living/basic/outpost_prisoner/proc/bounty_base_speed() - if(trouble == PRISONER_TROUBLE_LOOSE) - var/loose_speed = bounty_loose_speed() - if(loose_speed) - return loose_speed - return initial(speed) +/// Whether they run rather than walk when not running from anything: a runner runs (go_loose(), update_flee_speed(), back_in_custody()) +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_base_speed() + return trouble == PRISONER_TROUBLE_LOOSE /// How long going over a serving hatch takes them, or null for the usual: a meek bounty prisoner is quicker (start_climb()) -/mob/living/basic/outpost_prisoner/proc/bounty_climb_time() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_climb_time() return bounty_is_meek() ? BOUNTY_PRISON_MEEK_CLIMB_TIME : null /// Multiplier on their weight as the wildcard "snap" prisoner: a Most Wanted ringleader's is doubled (pick_snapper()) -/mob/living/basic/outpost_prisoner/proc/bounty_snap_weight_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_snap_weight_mult() return bounty_record?.tier == BOUNTY_TIER_MOST_WANTED ? BOUNTY_PRISON_RINGLEADER_SNAP_MULT : 1 /// `rioters` with any Most Wanted first, so a ringleader is among the first to shout when a riot starts (start_riot()). Returns a new list. /proc/bounty_ringleaders_first(list/rioters) var/list/leaders = list() var/list/others = list() - for(var/mob/living/basic/outpost_prisoner/rioter as anything in rioters) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter as anything in rioters) if(rioter.bounty_record?.tier == BOUNTY_TIER_MOST_WANTED) leaders += rioter else @@ -654,10 +630,10 @@ GLOBAL_VAR(bounty_pool_timer) /// An awake, uncuffed Most Wanted prisoner out in the yard, not shut in a cell, who stirs the others up, or null /datum/outpost_prison/proc/bounty_ringleader() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.bounty_record?.tier != BOUNTY_TIER_MOST_WANTED || !counts_for_tension(prisoner)) continue - if(prisoner.stat != CONSCIOUS || prisoner.cuffs || !in_cell_block(prisoner) || prisoner.is_confined()) + if(prisoner.stat != CONSCIOUS || prisoner.handcuffed || !in_cell_block(prisoner) || prisoner.is_confined()) continue return prisoner return null @@ -670,7 +646,7 @@ GLOBAL_VAR(bounty_pool_timer) /datum/outpost_prison/proc/bounty_restless_causes() if(BOUNTY_PRISON_RINGLEADER_TENSION <= 0) return list() - var/mob/living/basic/outpost_prisoner/ringleader = bounty_ringleader() + var/mob/living/carbon/human/npc/outpost_prisoner/ringleader = bounty_ringleader() return ringleader ? list("[ringleader.real_name] stirring them up") : list() // ===== MEEK BOUNTY PRISONERS: HATCHES AND DOORS ===== @@ -906,7 +882,7 @@ GLOBAL_VAR(bounty_pool_timer) return best /// Whether they are a meek bounty prisoner in a mood to watch the staff door for their chance, with a member of the wing home (nothing new starts in an empty wing) -/mob/living/basic/outpost_prisoner/proc/bounty_may_watch_door() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_may_watch_door() return bounty_is_meek() && mood < BOUNTY_PRISON_MEEK_DART_MOOD && !!prison?.trouble_enabled && prison.crew_home() // ===== CONSOLES ===== @@ -916,7 +892,7 @@ GLOBAL_VAR(bounty_pool_timer) * "wanted for" line and whether they are Most Wanted, the one tier a wanted poster names. Petty and * Wanted show nothing more, and no pay multiplier. */ -/datum/outpost_prison/proc/bounty_roster_badge(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/bounty_roster_badge(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/bounty_record/record = prisoner?.bounty_record if(!record) return null @@ -932,7 +908,7 @@ GLOBAL_VAR(bounty_pool_timer) * since the yard's "In for ..." leaves it out (BUG-12): "Most Wanted, for smuggling. The crew of the * Meridian brought him in." */ -/datum/outpost_prison/proc/bounty_examine(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/bounty_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) var/datum/bounty_record/record = prisoner?.bounty_record if(!record) return null @@ -955,7 +931,7 @@ GLOBAL_VAR(bounty_pool_timer) * Values for their lines that name the ship that caught them, list("{place}" = "Meridian"), or null * for an ordinary prisoner or a record with no captor. The lines say "the {place}". */ -/mob/living/basic/outpost_prisoner/proc/bounty_captor_values() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_captor_values() if(!length(bounty_record?.captor_name)) return null return list("{place}" = bounty_record.captor_name) @@ -965,12 +941,12 @@ GLOBAL_VAR(bounty_pool_timer) * the Meridian crew sends people."), in place of the usual hello. Returns TRUE if they said it. * finish_beam_in() calls it before its own arrival lines. */ -/mob/living/basic/outpost_prisoner/proc/bounty_arrival_speech() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_arrival_speech() var/list/values = bounty_captor_values() return values ? say_context_with("bounty_arrival", values) : FALSE /// Someone awake off the ship that caught them, within BOUNTY_PRISON_CAPTOR_VIEW tiles and in sight, or null -/mob/living/basic/outpost_prisoner/proc/bounty_captor_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_captor_in_view() var/obj/structure/overmap/ship/captor_ship = bounty_record?.captor_ship?.resolve() if(QDELETED(captor_ship)) return null @@ -985,7 +961,7 @@ GLOBAL_VAR(bounty_pool_timer) * Rolled at `chance` percent, and at most once every BOUNTY_PRISON_CAPTOR_LINE_GAP each; the sight * check runs only once the roll comes up. Returns list(context, null, values), or null. */ -/datum/outpost_prison/proc/bounty_extra_speech(mob/living/basic/outpost_prisoner/prisoner, chance = BOUNTY_PRISON_CAPTOR_LINE_CHANCE) +/datum/outpost_prison/proc/bounty_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner, chance = BOUNTY_PRISON_CAPTOR_LINE_CHANCE) var/list/values = prisoner?.bounty_captor_values() if(!values || !COOLDOWN_FINISHED(prisoner, prison_captor_line_cooldown) || !prob(chance)) return null @@ -1079,7 +1055,7 @@ GLOBAL_VAR(bounty_pool_timer) var/datum/bounty_record/record = bounty_pool_find(params?["id"]) if(!record) return list("error" = "That record is no longer in the pool.") - var/mob/living/basic/outpost_prisoner/arrival = bounty_admit_record(record) + var/mob/living/carbon/human/npc/outpost_prisoner/arrival = bounty_admit_record(record) if(!arrival) return list("error" = "No free cell.") return "admit bounty prisoner [arrival.real_name] ([bounty_tier_name(record.tier)]) into cell [arrival.cell?.number]" diff --git a/voidcrew/modules/player_outposts/outpost_prison_breach.dm b/voidcrew/modules/player_outposts/outpost_prison_breach.dm new file mode 100644 index 00000000000..5c4fd7fcdd7 --- /dev/null +++ b/voidcrew/modules/player_outposts/outpost_prison_breach.dm @@ -0,0 +1,198 @@ +/** + * # Prison breaches: emergency air + * + * Prisoners breathe like anyone (they are real human bodies): a breach, a fire or a plasma leak in the + * cell block suffocates, freezes and burns them the way it does the crew. Owner ruling Q3: the cell + * block has emergency lockers of breath masks and air tanks, and a prisoner on bad air who isn't busy + * making trouble goes and gets one, puts the mask on, and breathes from the tank (the human NPC base + * opens a worn tank while the air is bad). The wing gets one locker per PRISON_EMERGENCY_LOCKER_TILES + * floor tiles of its cell block when the corrections service lays it out, against a yard wall. + * + * Nothing here heals or shields anyone: a prisoner who can't reach a locker, or finds it emptied, + * suffocates. + */ + +/// Floor tiles of cell block per emergency locker +#define PRISON_EMERGENCY_LOCKER_TILES 60 +/// How often a prisoner on bad air may try again for a locker after failing to get to one +#define PRISON_EMERGENCY_RETRY (15 SECONDS) + +/// The cell block's emergency locker: breath masks and air tanks, the same every time +/obj/structure/closet/emcloset/outpost_prison + name = "emergency locker" + desc = "Breath masks and air tanks for the cell block, for when the air goes bad." + anchored = TRUE + +/obj/structure/closet/emcloset/outpost_prison/Initialize(mapload) + . = ..() + AddElement(/datum/element/outpost_property) + +// Stocked when it is placed, not on first opening (tg's closets fill lazily), so a prisoner can tell from +// the yard which locker still has air in it (emergency_locker_for()). +/obj/structure/closet/emcloset/outpost_prison/populate_contents_immediate() + . = ..() + for(var/i in 1 to 4) + new /obj/item/clothing/mask/breath(src) + new /obj/item/tank/internals/emergency_oxygen(src) + +/obj/structure/closet/emcloset/outpost_prison/PopulateContents() + return + +/datum/outpost_prison + /// The emergency lockers the wing's cell block has (weakrefs) + var/list/emergency_lockers = list() + +/** + * Makes sure the cell block has its emergency lockers: one per PRISON_EMERGENCY_LOCKER_TILES of floor, + * counting any already standing there (mapped or placed), each new one against a yard wall away from the + * others. Called when the cell block is worked out. + */ +/datum/outpost_prison/proc/ensure_emergency_lockers() + if(!length(cell_block)) + return + var/list/standing = list() + var/floor = 0 + for(var/turf/tile as anything in cell_block) + if(isclosedturf(tile)) + continue + floor++ + for(var/obj/structure/closet/emcloset/locker in tile) + standing += locker + emergency_lockers.Cut() + for(var/obj/structure/closet/emcloset/locker as anything in standing) + emergency_lockers += WEAKREF(locker) + var/wanted = max(1, round(floor / PRISON_EMERGENCY_LOCKER_TILES)) + while(length(standing) < wanted) + var/turf/spot = emergency_locker_spot(standing) + if(!spot) + return + var/obj/structure/closet/emcloset/outpost_prison/locker = new(spot) + standing += locker + emergency_lockers += WEAKREF(locker) + +/// A yard tile against a wall, in no cell, with nothing on it and no door beside it, furthest from `others` +/datum/outpost_prison/proc/emergency_locker_spot(list/others) + var/turf/best + var/best_distance = -1 + for(var/turf/open/tile in cell_block) + if(cell_at(tile) || tile.is_blocked_turf(TRUE) || length(tile.contents)) + continue + var/against_wall = FALSE + var/by_door = FALSE + for(var/direction in GLOB.cardinals) + var/turf/beside = get_step(tile, direction) + if(isclosedturf(beside)) + against_wall = TRUE + if(locate(/obj/machinery/door) in beside) + by_door = TRUE + if(!against_wall || by_door) + continue + var/nearest = INFINITY + for(var/atom/other as anything in others) + nearest = min(nearest, get_dist(other, tile)) + if(nearest > best_distance) + best = tile + best_distance = nearest + return best + +/// The nearest emergency locker `prisoner` can walk to that still has a mask and a tank in it +/datum/outpost_prison/proc/emergency_locker_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/obj/structure/closet/best + var/best_distance = INFINITY + for(var/datum/weakref/locker_ref as anything in emergency_lockers) + var/obj/structure/closet/locker = locker_ref.resolve() + if(QDELETED(locker) || !prisoner.approach_turf(locker)) + continue + // A locker nobody has opened yet (tg fills them on first opening) is worth a look. + if(locker.contents_initialized || locker.opened) + var/atom/inside = locker.opened ? get_turf(locker) : locker + if(!(locate(/obj/item/clothing/mask/breath) in inside) && !(locate(/obj/item/clothing/mask/gas) in inside)) + continue + if(!(locate(/obj/item/tank/internals) in inside)) + continue + var/distance = get_dist(prisoner, locker) + if(distance < best_distance) + best = locker + best_distance = distance + return best + +/mob/living/carbon/human/npc/outpost_prisoner + /// world.time they may next go for an emergency locker + var/next_emergency_try = 0 + +/// Whether they wear a mask they could breathe a tank through, and carry a tank +/mob/living/carbon/human/npc/outpost_prisoner/proc/has_emergency_air() + if(!can_breathe_internals()) + return FALSE + for(var/obj/item/tank/internals/tank in get_equipped_items(INCLUDE_POCKETS | INCLUDE_HELD)) + return TRUE + return FALSE + +/** + * The air where they stand is bad (air_tick()). Wearing a mask and a tank, they breathe from it. + * Otherwise, unless they are busy with trouble, they go for the nearest emergency locker. + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_bad_air() + if(stat != CONSCIOUS) + return + if(has_emergency_air()) + if(!internal) + npc_open_internals() + return + if(world.time < next_emergency_try || istype(activity, /datum/prisoner_activity/emergency_air) || !routine_allowed()) + return + var/datum/prisoner_activity/emergency_air/dash = new(src) + if(!dash.setup()) + qdel(dash) + next_emergency_try = world.time + PRISON_EMERGENCY_RETRY + return + start_activity(dash) + +/// To the emergency locker: open it, take a mask and a tank, put them on and breathe +/datum/prisoner_activity/emergency_air + name = "getting emergency air" + interruptible = FALSE + min_duration = 3 SECONDS + max_duration = 3 SECONDS + var/datum/weakref/locker_ref + +/datum/prisoner_activity/emergency_air/setup() + var/obj/structure/closet/locker = prisoner.prison?.emergency_locker_for(prisoner) + if(!locker) + return FALSE + locker_ref = WEAKREF(locker) + spot = prisoner.approach_turf(locker) + return !!spot + +/datum/prisoner_activity/emergency_air/begin() + . = ..() + var/obj/structure/closet/locker = locker_ref?.resolve() + if(QDELETED(locker) || get_dist(prisoner, locker) > 1) + return + prisoner.face_atom(locker) + if(!locker.opened) + locker.open(prisoner) + var/turf/inside = get_turf(locker) + var/obj/item/clothing/mask/mask = locate(/obj/item/clothing/mask/breath) in inside + mask ||= locate(/obj/item/clothing/mask/gas) in inside + var/obj/item/tank/internals/tank = locate(/obj/item/tank/internals) in inside + if(!mask || !tank) + return + prisoner.visible_message(span_notice("[prisoner] grabs [mask] and [tank] out of [locker].")) + if(prisoner.wear_mask && !prisoner.can_breathe_internals()) + prisoner.dropItemToGround(prisoner.wear_mask) + if(!prisoner.wear_mask) + mask.forceMove(prisoner) + if(!prisoner.equip_to_slot_if_possible(mask, ITEM_SLOT_MASK, disable_warning = TRUE)) + mask.forceMove(inside) + tank.forceMove(prisoner) + if(!prisoner.equip_to_slot_if_possible(tank, ITEM_SLOT_BELT, disable_warning = TRUE) && !prisoner.equip_to_slot_if_possible(tank, ITEM_SLOT_LPOCKET, disable_warning = TRUE) && !prisoner.equip_to_slot_if_possible(tank, ITEM_SLOT_RPOCKET, disable_warning = TRUE) && !prisoner.put_in_hands(tank)) + tank.forceMove(inside) + prisoner.npc_open_internals() + +/datum/prisoner_activity/emergency_air/Destroy() + locker_ref = null + return ..() + +#undef PRISON_EMERGENCY_LOCKER_TILES +#undef PRISON_EMERGENCY_RETRY diff --git a/voidcrew/modules/player_outposts/outpost_prison_breakout.dm b/voidcrew/modules/player_outposts/outpost_prison_breakout.dm index a54a098ff3f..9d6cc54d253 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_breakout.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_breakout.dm @@ -80,7 +80,7 @@ * The ways out a rioter can get at, exit tile = its blocker: tiles on the cell block's edge beside * ground they can walk, whose blocker they have not given up on for now. */ -/datum/outpost_prison/proc/exit_tiles(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/exit_tiles(mob/living/carbon/human/npc/outpost_prisoner/rioter) var/list/exits = list() for(var/turf/tile as anything in rioter.reachable) if(rioter.walkable?[tile] || !is_exit_tile(tile)) @@ -96,9 +96,9 @@ return istype(blocker, /obj/structure/table/reinforced/prison_hatch) ? 1 : PRISON_RIOT_EXIT_CROWD /// How many rioters other than `except`, on their feet, are working at the way out on `tile` -/datum/outpost_prison/proc/rioters_at_exit(turf/tile, mob/living/basic/outpost_prisoner/except) +/datum/outpost_prison/proc/rioters_at_exit(turf/tile, mob/living/carbon/human/npc/outpost_prisoner/except) var/count = 0 - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == except || !other.is_rioting() || !other.trouble_can_act()) continue var/atom/target = other.riot_target_ref?.resolve() @@ -112,7 +112,7 @@ * farther than a door. One with no room left (exit_room()) is passed over unless `crowded_ok`. * Null if none will do. */ -/datum/outpost_prison/proc/choose_exit(mob/living/basic/outpost_prisoner/rioter, list/exits, crowded_ok = FALSE) +/datum/outpost_prison/proc/choose_exit(mob/living/carbon/human/npc/outpost_prisoner/rioter, list/exits, crowded_ok = FALSE) var/turf/best var/best_score = INFINITY for(var/turf/tile as anything in exits) @@ -148,7 +148,7 @@ * A fixture for a rioter to smash: one within PRISON_RIOT_DETOUR_RANGE tiles of `near` if there is * one, else any they can reach, or null with `near_only`. */ -/datum/outpost_prison/proc/pick_fixture(mob/living/basic/outpost_prisoner/rioter, turf/near, near_only = FALSE) +/datum/outpost_prison/proc/pick_fixture(mob/living/carbon/human/npc/outpost_prisoner/rioter, turf/near, near_only = FALSE) var/list/found = list() var/list/close = list() for(var/turf/tile as anything in rioter.reachable) @@ -173,7 +173,7 @@ * at, the wing's fixtures. Breaking out, they go for the ways out, crowded or not, and never stop * for a fixture on the way. */ -/datum/outpost_prison/proc/pick_smash_target(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/pick_smash_target(mob/living/carbon/human/npc/outpost_prisoner/rioter) var/atom/priority = priority_smash_target(rioter) if(priority) return priority @@ -192,7 +192,7 @@ return pick_fixture(rioter) /// Whether a rioter can make for `tile` to get out: walkable for them, in the wing, outside the cell block, and not given up on -/datum/outpost_prison/proc/escape_spot_ok(turf/tile, mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/escape_spot_ok(turf/tile, mob/living/carbon/human/npc/outpost_prisoner/rioter) return isturf(tile) && tile.loc == wing && !cell_block[tile] && rioter.walkable?[tile] && LAZYACCESS(rioter.riot_skips, REF(tile)) <= world.time /** @@ -200,7 +200,7 @@ * can walk to, keeping the one they chose while it still works, and preferring one nobody stands on. * Null with nobody home, so the rioters stay in the yard, and in a wing with no cells. */ -/datum/outpost_prison/proc/escape_spot(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/escape_spot(mob/living/carbon/human/npc/outpost_prisoner/rioter) if(!length(cell_block) || !length(rioter.walkable) || !crew_home()) return null var/turf/chosen = rioter.riot_target_ref?.resolve() @@ -233,7 +233,7 @@ * each way out this riot is announced; with nobody home it only booms. When the way gives, everyone's * reach is worked out again so the rioters see the gap. Returns TRUE. */ -/datum/outpost_prison/proc/hit_exit(mob/living/basic/outpost_prisoner/rioter, obj/target) +/datum/outpost_prison/proc/hit_exit(mob/living/carbon/human/npc/outpost_prisoner/rioter, obj/target) var/turf/tile = get_turf(target) rioter.trouble_line("riot_break_door", fallback = "riot") if(!crew_home()) @@ -300,7 +300,7 @@ * hatch is forced open (force_open()). With the office side already open or gone, the yard side opens * for them, or takes the blow if it cannot open. Returns TRUE if the office side gave. */ -/obj/structure/table/reinforced/prison_hatch/proc/take_rioter_blow(mob/living/basic/outpost_prisoner/rioter) +/obj/structure/table/reinforced/prison_hatch/proc/take_rioter_blow(mob/living/carbon/human/npc/outpost_prisoner/rioter) playsound(src, 'sound/effects/glass/glassbash.ogg', 50, TRUE) var/obj/machinery/door/window/staff_door = staff_windoor() if(staff_door?.density) diff --git a/voidcrew/modules/player_outposts/outpost_prison_capture.dm b/voidcrew/modules/player_outposts/outpost_prison_capture.dm index db9a890e563..75db2b47b69 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_capture.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_capture.dm @@ -1,18 +1,17 @@ /** * # Prison capture: cuffs and lockdown * - * Cuffs. A member of the wing can put any handcuffs, cable restraints or zipties on a prisoner. - * One who is down takes PRISONER_CUFF_TIME_DOWN; one on their feet takes PRISONER_CUFF_TIME_STANDING, - * holding still meanwhile, and only while making no trouble: rioters, runners, fighters, anyone - * threatening staff and anyone wrecking their cell must be put down first. Cuffed, they drop what - * they hold and do nothing but stand or sit where they are and complain: no blows, threats, - * fights, climbing, smashing, eating or activities, and a runner's loose clock waits. Anyone can - * drag them, and a member can take them through the wing's staff doors (outpost_prisoner_escorted()). - * Cuffs kept on past PRISONER_CUFFED_GRACE sour them and stop their pay like a lock-in, unless it - * is for good reason: the wing is in protective custody, or they owe lockdown. A member takes the - * cuffs off from the talk menu ("Uncuff"), or with an empty hand while they are down: real cuffs go - * to the member's hand, cable and zipties are cut away. - * The cuffs drop to the floor when the prisoner dies, beams out or is deleted. + * Cuffs are tg's own handcuffs, cable restraints and zipties, put on through tg's cuffing (4 seconds, + * its sounds and messages). Only a member of the wing may cuff a prisoner (COMSIG_CARBON_CUFF_ATTEMPTED). + * Anyone making trouble on their feet (a rioter, a runner, a fighter, anyone squaring up to staff or + * climbing out) won't hold still: they step out of the cuffer's reach, which breaks the cuffing, so + * they get put down first. Cuffed, they can walk, as anyone can, but they choose to stand or sit where + * they are and complain: no blows, threats, fights, climbing, smashing, eating or activities, and a + * runner's loose clock waits. Anyone can drag them, and a member can take them through the wing's staff + * doors (outpost_prisoner_escorted()). Cuffs kept on past PRISONER_CUFFED_GRACE sour them and stop + * their pay like a lock-in, unless it is for good reason: the wing is in protective custody, or they owe + * lockdown. A member takes the cuffs off from the talk menu ("Uncuff"), or through the strip menu like + * anyone's: real cuffs go to the member's hand, cable and zipties are cut away. A body keeps its cuffs. * * Lockdown. A rioter shut in a cell, and a runner caught after getting out during a riot, owes * PRISON_RIOT_LOCKDOWN_TIME of lockdown. It counts down only while they are shut in a cell, and @@ -25,12 +24,7 @@ * outpost_prison_riot.dm). Only the ones shut in a cell owe lockdown. */ -/// Trait source for the cuffs holding a prisoner still -#define PRISONER_CUFFS_TRAIT "outpost_prisoner_cuffs" - -/mob/living/basic/outpost_prisoner - /// The cuffs on them, held in their contents - var/obj/item/restraints/handcuffs/cuffs +/mob/living/carbon/human/npc/outpost_prisoner /// Seconds cuffed without good reason; falls PRISONER_LOCKED_IN_RECOVERY seconds a second once uncuffed var/cuffed_seconds = 0 /// Someone is putting cuffs on them or taking them off @@ -46,199 +40,143 @@ // ===== CUFFING ===== -/// Why `user` can't put `restraints` on them now, as a balloon alert, or null if they can -/mob/living/basic/outpost_prisoner/proc/cuff_refusal(mob/living/user, obj/item/restraints/handcuffs/restraints) +/// Why `user` can't put cuffs on them now, as a balloon alert, or null if they can +/mob/living/carbon/human/npc/outpost_prisoner/proc/cuff_refusal(mob/living/user) if(!prison || !prison.is_member(user)) return "members only" if(stat == DEAD || phase != PRISONER_PRESENT) return "can't cuff [p_them()]" - if(cuffs) - return "already cuffed" - if(istype(restraints, /obj/item/restraints/handcuffs/cable/zipties/used)) - return "used up" - // On their feet only when they are making no trouble; rioters and runners go down first. - if(!is_down() && (trouble || threat_ref || swing_ref || climb_ref)) - return "won't hold still" return null -/// How long cuffing them takes: quicker when they are down -/mob/living/basic/outpost_prisoner/proc/cuff_time() - return (is_down() ? PRISONER_CUFF_TIME_DOWN : PRISONER_CUFF_TIME_STANDING) * bounty_cuff_mult() - -/// Cuffs used on them (from the item interaction signal): refused with a balloon alert, or put on in the background -/mob/living/basic/outpost_prisoner/proc/on_cuffs_used(mob/living/user, obj/item/restraints/handcuffs/restraints) - var/refusal = cuff_refusal(user, restraints) - if(!refusal && cuff_work) - refusal = "already busy" - if(refusal) - balloon_alert(user, refusal) - return ITEM_INTERACT_BLOCKING - INVOKE_ASYNC(src, PROC_REF(cuff_by), user, restraints) - return ITEM_INTERACT_SUCCESS +/// Making trouble on their feet: they won't stand still for the cuffs +/mob/living/carbon/human/npc/outpost_prisoner/proc/fights_the_cuffs() + return !is_down() && (trouble || threat_ref || swing_ref || climb_ref) /** - * `user` puts `restraints` on them over cuff_time(), with tg's cuff sounds. They hold still - * meanwhile (in_trouble() counts it, so their routine waits). Returns TRUE if they are cuffed. Sleeps. + * Someone starts cuffing them with tg's cuffs. Not a member: refused. Making trouble on their feet: + * they pull away out of reach, which breaks tg's cuffing do_after. Otherwise they hold still, and their + * routine waits while the cuffs go on. */ -/mob/living/basic/outpost_prisoner/proc/cuff_by(mob/living/user, obj/item/restraints/handcuffs/restraints) - if(cuff_work || cuff_refusal(user, restraints)) - return FALSE +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_cuff_attempt(datum/source, mob/living/user) + SIGNAL_HANDLER + var/refusal = cuff_refusal(user) + if(refusal) + balloon_alert(user, refusal) + return COMSIG_CARBON_CUFF_PREVENT + if(fights_the_cuffs() && !HAS_TRAIT(src, TRAIT_IMMOBILIZED)) + INVOKE_ASYNC(src, PROC_REF(pull_away_from), user) + return NONE cuff_work = TRUE ai_controller?.CancelActions() - user.visible_message(span_danger("[user] is trying to put [restraints] on [src]!"), span_danger("You try to put [restraints] on [src].")) - playsound(src, restraints.cuffsound, 30, TRUE, -2) - var/done = do_after(user, cuff_time(), target = src, timed_action_flags = IGNORE_SLOWDOWNS) - cuff_work = FALSE - if(QDELETED(src)) - return FALSE - // A borg's cuffs are part of its module: it dispenses a pair, as tg's cuffs do for borgs. - var/dispense = iscyborg(user) - if(!done || QDELETED(restraints) || (restraints.loc != user && !dispense) || cuff_refusal(user, restraints)) - balloon_alert(user, "failed to cuff!") - return FALSE - var/success_sound = restraints.cuffsuccesssound - if(!apply_cuffs(restraints, user, dispense)) - balloon_alert(user, "failed to cuff!") - return FALSE - playsound(src, success_sound, 30, TRUE, -2) - user.visible_message(span_notice("[user] handcuffs [src]."), span_notice("You handcuff [src].")) - log_combat(user, src, "handcuffed") - return TRUE + addtimer(VARSET_CALLBACK(src, cuff_work, FALSE), PRISONER_CUFF_TIME_STANDING + 1 SECONDS, TIMER_UNIQUE | TIMER_OVERRIDE) + return NONE + +/// A step out of `user`'s reach, as anyone twists away from someone with cuffs +/mob/living/carbon/human/npc/outpost_prisoner/proc/pull_away_from(mob/living/user) + if(QDELETED(src) || QDELETED(user) || stat != CONSCIOUS) + return + step_away(src, user) /** - * Puts `restraints` on them at once; zipties become their used pair, as on people. `user`, if - * any, gives them up from their hands, unless `dispense` (a borg), which puts on a new pair of the - * same kind instead. What they hold drops and whatever they were up to stops. Returns TRUE if they - * are cuffed. + * Cuffs went on: what they were up to stops. Called once the cuffs are on (the restrained trait + * handler, outpost_prison_prisoner.dm); their line comes from npc_bark(). */ -/mob/living/basic/outpost_prisoner/proc/apply_cuffs(obj/item/restraints/handcuffs/restraints, mob/living/user, dispense = FALSE) - if(cuffs || QDELETED(restraints) || stat == DEAD) - return FALSE - var/obj/item/restraints/handcuffs/worn = restraints - if(dispense) - var/dispensed_path = restraints.trashtype || restraints.type - worn = new dispensed_path(src) - else - if(user && !user.temporarilyRemoveItemFromInventory(restraints)) - return FALSE - if(restraints.trashtype) - var/trash_path = restraints.trashtype - worn = new trash_path(src) - qdel(restraints) - else - restraints.forceMove(src) - if(QDELETED(worn) || worn.loc != src) - return FALSE - cuffs = worn - ADD_TRAIT(src, TRAIT_IMMOBILIZED, PRISONER_CUFFS_TRAIT) - if(has_shiv()) - drop_shiv() - else - drop_held_item() +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_cuffed() + cuff_work = FALSE end_activity() cancel_threat() stop_climb(fell = TRUE) // The breakout AI too: a caught runner stays put until walked home. ai_controller?.CancelActions() - // Draggable now: whatever trouble they were in stops (on_downed()). - update_drag_resistance() - update_appearance(UPDATE_OVERLAYS) update_bubble() - if(stat == CONSCIOUS) - say_context("cuffed") - return TRUE // ===== UNCUFFING ===== /** - * A member takes the cuffs off by hand, over PRISONER_UNCUFF_TIME. Returns TRUE if + * A member takes the cuffs off by hand from the talk menu, over PRISONER_UNCUFF_TIME. Returns TRUE if * they came off. Sleeps. */ -/mob/living/basic/outpost_prisoner/proc/uncuff_by(mob/living/user) - if(!cuffs || cuff_work) +/mob/living/carbon/human/npc/outpost_prisoner/proc/uncuff_by(mob/living/user) + var/obj/item/restraints/handcuffs/worn = handcuffed + if(!worn || cuff_work) return FALSE cuff_work = TRUE - user.visible_message(span_notice("[user] starts taking [cuffs] off [src]."), span_notice("You start taking [cuffs] off [src].")) + user.visible_message(span_notice("[user] starts taking [worn] off [src]."), span_notice("You start taking [worn] off [src].")) var/done = do_after(user, PRISONER_UNCUFF_TIME, target = src) cuff_work = FALSE - if(!done || QDELETED(src) || !cuffs || !prison?.is_member(user)) + if(!done || QDELETED(src) || handcuffed != worn || !prison?.is_member(user)) return FALSE - var/cut = istype(cuffs, /obj/item/restraints/handcuffs/cable) - user.visible_message(span_notice("[user] [cut ? "cuts" : "takes"] [cuffs] off [src]."), span_notice("You [cut ? "cut" : "take"] [cuffs] off [src].")) + var/cut = istype(worn, /obj/item/restraints/handcuffs/cable) + user.visible_message(span_notice("[user] [cut ? "cuts" : "takes"] [worn] off [src]."), span_notice("You [cut ? "cut" : "take"] [worn] off [src].")) remove_cuffs(user) - if(stat == CONSCIOUS) - say_context("cuffs_off") return TRUE /** - * Takes their cuffs off. With `user`, real cuffs go to their hands (or the floor) and cable - * restraints and zipties are cut away; without, the cuffs drop where they are. Returns the cuffs - * still about, or null. + * Takes their cuffs off through tg's uncuff(). With `user`, real cuffs go to their hands (or the floor) + * and cable restraints and zipties are cut away; without, the cuffs drop where they are. Returns the + * cuffs still about, or null. */ -/mob/living/basic/outpost_prisoner/proc/remove_cuffs(mob/living/user) - var/obj/item/restraints/handcuffs/worn = cuffs +/mob/living/carbon/human/npc/outpost_prisoner/proc/remove_cuffs(mob/living/user) + var/obj/item/restraints/handcuffs/worn = handcuffed if(!worn) return null - // Cleared first, so Exited() leaves the rest to this proc. - cuffs = null + uncuff() + if(QDELETED(worn)) + return null if(user && istype(worn, /obj/item/restraints/handcuffs/cable)) qdel(worn) - worn = null - else - worn.forceMove(drop_location()) - if(user) - user.put_in_hands(worn) - uncuffed() + return null + user?.put_in_hands(worn) return worn /// The cuffs are off, however they came off -/mob/living/basic/outpost_prisoner/proc/uncuffed() - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, PRISONER_CUFFS_TRAIT) +/mob/living/carbon/human/npc/outpost_prisoner/proc/uncuffed() if(QDELETED(src)) return - update_drag_resistance() - update_appearance(UPDATE_OVERLAYS) prison?.refresh_prisoner_reach(src) -// ===== CUFFED ===== +/// Cuffs on or off (tg's TRAIT_RESTRAINED) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_restraint_changed() + if(QDELETED(src)) + return + if(handcuffed) + INVOKE_ASYNC(src, PROC_REF(on_cuffed)) + else + INVOKE_ASYNC(src, PROC_REF(uncuffed)) -// Cuffed hands throw no punches, whatever their AI wants. -/mob/living/basic/outpost_prisoner/early_melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - if(cuffs) - return FALSE - return ..() +// ===== CUFFED ===== /// Whether their cuffs are souring them and stopping their pay: on past PRISONER_CUFFED_GRACE, and not for good reason -/mob/living/basic/outpost_prisoner/proc/cuffs_souring() +/mob/living/carbon/human/npc/outpost_prisoner/proc/cuffs_souring() return cuffed_seconds > PRISONER_CUFFED_GRACE && !prison?.restraint_justified(src) /// Whether restraining a prisoner is for good reason right now, so it costs them nothing: protective custody, or lockdown they owe -/datum/outpost_prison/proc/restraint_justified(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/restraint_justified(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.lockdown_left > 0 || protective_custody() /** * Advances a prisoner's cuffed clock by `seconds`: up while cuffed without good reason, and down * PRISONER_LOCKED_IN_RECOVERY seconds a second once the cuffs are off, like the lock-in clock. */ -/datum/outpost_prison/proc/update_cuffed(mob/living/basic/outpost_prisoner/prisoner, seconds) - if(prisoner.cuffs) +/datum/outpost_prison/proc/update_cuffed(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) + if(prisoner.handcuffed) if(!restraint_justified(prisoner)) prisoner.cuffed_seconds += seconds return prisoner.cuffed_seconds = max(0, prisoner.cuffed_seconds - PRISONER_LOCKED_IN_RECOVERY * seconds) -/// Cuffed, they plan nothing: no routine, no trouble, no patrol, no doors. First on both prisoner controllers. +/// Cuffed, they choose to plan nothing: no routine, no trouble, no patrol, no doors. First on both prisoner controllers. /datum/ai_planning_subtree/outpost_prisoner_cuffed /datum/ai_planning_subtree/outpost_prisoner_cuffed/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn - if(istype(prisoner) && prisoner.cuffs) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn + if(istype(prisoner) && prisoner.handcuffed) return SUBTREE_RETURN_FINISH_PLANNING // ===== LOCKDOWN ===== /// They owe PRISON_RIOT_LOCKDOWN_TIME of lockdown from now. `quiet` leaves the line to the caller. -/datum/outpost_prison/proc/start_lockdown(mob/living/basic/outpost_prisoner/prisoner, quiet = FALSE) +/datum/outpost_prison/proc/start_lockdown(mob/living/carbon/human/npc/outpost_prisoner/prisoner, quiet = FALSE) prisoner.lockdown_left = PRISON_RIOT_LOCKDOWN_TIME prisoner.lockdown_out = 0 add_log("[prisoner.real_name] is on lockdown.") @@ -251,7 +189,7 @@ * out early. Down, cuffed or loose, the grace waits, and so does it while the cell they were serving * in is still bolted: they got out through a hole, and nobody let them out. */ -/datum/outpost_prison/proc/lockdown_tick(mob/living/basic/outpost_prisoner/prisoner, seconds) +/datum/outpost_prison/proc/lockdown_tick(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) if(prisoner.lockdown_left <= 0) prisoner.lockdown_out = 0 prisoner.lockdown_cell_ref = null @@ -274,7 +212,7 @@ let_out_early(prisoner) /// Served: the warden's log notes it once, and the prisoner says so. The usual lock-in rules apply again. -/datum/outpost_prison/proc/lockdown_served(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lockdown_served(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.lockdown_left = 0 prisoner.lockdown_out = 0 prisoner.lockdown_cell_ref = null @@ -286,7 +224,7 @@ * Let out before the lockdown was served: logged once, and they riot again, joining a riot * that is on or starting one whatever the quiet after the last. Returns TRUE if they are rioting. */ -/datum/outpost_prison/proc/let_out_early(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/let_out_early(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.lockdown_left = 0 prisoner.lockdown_out = 0 add_log("[prisoner.real_name] was let out before their lockdown was up.") @@ -309,12 +247,10 @@ // ===== WHERE STAFF LOOK ===== -/mob/living/basic/outpost_prisoner/examine(mob/user) +/mob/living/carbon/human/npc/outpost_prisoner/examine(mob/user) . = ..() if(stat == DEAD) return - if(cuffs) - . += span_warning("[p_They()] [p_are()] handcuffed.") if(lockdown_left > 0) . += span_notice("On lockdown for [outpost_prison_lockdown_text(lockdown_left)].") @@ -324,7 +260,7 @@ var/datum/outpost_prison_cell/cell = prison?.cell_by_number(cell_number) if(!cell) return - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner.lockdown_left <= 0 || prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT) continue var/inside = cell.contains(prisoner) @@ -335,4 +271,3 @@ line += " [prisoner.p_They()] [prisoner.p_are()] not in the cell." . += span_notice(line) -#undef PRISONER_CUFFS_TRAIT diff --git a/voidcrew/modules/player_outposts/outpost_prison_changeling.dm b/voidcrew/modules/player_outposts/outpost_prison_changeling.dm index c621aff305f..ab3137d6a6f 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_changeling.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_changeling.dm @@ -71,7 +71,7 @@ * calls this once the host has eaten the tainted food. Returns the event, or null if it cannot * start (a dead host, someone else's prisoner, or a changeling already running in the wing). */ -/proc/outpost_changeling_infect(mob/living/basic/outpost_prisoner/host, datum/outpost_prison/prison) +/proc/outpost_changeling_infect(mob/living/carbon/human/npc/outpost_prisoner/host, datum/outpost_prison/prison) if(QDELETED(host) || QDELETED(prison) || host.stat == DEAD || host.prison != prison) return null if(prison.active_changeling && prison.active_changeling.stage != "done") @@ -89,7 +89,7 @@ /// Why it ended, once it has var/end_reason var/datum/outpost_prison/prison - var/mob/living/basic/outpost_prisoner/host + var/mob/living/carbon/human/npc/outpost_prisoner/host var/mob/living/basic/headslug/beakless/outpost/slug var/mob/living/basic/outpost_experiment/horror/horror /// The vent the slug is in, or the one that is giving way @@ -141,7 +141,7 @@ /// Prisoners whose deaths are watched while a creature is loose var/list/watched_prisoners = list() -/datum/outpost_changeling_event/New(mob/living/basic/outpost_prisoner/new_host, datum/outpost_prison/new_prison) +/datum/outpost_changeling_event/New(mob/living/carbon/human/npc/outpost_prisoner/new_host, datum/outpost_prison/new_prison) . = ..() prison = new_prison prison.active_changeling = src @@ -256,21 +256,41 @@ // ===== THE HOST ===== -/datum/outpost_changeling_event/proc/link_host(mob/living/basic/outpost_prisoner/new_host) +/datum/outpost_changeling_event/proc/link_host(mob/living/carbon/human/npc/outpost_prisoner/new_host) host = new_host host.experiment_subject = TRUE host.update_bubble() RegisterSignal(host, COMSIG_LIVING_DEATH, PROC_REF(on_host_death)) RegisterSignal(host, COMSIG_QDELETING, PROC_REF(on_host_deleted)) + // EX-8: the specimen is a real thing in their chest, which surgery can cut out. + var/obj/item/organ/body_egg/outpost_specimen/specimen = new() + specimen.event_ref = WEAKREF(src) + if(!specimen.Insert(new_host, special = TRUE)) + qdel(specimen) + +/// The specimen in the host, if there is one +/datum/outpost_changeling_event/proc/host_specimen() + if(QDELETED(host)) + return null + return locate(/obj/item/organ/body_egg/outpost_specimen) in host.organs + +/// Takes the specimen out of the host for good, as the burst or the end of the experiment does +/datum/outpost_changeling_event/proc/remove_specimen() + var/obj/item/organ/body_egg/outpost_specimen/specimen = host_specimen() + if(!specimen) + return + specimen.event_ref = null + specimen.Remove(specimen.owner, special = TRUE) + qdel(specimen) /// Lets go of the host: signals off and back on their feet. `cured` also ends their part in the experiment. /datum/outpost_changeling_event/proc/release_host(cured = FALSE) if(!host) return - var/mob/living/basic/outpost_prisoner/was_host = host + remove_specimen() + var/mob/living/carbon/human/npc/outpost_prisoner/was_host = host host = null UnregisterSignal(was_host, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) - was_host.remove_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), CHANGELING_HOST_TRAIT) if(istype(was_host.activity, /datum/prisoner_activity/changeling_bed)) was_host.end_activity() if(cured && !QDELETED(was_host)) @@ -319,7 +339,7 @@ if(!said_pain && incubation_elapsed >= OUTPOST_CHANGELING_TELL_PAIN) said_pain = TRUE INVOKE_ASYNC(host, TYPE_PROC_REF(/atom, manual_emote), "rubs [host.p_their()] stomach and winces.") - INVOKE_ASYNC(host, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "experiment_dose") + INVOKE_ASYNC(host, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "experiment_dose") prison.add_log("[host.real_name] complains of stomach pain.") return if(!said_retch && incubation_elapsed >= OUTPOST_CHANGELING_TELL_RETCH) @@ -368,25 +388,33 @@ heave_left = rand(3, 5) heave() -/// The host goes down, on their bunk if they got there: groaning, and draggable +/** + * The host goes down, on their bunk if they got there: groaning, and draggable. They lie down the way + * anyone sick does, and the heaves keep knocking them flat (heave()). + */ /datum/outpost_changeling_event/proc/host_lie_down() if(host_down || QDELETED(host)) return host_down = TRUE - host.add_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), CHANGELING_HOST_TRAIT) + host.set_resting(TRUE, silent = TRUE) + host.Knockdown(OUTPOST_CHANGELING_HOST_KNOCKDOWN) host.visible_message(span_warning("[host] curls up, groaning, arms wrapped around [host.p_their()] belly.")) -/// The host's belly heaves; now and then a neighbour notices +/// The host's belly heaves, and they are sick and knocked flat by it; now and then a neighbour notices /datum/outpost_changeling_event/proc/heave() host.Shake(1, 0, 0.6 SECONDS) playsound(host, 'sound/effects/meatslap.ogg', 15, TRUE, -2) + if(host.stat != DEAD) + if(prob(40)) + host.vomit(VOMIT_CATEGORY_KNOCKDOWN, lost_nutrition = 0) + host.Knockdown(OUTPOST_CHANGELING_HOST_KNOCKDOWN) if(prob(50)) host.visible_message(span_warning("Something moves under [host]'s jumpsuit. [host.p_Their()] belly heaves.")) else INVOKE_ASYNC(host, TYPE_PROC_REF(/atom, manual_emote), pick("groans.", "moans.", "whimpers.")) if(!prob(30)) return - for(var/mob/living/basic/outpost_prisoner/neighbour in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/neighbour in shuffle(prison.prisoners)) if(neighbour == host || neighbour.stat != CONSCIOUS || neighbour.phase != PRISONER_PRESENT || !(host in view(6, neighbour))) continue INVOKE_ASYNC(neighbour, TYPE_PROC_REF(/atom, manual_emote), pick("stares at [host].", "backs away from [host].", "covers [neighbour.p_their()] mouth.", "calls out to [host]. No answer.")) @@ -427,7 +455,7 @@ if(!spot) lose_specimen("the host is gone") return FALSE - var/mob/living/basic/outpost_prisoner/victim = host + var/mob/living/carbon/human/npc/outpost_prisoner/victim = host release_host() stage = "burst" early_burst_left = 0 @@ -495,7 +523,7 @@ /// Prisoners who saw it scream /datum/outpost_changeling_event/proc/scream_near(turf/spot) var/screamed = 0 - for(var/mob/living/basic/outpost_prisoner/witness in view(7, spot)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in view(7, spot)) if(witness.stat != CONSCIOUS || witness.phase != PRISONER_PRESENT || witness.can_be_dragged()) continue INVOKE_ASYNC(witness, TYPE_PROC_REF(/atom, manual_emote), pick("screams!", "shrieks and scrambles back!", "yells in horror!")) @@ -656,13 +684,13 @@ if(level < 2 || !prison.wing_can_speak()) return FALSE var/context = level >= 3 ? "creature_panic" : "vent_noise" - for(var/mob/living/basic/outpost_prisoner/listener in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/listener in shuffle(prison.prisoners)) if(listener.stat != CONSCIOUS || listener.phase != PRISONER_PRESENT || listener.can_be_dragged() || !listener.ai_running()) continue if(get_dist(listener, vent) > 7) continue prison.note_speech() - INVOKE_ASYNC(listener, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), context) + INVOKE_ASYNC(listener, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), context) return TRUE return FALSE @@ -813,13 +841,13 @@ /datum/outpost_changeling_event/proc/horror_remark(context) if(QDELETED(horror) || QDELETED(prison) || !prison.wing_can_speak()) return FALSE - for(var/mob/living/basic/outpost_prisoner/witness in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in shuffle(prison.prisoners)) if(witness.stat != CONSCIOUS || witness.phase != PRISONER_PRESENT || witness.can_be_dragged() || !witness.ai_running()) continue if(!(horror in view(7, witness))) continue prison.note_speech() - INVOKE_ASYNC(witness, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), context) + INVOKE_ASYNC(witness, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), context) return TRUE return FALSE @@ -909,7 +937,7 @@ /datum/outpost_changeling_event/proc/panic_prisoners() panicked = TRUE var/shouts = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner == host || !prisoner.routine_allowed() || prisoner.is_confined()) continue if(istype(prisoner.activity, /datum/prisoner_activity/flee_creature) || istype(prisoner.activity, /datum/prisoner_activity/creature_panic)) @@ -921,11 +949,11 @@ prisoner.start_activity(flee) if(shouts < 2 && prisoner.ai_running()) shouts++ - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_panic") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_panic") /// Watches the prisoners' deaths while a creature is loose /datum/outpost_changeling_event/proc/watch_prisoners() - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner.stat == DEAD || watched_prisoners[prisoner]) continue watched_prisoners[prisoner] = TRUE @@ -933,7 +961,7 @@ RegisterSignal(prisoner, COMSIG_QDELETING, PROC_REF(on_watched_prisoner_deleted)) /datum/outpost_changeling_event/proc/unwatch_prisoners() - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in watched_prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in watched_prisoners) UnregisterSignal(prisoner, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) watched_prisoners.Cut() @@ -943,7 +971,7 @@ watched_prisoners -= source /// A prisoner died while a creature was loose: the creature's kill if it was beside them -/datum/outpost_changeling_event/proc/on_prisoner_died(mob/living/basic/outpost_prisoner/victim, gibbed) +/datum/outpost_changeling_event/proc/on_prisoner_died(mob/living/carbon/human/npc/outpost_prisoner/victim, gibbed) SIGNAL_HANDLER var/mob/living/creature = current_creature() note_death(victim, creature && get_dist(creature, victim) <= 2) @@ -962,7 +990,7 @@ var/turf/spot = get_turf(victim) if(!spot) return TRUE - for(var/mob/living/basic/outpost_prisoner/witness in view(7, spot)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in view(7, spot)) if(witness == victim || witness.stat != CONSCIOUS || witness.phase != PRISONER_PRESENT) continue witnesses |= WEAKREF(witness) @@ -978,7 +1006,7 @@ if(QDELETED(prison)) return for(var/datum/weakref/ref as anything in witnesses) - var/mob/living/basic/outpost_prisoner/witness = ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/witness = ref.resolve() if(QDELETED(witness) || witness.stat == DEAD || !(witness in prison.prisoners)) continue witness.adjust_mood(-OUTPOST_CHANGELING_WITNESS_MOOD) @@ -1421,3 +1449,34 @@ #undef CHANGELING_HOST_TRAIT #undef HEADSLUG_VENT_TRAIT #undef BB_OUTPOST_HEADSLUG_VENT + +// ===== THE SPECIMEN IN A BODY ===== + +/** + * The specimen growing in a host with a body (EX-8): a real organ in the chest, as tg's alien embryo is. + * The event's clock grows it and bursts the host; the organ is what is there to find on a scanner or cut + * out in surgery. Cut out, it dies, and the experiment loses its specimen. + */ +/obj/item/organ/body_egg/outpost_specimen + name = "Kessler specimen" + desc = "A small, wet, curled-up thing that never stops moving." + icon_state = "innards" + /// The changeling event it is part of + var/datum/weakref/event_ref + +/obj/item/organ/body_egg/outpost_specimen/Destroy() + event_ref = null + return ..() + +/obj/item/organ/body_egg/outpost_specimen/on_mob_remove(mob/living/carbon/egg_owner, special, movement_flags) + . = ..() + // The event's own removals (the burst, the end of the experiment) are special. + if(special) + return + var/datum/outpost_changeling_event/event = event_ref?.resolve() + event_ref = null + // Cut out: it dies outside a body, and the specimen is lost. + if(event) + INVOKE_ASYNC(event, TYPE_PROC_REF(/datum/outpost_changeling_event, lose_specimen), "the specimen was cut out") + visible_message(span_warning("[src] shudders and goes still.")) + QDEL_IN(src, 1) diff --git a/voidcrew/modules/player_outposts/outpost_prison_conditions.dm b/voidcrew/modules/player_outposts/outpost_prison_conditions.dm index 287e720cf6f..46ce61e5564 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_conditions.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_conditions.dm @@ -24,9 +24,7 @@ /// How often mess, air and rats are refreshed, in seconds #define PRISON_MESS_REFRESH_SECONDS 5 -/// Seconds per step of damage from bad air -#define PRISON_AIR_STEP_SECONDS 5 -/// Percent chance a prisoner hurt by bad air says so +/// Percent chance a prisoner on bad air says so, each check #define PRISON_AIR_LINE_CHANCE 15 /// Mess weight by type (see PRISON_MESS_WEIGHT_*); types not listed are not mess @@ -530,7 +528,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( /datum/outpost_prison/proc/check_battery() var/battery = battery_percent() if(!isnull(battery) && !isnull(last_battery) && last_battery >= PRISON_BATTERY_WARNING && battery < PRISON_BATTERY_WARNING) - var/mob/living/basic/outpost_prisoner/speaker = pick_conditions_speaker() + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick_conditions_speaker() if(speaker?.say_context("lights_flicker")) note_speech() last_battery = battery @@ -572,7 +570,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( * no power costs in proportion; a clean, lit, powered wing is a small comfort; and a dark cell of * their own costs a little more. */ -/mob/living/basic/outpost_prisoner/proc/wing_mood_per_minute() +/mob/living/carbon/human/npc/outpost_prisoner/proc/wing_mood_per_minute() var/loss = 0 var/gain = 0 if(prison && trouble != PRISONER_TROUBLE_LOOSE) @@ -587,20 +585,18 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( // ===== AIR ===== -/// Prisoners on bad air lose a little health every PRISON_AIR_STEP_SECONDS, never below PRISON_AIR_HEALTH_FLOOR percent +/** + * Bad air is tg's own: prisoners breathe like anyone, and a breach, a fire or a plasma leak hurts them + * the way it hurts the crew. Each check, a prisoner on bad air may say so, and goes for a mask and a tank + * from the cell block's emergency locker (outpost_prison_breach.dm); once they wear one, they breathe + * from it while the air is bad. + */ /datum/outpost_prison/proc/air_tick(seconds) - var/steps = max(1, round(seconds / PRISON_AIR_STEP_SECONDS)) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD || !outpost_prison_unsafe_air(prisoner.loc?.return_air())) continue - var/hurt = FALSE - for(var/i in 1 to steps) - var/above_floor = prisoner.health - prisoner.maxHealth * PRISON_AIR_HEALTH_FLOOR / 100 - if(above_floor <= 0) - break - prisoner.adjustBruteLoss(min(PRISON_AIR_DAMAGE, above_floor)) - hurt = TRUE - if(hurt && prisoner.stat == CONSCIOUS && prob(PRISON_AIR_LINE_CHANCE) && wing_can_speak() && prisoner.say_context("no_air")) + prisoner.on_bad_air() + if(prisoner.stat == CONSCIOUS && prob(PRISON_AIR_LINE_CHANCE) && wing_can_speak() && prisoner.say_context("no_air")) note_speech() /// Whether air is too thin, too thick, too hot, too cold or too full of plasma to live in @@ -652,7 +648,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( var/mob/living/basic/mouse/rat = new(spot) rat_refs += WEAKREF(rat) log_game("PLAYER OUTPOST PRISON: a rat turned up in the prison wing at '[outpost?.name]'") - for(var/mob/living/basic/outpost_prisoner/prisoner in view(7, spot)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in view(7, spot)) if(prisoner.prison == src && prisoner.stat == CONSCIOUS && prisoner.say_context("rat")) note_speech() break @@ -661,7 +657,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( /// Someone in the wing to remark on its state /datum/outpost_prison/proc/pick_conditions_speaker() var/list/candidates = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat == CONSCIOUS && prisoner.trouble != PRISONER_TROUBLE_LOOSE) candidates += prisoner return length(candidates) ? pick(candidates) : null diff --git a/voidcrew/modules/player_outposts/outpost_prison_containment.dm b/voidcrew/modules/player_outposts/outpost_prison_containment.dm index 077e9c53675..2e9538328ab 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_containment.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_containment.dm @@ -116,7 +116,7 @@ * pulled by a member of the wing who may use the door. Visitors let in may use the door, but * never take a prisoner with them, so nobody but the wing's own people can drag one off the outpost. */ -/proc/outpost_prisoner_escorted(atom/door, mob/living/basic/outpost_prisoner/prisoner) +/proc/outpost_prisoner_escorted(atom/door, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!istype(prisoner) || !prisoner.can_be_dragged()) return FALSE var/mob/living/puller = prisoner.pulledby @@ -165,7 +165,7 @@ /// Refreshes where every prisoner can walk and reach. The prisoners share one look at each tile. /datum/outpost_prison/proc/refresh_reach() var/list/standable = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) refresh_prisoner_reach(prisoner, standable) /** @@ -174,7 +174,7 @@ * seen through the office windows is not. `standable` (turf = whether a prisoner can stand there) * is shared by every prisoner in one refresh, so each tile is looked at once. */ -/datum/outpost_prison/proc/refresh_prisoner_reach(mob/living/basic/outpost_prisoner/prisoner, list/standable) +/datum/outpost_prison/proc/refresh_prisoner_reach(mob/living/carbon/human/npc/outpost_prisoner/prisoner, list/standable) var/list/walked = list() var/list/reach = list() var/turf/start = get_turf(prisoner) @@ -244,6 +244,8 @@ if(!found[tile] && !isclosedturf(tile)) staff_ground[tile] = TRUE record_layout() + // Masks and tanks for a breach (outpost_prison_breach.dm) + ensure_emergency_lockers() /** * Whether the cell block flood carries on through a tile. Only the wing's structure stops it: @@ -413,49 +415,52 @@ /** * Hooks up what keeps a prisoner contained; called from Initialize(). Teleports fail, forced ones - * included (drop pods, quantum pads); polymorph and type changes fail; and a dead prisoner stays - * dead: anything that tries to bring them back gets the body collected at once instead. - * TRAIT_NO_TRANSFORM would stop polymorph too, but it also stops their AI moving them. + * included (drop pods, quantum pads); polymorph and type changes fail. A dead prisoner can be brought + * back like anyone (on_revived()). TRAIT_NO_TRANSFORM would stop polymorph too, but it also stops their + * AI moving them. */ -/mob/living/basic/outpost_prisoner/proc/setup_containment() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_containment() RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - RegisterSignal(src, COMSIG_LIVING_REVIVE, PROC_REF(on_revive_attempt)) + RegisterSignal(src, COMSIG_LIVING_REVIVE, PROC_REF(on_revived)) -/mob/living/basic/outpost_prisoner/proc/refuse_teleport(datum/source, atom/destination, channel) +/mob/living/carbon/human/npc/outpost_prisoner/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER if(isturf(loc)) visible_message(span_notice("[src] flickers for a moment, but stays where [p_they()] [p_are()].")) return TRUE -/mob/living/basic/outpost_prisoner/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/outpost_prisoner/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER visible_message(span_notice("[src] shimmers for a moment, then looks the same as before.")) return STOP_WABBAJACK -/mob/living/basic/outpost_prisoner/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/// The dead stay dead: the corrections service has already logged the death, whether or not the body is still in the cell block -/mob/living/basic/outpost_prisoner/can_be_revived() - if(died_at || (prison && phase == PRISONER_PRESENT)) - return FALSE - return ..() - -/// Someone tried to bring a dead prisoner back -/mob/living/basic/outpost_prisoner/proc/on_revive_attempt(datum/source, full_heal_flags) +/** + * Brought back (a defibrillator, strange reagent: tg's revival works on them as on anyone). Still on the + * roster, they are the same inmate again: the death stays on the books (its record closed, the yard's + * blame not taken back) and the log says so. Revived after their body left the roster, the corrections + * service takes them back off the outpost. + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_revived(datum/source, full_heal_flags) SIGNAL_HANDLER - if(stat != DEAD || !prison || phase != PRISONER_PRESENT) + if(prison && phase == PRISONER_PRESENT) + if(died_at) + prison.add_log("[real_name] was revived. The corrections service has already logged the death.") + died_at = 0 return - prison.add_log("Someone tried to revive [real_name]. The corrections service has already logged the death.") + if(phase != PRISONER_LEAVING) + INVOKE_ASYNC(src, PROC_REF(beam_out)) /** * Whether they are shut in their cell: standing in a cell with its door bolted shut, or with * nowhere to walk but the cell and its doorway (a welded, walled-in or unpowered door). */ -/mob/living/basic/outpost_prisoner/proc/is_confined() +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_confined() var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(src)) if(!holding) return FALSE diff --git a/voidcrew/modules/player_outposts/outpost_prison_contraband.dm b/voidcrew/modules/player_outposts/outpost_prison_contraband.dm index b61ff885838..98cb3a30eb6 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_contraband.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_contraband.dm @@ -39,7 +39,7 @@ /// world.time a search of this cell last cost its owner mood var/searched_at = 0 -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Seconds their mood has stayed low enough to make a shiv, and to brew pruno var/contraband_shiv_sour = 0 var/contraband_brew_sour = 0 @@ -62,12 +62,12 @@ // ===== THE PRISONER'S SIDE ===== /// Hooks up the prisoner's side of contraband and mail (the letter hand-over); called from setup_extras() -/mob/living/basic/outpost_prisoner/proc/setup_contraband() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_contraband() // An element, since the prisoner's own handler for item use already takes the signal (outpost_prison_mail.dm) AddElement(/datum/element/outpost_prison_mail_handover) /// The sour clocks, the drink wearing off and the drunk's sway -/mob/living/basic/outpost_prisoner/proc/contraband_counters(seconds) +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_counters(seconds) // Each sour clock counts while mood is under its line, holds just above it, and resets once they cheer up if(mood >= OUTPOST_CONTRABAND_SOUR_RESET) contraband_shiv_sour = 0 @@ -87,19 +87,19 @@ if(stat == CONSCIOUS && isturf(loc) && !buckled) Shake(1, 0, 0.5 SECONDS) -/mob/living/basic/outpost_prisoner/proc/contraband_drunk() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_drunk() return contraband_drunk_left > 0 /// Whether they have been sour long enough to make a shiv, and their cell has none -/mob/living/basic/outpost_prisoner/proc/contraband_can_make_shiv() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_can_make_shiv() return cell && !cell.stash_shiv && contraband_shiv_sour >= OUTPOST_CONTRABAND_SOUR_TIME /// Whether they have been grumbling long enough to brew, and their cell's cistern has no pruno -/mob/living/basic/outpost_prisoner/proc/contraband_can_brew() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_can_brew() return cell && contraband_brew_sour >= OUTPOST_CONTRABAND_BREW_SOUR_TIME && !prison?.contraband_pruno(cell) /// Hides `thing` (a razor blade or yeast from the mail) on them until they can stash it. FALSE if they already carry something. -/mob/living/basic/outpost_prisoner/proc/contraband_carry(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_carry(obj/item/thing) if(QDELETED(thing) || carried_contraband) return FALSE thing.forceMove(src) @@ -110,12 +110,12 @@ return TRUE /// Forgets what they carried, leaving the item wherever it is -/mob/living/basic/outpost_prisoner/proc/contraband_clear_carried() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_clear_carried() if(carried_contraband) UnregisterSignal(carried_contraband, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) carried_contraband = null -/mob/living/basic/outpost_prisoner/proc/on_carried_contraband_moved(obj/item/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_carried_contraband_moved(obj/item/source) SIGNAL_HANDLER if(source != carried_contraband) UnregisterSignal(source, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) @@ -124,7 +124,7 @@ contraband_clear_carried() /// A new arrival finds what the last occupant left and says so, if it is still there -/mob/living/basic/outpost_prisoner/proc/contraband_remark_inherited() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_remark_inherited() if(stat != CONSCIOUS || phase != PRISONER_PRESENT || !cell || in_trouble()) return FALSE if(!cell.stash_shiv && !prison?.contraband_pruno(cell)) @@ -139,7 +139,7 @@ /// Sour clocks, shiv making, brewing and drinking, and the cells' beds and toilets /datum/outpost_prison/proc/contraband_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue prisoner.contraband_counters(seconds) @@ -151,12 +151,12 @@ return contraband_clock = 0 contraband_hook_fixtures() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue // Nobody on the level: whatever came in the mail is stashed wherever they are, unless they are cuffed if(prisoner.carried_contraband && !prisoner.ai_running()) - if(!prisoner.cuffs) + if(!prisoner.handcuffed) contraband_stash_carried(prisoner) continue contraband_try_start(prisoner) @@ -166,7 +166,7 @@ * they are free, nothing more pressing has them busy and no staff are in sight. Returns the * activity started, or null. */ -/datum/outpost_prison/proc/contraband_try_start(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_try_start(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.ai_running() || !prisoner.routine_allowed() || !prisoner.cell) return null var/datum/prisoner_activity/current = prisoner.activity @@ -188,14 +188,14 @@ return stash /// Staff (on-duty guards included) who can see `prisoner`, or null -/datum/outpost_prison/proc/contraband_watcher(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_watcher(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/mob/living/person in view(OUTPOST_CONTRABAND_WATCH_RANGE, prisoner)) if(is_outpost_prison_staff(person)) return person return null /// Caught at it: they stop, deny it, and have to sour all over again before the next try -/datum/outpost_prison/proc/contraband_caught(mob/living/basic/outpost_prisoner/prisoner, mob/living/watcher, shiv = TRUE) +/datum/outpost_prison/proc/contraband_caught(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/watcher, shiv = TRUE) if(shiv) prisoner.contraband_shiv_sour = 0 else @@ -268,7 +268,7 @@ return TRUE /// A prisoner drinks the pruno in their cell's cistern: a lift, then a while drunk. Returns TRUE if they drank. -/datum/outpost_prison/proc/contraband_drink(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_drink(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/obj/item/reagent_containers/cup/glass/bottle/pruno/bag = contraband_pruno(prisoner?.cell) if(!outpost_prison_pruno_ready(bag)) return FALSE @@ -281,7 +281,7 @@ return TRUE /// Home and unwatched, with the AI running: whatever came in the mail goes into hiding -/datum/outpost_prison/proc/contraband_try_stash_carried(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_try_stash_carried(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.cell?.contains(prisoner) || !prisoner.routine_allowed() || contraband_watcher(prisoner)) return FALSE return contraband_stash_carried(prisoner) @@ -291,7 +291,7 @@ * the cistern. A cell that already has that stash, or no room for it, leaves them carrying it. * Returns TRUE if it was stashed. */ -/datum/outpost_prison/proc/contraband_stash_carried(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_stash_carried(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/obj/item/thing = prisoner.carried_contraband var/datum/outpost_prison_cell/home = prisoner.cell if(!thing || thing.loc != prisoner || !home) @@ -366,7 +366,7 @@ return COMPONENT_CANCEL_ATTACK_CHAIN if(source.has_buckled_mobs()) // A prisoner lying on it can be told to get up from the talk menu, or shaken awake; the balloon only says who is in the way. - var/mob/living/basic/outpost_prisoner/lying = locate() in source.buckled_mobs + var/mob/living/carbon/human/npc/outpost_prisoner/lying = locate() in source.buckled_mobs source.balloon_alert(user, lying?.activity?.sleeping ? "someone's asleep on it" : "someone's lying on it") return COMPONENT_CANCEL_ATTACK_CHAIN INVOKE_ASYNC(src, PROC_REF(contraband_search_mattress), user, source) @@ -388,7 +388,7 @@ return null if(QDELETED(src) || QDELETED(bed) || QDELETED(user) || cell_at(get_turf(bed)) != cell) return null - var/mob/living/basic/outpost_prisoner/owner = cell.occupant + var/mob/living/carbon/human/npc/outpost_prisoner/owner = cell.occupant if(cell.stash_shiv) cell.stash_shiv = FALSE var/obj/item/knife/shiv/shiv = new(get_turf(user)) @@ -457,7 +457,7 @@ to_chat(user, span_notice("There's nothing in the cistern.")) else to_chat(user, span_notice("You find [english_list(found)] in the cistern.")) - var/mob/living/basic/outpost_prisoner/owner = cell.occupant + var/mob/living/carbon/human/npc/outpost_prisoner/owner = cell.occupant if(!caught) contraband_search_came_up_empty(owner, user, cell) return "empty" @@ -477,11 +477,11 @@ return TRUE /// Whether a cell's owner is there to take it: present, awake and not loose -/datum/outpost_prison/proc/contraband_owner_home(mob/living/basic/outpost_prisoner/owner) +/datum/outpost_prison/proc/contraband_owner_home(mob/living/carbon/human/npc/outpost_prisoner/owner) return !QDELETED(owner) && (owner in prisoners) && owner.stat == CONSCIOUS && owner.phase == PRISONER_PRESENT && owner.trouble != PRISONER_TROUBLE_LOOSE /// Something was found in `owner`'s cell by `finder`: they deny it and lose a little mood -/datum/outpost_prison/proc/contraband_owner_caught(mob/living/basic/outpost_prisoner/owner, mob/living/finder, datum/outpost_prison_cell/cell) +/datum/outpost_prison/proc/contraband_owner_caught(mob/living/carbon/human/npc/outpost_prisoner/owner, mob/living/finder, datum/outpost_prison_cell/cell) if(!contraband_owner_home(owner)) return FALSE if(contraband_search_costs(cell)) @@ -493,12 +493,12 @@ return TRUE /// A search of `owner`'s cell found nothing: the owner and anyone watching take it badly. An empty cell costs nothing. -/datum/outpost_prison/proc/contraband_search_came_up_empty(mob/living/basic/outpost_prisoner/owner, mob/living/searcher, datum/outpost_prison_cell/cell) +/datum/outpost_prison/proc/contraband_search_came_up_empty(mob/living/carbon/human/npc/outpost_prisoner/owner, mob/living/searcher, datum/outpost_prison_cell/cell) if(!contraband_owner_home(owner)) return FALSE if(contraband_search_costs(cell)) owner.adjust_mood(-OUTPOST_CONTRABAND_EMPTY_MOOD) - for(var/mob/living/basic/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_WATCH_RANGE, searcher)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_WATCH_RANGE, searcher)) if(onlooker == owner || !(onlooker in prisoners) || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT) continue onlooker.adjust_mood(-OUTPOST_CONTRABAND_ONLOOKER_MOOD) @@ -530,13 +530,13 @@ // ===== THE PAT-DOWN ===== /// The pat-down choice for the talk menu: name -> image. Hands on the wall, or held still in cuffs. -/datum/outpost_prison/proc/contraband_talk_choices(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/contraband_talk_choices(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!prisoner || !user || !is_member(user)) return list() return list((CONTRABAND_PATDOWN_CHOICE) = image(icon = 'voidcrew/icons/hud/radial.dmi', icon_state = "radial_search")) /// Runs a talk menu choice of this package; TRUE if it was one. May sleep. -/datum/outpost_prison/proc/contraband_talk_act(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, choice) +/datum/outpost_prison/proc/contraband_talk_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, choice) if(choice != CONTRABAND_PATDOWN_CHOICE) return FALSE contraband_pat_down(prisoner, user) @@ -549,12 +549,12 @@ * in it: rioting, loose or sour, they hold still and are searched, unless someone is already * talking to them or working on their cuffs. Returns "found", "empty" or null. Sleeps. */ -/datum/outpost_prison/proc/contraband_pat_down(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/contraband_pat_down(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(QDELETED(prisoner) || QDELETED(user) || !is_member(user) || !(prisoner in prisoners)) return null if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) return null - if(prisoner.cuffs) + if(prisoner.handcuffed) if(prisoner.talking || prisoner.cuff_work) prisoner.balloon_alert(user, "busy") return null @@ -571,7 +571,7 @@ prisoner.talking = TRUE prisoner.end_activity() prisoner.stand_up() - if(prisoner.cuffs) + if(prisoner.handcuffed) // Their hands are cuffed already; nothing for them to put on the wall prisoner.face_atom(user) prisoner.manual_emote("stands still to be searched.") @@ -612,11 +612,11 @@ prisoner.say_context("patdown_empty") if(contraband_roll(OUTPOST_CONTRABAND_PATDOWN_ONLOOKER_CHANCE)) var/list/onlookers = list() - for(var/mob/living/basic/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_ONLOOKER_RANGE, prisoner)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_ONLOOKER_RANGE, prisoner)) if(onlooker != prisoner && (onlooker in prisoners) && onlooker.stat == CONSCIOUS && onlooker.phase == PRISONER_PRESENT && !onlooker.in_trouble()) onlookers += onlooker if(length(onlookers)) - var/mob/living/basic/outpost_prisoner/speaker = pick(onlookers) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(onlookers) speaker.face_atom(user) speaker.say_context("patdown_onlooker", prisoner) note_staff_event(user, prisoner, "patdown_empty") @@ -637,35 +637,35 @@ return contraband_tension() > 0 ? "word of a shiv" : null /// How much higher `prisoner`'s riot line is (a shiv under their mattress, and some bounty prisoners: outpost_prison_bounty.dm) -/datum/outpost_prison/proc/riot_join_bonus(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/riot_join_bonus(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return (prisoner?.cell?.stash_shiv ? OUTPOST_CONTRABAND_RIOT_JOIN_BONUS : 0) + (prisoner ? prisoner.bounty_riot_bonus() : 0) /** * A rioter draws the shiv from their cell's stash instead of a new one; TRUE if they did. Only * start_rioting() calls this: a stash never arms a threat or a fight (decision 8). */ -/datum/outpost_prison/proc/draw_stashed_shiv(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/draw_stashed_shiv(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison_cell/home = prisoner?.cell if(!home?.stash_shiv || prisoner.has_shiv()) return FALSE home.stash_shiv = FALSE prisoner.drop_held_item() - var/obj/item/knife/shiv/shiv = new(prisoner) - prisoner.held_item = shiv - prisoner.update_melee() - prisoner.update_appearance(UPDATE_OVERLAYS) + // The stash comes out into their hand, a real shiv like any other. + var/obj/item/knife/shiv/shiv = new(prisoner.drop_location()) + if(!prisoner.take_item(shiv)) + return FALSE prisoner.manual_emote("pulls out a shiv [prisoner.p_they()] had been saving.") return TRUE /// Multiplier on the chance these two start a fight: either one drunk -/datum/outpost_prison/proc/contraband_fight_mult(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/contraband_fight_mult(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(one?.contraband_drunk() || two?.contraband_drunk()) return OUTPOST_CONTRABAND_DRUNK_FIGHT_MULT return 1 /// Multiplier on the wing's chance of a spat: anyone drunk /datum/outpost_prison/proc/contraband_spat_mult() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat == CONSCIOUS && prisoner.contraband_drunk()) return OUTPOST_CONTRABAND_DRUNK_SPAT_MULT return 1 @@ -673,13 +673,13 @@ // ===== SPEECH AND EXAMINE ===== /// A drunk line `prisoner` wants to say now, as list(context, other), or null -/datum/outpost_prison/proc/contraband_extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner?.contraband_drunk() && prob(50)) return list("drunk", null) return null /// The tells for examine: a hand kept in a pocket, a drunk's sway; or null -/datum/outpost_prison/proc/contraband_examine(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/contraband_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) if(!prisoner || prisoner.stat == DEAD) return null var/list/tells = list() @@ -699,22 +699,22 @@ UnregisterSignal(thing, list(COMSIG_ATOM_ATTACK_HAND, COMSIG_ATOM_ATTACK_HAND_SECONDARY, COMSIG_ATOM_EXITED)) contraband_hooked.Cut() // Prisoners deleted with the prison never pass through forget() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) var/obj/item/thing = prisoner.carried_contraband prisoner.contraband_clear_carried() qdel(thing) /// A new arrival: half the time they notice what the last occupant left, a little after they arrive -/datum/outpost_prison/proc/contraband_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison_cell/home = prisoner?.cell if(!home || (!home.stash_shiv && !contraband_pruno(home))) return if(!contraband_roll(OUTPOST_CONTRABAND_INHERIT_CHANCE)) return - addtimer(CALLBACK(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, contraband_remark_inherited)), OUTPOST_CONTRABAND_INHERIT_DELAY) + addtimer(CALLBACK(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, contraband_remark_inherited)), OUTPOST_CONTRABAND_INHERIT_DELAY) /// A prisoner is leaving: what they carry goes with them. Their cell's stash stays for the next. -/datum/outpost_prison/proc/contraband_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/obj/item/thing = prisoner.carried_contraband prisoner.contraband_clear_carried() qdel(thing) @@ -734,7 +734,7 @@ )) var/list/drunk = list() var/list/carrying = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.contraband_drunk()) drunk += REF(prisoner) if(prisoner.carried_contraband) diff --git a/voidcrew/modules/player_outposts/outpost_prison_core.dm b/voidcrew/modules/player_outposts/outpost_prison_core.dm index 91f70cae088..82ab99026dc 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_core.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_core.dm @@ -47,7 +47,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/datum/outpost_upgrade/prison/upgrade var/obj/structure/overmap/dynamic/player_outpost/outpost var/area/voidcrew/player_outpost/prison/wing - var/list/mob/living/basic/outpost_prisoner/prisoners = list() + var/list/mob/living/carbon/human/npc/outpost_prisoner/prisoners = list() /// The wing's cells, in number order var/list/datum/outpost_prison_cell/cells = list() /// Prisoners the wing holds at once: one per cell, at most OUTPOST_PRISON_MAX_CAPACITY @@ -108,7 +108,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) wing.prison = null var/list/leaving = prisoners prisoners = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in leaving) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in leaving) close_bounty_record(prisoner, BOUNTY_RECORD_CLOSED) prisoner.prison = null prisoner.cell = null @@ -325,7 +325,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(supply_clock < PRISON_SUPPLY_REFRESH_SECONDS) return supply_clock = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) prisoner.fend_for_self() prisoner.check_sick_call() check_hatch_shortage() @@ -358,7 +358,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) dirty_suits++ // How long it lasts at the usual rates, for the prisoners here (or a full wing while intake is open). var/eaters = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_ARRIVING && prisoner.stat != DEAD && prisoner.trouble != PRISONER_TROUBLE_LOOSE) eaters++ if(!eaters && intake_open) @@ -389,7 +389,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/list/all_hatches = hatches() if(!length(all_hatches)) return - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.trouble) continue var/reaches_hatch = FALSE @@ -442,7 +442,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) * hatch, a table, a cell, the floor), or a cleaner uniform off a serving hatch when `want_uniform` * is set. Anything another prisoner is already fetching is left alone. */ -/datum/outpost_prison/proc/find_supply(mob/living/basic/outpost_prisoner/prisoner, want_uniform = FALSE) +/datum/outpost_prison/proc/find_supply(mob/living/carbon/human/npc/outpost_prisoner/prisoner, want_uniform = FALSE) if(!prisoner.reachable) refresh_prisoner_reach(prisoner) var/obj/item/best @@ -514,14 +514,14 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(user) note_staff_stock(user) // Whoever wants it drops what they were idling at and comes over. - for(var/mob/living/basic/outpost_prisoner/wanting in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/wanting in prisoners) var/datum/prisoner_activity/idle = wanting.activity if(!wanting.ai_running() || !idle?.leisure || !idle.interruptible || idle.sleeping) continue if((food && wanting.wants_food()) || (suits && wanting.wants_clean_uniform())) wanting.end_activity() - var/mob/living/basic/outpost_prisoner/thanker - for(var/mob/living/basic/outpost_prisoner/waiting in prisoners) + var/mob/living/carbon/human/npc/outpost_prisoner/thanker + for(var/mob/living/carbon/human/npc/outpost_prisoner/waiting in prisoners) var/datum/prisoner_activity/hatch_wait/wait = waiting.activity if(!istype(wait) || wait.hatch_ref?.resolve() != hatch) continue @@ -529,11 +529,11 @@ GLOBAL_LIST_EMPTY(outpost_prisons) thanker = waiting if(user) waiting.note_carer(user) - INVOKE_ASYNC(waiting, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, thank), (food && waiting.wants_food()) ? "thanks_food" : "thanks_uniform") + INVOKE_ASYNC(waiting, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, thank), (food && waiting.wants_food()) ? "thanks_food" : "thanks_uniform") break if(!COOLDOWN_FINISHED(src, hatch_call_cooldown)) return null - for(var/mob/living/basic/outpost_prisoner/crier as anything in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/crier as anything in shuffle(prisoners)) if(crier == thanker || crier.stat != CONSCIOUS || crier.phase != PRISONER_PRESENT || crier.in_trouble() || crier.activity?.sleeping) continue var/context @@ -545,7 +545,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) continue COOLDOWN_START(src, hatch_call_cooldown, OUTPOST_PRISON_HATCH_CALL_GAP) note_speech() - INVOKE_ASYNC(crier, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), context) + INVOKE_ASYNC(crier, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), context) return crier return null @@ -554,15 +554,15 @@ GLOBAL_LIST_EMPTY(outpost_prisons) * PRISONER_SHARED_MEAL_WINDOW makes a shared meal: each of them cheers up a little, once, and one * of them says so. Returns the prisoners who got the lift. */ -/datum/outpost_prison/proc/note_table_meal(mob/living/basic/outpost_prisoner/eater) - for(var/mob/living/basic/outpost_prisoner/earlier as anything in table_eaters.Copy()) +/datum/outpost_prison/proc/note_table_meal(mob/living/carbon/human/npc/outpost_prisoner/eater) + for(var/mob/living/carbon/human/npc/outpost_prisoner/earlier as anything in table_eaters.Copy()) if(QDELETED(earlier) || !(earlier in prisoners) || world.time - table_eaters[earlier] > PRISONER_SHARED_MEAL_WINDOW) table_eaters -= earlier table_eaters[eater] = world.time var/list/lifted = list() if(length(table_eaters) < PRISONER_SHARED_MEAL_COUNT) return lifted - for(var/mob/living/basic/outpost_prisoner/diner as anything in table_eaters) + for(var/mob/living/carbon/human/npc/outpost_prisoner/diner as anything in table_eaters) if(diner.shared_meal_at && world.time - diner.shared_meal_at <= PRISONER_SHARED_MEAL_WINDOW) continue diner.shared_meal_at = world.time @@ -572,8 +572,8 @@ GLOBAL_LIST_EMPTY(outpost_prisons) note_shared_meal(lifted) if(length(lifted) && COOLDOWN_FINISHED(src, mess_hall_cooldown)) COOLDOWN_START(src, mess_hall_cooldown, OUTPOST_PRISON_MESS_HALL_GAP) - var/mob/living/basic/outpost_prisoner/speaker = pick(lifted) - INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "mess_hall") + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(lifted) + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "mess_hall") return lifted /** @@ -584,22 +584,22 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(!is_member(shooter) || !COOLDOWN_FINISHED(src, staff_basket_cooldown)) return FALSE var/list/players = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == CONSCIOUS && prisoner.phase == PRISONER_PRESENT && istype(prisoner.activity, /datum/prisoner_activity/basketball)) players += prisoner if(length(players) < 2) return FALSE COOLDOWN_START(src, staff_basket_cooldown, OUTPOST_PRISON_STAFF_BASKET_GAP) note_staff_basket(shooter) - for(var/mob/living/basic/outpost_prisoner/player as anything in players) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player as anything in players) player.adjust_mood(PRISONER_MOOD_STAFF_BASKET) player.face_atom(hoop) - var/mob/living/basic/outpost_prisoner/cheering = pick(players) + var/mob/living/carbon/human/npc/outpost_prisoner/cheering = pick(players) INVOKE_ASYNC(cheering, TYPE_PROC_REF(/atom, manual_emote), pick("whistles.", "claps.", "cheers.")) return TRUE /// The nearest trash bin the prisoner can reach -/datum/outpost_prison/proc/find_bin(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_bin(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.reachable) refresh_prisoner_reach(prisoner) var/obj/structure/closet/crate/bin/best @@ -613,7 +613,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) return best /// The nearest litter lying where the prisoner may walk, off the serving hatches and out of other cells -/datum/outpost_prison/proc/find_litter(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_litter(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.walkable) refresh_prisoner_reach(prisoner) var/obj/item/trash/best @@ -631,7 +631,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) return best /// A basketball the prisoner can get at: their own, loose within reach, or in another player's hands -/datum/outpost_prison/proc/find_ball(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_ball(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(istype(prisoner.held_item, /obj/item/toy/basketball)) return prisoner.held_item if(!prisoner.reachable) @@ -640,13 +640,13 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/obj/item/toy/basketball/ball = locate() in spot if(ball) return ball - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(istype(other.held_item, /obj/item/toy/basketball) && prisoner.walkable?[get_turf(other)]) return other.held_item return null /// The nearest book lying about within reach, not on a shelf -/datum/outpost_prison/proc/find_loose_book(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_loose_book(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.reachable) refresh_prisoner_reach(prisoner) var/obj/item/book/best @@ -681,8 +681,8 @@ GLOBAL_LIST_EMPTY(outpost_prisons) return FALSE /// Whether a prisoner other than `except` is doing an activity of this type -/datum/outpost_prison/proc/anyone_doing(activity_type, mob/living/basic/outpost_prisoner/except) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) +/datum/outpost_prison/proc/anyone_doing(activity_type, mob/living/carbon/human/npc/outpost_prisoner/except) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner != except && istype(prisoner.activity, activity_type)) return TRUE return FALSE @@ -690,7 +690,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) // ===== CLAIMS ===== /// Reserves `thing` for `prisoner`. FALSE if someone else is using it. -/datum/outpost_prison/proc/claim(atom/thing, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/claim(atom/thing, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(claimed_by_other(thing, prisoner)) return FALSE claims[REF(thing)] = prisoner @@ -698,13 +698,13 @@ GLOBAL_LIST_EMPTY(outpost_prisons) /// The prisoner using `thing`, if any /datum/outpost_prison/proc/claimant(atom/thing) - var/mob/living/basic/outpost_prisoner/holder = claims[REF(thing)] + var/mob/living/carbon/human/npc/outpost_prisoner/holder = claims[REF(thing)] if(QDELETED(holder) || !holder.activity) return null return holder -/datum/outpost_prison/proc/claimed_by_other(atom/thing, mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/holder = claimant(thing) +/datum/outpost_prison/proc/claimed_by_other(atom/thing, mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/holder = claimant(thing) return holder && holder != prisoner // ===== SPEECH ===== @@ -722,10 +722,9 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(QDELETED(outpost) || !wing) return tick(seconds_per_tick) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT) continue - prisoner.maybe_drip(seconds_per_tick) if(prisoner.stat != CONSCIOUS) continue if(prisoner.trouble != PRISONER_TROUBLE_LOOSE && SPT_PROB(OUTPOST_PRISON_MESS_CHANCE / 60, seconds_per_tick)) @@ -747,7 +746,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) conditions_tick(seconds) containment_tick(seconds) supply_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT) continue if(prisoner.stat == DEAD) @@ -778,7 +777,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) // ===== COMINGS AND GOINGS ===== /// Drops a prisoner from the roster and their cell, and starts refilling it -/datum/outpost_prison/proc/forget(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/forget(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!(prisoner in prisoners)) return // Whatever else closed their bounty record first decided how it ended (outpost_prison_bounty.dm). @@ -797,6 +796,11 @@ GLOBAL_LIST_EMPTY(outpost_prisons) emptied.occupant = null prisoner.cell = null prisoner.prison = null + // Off the roster, a body is like any human NPC's: cleaned up once it has lain unwatched for a while + // (human_npc_spawning.dm), unless someone is dragging, carrying or watching it. + if(prisoner.stat == DEAD && !QDELETED(prisoner)) + prisoner.npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + SShuman_npcs.track_corpse(prisoner) for(var/key in claims.Copy()) if(claims[key] == prisoner) claims -= key @@ -823,7 +827,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/list/turfs var/list/turf_set /// The prisoner who owns it - var/mob/living/basic/outpost_prisoner/occupant + var/mob/living/carbon/human/npc/outpost_prisoner/occupant /// world.time from which the cell may take a new arrival var/ready_at = 0 diff --git a/voidcrew/modules/player_outposts/outpost_prison_creatures.dm b/voidcrew/modules/player_outposts/outpost_prison_creatures.dm index 1a127916052..56161b68613 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_creatures.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_creatures.dm @@ -1,59 +1,21 @@ /** - * # Experiment creatures + * # Experiment creatures: the tells, and the changeling's horror's base * - * What a serum turns a prisoner into, with tg's looks and mechanics where tg has them: - * - The hulk: a green, swollen prisoner (tg's hulk skin). Punches hard, charges with a 1.5 second - * wind-up and a line on the floor, stuns itself on a wall, tears through interior walls (tg's - * wall_tearer, limited to the outpost's inside) and forces doors. It knocks prisoners flat but - * never kills them. Worn down to a quarter it is exhausted, and a baton or disabler puts it down - * alive for a bigger bonus. - * - The fly person: tg's fly species, flying. It zips about the wing in short random darts, darts - * away from anyone close and from whoever just hit it, and jinks out of the way of some shots while - * it flies freely. Cornered, it bites. It eats whatever food it finds and throws up every few - * seconds. A flyswatter hits it thirty times harder, as it does tg's fly people; a baton knocks it - * to the floor for a moment, and stamina weapons slow it down and then put it down alive. - * - The nightmare: tg's nightmare with its light eater. It spends its first half minute breaking - * the lights, then hunts prisoners in the dark. It heals and dodges in the dark and burns in the - * light, jaunts beside a target through the dark after a warning ripple, and a flash burns it. - * Before the change, the dosed prisoner shows the form's tells (outpost_experiment_tell()). Put - * down, alive or dead, a creature lies on the floor where everyone can see it (show_down()), a - * subdued one twitching, until Kessler's team beams in for it (kessler_collect()). - * - * All of them are basic mobs under /mob/living/basic/outpost_experiment, never megafauna, with no - * sentience, no need for air, and no way to be boxed, teleported, polymorphed or revived. They - * only break the outpost's interior (outpost_experiment_can_smash()). Their AI runs only while - * someone is on the level, like everything else in the wing. + * The serum's forms (the hulk, the fly person and the nightmare) are the subject's own body changed by + * tg's biology: outpost_prison_experiment_forms.dm. This file keeps what the serum shows before it takes + * (outpost_experiment_tell()), the forms' ability hit, and the basic mob the + * changeling's horror is filed under (outpost_prison_horror.dm), which stays a monster (owner ruling OC-4): + * a basic mob, never megafauna, with no sentience, no need for air, and no way to be boxed, teleported or + * polymorphed. It only breaks the outpost's interior (outpost_experiment_can_smash()). */ -/// The creature a serum form becomes -/proc/outpost_experiment_creature_type(form) - switch(form) - if("hulk") - return /mob/living/basic/outpost_experiment/hulk - if("fly") - return /mob/living/basic/outpost_experiment/fly - if("nightmare") - return /mob/living/basic/outpost_experiment/nightmare - return null - -/// Living players on level `z` beyond the first, up to OUTPOST_EXPERIMENT_EXTRA_PLAYERS_MAX, for a creature's health -/proc/outpost_experiment_extra_players(z) - if(!z || z > length(SSmobs.clients_by_zlevel)) - return 0 - var/count = 0 - for(var/mob/living/person as anything in SSmobs.clients_by_zlevel[z]) - if(QDELETED(person) || person.stat == DEAD || !person.client || is_outpost_prisoner(person) || is_outpost_experiment_mob(person)) - continue - count++ - return clamp(count - 1, 0, OUTPOST_EXPERIMENT_EXTRA_PLAYERS_MAX) - // ===== TELLS ===== /** * One tell of what the serum is turning `subject` into, `progress` (0 to 1) through the last * OUTPOST_EXPERIMENT_TELLS seconds. Plain to see, so the crew can fetch the right tool. */ -/proc/outpost_experiment_tell(form, mob/living/basic/outpost_prisoner/subject, progress) +/proc/outpost_experiment_tell(form, mob/living/carbon/human/npc/outpost_prisoner/subject, progress) if(QDELETED(subject) || subject.stat != CONSCIOUS) return subject.Shake(1, 1, 0.5 SECONDS) @@ -86,8 +48,20 @@ subject.manual_emote(pick("flinches away from the light.", "whispers to nobody.", "shades [subject.p_their()] eyes.", "stands very still in the dark.")) playsound(subject, pick('sound/effects/hallucinations/i_see_you1.ogg', 'sound/effects/hallucinations/behind_you1.ogg'), 20, TRUE) +/** + * One of a creature's ability hits on `victim` (the hulk's charge, the nightmare's lunge out of the dark): + * at a body zone, through the armour on it, and able to wound like any other blow of its kind. A blunt hit + * when `sharpness` is NONE. Returns the damage dealt. + */ +/proc/outpost_experiment_ability_hit(mob/living/victim, damage, sharpness = NONE, armour_penetration = 0, zone, atom/attacker) + if(QDELETED(victim)) + return 0 + zone ||= victim.get_random_valid_zone(BODY_ZONE_CHEST) + var/blocked = victim.run_armor_check(zone, MELEE, armour_penetration = armour_penetration) + return victim.apply_damage(damage, BRUTE, zone, blocked, sharpness = sharpness, attack_direction = attacker ? get_dir(attacker, victim) : null) + /// Takes the tells off a subject the serum never finished with -/proc/outpost_experiment_clear_tells(mob/living/basic/outpost_prisoner/subject) +/proc/outpost_experiment_clear_tells(mob/living/carbon/human/npc/outpost_prisoner/subject) if(QDELETED(subject)) return subject.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) @@ -95,9 +69,9 @@ if(subject.current_size != RESIZE_DEFAULT_SIZE) subject.update_transform(RESIZE_DEFAULT_SIZE / subject.current_size) -// ===== THE CREATURE ===== +// ===== THE HORROR'S BASE ===== -/// What a prisoner turned into. Kept at home and out of players' hands; see the file header. +/// What the changeling's specimen grows into is filed under this: kept at home and out of players' hands. /mob/living/basic/outpost_experiment name = "experiment" desc = "What a prisoner turned into." @@ -117,21 +91,15 @@ unsuitable_heat_damage = 0 damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 0, STAMINA = 0, OXY = 0) environment_smash = ENVIRONMENT_SMASH_NONE - ai_controller = /datum/ai_controller/basic_controller/outpost_experiment // tg's human deathgasp death_message = "seizes up and falls limp, their eyes dead and lifeless..." - // Knocked down, subdued or dead, it lies on the floor like a person. + // Knocked down or dead, it lies on the floor like a person. mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT rotate_on_lying = TRUE - /// What the log calls it: "a hulk" + /// What the log calls it var/form_name = "a creature" - /// What everyone sees when it is subdued, after its name - var/subdued_message = "collapses, spent." /// A tell on examine, if any: something anyone watching it would notice var/win_hint - /// Health with one player on the level, and per extra player - var/base_health = 100 - var/health_per_player = 0 /// Dialogue context it says now and then, and seconds to the next line var/line_context var/line_left = 10 @@ -139,31 +107,15 @@ var/datum/outpost_prison/prison /// The prisoner's personality, for its lines var/personality - /// Put down alive - var/subdued = FALSE /// Its footsteps: the footstep element's sound set, volume multiplier and extra range var/footstep_kind = FOOTSTEP_MOB_SHOE var/footstep_volume = 0.5 var/footstep_range = -8 - /// The target it is chasing now, the closest it has gotten to it, where it last stood, and when it last - /// got anywhere: to notice it is stuck (track_reach()) - var/datum/weakref/chase_target_ref - var/chase_best_distance = 0 - var/turf/chase_last_turf - var/chase_progress_time = 0 - /// Targets it has given up on reaching -> world.time it may try them again (find_light(), choose_target()) - var/list/unreachable_until -/mob/living/basic/outpost_experiment/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) - if(subject) - gender = subject.gender +/mob/living/basic/outpost_experiment/Initialize(mapload, datum/outpost_prison/owner) . = ..() prison = owner damage_coeff = damage_coeff.Copy() - if(subject) - personality = subject.personality - real_name = subject.real_name - name = real_name ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) @@ -171,22 +123,15 @@ RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) AddComponent(/datum/component/experiment_damage_ledger) AddElement(/datum/element/footstep, footstep_type = footstep_kind, volume = footstep_volume, e_range = footstep_range) - var/turf/here = get_turf(src) - var/full_health = base_health + health_per_player * outpost_experiment_extra_players(here?.z) - maxHealth = full_health - health = full_health - build_look(subject?.outfit_path || /datum/outfit/outpost_prisoner) + build_look() /mob/living/basic/outpost_experiment/Destroy() prison = null - chase_target_ref = null - chase_last_turf = null - unreachable_until = null return ..() -/// Puts on its look, from the prisoner's outfit +/// Puts on its look /mob/living/basic/outpost_experiment/proc/build_look(outfit_path) - apply_dynamic_human_appearance(src, outfit_path, /datum/species/human) + return /mob/living/basic/outpost_experiment/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER @@ -203,56 +148,45 @@ SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/// Kessler has already written it off: nothing brings it back -/mob/living/basic/outpost_experiment/can_be_revived() - return FALSE - /// Down for good, its body can be dragged about until Kessler collects it /mob/living/basic/outpost_experiment/death(gibbed) . = ..() move_resist = MOVE_RESIST_DEFAULT if(. && !gibbed && !QDELETED(src)) - show_down(FALSE) + balloon_alert_to_viewers("dead") /mob/living/basic/outpost_experiment/examine(mob/user) . = ..() - if(HAS_TRAIT_FROM(src, TRAIT_GODMODE, OUTPOST_KESSLER_TRAIT)) + if(HAS_TRAIT_FROM(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) . += span_notice("Kessler Biolabs is taking [p_them()] away.") - else if(subdued || stat == DEAD) + else if(stat == DEAD) . += span_notice("[p_They()] [p_are()] down. Kessler Biolabs will collect [p_them()] shortly.") else if(win_hint) . += span_notice(win_hint) -/** - * Put down: everyone in sight is told, with a balloon over it. `subdued` is alive (the death - * message has already said the rest); it lies there twitching until Kessler collects it (Life()). - */ -/mob/living/basic/outpost_experiment/proc/show_down(subdued) - if(subdued) - visible_message(span_danger("[src] [subdued_message]")) - balloon_alert_to_viewers(subdued ? "subdued" : "dead") - -/// Twitches on the floor, subdued, waiting for Kessler -/mob/living/basic/outpost_experiment/proc/down_twitch() - Shake(1, 0, 0.4 SECONDS) - if(prob(30)) - manual_emote(pick("twitches.", "groans.", "stirs weakly.")) - /// Health it still had, which a gib or dust takes without it counting as damage on its ledger /mob/living/basic/outpost_experiment/proc/unspent_health() return health /** * Interim turret rule (outpost_prison_riot.dm): outpost turrets shoot a creature wherever it is, - * until it is dead, subdued or in Kessler's hands. Turret damage is not the crew's, so a turret - * kill pays no containment bonus. + * until it is dead or Kessler's beam has it. Turret damage is not the crew's, so a turret kill pays + * no containment bonus. */ /mob/living/basic/outpost_experiment/proc/turret_target() - return stat != DEAD && !subdued && !HAS_TRAIT(src, TRAIT_GODMODE) + return stat != DEAD && !HAS_TRAIT_FROM(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) +/** + * Whether outpost turrets shoot `creature`, an experiment's creature: the horror by its own rule; a body the + * serum took while it is up and fighting (not down for Kessler, not being beamed away). + */ /proc/is_outpost_experiment_turret_target(mob/living/creature) var/mob/living/basic/outpost_experiment/experiment_creature = creature - return istype(experiment_creature) && experiment_creature.turret_target() + if(istype(experiment_creature)) + return experiment_creature.turret_target() + if(!creature?.GetComponent(/datum/component/outpost_experiment_form)) + return FALSE + return !outpost_experiment_down_state(creature) && !HAS_TRAIT_FROM(creature, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) /// Whether its AI runs: only while someone is on the level /mob/living/basic/outpost_experiment/proc/awake() @@ -260,11 +194,7 @@ /mob/living/basic/outpost_experiment/Life(seconds_per_tick = SSMOBS_DT, times_fired) . = ..() - if(subdued && stat != DEAD && !HAS_TRAIT_FROM(src, TRAIT_GODMODE, OUTPOST_KESSLER_TRAIT) && !QDELETED(src)) - if(prob(60)) - down_twitch() - return - if(stat == DEAD || subdued || HAS_TRAIT(src, TRAIT_GODMODE) || !awake()) + if(stat == DEAD || HAS_TRAIT_FROM(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) || !awake()) return creature_life(seconds_per_tick) if(!line_context) @@ -279,1118 +209,3 @@ /// Each second while awake: what it does besides fighting /mob/living/basic/outpost_experiment/proc/creature_life(seconds_per_tick) return - -/** - * Worn out on stamina: down alive on the floor, held there, and the containment bonus for a live - * one. Kessler's team comes for it OUTPOST_EXPERIMENT_PICKUP seconds later. - */ -/mob/living/basic/outpost_experiment/proc/subdue() - if(subdued || stat == DEAD) - return FALSE - subdued = TRUE - move_resist = MOVE_RESIST_DEFAULT - ADD_TRAIT(src, TRAIT_INCAPACITATED, OUTPOST_KESSLER_TRAIT) - ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) - ADD_TRAIT(src, TRAIT_FLOORED, OUTPOST_KESSLER_TRAIT) - ai_controller?.CancelActions() - show_down(TRUE) - prison?.experiment_creature_down(src, subdued = TRUE) - return TRUE - -/mob/living/basic/outpost_experiment/received_stamina_damage(current_level, amount_actual, amount) - . = ..() - if(has_status_effect(/datum/status_effect/incapacitating/stamcrit)) - subdue() - -// ----- targets ----- - -/// Whether it goes after `target`. By default only its victims (is_victim()); the fly adds food, the nightmare lights. -/mob/living/basic/outpost_experiment/proc/can_target(atom/target) - return is_victim(target) - -/// People, borgs and prisoners, never its own kind or Kessler's staff -/mob/living/basic/outpost_experiment/proc/is_victim(atom/target) - var/mob/living/victim = target - if(!istype(victim) || victim == src || victim.stat == DEAD) - return FALSE - if(is_outpost_experiment_mob(victim) || HAS_TRAIT(victim, TRAIT_GODMODE)) - return FALSE - if(is_outpost_prisoner(victim)) - var/mob/living/basic/outpost_prisoner/prisoner = victim - if(prisoner.phase != PRISONER_PRESENT || !may_hit_prisoner(prisoner)) - return FALSE - return !blocked_by_cell(prisoner) - return ishuman(victim) || issilicon(victim) || !isnull(victim.mind) - -/// Whether it hits this prisoner -/mob/living/basic/outpost_experiment/proc/may_hit_prisoner(mob/living/basic/outpost_prisoner/prisoner) - return TRUE - -/// How much less it wants a target than its distance says; prisoners and people may count differently -/mob/living/basic/outpost_experiment/proc/target_penalty(mob/living/target) - return 0 - -/** - * Its target now: whoever hurt it lately, else the nearest it wants in sight. While nobody from the - * wing is home it fights back but hunts nobody, as the horror does (crew_about()), so a loose - * creature is never a trap left for visitors. - */ -/mob/living/basic/outpost_experiment/proc/choose_target() - var/datum/component/experiment_damage_ledger/ledger = GetComponent(/datum/component/experiment_damage_ledger) - var/mob/living/attacker = ledger?.recent_player() - if(attacker && can_target(attacker) && get_dist(src, attacker) <= 9 && can_see(src, attacker, 9) && !target_unreachable(attacker)) - return attacker - if(prison && !prison.crew_home()) - return null - var/mob/living/best - var/best_score = INFINITY - for(var/mob/living/candidate in range(7, src)) - if(!can_target(candidate) || !can_see(src, candidate, 7) || target_unreachable(candidate)) - continue - var/score = get_dist(src, candidate) + target_penalty(candidate) - if(score < best_score) - best = candidate - best_score = score - return best - -/** - * Its AI's first thought each plan: picks the target and may queue something of its own. Returns - * SUBTREE_RETURN_FINISH_PLANNING when that is all it does this plan. - */ -/mob/living/basic/outpost_experiment/proc/ai_think(datum/ai_controller/controller) - var/atom/target = choose_target() - if(target) - controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) - else - controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - return null - -// ----- reach ----- - -/// Whether nothing shut stops it: the hulk smashes through walls and doors, so a bolted cell or a target it can't get to never is out of its reach -/mob/living/basic/outpost_experiment/proc/smashes_through() - return FALSE - -/// Whether it is mid-jaunt or otherwise between motives right now, and should not be judged on progress -/mob/living/basic/outpost_experiment/proc/chasing_paused() - return FALSE - -/// Whether a bolted cell keeps `prisoner` away from it: shut and locked, and it is not inside with them -/mob/living/basic/outpost_experiment/proc/blocked_by_cell(mob/living/basic/outpost_prisoner/prisoner) - if(smashes_through()) - return FALSE - var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(prisoner)) - return holding && holding.is_bolted() && !holding.contains(src) - -/** - * Notes whether it is getting anywhere with `controller`'s current target: closer, beside it (Adjacent(), - * which a shut window or door fails even at a tile's distance), or at least on the move, so a target - * that runs is chased rather than given up on. OUTPOST_EXPERIMENT_STUCK_TIME standing still and apart - * writes the target off as unreachable for OUTPOST_EXPERIMENT_UNREACHABLE_TIME; find_light() and - * choose_target() skip anything on that list. Distance and Adjacent() only: no pathfinding call. - */ -/mob/living/basic/outpost_experiment/proc/track_reach(datum/ai_controller/controller) - if(smashes_through() || chasing_paused()) - return - var/atom/movable/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - if(QDELETED(target)) - chase_target_ref = null - return - if(chase_target_ref?.resolve() != target) - chase_target_ref = WEAKREF(target) - chase_best_distance = get_dist(src, target) - chase_last_turf = loc - chase_progress_time = world.time - return - if(Adjacent(target) || loc != chase_last_turf) - chase_last_turf = loc - chase_progress_time = world.time - return - var/distance = get_dist(src, target) - if(distance < chase_best_distance) - chase_best_distance = distance - chase_progress_time = world.time - return - if(world.time - chase_progress_time < OUTPOST_EXPERIMENT_STUCK_TIME) - return - mark_unreachable(target) - chase_target_ref = null - -/// Whether it gave up on reaching `target` recently -/mob/living/basic/outpost_experiment/proc/target_unreachable(atom/target) - if(!length(unreachable_until)) - return FALSE - var/datum/weakref/ref = WEAKREF(target) - var/until = unreachable_until[ref] - if(!until) - return FALSE - if(until > world.time) - return TRUE - unreachable_until -= ref - return FALSE - -/// Writes `target` off for OUTPOST_EXPERIMENT_UNREACHABLE_TIME: something is stopping it getting there -/mob/living/basic/outpost_experiment/proc/mark_unreachable(atom/target) - LAZYSET(unreachable_until, WEAKREF(target), world.time + OUTPOST_EXPERIMENT_UNREACHABLE_TIME) - prune_unreachable() - -/// Drops expired entries, then the oldest ones if it somehow grew past OUTPOST_EXPERIMENT_UNREACHABLE_MAX -/mob/living/basic/outpost_experiment/proc/prune_unreachable() - for(var/datum/weakref/ref as anything in unreachable_until.Copy()) - if(unreachable_until[ref] <= world.time) - unreachable_until -= ref - while(length(unreachable_until) > OUTPOST_EXPERIMENT_UNREACHABLE_MAX) - var/datum/weakref/oldest - for(var/datum/weakref/ref as anything in unreachable_until) - oldest = ref - break - unreachable_until -= oldest - -// ----- breaking things ----- - -/// Whether it may break `thing` out of its way. Only the hulk does. -/mob/living/basic/outpost_experiment/proc/can_break(atom/thing) - return FALSE - -/// Breaks `thing` out of its way -/mob/living/basic/outpost_experiment/proc/break_obstacle(atom/thing) - return FALSE - -// ===== HULK ===== - -/mob/living/basic/outpost_experiment/hulk - desc = "A prisoner swollen into a green giant, still in the rags of a jumpsuit." - form_name = "a hulk" - mob_size = MOB_SIZE_LARGE - footstep_kind = FOOTSTEP_MOB_HEAVY - footstep_volume = 1 - footstep_range = -4 - status_flags = NONE - base_health = OUTPOST_HULK_HEALTH - health_per_player = OUTPOST_HULK_HEALTH_PER_PLAYER - speed = OUTPOST_HULK_SPEED - melee_damage_lower = OUTPOST_HULK_PUNCH_MIN - melee_damage_upper = OUTPOST_HULK_PUNCH_MAX - melee_attack_cooldown = OUTPOST_HULK_PUNCH_COOLDOWN - obj_damage = 60 - environment_smash = ENVIRONMENT_SMASH_STRUCTURES - attack_verb_continuous = "smashes" - attack_verb_simple = "smash" - attack_sound = 'sound/effects/meteorimpact.ogg' - attack_vis_effect = ATTACK_EFFECT_SMASH - max_stamina = OUTPOST_HULK_STAMINA - line_context = "hulk" - /// Worn down: slower, no charge, and batons work - var/exhausted = FALSE - /// Its charge - var/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/charge - -/mob/living/basic/outpost_experiment/hulk/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) - . = ..() - update_transform(1.25) - charge = new(src) - charge.Grant(src) - ai_controller?.set_blackboard_key(BB_TARGETED_ACTION, charge) - AddElement(/datum/element/wall_tearer/outpost_interior, allow_reinforced = FALSE, tear_time = OUTPOST_HULK_TEAR_TIME) - RegisterSignal(src, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(check_exhausted)) - playsound(src, 'sound/mobs/non-humanoids/gorilla/gorilla.ogg', 80, TRUE) - visible_message(span_danger("[src] swells, splits [p_their()] jumpsuit and roars!")) - -/mob/living/basic/outpost_experiment/hulk/Destroy() - QDEL_NULL(charge) - return ..() - -/mob/living/basic/outpost_experiment/hulk/build_look(outfit_path) - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(outpost_hulk_look), src, outfit_path) - -/// Players first, then prisoners still on their feet -/mob/living/basic/outpost_experiment/hulk/target_penalty(mob/living/target) - return is_outpost_prisoner(target) ? 3 : 0 - -/// Only prisoners still standing: it knocks them flat and leaves them -/mob/living/basic/outpost_experiment/hulk/may_hit_prisoner(mob/living/basic/outpost_prisoner/prisoner) - return !prisoner.can_be_dragged() && prisoner.beaten_left <= 0 - -/// It tears through walls and forces doors, so a bolted cell and a blocked path never stop it -/mob/living/basic/outpost_experiment/hulk/smashes_through() - return TRUE - -/mob/living/basic/outpost_experiment/hulk/ai_think(datum/ai_controller/controller) - . = ..() - var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - if(exhausted || !isliving(target) || !charge?.IsAvailable()) - return - var/distance = get_dist(src, target) - if(distance < 2 || distance > OUTPOST_HULK_CHARGE_RANGE || !can_see(src, target, OUTPOST_HULK_CHARGE_RANGE)) - return - controller.queue_behavior(/datum/ai_behavior/targeted_mob_ability, BB_TARGETED_ACTION, BB_BASIC_MOB_CURRENT_TARGET) - return SUBTREE_RETURN_FINISH_PLANNING - -/// A punch; a prisoner is knocked flat instead, and a person may go flying -/mob/living/basic/outpost_experiment/hulk/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - if(is_outpost_prisoner(target)) - if(!early_melee_attack(target, modifiers, ignore_cooldown)) - return FALSE - do_attack_animation(target, ATTACK_EFFECT_SMASH) - playsound(target, 'sound/items/weapons/punch1.ogg', 60, TRUE) - knock_down_prisoner(target) - return TRUE - . = ..() - if(!. || !isliving(target) || !prob(OUTPOST_HULK_THROW_CHANCE)) - return - var/mob/living/victim = target - if(victim.stat == DEAD || victim.anchored || victim.move_resist >= MOVE_FORCE_OVERPOWERING) - return - victim.visible_message(span_danger("[src] sends [victim] flying!")) - victim.throw_at(get_ranged_target_turf(victim, get_dir(src, victim), OUTPOST_HULK_THROW_RANGE), OUTPOST_HULK_THROW_RANGE, 1, src) - -/// Knocks a prisoner into the beaten state: hurt, flat on the floor, never killed -/mob/living/basic/outpost_experiment/hulk/proc/knock_down_prisoner(mob/living/basic/outpost_prisoner/prisoner) - if(QDELETED(prisoner) || prisoner.stat == DEAD) - return FALSE - var/damage = min(rand(OUTPOST_HULK_PUNCH_MIN, OUTPOST_HULK_PUNCH_MAX), max(0, prisoner.health - 1)) - if(damage > 0) - prisoner.apply_damage(damage, BRUTE) - prisoner.visible_message(span_danger("[src] knocks [prisoner] flat!")) - if(prisoner.stat == CONSCIOUS && prisoner.beaten_left <= 0) - prisoner.collapse() - return TRUE - -/// Worn down to OUTPOST_HULK_EXHAUSTED_AT percent: slower, no more charging, and stamina weapons work -/mob/living/basic/outpost_experiment/hulk/proc/check_exhausted(datum/source) - SIGNAL_HANDLER - if(exhausted || stat == DEAD || health > maxHealth * OUTPOST_HULK_EXHAUSTED_AT / 100) - return - exhausted = TRUE - set_varspeed(OUTPOST_HULK_EXHAUSTED_SPEED) - damage_coeff[STAMINA] = 1 - status_flags |= CANSTUN | CANKNOCKDOWN - charge?.disable() - visible_message(span_warning("[src] staggers, heaving for breath. [p_They()] look[p_s()] spent.")) - -/// Charged into a wall: stunned, and hit harder for a moment -/mob/living/basic/outpost_experiment/hulk/proc/stagger_on_wall() - Stun(OUTPOST_HULK_WALL_STUN, ignore_canstun = TRUE) - damage_coeff[BRUTE] = OUTPOST_HULK_WALL_VULNERABLE - damage_coeff[BURN] = OUTPOST_HULK_WALL_VULNERABLE - addtimer(CALLBACK(src, PROC_REF(recover_from_wall)), OUTPOST_HULK_WALL_STUN, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_DELETE_ME) - -/mob/living/basic/outpost_experiment/hulk/proc/recover_from_wall() - damage_coeff[BRUTE] = 1 - damage_coeff[BURN] = 1 - -/// Interior walls, windows, grilles, tables, chairs and doors, never the hatch counters or anything protected -/mob/living/basic/outpost_experiment/hulk/can_break(atom/thing) - if(QDELETED(thing) || !outpost_experiment_can_smash(thing, prison)) - return FALSE - if(isturf(thing)) - return iswallturf(thing) && !istype(thing, /turf/closed/wall/r_wall) && !isindestructiblewall(thing) - var/obj/object = thing - if(!istype(object) || (object.resistance_flags & INDESTRUCTIBLE) || HAS_TRAIT(object, TRAIT_OUTPOST_PROPERTY)) - return FALSE - if(istype(object, /obj/structure/table/reinforced/prison_hatch)) - return FALSE - return istype(object, /obj/structure/window) || istype(object, /obj/structure/grille) || istype(object, /obj/structure/table) \ - || istype(object, /obj/structure/chair) || istype(object, /obj/machinery/door) - -/mob/living/basic/outpost_experiment/hulk/break_obstacle(atom/thing) - if(!can_break(thing)) - return FALSE - if(isturf(thing)) - // The wall tearer takes it from here, in three pulls. - melee_attack(thing) - return TRUE - if(istype(thing, /obj/machinery/door/airlock)) - INVOKE_ASYNC(src, PROC_REF(force_door), thing) - return TRUE - smash(thing) - return TRUE - -/// Batters a door until it gives: bolts sheared, weld broken, forced open -/mob/living/basic/outpost_experiment/hulk/proc/force_door(obj/machinery/door/airlock/door) - if(DOING_INTERACTION_WITH_TARGET(src, door) || !door.density) - return FALSE - visible_message(span_danger("[src] batters at [door]!")) - playsound(door, 'sound/effects/bang.ogg', 70, TRUE) - door.Shake(2, 2, 0.5 SECONDS) - if(!do_after(src, OUTPOST_HULK_DOOR_TIME, target = door) || QDELETED(door) || !can_break(door)) - return FALSE - if(door.locked) - door.unbolt() - if(door.welded) - door.welded = FALSE - door.update_appearance() - door.visible_message(span_danger("[src] forces [door] open!")) - playsound(door, 'sound/machines/airlock/airlock_alien_prying.ogg', 80, TRUE) - INVOKE_ASYNC(door, TYPE_PROC_REF(/obj/machinery/door, open), BYPASS_DOOR_CHECKS) - prison?.refresh_reach() - return TRUE - -/// Puts something through: windows shatter, tables fold, chairs splinter, window doors break -/mob/living/basic/outpost_experiment/hulk/proc/smash(obj/thing) - if(QDELETED(thing)) - return FALSE - do_attack_animation(thing, ATTACK_EFFECT_SMASH) - visible_message(span_danger("[src] smashes [thing]!")) - playsound(thing, istype(thing, /obj/structure/window) ? 'sound/effects/glass/glassbr1.ogg' : 'sound/effects/woodhit.ogg', 70, TRUE) - thing.take_damage(thing.get_integrity(), BRUTE, MELEE, FALSE, get_dir(thing, src)) - return TRUE - -/// tg's hulk skin on the prisoner's own clothes, cached per outfit -/proc/outpost_hulk_look(mob/living/basic/outpost_experiment/hulk/hulk, outfit_path) - var/static/list/looks = list() - var/key = "[outfit_path]" - var/mutable_appearance/look = looks[key] - if(!look) - var/mob/living/carbon/human/dummy/consistent/dummy = new() - dummy.set_species(/datum/species/human) - dummy.stat = DEAD - dummy.underwear = "Nude" - dummy.undershirt = "Nude" - dummy.socks = "Nude" - if(outfit_path) - dummy.equipOutfit(outfit_path, visuals_only = TRUE) - for(var/obj/item/bodypart/part as anything in dummy.bodyparts) - part.add_color_override(COLOR_DARK_LIME, LIMB_COLOR_HULK) - dummy.update_body_parts() - look = new(dummy.appearance) - looks[key] = look - qdel(dummy) - if(QDELETED(hulk)) - return - hulk.icon = 'icons/mob/human/human.dmi' - hulk.icon_state = "" - hulk.appearance_flags |= KEEP_TOGETHER - hulk.copy_overlays(look, cut_old = TRUE) - -/// The hulk's charge: a roar, a stamp and a line on the floor, then six tiles of it -/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk - name = "Charge" - desc = "Put your head down and run through whatever is in the way." - cooldown_time = OUTPOST_HULK_CHARGE_COOLDOWN - charge_delay = OUTPOST_HULK_CHARGE_WINDUP - charge_distance = OUTPOST_HULK_CHARGE_RANGE - charge_past = 1 - charge_damage = OUTPOST_HULK_CHARGE_DAMAGE - destroy_objects = FALSE - shake_duration = OUTPOST_HULK_CHARGE_WINDUP - recoil_duration = OUTPOST_HULK_WALL_STUN - knockdown_duration = OUTPOST_HULK_CHARGE_KNOCKDOWN - -/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/do_charge_indicator(atom/charger, atom/charge_target) - . = ..() - playsound(charger, 'sound/mobs/non-humanoids/gorilla/gorilla.ogg', 80, TRUE) - charger.visible_message(span_danger("[charger] roars and stamps, lowering [charger.p_their()] head!")) - var/direction = get_dir(charger, charge_target) - var/turf/step_turf = get_turf(charger) - for(var/i in 1 to charge_distance) - step_turf = get_step(step_turf, direction) - if(!step_turf || isclosedturf(step_turf)) - break - new /obj/effect/temp_visual/telegraphing/outpost_hulk_charge(step_turf) - -/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/on_bump(atom/movable/source, atom/target) - var/mob/living/basic/outpost_experiment/hulk/hulk = source - if(istype(hulk) && isobj(target) && target.density && !istype(target, /obj/machinery/door/airlock) && hulk.can_break(target)) - INVOKE_ASYNC(hulk, TYPE_PROC_REF(/mob/living/basic/outpost_experiment/hulk, smash), target) - return - return ..() - -/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/on_moved(atom/source) - . = ..() - var/mob/living/basic/outpost_experiment/hulk/hulk = source - if(!istype(hulk)) - return - for(var/obj/structure/chair/chair in get_turf(hulk)) - if(hulk.can_break(chair)) - INVOKE_ASYNC(hulk, TYPE_PROC_REF(/mob/living/basic/outpost_experiment/hulk, smash), chair) - -/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/hit_target(atom/movable/source, atom/target, damage_dealt) - var/mob/living/basic/outpost_experiment/hulk/hulk = source - if(is_outpost_prisoner(target)) - hulk?.knock_down_prisoner(target) - return - if(isliving(target)) - var/mob/living/victim = target - victim.apply_damage(damage_dealt, BRUTE, wound_bonus = CANT_WOUND) - playsound(get_turf(victim), 'sound/effects/meteorimpact.ogg', 80, TRUE) - return ..() - hulk?.visible_message(span_danger("[hulk] slams into [target] and reels!")) - playsound(get_turf(source), 'sound/effects/bang.ogg', 80, TRUE) - hulk?.stagger_on_wall() - -/// The floor in the charge's path, for the length of the wind-up -/obj/effect/temp_visual/telegraphing/outpost_hulk_charge - duration = OUTPOST_HULK_CHARGE_WINDUP - color = "#9ae05a" - -/// tg's wall tearer, for walls inside the outpost only -/datum/element/wall_tearer/outpost_interior - -/datum/element/wall_tearer/outpost_interior/validate_target(atom/target, mob/living/tearer) - . = ..() - if(. != TRUE) - return - var/mob/living/basic/outpost_experiment/hulk/hulk = tearer - if(!istype(hulk) || !hulk.can_break(target)) - target.balloon_alert(tearer, "it won't give!") - return -1 - -// ===== FLY PERSON ===== - -/mob/living/basic/outpost_experiment/fly - desc = "A prisoner turned into something like a fly: bulging eyes, a proboscis, and a buzz you can hear across the room." - form_name = "a fly person" - base_health = OUTPOST_FLY_HEALTH - speed = OUTPOST_FLY_SPEED - melee_damage_lower = OUTPOST_FLY_BITE - melee_damage_upper = OUTPOST_FLY_BITE - melee_attack_cooldown = 1.5 SECONDS - attack_verb_continuous = "bites" - attack_verb_simple = "bite" - attack_sound = 'sound/items/weapons/bite.ogg' - attack_vis_effect = ATTACK_EFFECT_BITE - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 0, STAMINA = 1, OXY = 0) - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - max_stamina = OUTPOST_FLY_STAMINA - line_context = "fly" - ai_controller = /datum/ai_controller/basic_controller/outpost_experiment/fly - death_message = "drops out of the air and goes still." - subdued_message = "drops out of the air, stunned." - win_hint = "It never lands for long." - /// Seconds to its next heave - var/vomit_left = OUTPOST_FLY_VOMIT_MIN - /// REF() of prisoners who watched it throw up -> world.time - var/list/disgusted = list() - /// world.time of its next random dart, and by when the dart under way must be over - var/next_dart = 0 - var/dart_until = 0 - /// Whoever hit it last, whom it keeps away from until flit_until - var/datum/weakref/flit_from_ref - var/flit_until = 0 - -/mob/living/basic/outpost_experiment/fly/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) - . = ..() - RegisterSignal(src, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(swatter_weakness)) - RegisterSignal(src, COMSIG_PROJECTILE_PREHIT, PROC_REF(dodge)) - RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) - ADD_TRAIT(src, TRAIT_MOVE_FLYING, OUTPOST_FLY_TRAIT) - playsound(src, 'sound/mobs/non-humanoids/bee/bee_swarm.ogg', 60, TRUE) - visible_message(span_danger("[src] folds up, splits open and unfolds as something with wings!")) - -/mob/living/basic/outpost_experiment/fly/Destroy() - flit_from_ref = null - return ..() - -/mob/living/basic/outpost_experiment/fly/build_look(outfit_path) - apply_dynamic_human_appearance(src, outfit_path, /datum/species/fly) - -/// On the floor (knocked down, subdued or dead) it is not flying -/mob/living/basic/outpost_experiment/fly/on_lying_down(new_lying_angle) - . = ..() - REMOVE_TRAIT(src, TRAIT_MOVE_FLYING, OUTPOST_FLY_TRAIT) - -/// Back up, it takes off again -/mob/living/basic/outpost_experiment/fly/on_standing_up() - . = ..() - if(stat == CONSCIOUS && !subdued) - ADD_TRAIT(src, TRAIT_MOVE_FLYING, OUTPOST_FLY_TRAIT) - -/mob/living/basic/outpost_experiment/fly/down_twitch() - Shake(1, 0, 0.4 SECONDS) - playsound(src, 'sound/mobs/non-humanoids/bee/bee.ogg', 15, TRUE) - if(prob(40)) - manual_emote(pick("buzzes weakly.", "twitches [p_their()] wings.", "twitches.")) - -/// tg's fly people take thirty times the damage from a flyswatter -/mob/living/basic/outpost_experiment/fly/proc/swatter_weakness(datum/source, list/damage_mods, damage_amount, damagetype, def_zone, sharpness, attack_direction, obj/item/attacking_item) - SIGNAL_HANDLER - if(istype(attacking_item, /obj/item/melee/flyswatter)) - damage_mods += OUTPOST_FLY_SWATTER_MULT - -/// In the air and free to move: not knocked down, stunned, held, pulled, subdued or being taken away -/mob/living/basic/outpost_experiment/fly/proc/flying_freely() - if(stat != CONSCIOUS || subdued || !isturf(loc) || pulledby || buckled || body_position != STANDING_UP) - return FALSE - return !HAS_TRAIT(src, TRAIT_IMMOBILIZED) && !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_GODMODE) - -/// Jinks out of the way of some shots while it flies freely -/mob/living/basic/outpost_experiment/fly/proc/dodge(datum/source, obj/projectile/shot) - SIGNAL_HANDLER - if(!flying_freely() || !prob(OUTPOST_FLY_DODGE)) - return NONE - visible_message(span_warning("[src] jinks out of the way of [shot]!")) - playsound(src, 'sound/mobs/non-humanoids/bee/bee.ogg', 30, TRUE) - return PROJECTILE_INTERRUPT_HIT_PHASE - -/// Hit while flying: it flits away from whoever did it, and keeps away from them for a few seconds -/mob/living/basic/outpost_experiment/fly/proc/on_attacked(datum/source, atom/attacker, attack_flags) - SIGNAL_HANDLER - if(!attacker || attacker == src || !flying_freely()) - return - flit_from_ref = WEAKREF(attacker) - flit_until = world.time + OUTPOST_FLY_FLIT_TIME - // Once the blow is over, not in the middle of it. - addtimer(CALLBACK(src, PROC_REF(flit)), 1, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_DELETE_ME) - -/// Whoever hit it within OUTPOST_FLY_FLIT_TIME, if still near enough to keep away from -/mob/living/basic/outpost_experiment/fly/proc/flit_threat() - if(world.time > flit_until) - return null - var/atom/movable/attacker = flit_from_ref?.resolve() - if(QDELETED(attacker) || attacker.z != z || get_dist(src, attacker) > OUTPOST_FLY_DART_MAX + OUTPOST_FLY_FLEE_RANGE) - return null - return attacker - -/// Darts away from whoever just hit it, at once rather than at its next thought. Returns TRUE if it went. -/mob/living/basic/outpost_experiment/fly/proc/flit() - var/atom/attacker = flit_threat() - if(!attacker || !flying_freely() || !ai_controller || !awake()) - return FALSE - var/turf/spot = dart_spot(attacker) - if(!spot) - return FALSE - ai_controller.CancelActions() - ai_controller.queue_behavior(/datum/ai_behavior/outpost_fly_dart/away, spot) - return TRUE - -/// It goes for food and bites only when cornered: its "targets" are meals, and people it cannot get away from -/mob/living/basic/outpost_experiment/fly/can_target(atom/target) - if(istype(target, /obj/item/food)) - return isturf(target.loc) - return ..() - -/// Whoever is closest within OUTPOST_FLY_FLEE_RANGE, to keep away from -/mob/living/basic/outpost_experiment/fly/proc/nearest_threat() - var/mob/living/nearest - var/nearest_distance = INFINITY - for(var/mob/living/person in range(OUTPOST_FLY_FLEE_RANGE, src)) - if(person == src || !is_victim(person)) - continue - var/distance = get_dist(src, person) - if(distance < nearest_distance) - nearest = person - nearest_distance = distance - return nearest - -/** - * Somewhere to dart to: an open tile OUTPOST_FLY_DART_MIN to OUTPOST_FLY_DART_MAX tiles off, in a - * clear line, on ground it keeps to (may_dart_to()). Away from `threat`, only somewhere farther - * from it than it is now, and out of its OUTPOST_FLY_FLEE_RANGE if it can. Null if there is - * nowhere: cornered. - */ -/mob/living/basic/outpost_experiment/fly/proc/dart_spot(atom/threat) - var/turf/here = get_turf(src) - if(!here) - return null - var/threat_distance = threat ? get_dist(here, threat) : 0 - var/list/spots = list() - var/list/clear_spots = list() - for(var/turf/open/tile in RANGE_TURFS(OUTPOST_FLY_DART_MAX, here)) - if(get_dist(here, tile) < OUTPOST_FLY_DART_MIN) - continue - var/from_threat = threat ? get_dist(tile, threat) : INFINITY - if(from_threat <= threat_distance || !may_dart_to(tile) || !clear_line(here, tile)) - continue - spots += tile - if(from_threat > OUTPOST_FLY_FLEE_RANGE) - clear_spots += tile - if(length(clear_spots)) - return pick(clear_spots) - return length(spots) ? pick(spots) : null - -/** - * Whether it keeps to `tile`: free and safe, and in the wing (its cell block, while it is in - * there) like the other creatures. Got out of the wing, it darts about the room it is in. - */ -/mob/living/basic/outpost_experiment/fly/proc/may_dart_to(turf/tile) - if(isspaceturf(tile) || isopenspaceturf(tile) || tile.is_blocked_turf(FALSE, src) || !tile.can_cross_safely(src)) - return FALSE - var/turf/here = get_turf(src) - if(prison?.wing && here.loc == prison.wing) - return tile.loc == prison.wing && (!prison.in_cell_block(here) || prison.in_cell_block(tile)) - return tile.loc == here.loc - -/// Whether it can fly straight from `start` to `finish`: nothing solid or opaque in the way -/mob/living/basic/outpost_experiment/fly/proc/clear_line(turf/start, turf/finish) - for(var/turf/step as anything in get_line(start, finish)) - if(step == start) - continue - if(step.opacity || step.is_blocked_turf(TRUE, src)) - return FALSE - return TRUE - -/** - * Keeps moving: darts away from anyone close or who just hit it, and bites only when cornered - * with someone at it. Otherwise it goes for food in sight, or darts about at random with short - * pauses. It hovers (outpost_fly_hover) rather than planning nothing, so it notices people at once. - */ -/mob/living/basic/outpost_experiment/fly/ai_think(datum/ai_controller/controller) - if(!flying_freely()) - controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - controller.queue_behavior(/datum/ai_behavior/outpost_fly_hover) - return SUBTREE_RETURN_FINISH_PLANNING - var/atom/threat = flit_threat() || nearest_threat() - if(threat) - var/turf/away = dart_spot(threat) - if(away) - controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - controller.queue_behavior(/datum/ai_behavior/outpost_fly_dart/away, away) - return SUBTREE_RETURN_FINISH_PLANNING - // Nowhere farther to go: it bites whoever is at it. - var/mob/living/at_it = threat - if(istype(at_it) && Adjacent(at_it) && can_target(at_it)) - controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, at_it) - return null - controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - controller.queue_behavior(/datum/ai_behavior/outpost_fly_hover) - return SUBTREE_RETURN_FINISH_PLANNING - var/obj/item/food/meal = find_meal() - if(meal) - controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, meal) - return null - controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - var/turf/spot = world.time >= next_dart ? dart_spot() : null - if(spot) - controller.queue_behavior(/datum/ai_behavior/outpost_fly_dart, spot) - return SUBTREE_RETURN_FINISH_PLANNING - if(world.time >= next_dart) - next_dart = world.time + rand(OUTPOST_FLY_DART_GAP_MIN, OUTPOST_FLY_DART_GAP_MAX) - controller.queue_behavior(/datum/ai_behavior/outpost_fly_hover) - return SUBTREE_RETURN_FINISH_PLANNING - -/// The nearest food in sight: a serving hatch, a table or the floor -/mob/living/basic/outpost_experiment/fly/proc/find_meal() - var/obj/item/food/best - var/best_distance = INFINITY - for(var/obj/item/food/meal in range(5, src)) - if(!isturf(meal.loc) || !can_see(src, meal, 5)) - continue - var/distance = get_dist(src, meal) - if(distance < best_distance) - best = meal - best_distance = distance - return best - -/mob/living/basic/outpost_experiment/fly/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - var/obj/item/food/meal = target - if(istype(meal)) - if(!early_melee_attack(target, modifiers, ignore_cooldown)) - return FALSE - visible_message(span_warning("[src] slurps up [meal].")) - playsound(src, 'sound/items/eatfood.ogg', 40, TRUE) - if(isturf(meal.loc)) - new /obj/effect/decal/cleanable/food/crumbs(meal.loc) - qdel(meal) - return TRUE - return ..() - -/// It throws up every few seconds, now and then over someone beside it; prisoners who see it lose a little mood -/mob/living/basic/outpost_experiment/fly/creature_life(seconds_per_tick) - vomit_left -= seconds_per_tick - if(vomit_left > 0 || !isturf(loc)) - return - vomit_left = rand(OUTPOST_FLY_VOMIT_MIN, OUTPOST_FLY_VOMIT_MAX) - throw_up() - -/mob/living/basic/outpost_experiment/fly/proc/throw_up() - var/turf/spot = loc - var/list/beside = list() - for(var/mob/living/person in orange(1, src)) - if(is_victim(person)) - beside += person - if(length(beside) && prob(OUTPOST_FLY_VOMIT_ON_CHANCE)) - var/mob/living/unlucky = pick(beside) - spot = get_turf(unlucky) - visible_message(span_danger("[src] throws up all over [unlucky]!")) - else - visible_message(span_warning("[src] throws up.")) - playsound(src, 'sound/mobs/humanoids/human/gag_vomit/crack_vomit.ogg', 50, TRUE) - if(isopenturf(spot)) - new /obj/effect/decal/cleanable/vomit(spot) - for(var/mob/living/basic/outpost_prisoner/watcher in viewers(5, src)) - if(watcher.stat != CONSCIOUS || LAZYACCESS(disgusted, REF(watcher)) > world.time) - continue - LAZYSET(disgusted, REF(watcher), world.time + OUTPOST_EXPERIMENT_FLY_DISGUST_GAP) - watcher.adjust_mood(-OUTPOST_EXPERIMENT_FLY_DISGUST_MOOD) - return spot - -// ===== NIGHTMARE ===== - -/mob/living/basic/outpost_experiment/nightmare - desc = "A shape like a person cut out of the dark, with a long blade where one arm should be." - form_name = "a nightmare" - footstep_kind = FOOTSTEP_MOB_BAREFOOT - footstep_volume = 0.3 - base_health = OUTPOST_NIGHTMARE_HEALTH - health_per_player = OUTPOST_NIGHTMARE_HEALTH_PER_PLAYER - speed = OUTPOST_NIGHTMARE_DARK_SPEED - melee_damage_lower = OUTPOST_NIGHTMARE_DAMAGE - melee_damage_upper = OUTPOST_NIGHTMARE_DAMAGE - melee_attack_cooldown = OUTPOST_NIGHTMARE_ATTACK_COOLDOWN - armour_penetration = OUTPOST_NIGHTMARE_ARMOUR_PENETRATION - attack_verb_continuous = "slashes" - attack_verb_simple = "slash" - attack_sound = 'sound/items/weapons/bladeslice.ogg' - attack_vis_effect = ATTACK_EFFECT_SLASH - sharpness = SHARP_EDGED - lighting_cutoff = LIGHTING_CUTOFF_HIGH - /// Seconds left of breaking the lights before it hunts - var/opening_left = OUTPOST_NIGHTMARE_OPENING_MIN - /// Mid jaunt: gone into the dark, about to come out beside someone - var/jaunting = FALSE - /// Where it will come out, and at whom - var/turf/jaunt_spot - var/datum/weakref/jaunt_target_ref - /// Its last light reading was dark - var/in_shadow = TRUE - COOLDOWN_DECLARE(jaunt_cooldown) - -/mob/living/basic/outpost_experiment/nightmare/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) - . = ..() - opening_left = rand(OUTPOST_NIGHTMARE_OPENING_MIN, OUTPOST_NIGHTMARE_OPENING_MAX) - RegisterSignal(src, COMSIG_PROJECTILE_PREHIT, PROC_REF(dodge)) - RegisterSignal(src, COMSIG_ATOM_ATTACKBY, PROC_REF(on_attackby)) - playsound(src, 'sound/effects/hallucinations/wail.ogg', 60, TRUE) - visible_message(span_danger("The light seems to drain out of [src], and something else stands where [p_they()] stood.")) - -/mob/living/basic/outpost_experiment/nightmare/Destroy() - jaunt_spot = null - return ..() - -/mob/living/basic/outpost_experiment/nightmare/build_look(outfit_path) - apply_dynamic_human_appearance(src, /datum/outfit/outpost_experiment_nightmare, /datum/species/shadow/nightmare) - -/// Whether the tile it stands on is dark -/mob/living/basic/outpost_experiment/nightmare/proc/in_dark(turf/tile = get_turf(src)) - return tile && tile.get_lumcount() < OUTPOST_NIGHTMARE_DARK - -/// Each second: heal in the dark and move fast, burn in the light and move slow; the opening runs down -/mob/living/basic/outpost_experiment/nightmare/creature_life(seconds_per_tick) - if(opening_left > 0) - opening_left -= seconds_per_tick - in_shadow = in_dark() - set_varspeed(in_shadow ? OUTPOST_NIGHTMARE_DARK_SPEED : OUTPOST_NIGHTMARE_LIGHT_SPEED) - if(in_shadow) - if(health < maxHealth) - adjustBruteLoss(-OUTPOST_NIGHTMARE_REGEN * seconds_per_tick) - return - if(jaunting) - cancel_jaunt() - var/burn = OUTPOST_NIGHTMARE_BURN * seconds_per_tick - adjustFireLoss(burn) - // The crew's lights are their weapon. - var/datum/component/experiment_damage_ledger/ledger = GetComponent(/datum/component/experiment_damage_ledger) - ledger?.add_damage(burn, TRUE) - if(prob(20)) - visible_message(span_warning("[src] smokes and shrinks from the light.")) - -/// Whether it is still breaking the lights: for its first half minute, until it is hurt, or until there are none -/mob/living/basic/outpost_experiment/nightmare/proc/in_opening() - if(opening_left <= 0) - return FALSE - var/datum/component/experiment_damage_ledger/ledger = GetComponent(/datum/component/experiment_damage_ledger) - if(ledger && (ledger.player_damage + ledger.other_damage + ledger.pending) > 0) - opening_left = 0 - return FALSE - return TRUE - -/// Prisoners first, standing or down; it finishes what it starts -/mob/living/basic/outpost_experiment/nightmare/target_penalty(mob/living/target) - return is_outpost_prisoner(target) ? -3 : 0 - -/// Mid-jaunt it is immobile by design, not stuck -/mob/living/basic/outpost_experiment/nightmare/chasing_paused() - return jaunting - -/// Lights, during the opening -/mob/living/basic/outpost_experiment/nightmare/can_target(atom/target) - if(istype(target, /obj/machinery/light)) - var/obj/machinery/light/fixture = target - return fixture.status != LIGHT_BROKEN && fixture.status != LIGHT_EMPTY - return ..() - -/// The nearest working light in sight -/mob/living/basic/outpost_experiment/nightmare/proc/find_light() - var/obj/machinery/light/best - var/best_distance = INFINITY - for(var/obj/machinery/light/fixture in range(9, src)) - if(!can_target(fixture) || !can_see(src, fixture, 9) || target_unreachable(fixture)) - continue - var/distance = get_dist(src, fixture) - if(distance < best_distance) - best = fixture - best_distance = distance - return best - -/mob/living/basic/outpost_experiment/nightmare/ai_think(datum/ai_controller/controller) - if(jaunting) - controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - return SUBTREE_RETURN_FINISH_PLANNING - if(in_opening()) - var/obj/machinery/light/fixture = find_light() - if(fixture) - controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, fixture) - return null - opening_left = 0 - . = ..() - var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - if(!isliving(target) || get_dist(src, target) <= 1) - return - var/turf/spot = jaunt_spot_near(target) - if(!spot) - return - start_jaunt(target, spot) - return SUBTREE_RETURN_FINISH_PLANNING - -/// A light eater hit; a light is simply put out, and a flashlight in the victim's hands may burn out for good -/mob/living/basic/outpost_experiment/nightmare/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - var/obj/machinery/light/fixture = target - if(istype(fixture)) - if(!early_melee_attack(target, modifiers, ignore_cooldown)) - return FALSE - do_attack_animation(fixture, ATTACK_EFFECT_SLASH) - if(fixture.status == LIGHT_OK || fixture.status == LIGHT_BURNED) - fixture.visible_message(span_danger("[src] cuts through [fixture], and it goes dark.")) - fixture.break_light_tube() - return TRUE - . = ..() - if(. && isliving(target)) - eat_held_light(target) - -/// OUTPOST_NIGHTMARE_BURNOUT_CHANCE to put out a flashlight `victim` holds for good; never a flare or glowstick -/mob/living/basic/outpost_experiment/nightmare/proc/eat_held_light(mob/living/victim) - if(!prob(OUTPOST_NIGHTMARE_BURNOUT_CHANCE)) - return null - for(var/obj/item/flashlight/light in victim.held_items) - if(istype(light, /obj/item/flashlight/flare) || istype(light, /obj/item/flashlight/glowstick) || !light.light_on) - continue - if(SEND_SIGNAL(light, COMSIG_LIGHT_EATER_ACT, src) & COMPONENT_BLOCK_LIGHT_EATER) - continue - light.AddElement(/datum/element/light_eaten) - victim.visible_message(span_danger("[src]'s blade passes through [light], and it dies in [victim]'s hand.")) - return light - return null - -/// Sidesteps most shots in the dark -/mob/living/basic/outpost_experiment/nightmare/proc/dodge(datum/source, obj/projectile/shot) - SIGNAL_HANDLER - if(stat != CONSCIOUS || !in_dark() || !prob(OUTPOST_NIGHTMARE_DODGE)) - return NONE - visible_message(span_warning("[src] melts aside from [shot]!")) - return PROJECTILE_INTERRUPT_HIT_PHASE - -/// A handheld flash burns it, staggers it and throws it out of a jaunt -/mob/living/basic/outpost_experiment/nightmare/proc/on_attackby(datum/source, obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) - SIGNAL_HANDLER - var/obj/item/assembly/flash/flash = attacking_item - if(!istype(flash) || stat == DEAD || flash.burnt_out || world.time < flash.last_trigger + flash.cooldown) - return NONE - INVOKE_ASYNC(flash, TYPE_PROC_REF(/obj/item/assembly/flash, try_use_flash), user) - INVOKE_ASYNC(src, PROC_REF(flashed), user) - return COMPONENT_NO_AFTERATTACK - -/mob/living/basic/outpost_experiment/nightmare/proc/flashed(mob/living/user) - var/datum/component/experiment_damage_ledger/ledger = GetComponent(/datum/component/experiment_damage_ledger) - ledger?.note_attacker(user) - cancel_jaunt() - visible_message(span_danger("[src] shrieks and recoils from the flash!")) - apply_damage(OUTPOST_NIGHTMARE_FLASH_DAMAGE, BURN) - Stun(OUTPOST_NIGHTMARE_FLASH_STAGGER, ignore_canstun = TRUE) - -/// A dark tile beside `target` it could jaunt to, if the jaunt is ready and it stands in the dark -/mob/living/basic/outpost_experiment/nightmare/proc/jaunt_spot_near(mob/living/target) - if(jaunting || !COOLDOWN_FINISHED(src, jaunt_cooldown) || !in_dark()) - return null - var/list/spots = list() - for(var/turf/open/tile in orange(1, target)) - if(tile.is_blocked_turf(TRUE) || !in_dark(tile) || !prison?.outpost_holds(tile)) - continue - spots += tile - return length(spots) ? pick(spots) : null - -/// Fades into the dark; a ripple and a whisper warn where it will come out, OUTPOST_NIGHTMARE_JAUNT_RIPPLE later -/mob/living/basic/outpost_experiment/nightmare/proc/start_jaunt(mob/living/target, turf/spot) - jaunting = TRUE - jaunt_spot = spot - jaunt_target_ref = WEAKREF(target) - COOLDOWN_START(src, jaunt_cooldown, OUTPOST_NIGHTMARE_JAUNT_COOLDOWN) - ADD_TRAIT(src, TRAIT_IMMOBILIZED, REF(src)) - ai_controller?.CancelActions() - animate(src, alpha = 0, time = 0.3 SECONDS) - new /obj/effect/temp_visual/telegraphing/outpost_shadow_ripple(spot) - playsound(spot, pick('sound/effects/hallucinations/behind_you1.ogg', 'sound/effects/hallucinations/behind_you2.ogg', 'sound/effects/hallucinations/i_see_you2.ogg'), 35, TRUE) - addtimer(CALLBACK(src, PROC_REF(finish_jaunt)), OUTPOST_NIGHTMARE_JAUNT_RIPPLE, TIMER_DELETE_ME) - -/// Comes out beside the target, if the spot is still dark, and strikes -/mob/living/basic/outpost_experiment/nightmare/proc/finish_jaunt() - if(!jaunting) - return FALSE - var/turf/spot = jaunt_spot - var/mob/living/target = jaunt_target_ref?.resolve() - end_jaunt() - if(stat == DEAD || !spot || !in_dark(spot) || spot.is_blocked_turf(TRUE)) - return FALSE - forceMove(spot) - if(!target || !Adjacent(target) || !can_target(target)) - return TRUE - face_atom(target) - do_attack_animation(target, ATTACK_EFFECT_SLASH) - playsound(target, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) - target.visible_message(span_danger("[src] lunges out of the dark at [target]!"), span_userdanger("[src] lunges out of the dark at you!")) - target.apply_damage(OUTPOST_NIGHTMARE_JAUNT_DAMAGE, BRUTE, wound_bonus = CANT_WOUND) - target.Knockdown(OUTPOST_NIGHTMARE_JAUNT_KNOCKDOWN) - return TRUE - -/// Thrown out of a jaunt (light, a flash): back where it went in -/mob/living/basic/outpost_experiment/nightmare/proc/cancel_jaunt() - if(!jaunting) - return - end_jaunt() - visible_message(span_warning("[src] is forced out of the shadows!")) - -/mob/living/basic/outpost_experiment/nightmare/proc/end_jaunt() - jaunting = FALSE - jaunt_spot = null - jaunt_target_ref = null - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, REF(src)) - animate(src, alpha = 255, time = 0.2 SECONDS) - -/// Where the nightmare will come out of the dark -/obj/effect/temp_visual/telegraphing/outpost_shadow_ripple - icon = 'icons/mob/telegraphing/telegraph.dmi' - icon_state = "target_circle" - color = "#2a0a3a" - light_range = 0 - duration = OUTPOST_NIGHTMARE_JAUNT_RIPPLE - -/// The nightmare's look: tg's light eater in hand -/datum/outfit/outpost_experiment_nightmare - name = "Prison experiment nightmare" - r_hand = /obj/item/light_eater - -// ===== AI ===== - -/// Chases what the creature's own ai_think() picks, breaks what it may out of the way, and fights -/datum/ai_controller/basic_controller/outpost_experiment - blackboard = list( - BB_TARGETING_STRATEGY = /datum/targeting_strategy/outpost_experiment, - BB_TARGET_MINIMUM_STAT = HARD_CRIT, - ) - ai_movement = /datum/ai_movement/jps - idle_behavior = /datum/idle_behavior/idle_random_walk - planning_subtrees = list( - /datum/ai_planning_subtree/outpost_experiment_think, - /datum/ai_planning_subtree/attack_obstacle_in_path/outpost_experiment, - /datum/ai_planning_subtree/basic_melee_attack_subtree, - ) - -/// The fly person moves only in its own darts: no aimless steps, which would also crawl it about while knocked down -/datum/ai_controller/basic_controller/outpost_experiment/fly - idle_behavior = null - -/// The fly person's darts: straight hops that start at once -/datum/ai_movement/basic_avoidance/outpost_fly - move_flags = MOVEMENT_LOOP_START_FAST - -/** - * A fly person's dart to the turf it picked (dart_spot()): done when it gets there, given up after - * OUTPOST_FLY_DART_TIMEOUT. Planning goes on meanwhile, so a dart away from someone can cut a - * random one short. A random dart is followed by a short pause. - */ -/datum/ai_behavior/outpost_fly_dart - required_distance = 0 - action_cooldown = 0 - behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION - -/datum/ai_behavior/outpost_fly_dart/setup(datum/ai_controller/controller, turf/destination) - var/mob/living/basic/outpost_experiment/fly/fly = controller.pawn - if(!istype(fly) || !isturf(destination) || destination.z != fly.z) - return FALSE - fly.dart_until = world.time + OUTPOST_FLY_DART_TIMEOUT - set_movement_target(controller, destination, /datum/ai_movement/basic_avoidance/outpost_fly) - return ..() - -/datum/ai_behavior/outpost_fly_dart/perform(seconds_per_tick, datum/ai_controller/controller, turf/destination) - var/mob/living/basic/outpost_experiment/fly/fly = controller.pawn - if(!istype(fly) || world.time >= fly.dart_until) - return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED - if(get_turf(fly) == destination) - return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED - return AI_BEHAVIOR_DELAY - -/datum/ai_behavior/outpost_fly_dart/finish_action(datum/ai_controller/controller, succeeded, turf/destination) - // A dart that took over from this one has set its own movement; leave that be. - var/moving_for_us = controller.movement_target_source == type - . = ..() - if(moving_for_us) - controller.change_ai_movement_type(initial(controller.ai_movement)) - var/mob/living/basic/outpost_experiment/fly/fly = controller.pawn - if(istype(fly)) - fly.next_dart = world.time + pause_after() - -/// How long it hangs in the air after this dart before the next random one -/datum/ai_behavior/outpost_fly_dart/proc/pause_after() - return rand(OUTPOST_FLY_DART_GAP_MIN, OUTPOST_FLY_DART_GAP_MAX) - -/// Away from someone: no pause after it -/datum/ai_behavior/outpost_fly_dart/away - -/datum/ai_behavior/outpost_fly_dart/away/pause_after() - return 0 - -/// The fly person hanging in the air between darts. Planning goes on, so it notices anyone coming at once. -/datum/ai_behavior/outpost_fly_hover - action_cooldown = 0 - behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION - -/datum/ai_behavior/outpost_fly_hover/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_experiment/fly/fly = controller.pawn - if(!istype(fly) || world.time >= fly.next_dart) - return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED - return AI_BEHAVIOR_DELAY - -/// The creature decides what it is after -/datum/ai_planning_subtree/outpost_experiment_think - -/datum/ai_planning_subtree/outpost_experiment_think/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_experiment/creature = controller.pawn - if(!istype(creature) || creature.subdued) - return SUBTREE_RETURN_FINISH_PLANNING - creature.track_reach(controller) - return creature.ai_think(controller) - -/// Only what the creature may break -/datum/ai_planning_subtree/attack_obstacle_in_path/outpost_experiment - attack_behaviour = /datum/ai_behavior/attack_obstructions/outpost_experiment - -/datum/ai_behavior/attack_obstructions/outpost_experiment - -/datum/ai_behavior/attack_obstructions/outpost_experiment/attack_in_direction(datum/ai_controller/controller, mob/living/basic/basic_mob, direction) - var/mob/living/basic/outpost_experiment/creature = basic_mob - if(!istype(creature)) - return FALSE - var/turf/next_step = get_step(basic_mob, direction) - if(!next_step?.is_blocked_turf(exclude_mobs = TRUE, source_atom = basic_mob)) - return FALSE - for(var/obj/object in next_step) - if(!object.density || object.IsObscured() || !creature.can_break(object)) - continue - return creature.break_obstacle(object) - if(isclosedturf(next_step) && creature.can_break(next_step)) - return creature.break_obstacle(next_step) - return FALSE - -/// A creature's targets are whatever its can_target() says, in reach on its level -/datum/targeting_strategy/outpost_experiment - -/datum/targeting_strategy/outpost_experiment/can_attack(mob/living/living_mob, atom/target, vision_range) - var/mob/living/basic/outpost_experiment/creature = living_mob - if(!istype(creature) || QDELETED(target) || !isturf(creature.loc)) - return FALSE - var/turf/target_turf = get_turf(target) - if(!target_turf || target_turf.z != creature.z) - return FALSE - if(vision_range && get_dist(creature, target) > vision_range) - return FALSE - return creature.can_target(target) diff --git a/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm b/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm index 6b4665a7f4b..c4231e4e93b 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm @@ -33,7 +33,7 @@ return islist(entry) ? entry : list() /// Scales a prisoner's pause between spontaneous lines -/mob/living/basic/outpost_prisoner/proc/speech_pace() +/mob/living/carbon/human/npc/outpost_prisoner/proc/speech_pace() switch(personality) if("chatty") return 0.6 @@ -46,7 +46,7 @@ return 1 /// Their sentence left in words, for {time_left} -/mob/living/basic/outpost_prisoner/proc/time_left_text() +/mob/living/carbon/human/npc/outpost_prisoner/proc/time_left_text() var/seconds = max(0, round(sentence_left)) var/minutes = round(seconds / 60 + 0.5) if(minutes >= 2) @@ -56,7 +56,7 @@ return "[max(seconds, 1)] seconds" /// Fills a line's placeholders for this speaker, talking to `other` -/mob/living/basic/outpost_prisoner/proc/fill_line(line, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/fill_line(line, mob/living/carbon/human/npc/outpost_prisoner/other) line = replacetext(line, "{name}", speech_name()) line = replacetext(line, "{other}", other ? other.speech_name() : "pal") line = replacetext(line, "{crime}", crime) @@ -70,7 +70,7 @@ * A line for `context`, filled in, or null when the file has none that fits. Personality lines * come up more often than the shared ones; lines naming {other} need someone to talk to. */ -/mob/living/basic/outpost_prisoner/proc/pick_line(context, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/pick_line(context, mob/living/carbon/human/npc/outpost_prisoner/other) // The main file first, then the extras' files (outpost_prison_extras.dm) var/list/by_personality = outpost_prisoner_context_lines(context) if(!islist(by_personality)) @@ -86,7 +86,7 @@ return fill_line(pick(usable), other) /// The lines of `pool` they can say now: not the last thing they said, naming nobody when alone, and no extra placeholder they have no value for -/mob/living/basic/outpost_prisoner/proc/usable_lines(list/pool, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/usable_lines(list/pool, mob/living/carbon/human/npc/outpost_prisoner/other) var/list/usable = list() for(var/line in pool) if(!istext(line) || line == last_line || (!other && findtext(line, "{other}"))) @@ -97,7 +97,7 @@ return usable /// Whether `line` names an extra placeholder ({staff}, {place}, ...) that extra_line_values has no value for -/mob/living/basic/outpost_prisoner/proc/missing_extra_value(line) +/mob/living/carbon/human/npc/outpost_prisoner/proc/missing_extra_value(line) for(var/placeholder in GLOB.outpost_prisoner_extra_placeholders) if(findtext(line, placeholder) && isnull(LAZYACCESS(extra_line_values, placeholder))) return TRUE @@ -107,11 +107,11 @@ * Whether they may say anything now: awake and all the way here. Never while beaming in or out, * when they are invisible or only half there. */ -/mob/living/basic/outpost_prisoner/proc/may_speak() +/mob/living/carbon/human/npc/outpost_prisoner/proc/may_speak() return !QDELETED(src) && stat == CONSCIOUS && phase == PRISONER_PRESENT /// Says a line for `context`. Returns TRUE if they said something. -/mob/living/basic/outpost_prisoner/proc/say_context(context, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/say_context(context, mob/living/carbon/human/npc/outpost_prisoner/other) if(!may_speak()) return FALSE var/line = pick_line(context, other) @@ -122,7 +122,7 @@ return TRUE /// Thanks whoever fed, clothed or treated them, no more than every 20 seconds -/mob/living/basic/outpost_prisoner/proc/thank(context) +/mob/living/carbon/human/npc/outpost_prisoner/proc/thank(context) if(phase != PRISONER_PRESENT || !COOLDOWN_FINISHED(src, thanks_cooldown)) return FALSE if(!say_context(context)) @@ -134,7 +134,7 @@ * Opens a two-person exchange with `partner`, who answers a few seconds later with one of the * opener's replies. Returns TRUE if it started. */ -/mob/living/basic/outpost_prisoner/proc/start_conversation(mob/living/basic/outpost_prisoner/partner) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_conversation(mob/living/carbon/human/npc/outpost_prisoner/partner) if(!may_speak() || QDELETED(partner) || !partner.may_speak()) return FALSE var/list/conversations = outpost_prisoner_dialogue("conversations") @@ -153,8 +153,8 @@ return TRUE /// The second half of a conversation -/mob/living/basic/outpost_prisoner/proc/reply_in_conversation(line, datum/weakref/opener_ref) - var/mob/living/basic/outpost_prisoner/opener = opener_ref?.resolve() +/mob/living/carbon/human/npc/outpost_prisoner/proc/reply_in_conversation(line, datum/weakref/opener_ref) + var/mob/living/carbon/human/npc/outpost_prisoner/opener = opener_ref?.resolve() if(!may_speak() || QDELETED(opener) || get_dist(src, opener) > 5) return FALSE face_atom(opener) @@ -163,15 +163,15 @@ return TRUE /// Whether a member of staff (see is_outpost_prison_staff()) is in sight, borgs included -/mob/living/basic/outpost_prisoner/proc/staff_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/staff_in_view() for(var/mob/living/person in view(5, src)) if(is_outpost_prison_staff(person)) return TRUE return FALSE /// Another prisoner close by, to talk at -/mob/living/basic/outpost_prisoner/proc/nearby_prisoner() - for(var/mob/living/basic/outpost_prisoner/other in view(3, src)) +/mob/living/carbon/human/npc/outpost_prisoner/proc/nearby_prisoner() + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in view(3, src)) if(other != src && other.stat == CONSCIOUS && other.phase == PRISONER_PRESENT) return other return null @@ -180,18 +180,18 @@ * What to talk about now, as list(context, other), most pressing first: cuffs, trouble, lockdown, * being locked in, needs, the state of the wing, staff in sight, what they are doing, then small talk. */ -/mob/living/basic/outpost_prisoner/proc/pick_speech() +/mob/living/carbon/human/npc/outpost_prisoner/proc/pick_speech() if(activity?.sleeping) return prob(25) ? list("sleeping", null) : null // Cuffed, they complain about it (outpost_prison_capture.dm). - if(cuffs && prob(50)) + if(handcuffed && prob(50)) return list("cuffed", null) // Trouble has its own lines, said as it happens (outpost_prison_trouble.dm). if(trouble == PRISONER_TROUBLE_LOOSE) return prob(50) ? list("breakout", null) : null if(is_rioting()) return list("riot", null) - if(trouble || beaten_left > 0 || threat_ref || climb_ref) + if(trouble || beaten || threat_ref || climb_ref) return null // Running from a creature, they shout about that instead (outpost_prison_panic.dm). if(is_panicking()) @@ -230,7 +230,7 @@ return extra if(prob(30) && staff_in_view()) return list("staff_near", null) - var/mob/living/basic/outpost_prisoner/partner = activity?.chat_partner() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = activity?.chat_partner() if(activity?.context && prob(60)) return list(activity.context, partner) return list("idle", partner || nearby_prisoner()) @@ -239,7 +239,7 @@ * Called about once a second by the prison. Most seconds they say nothing; once their own and * the wing's cooldowns are up there is a small chance each second. */ -/mob/living/basic/outpost_prisoner/proc/speech_tick() +/mob/living/carbon/human/npc/outpost_prisoner/proc/speech_tick() if(stat != CONSCIOUS || phase != PRISONER_PRESENT || !prison) return FALSE if(!said_release_soon && sentence_left <= 90) @@ -290,7 +290,7 @@ return 1 /// A piece of mess on the worst tile of the cell block's floor they can see nearby, or null -/mob/living/basic/outpost_prisoner/proc/worst_mess_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/worst_mess_in_view() var/list/tile_load = list() var/atom/movable/worst var/worst_load = 0 @@ -308,7 +308,7 @@ return worst /// The nearest light in the wing they can see that gives no light, or null -/mob/living/basic/outpost_prisoner/proc/dead_light_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/dead_light_in_view() var/obj/machinery/light/nearest var/nearest_distance = INFINITY for(var/obj/machinery/light/fixture in view(PRISONER_POINT_RANGE, src)) @@ -326,7 +326,7 @@ * After a dirty_prison or dark line, with staff in sight, points at what they mean: the worst mess * or the dead light near them. Returns what they pointed at, or null. */ -/mob/living/basic/outpost_prisoner/proc/show_complaint(context) +/mob/living/carbon/human/npc/outpost_prisoner/proc/show_complaint(context) if(context != "dirty_prison" && context != "dark") return null if(stat != CONSCIOUS || !isturf(loc) || !staff_in_view()) diff --git a/voidcrew/modules/player_outposts/outpost_prison_doors.dm b/voidcrew/modules/player_outposts/outpost_prison_doors.dm index d9005801156..0e49bf6ef6f 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_doors.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_doors.dm @@ -101,7 +101,7 @@ MAPPING_DIRECTIONAL_HELPERS(/obj/machinery/door/window/brigdoor/outpost_prison_s /// Whether `thing` is a loose prisoner: out of the cell block and on the run /proc/outpost_prisoner_loose(atom/thing) - var/mob/living/basic/outpost_prisoner/prisoner = thing + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = thing return istype(prisoner) && prisoner.trouble == PRISONER_TROUBLE_LOOSE /obj/machinery/door/airlock/security/prison_staff/glass diff --git a/voidcrew/modules/player_outposts/outpost_prison_economy.dm b/voidcrew/modules/player_outposts/outpost_prison_economy.dm index 501f1c0c41d..f8ee9c662f0 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_economy.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_economy.dm @@ -61,7 +61,7 @@ * long enough to stop paying. Not while it is for their own safety, or they are hiding in their own * cell from a creature (outpost_prison_panic.dm), or for lockdown they owe. */ -/datum/outpost_prison/proc/confined_unpaid(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/confined_unpaid(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return (prisoner.locked_in_seconds > OUTPOST_PRISON_CONFINED_PAY_AFTER && !protective_custody() && !prisoner.sheltering_from_creature()) || prisoner.cuffs_souring() /** @@ -69,7 +69,7 @@ * Nothing while they are dead, arriving or leaving, not earning (fighting, rioting, loose and the * like), or confined to their cell too long. */ -/datum/outpost_prison/proc/pay_factor(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/pay_factor(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT || !prisoner.earning_pay()) return 0 if(confined_unpaid(prisoner)) @@ -77,7 +77,7 @@ return care_grade(prisoner.care()) * conditions_pay_factor() /// A prisoner served `served` seconds of their sentence: their stipend for it, and the seconds served and kept for the release bonus -/datum/outpost_prison/proc/accrue_pay(mob/living/basic/outpost_prisoner/prisoner, served) +/datum/outpost_prison/proc/accrue_pay(mob/living/carbon/human/npc/outpost_prisoner/prisoner, served) var/factor = pay_factor(prisoner) pay_owed += OUTPOST_PRISON_BASE_PAY * bounty_pay_mult(prisoner) * factor * served / 60 prisoner.served_seconds += served @@ -116,13 +116,13 @@ return 0 /// What a prisoner earns the treasury per minute right now -/datum/outpost_prison/proc/prisoner_pay_rate(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/prisoner_pay_rate(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return OUTPOST_PRISON_BASE_PAY * bounty_pay_mult(prisoner) * pay_factor(prisoner) /// What every prisoner earns the treasury per minute right now /datum/outpost_prison/proc/pay_rate() var/total = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) total += prisoner_pay_rate(prisoner) return total @@ -201,8 +201,8 @@ /datum/outpost_prison/proc/incident_ongoing() if(riot_active) return TRUE - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) - if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && (prisoner.is_rioting() || (prisoner.trouble == PRISONER_TROUBLE_LOOSE && !prisoner.cuffs))) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) + if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && (prisoner.is_rioting() || (prisoner.trouble == PRISONER_TROUBLE_LOOSE && !prisoner.handcuffed))) return TRUE return FALSE @@ -213,7 +213,7 @@ * OUTPOST_PRISON_LOST_TO_SUSPEND lost within OUTPOST_PRISON_LOST_WINDOW, the corrections service * suspends transfers until a manager reopens intake. */ -/datum/outpost_prison/proc/note_prisoner_lost(mob/living/basic/outpost_prisoner/prisoner, reason) +/datum/outpost_prison/proc/note_prisoner_lost(mob/living/carbon/human/npc/outpost_prisoner/prisoner, reason) lost_log += world.time var/lost = lost_recently() log_game("PLAYER OUTPOST PRISON: '[outpost?.name]' lost [prisoner?.real_name || "a prisoner"] ([reason || "lost"]), [lost] lost recently") @@ -401,7 +401,7 @@ if(!spot) continue // An ordinary arrival may be a caught bounty criminal from the pool; the admin panel's never is (outpost_prison_bounty.dm). - var/mob/living/basic/outpost_prisoner/prisoner = new(spot, forced ? null : bounty_pool_take(src)) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = new(spot, forced ? null : bounty_pool_take(src)) admit(prisoner, cell) prisoner.beam_in() start_arrival_gap(lane || soonest_lane()) @@ -410,7 +410,7 @@ return null /// Books a prisoner into a cell (the first free one when none is given), in the mood the yard puts newcomers in -/datum/outpost_prison/proc/admit(mob/living/basic/outpost_prisoner/prisoner, datum/outpost_prison_cell/into) +/datum/outpost_prison/proc/admit(mob/living/carbon/human/npc/outpost_prisoner/prisoner, datum/outpost_prison_cell/into) if(!into) for(var/datum/outpost_prison_cell/cell as anything in cells) if(!cell.occupant) @@ -435,7 +435,7 @@ * After a tick of a prisoner's sentence: released once it has run out, and sent back to their * cell in its last OUTPOST_PRISON_RELEASE_WALK seconds. */ -/datum/outpost_prison/proc/check_release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/check_release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.sentence_left <= 0) release(prisoner) else if(prisoner.sentence_left <= OUTPOST_PRISON_RELEASE_WALK && !istype(prisoner.activity, /datum/prisoner_activity/go_home) && !prisoner.in_trouble()) @@ -443,7 +443,7 @@ prisoner.end_activity() /// Sentence served: pays the release bonus and beams the prisoner out of wherever they stand -/datum/outpost_prison/proc/release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) return 0 var/average = prisoner.served_seconds ? prisoner.kept_seconds / prisoner.served_seconds : 0 @@ -461,7 +461,7 @@ * A prisoner died. Nobody comes for the body (body_tick()): it keeps its cell until staff carry it * out of the cell block. A death blamed on staff costs no fine, but the yard takes it hard (blame_death()). */ -/datum/outpost_prison/proc/on_prisoner_death(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_prisoner_death(mob/living/carbon/human/npc/outpost_prisoner/prisoner) add_log("[prisoner.real_name] died.") close_bounty_record(prisoner, BOUNTY_RECORD_DEAD) prisoner.died_at = world.time @@ -478,7 +478,7 @@ * it leaves the roster, and its cell waits a refill. An experiment under way that still needs it, * a specimen host about to burst, keeps it on the roster wherever it is. */ -/datum/outpost_prison/proc/body_tick(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/body_tick(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(in_cell_block(prisoner) || held_for_experiment(prisoner)) return add_log("[prisoner.real_name]'s body is out of the cell block.") @@ -487,7 +487,7 @@ /// Prisoners' bodies lying in the cell block, which nobody comes for /datum/outpost_prison/proc/bodies_in_cell_block() . = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == DEAD && prisoner.phase == PRISONER_PRESENT && in_cell_block(prisoner)) .++ @@ -512,7 +512,7 @@ */ /datum/outpost_prison/proc/on_outpost_abandoned() var/out_of_hand = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && (prisoner.is_rioting() || prisoner.trouble == PRISONER_TROUBLE_LOOSE)) out_of_hand++ if(out_of_hand) @@ -524,7 +524,7 @@ lost_log.Cut() pay_owed = 0 var/transferred = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase == PRISONER_LEAVING) continue close_bounty_record(prisoner, BOUNTY_RECORD_CLOSED) diff --git a/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm b/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm new file mode 100644 index 00000000000..e62fdce1d11 --- /dev/null +++ b/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm @@ -0,0 +1,1258 @@ +/** + * # What the serum makes of a prisoner + * + * The serum (outpost_prison_experiments.dm) works on the subject's own body, with tg's own biology + * (human NPC migration, phase E2; owner defaults EX-1 to EX-15): + * - The hulk: tg's hulk gene (it shrugs off stuns and shoves, punches walls down and people about, and + * loses it when the body drops into crit) and tg's gigantism for its size. It charges with a roar and a + * line on the floor, staggers when it hits a wall, forces doors, smashes furniture in its way and knocks + * standing prisoners flat. Beaten into crit it is an ordinary person again, and batons and cuffs work. + * - The fly person: tg's fly species (a flyswatter does thirty times the damage, and it throws up what it + * eats). It darts about the wing, away from anyone close and from whoever just hit it, hits back only + * when cornered, and goes for any food it sees. Its sidestep is a Slipwire implant (owner ruling OC-6), + * which burns out with it like any NPC's chrome. + * - The nightmare: tg's nightmare species and organs (it heals and slips shots in the dark, burns in the + * light, a flash sears it, its light eater breaks lights and puts out torches). It spends its first + * half minute breaking the lights, then hunts prisoners, sinking into the dark and coming out beside one + * after a warning ripple. + * + * The body stays a body: wounds, blood, crit, cuffs, stamina, sedatives, death and revival are tg's. Its + * toughness is physiology (npc_damage_taken), never health, and never scaled by who is about. Its hits on + * people go through armour and wound like anyone's (EX-7). Kessler's team collects it only once it is down + * (outpost_experiment_down_state()), and pays by the state it finds it in. + * + * The form is a component on the prisoner's own body (human NPC phase E1): the serum turns the inmate, who + * leaves the prison's roster (transform_subject(), outpost_prisoner/proc/stand_down_for_experiment()) but + * keeps their name, face, wounds and blood. + */ + +/// Trait source for what the form puts on a body +#define OUTPOST_EXPERIMENT_FORM_TRAIT "outpost_experiment_form" +/// Blackboard key of the food a fly person is going for +#define BB_OUTPOST_FLY_MEAL "BB_outpost_fly_meal" + +// ===== TURNING ===== + +/// The form component for a serum form, or null +/proc/outpost_experiment_form_type(form) + switch(form) + if("hulk") + return /datum/component/outpost_experiment_form/hulk + if("fly") + return /datum/component/outpost_experiment_form/fly + if("nightmare") + return /datum/component/outpost_experiment_form/nightmare + return null + +/// What a creature is: a serum form ("hulk", "fly", "nightmare"), the changeling's "horror" or "headslug", or null +/proc/outpost_experiment_form_name(mob/living/creature) + if(!isliving(creature)) + return null + var/datum/component/outpost_experiment_form/form = creature.GetComponent(/datum/component/outpost_experiment_form) + if(form) + return form.form + if(istype(creature, /mob/living/basic/outpost_experiment/horror)) + return "horror" + if(istype(creature, /mob/living/basic/headslug)) + return "headslug" + return null + +/// Whether `creature` is an experiment's creature: a body the serum took, or the changeling's horror +/proc/is_outpost_experiment_creature(mob/living/creature) + if(!isliving(creature)) + return FALSE + return istype(creature, /mob/living/basic/outpost_experiment) || !!creature.GetComponent(/datum/component/outpost_experiment_form) + +/** + * Turns `body` into serum `form` for `prison` where it stands. Returns the form, or null if it didn't take. + * `personality` is the prisoner's, for its lines. + */ +/proc/outpost_experiment_turn(mob/living/carbon/human/body, form, datum/outpost_prison/prison, personality) + var/form_type = outpost_experiment_form_type(form) + if(!form_type || !ishuman(body) || QDELETED(body) || body.stat == DEAD) + return null + // One form to a body. + if(body.GetComponent(/datum/component/outpost_experiment_form)) + return null + var/datum/component/outpost_experiment_form/turned = body.AddComponent(form_type, prison, personality) + if(!istype(turned)) + return null + turned.change_body() + return turned + +// ===== THE FORM ===== + +/** + * A serum form on a body: its biology, its mind (an AI) and its side, what it says and does now and then, + * and how it chooses whom to go for. See the file header. + */ +/datum/component/outpost_experiment_form + dupe_mode = COMPONENT_DUPE_UNIQUE + /// "hulk", "fly" or "nightmare" + var/form + /// What the log calls it: "a hulk" + var/form_name = "a creature" + /// Brute and burn damage it takes as a multiple of a person's (physiology, npc_damage_taken) + var/damage_taken = 1 + /// The AI it thinks with + var/controller_type = /datum/ai_controller/basic_controller/human_npc/outpost_experiment + /// Dialogue context it says now and then, and seconds to the next line + var/line_context + var/line_left = 10 + /// A tell on examine: something anyone watching it would notice + var/win_hint + /// What everyone sees when it goes down alive, after its name + var/down_message = "collapses, spent." + /// Emotes it gives now and then lying there alive, waiting for Kessler + var/list/down_emotes = list("twitches.", "groans.", "stirs weakly.") + /// The prison it came from + var/datum/outpost_prison/prison + /// The prisoner's personality, for its lines + var/personality + /// The body has changed (change_body()) + var/changed = FALSE + /// The target it is chasing now, the closest it has got to it, where it last stood, and when it last got + /// anywhere: to notice it is stuck (track_reach()) + var/datum/weakref/chase_target_ref + var/chase_best_distance = 0 + var/turf/chase_last_turf + var/chase_progress_time = 0 + /// Targets it has given up on reaching -> world.time it may try them again + var/list/unreachable_until + +/datum/component/outpost_experiment_form/Initialize(datum/outpost_prison/owner, personality) + if(!ishuman(parent)) + return COMPONENT_INCOMPATIBLE + prison = owner + src.personality = personality + +/datum/component/outpost_experiment_form/RegisterWithParent() + var/mob/living/carbon/human/body = parent + body.add_traits(list(TRAIT_OUTPOST_EXPERIMENT, TRAIT_NO_CONTAINMENT, TRAIT_NO_STORAGE_INSERT), OUTPOST_EXPERIMENT_FORM_TRAIT) + RegisterSignal(body, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) + RegisterSignal(body, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) + RegisterSignal(body, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) + RegisterSignal(body, COMSIG_LIVING_LIFE, PROC_REF(on_life)) + RegisterSignal(body, COMSIG_ATOM_EXAMINE, PROC_REF(on_examine)) + +/datum/component/outpost_experiment_form/UnregisterFromParent() + var/mob/living/carbon/human/body = parent + body.remove_traits(list(TRAIT_OUTPOST_EXPERIMENT, TRAIT_NO_CONTAINMENT, TRAIT_NO_STORAGE_INSERT), OUTPOST_EXPERIMENT_FORM_TRAIT) + UnregisterSignal(body, list(COMSIG_MOVABLE_TELEPORTING, COMSIG_LIVING_PRE_WABBAJACKED, COMSIG_PRE_MOB_CHANGED_TYPE, COMSIG_LIVING_LIFE, COMSIG_ATOM_EXAMINE)) + +/datum/component/outpost_experiment_form/Destroy(force) + prison = null + chase_target_ref = null + chase_last_turf = null + unreachable_until = null + return ..() + +/** + * The change: the tells come off, the body takes the form's biology (change_biology()), its side, its + * toughness and its AI, and everyone in sight sees it happen. Once. + */ +/datum/component/outpost_experiment_form/proc/change_body() + if(changed) + return + changed = TRUE + var/mob/living/carbon/human/body = parent + outpost_experiment_clear_tells(body) + var/old_name = body.real_name + change_biology(body) + // A species can rename the body (nightmares do); it is still the prisoner. + if(body.real_name != old_name) + body.fully_replace_character_name(body.real_name, old_name) + body.faction = list(FACTION_OUTPOST_EXPERIMENT) + if(ishumannpc(body)) + var/mob/living/carbon/human/npc/npc = body + npc.set_npc_toughness(damage_taken) + QDEL_NULL(body.ai_controller) + var/datum/ai_controller/brain = new controller_type(body) + setup_controller(brain) + announce_change(body) + +/// What the form does to the body's biology. Each form's own. +/datum/component/outpost_experiment_form/proc/change_biology(mob/living/carbon/human/body) + return + +/// Readies a new AI for the form: its abilities' blackboard keys +/datum/component/outpost_experiment_form/proc/setup_controller(datum/ai_controller/brain) + return + +/// What everyone in sight sees as the serum takes +/datum/component/outpost_experiment_form/proc/announce_change(mob/living/carbon/human/body) + return + +/datum/component/outpost_experiment_form/proc/refuse_teleport(datum/source, atom/destination, channel) + SIGNAL_HANDLER + var/mob/living/body = parent + if(isturf(body.loc)) + body.visible_message(span_notice("[body] flickers for a moment, but stays where [body.p_they()] [body.p_are()].")) + return TRUE + +/datum/component/outpost_experiment_form/proc/refuse_polymorph(datum/source, what_to_randomize) + SIGNAL_HANDLER + var/mob/living/body = parent + body.visible_message(span_notice("[body] shimmers for a moment, then looks the same as before.")) + return STOP_WABBAJACK + +/datum/component/outpost_experiment_form/proc/refuse_type_change(datum/source) + SIGNAL_HANDLER + return COMPONENT_BLOCK_MOB_CHANGE + +/datum/component/outpost_experiment_form/proc/on_examine(datum/source, mob/user, list/examine_list) + SIGNAL_HANDLER + var/mob/living/body = parent + if(HAS_TRAIT_FROM(body, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) + examine_list += span_notice("Kessler Biolabs is taking [body.p_them()] away.") + else if(prison?.experiment_awaiting_pickup(body)) + examine_list += span_notice("[body.p_They()] [body.p_are()] down. Kessler Biolabs will collect [body.p_them()] shortly.") + else if(win_hint && body.stat == CONSCIOUS) + examine_list += span_notice(win_hint) + +/// Whether its AI runs: only while someone is on the level, like everything else in the wing +/datum/component/outpost_experiment_form/proc/awake() + var/mob/living/body = parent + return body.ai_controller?.ai_status == AI_STATUS_ON + +/// Each Life() tick while someone is about: its own doings and a line now and then while it is up, a twitch while it lies waiting for Kessler +/datum/component/outpost_experiment_form/proc/on_life(mob/living/carbon/human/body, seconds_per_tick, times_fired) + SIGNAL_HANDLER + if(body.stat == DEAD || HAS_TRAIT_FROM(body, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) + return + if(prison?.experiment_awaiting_pickup(body)) + if(prob(60)) + down_twitch(body) + return + if(body.stat != CONSCIOUS || !awake()) + return + form_life(body, seconds_per_tick) + if(!line_context) + return + line_left -= seconds_per_tick + if(line_left > 0) + return + line_left = rand(15, 25) + var/line = outpost_experiment_line(line_context, personality) + if(line) + INVOKE_ASYNC(body, TYPE_PROC_REF(/atom/movable, say), line) + +/// Each Life() tick while it is up: what it does besides fighting +/datum/component/outpost_experiment_form/proc/form_life(mob/living/carbon/human/body, seconds_per_tick) + return + +/// Lying there alive, waiting for Kessler +/datum/component/outpost_experiment_form/proc/down_twitch(mob/living/carbon/human/body) + body.Shake(1, 0, 0.4 SECONDS) + if(prob(30)) + INVOKE_ASYNC(body, TYPE_PROC_REF(/atom, manual_emote), pick(down_emotes)) + +/// Makes the hand it fights with the active one, before a blow at `target` +/datum/component/outpost_experiment_form/proc/ready_to_hit(atom/target) + return human_npc_ready_empty_hand(parent) + +// ----- whom it goes for ----- + +/// Whether it goes after `target`. By default only its victims (is_victim()); the fly adds food, the nightmare lights. +/datum/component/outpost_experiment_form/proc/can_target(atom/target) + return is_victim(target) + +/// People, borgs and prisoners, never its own kind or Kessler's staff +/datum/component/outpost_experiment_form/proc/is_victim(atom/target) + var/mob/living/victim = target + if(!istype(victim) || victim == parent || victim.stat == DEAD) + return FALSE + if(is_outpost_experiment_mob(victim) || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(cyberware_camo_hides(victim, parent)) // optical camo (cyberware_stealth.dm) + return FALSE + if(is_outpost_prisoner(victim)) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim + if(prisoner.phase != PRISONER_PRESENT || !may_hit_prisoner(prisoner)) + return FALSE + return !blocked_by_cell(prisoner) + return ishuman(victim) || issilicon(victim) || !isnull(victim.mind) + +/// Whether it hits this prisoner +/datum/component/outpost_experiment_form/proc/may_hit_prisoner(mob/living/prisoner) + return TRUE + +/// How much less it wants a target than its distance says; prisoners and people may count differently +/datum/component/outpost_experiment_form/proc/target_penalty(mob/living/target) + return 0 + +/** + * Its target now: whoever hurt it lately, else the nearest it wants in sight. While nobody from the + * wing is home it fights back but hunts nobody, so a loose creature is never a trap left for visitors. + */ +/datum/component/outpost_experiment_form/proc/choose_target() + var/mob/living/body = parent + var/datum/component/experiment_damage_ledger/ledger = body.GetComponent(/datum/component/experiment_damage_ledger) + var/mob/living/attacker = ledger?.recent_player() + if(attacker && can_target(attacker) && get_dist(body, attacker) <= 9 && can_see(body, attacker, 9) && !target_unreachable(attacker)) + return attacker + if(prison && !prison.crew_home()) + return null + var/mob/living/best + var/best_score = INFINITY + for(var/mob/living/candidate in range(7, body)) + if(!can_target(candidate) || !can_see(body, candidate, 7) || target_unreachable(candidate)) + continue + var/score = get_dist(body, candidate) + target_penalty(candidate) + if(score < best_score) + best = candidate + best_score = score + return best + +/** + * Its AI's first thought each plan: picks the target and may queue something of its own. Returns + * SUBTREE_RETURN_FINISH_PLANNING when that is all it does this plan. + */ +/datum/component/outpost_experiment_form/proc/ai_think(datum/ai_controller/controller) + var/atom/target = choose_target() + if(target) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + else + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + return null + +// ----- reach ----- + +/// Whether nothing shut stops it: the hulk goes through walls and doors, so a bolted cell never keeps it out +/datum/component/outpost_experiment_form/proc/smashes_through() + return FALSE + +/// Whether it is between moves by design (mid-jaunt), and should not be judged on progress +/datum/component/outpost_experiment_form/proc/chasing_paused() + return FALSE + +/// Whether a bolted cell keeps `prisoner` away from it: shut and locked, and it is not inside with them +/datum/component/outpost_experiment_form/proc/blocked_by_cell(mob/living/prisoner) + if(smashes_through()) + return FALSE + var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(prisoner)) + return holding && holding.is_bolted() && !holding.contains(parent) + +/** + * Notes whether it is getting anywhere with `controller`'s current target: closer, beside it, or at least + * on the move. OUTPOST_EXPERIMENT_STUCK_TIME standing still and apart writes the target off as unreachable + * for OUTPOST_EXPERIMENT_UNREACHABLE_TIME. Distance and Adjacent() only: no pathfinding call. + */ +/datum/component/outpost_experiment_form/proc/track_reach(datum/ai_controller/controller) + if(smashes_through() || chasing_paused()) + return + var/mob/living/body = parent + var/atom/movable/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(QDELETED(target)) + chase_target_ref = null + return + if(chase_target_ref?.resolve() != target) + chase_target_ref = WEAKREF(target) + chase_best_distance = get_dist(body, target) + chase_last_turf = body.loc + chase_progress_time = world.time + return + if(body.Adjacent(target) || body.loc != chase_last_turf) + chase_last_turf = body.loc + chase_progress_time = world.time + return + var/distance = get_dist(body, target) + if(distance < chase_best_distance) + chase_best_distance = distance + chase_progress_time = world.time + return + if(world.time - chase_progress_time < OUTPOST_EXPERIMENT_STUCK_TIME) + return + mark_unreachable(target) + chase_target_ref = null + +/// Whether it gave up on reaching `target` recently +/datum/component/outpost_experiment_form/proc/target_unreachable(atom/target) + if(!length(unreachable_until)) + return FALSE + var/datum/weakref/ref = WEAKREF(target) + var/until = unreachable_until[ref] + if(!until) + return FALSE + if(until > world.time) + return TRUE + unreachable_until -= ref + return FALSE + +/// Writes `target` off for OUTPOST_EXPERIMENT_UNREACHABLE_TIME +/datum/component/outpost_experiment_form/proc/mark_unreachable(atom/target) + LAZYSET(unreachable_until, WEAKREF(target), world.time + OUTPOST_EXPERIMENT_UNREACHABLE_TIME) + for(var/datum/weakref/ref as anything in unreachable_until.Copy()) + if(unreachable_until[ref] <= world.time) + unreachable_until -= ref + while(length(unreachable_until) > OUTPOST_EXPERIMENT_UNREACHABLE_MAX) + unreachable_until.Cut(1, 2) + +// ----- breaking things ----- + +/// Whether it may break `thing` out of its way. Only the hulk does. +/datum/component/outpost_experiment_form/proc/can_break(atom/thing) + return FALSE + +/// Breaks `thing` out of its way. Returns TRUE if it set about it. +/datum/component/outpost_experiment_form/proc/break_obstacle(atom/thing, datum/ai_controller/controller) + return FALSE + +// ===== HULK ===== + +/datum/component/outpost_experiment_form/hulk + form = "hulk" + form_name = "a hulk" + damage_taken = OUTPOST_HULK_DAMAGE_TAKEN + line_context = "hulk" + /// Its charge + var/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/charge + +/datum/component/outpost_experiment_form/hulk/Destroy(force) + QDEL_NULL(charge) + return ..() + +/datum/component/outpost_experiment_form/hulk/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, SIGNAL_REMOVETRAIT(TRAIT_HULK))) + return ..() + +/datum/component/outpost_experiment_form/hulk/change_biology(mob/living/carbon/human/body) + // tg's hulk gene takes only on a human body: the serum makes one of whatever it finds. + if(body.dna.species.id != SPECIES_HUMAN) + body.set_species(/datum/species/human) + // Before the gene: handlers of one signal run in the order they were registered, and on_punch() must + // see the target's health before the gene's own handler lands the blow. + RegisterSignal(body, COMSIG_LIVING_EARLY_UNARMED_ATTACK, PROC_REF(on_punch)) + body.dna.add_mutation(/datum/mutation/hulk, MUTATION_SOURCE_MUTATOR) + body.dna.add_mutation(/datum/mutation/gigantism, MUTATION_SOURCE_MUTATOR) + charge = new(body) + charge.Grant(body) + RegisterSignal(body, SIGNAL_REMOVETRAIT(TRAIT_HULK), PROC_REF(on_hulk_lost)) + +/datum/component/outpost_experiment_form/hulk/setup_controller(datum/ai_controller/brain) + brain.set_blackboard_key(BB_TARGETED_ACTION, charge) + +/datum/component/outpost_experiment_form/hulk/announce_change(mob/living/carbon/human/body) + playsound(body, 'sound/mobs/non-humanoids/gorilla/gorilla.ogg', 80, TRUE) + body.visible_message(span_danger("[body] swells until [body.p_their()] jumpsuit strains at the seams, and roars!")) + +/// Players first, then prisoners still on their feet +/datum/component/outpost_experiment_form/hulk/target_penalty(mob/living/target) + return is_outpost_prisoner(target) ? 3 : 0 + +/// Only prisoners still standing: it knocks them flat and leaves them +/datum/component/outpost_experiment_form/hulk/may_hit_prisoner(mob/living/prisoner) + return prisoner.stat == CONSCIOUS && prisoner.body_position == STANDING_UP + +/// While it is a hulk it goes through walls and forces doors, so a bolted cell never stops it +/datum/component/outpost_experiment_form/hulk/smashes_through() + return HAS_TRAIT(parent, TRAIT_HULK) + +/datum/component/outpost_experiment_form/hulk/ai_think(datum/ai_controller/controller) + . = ..() + var/mob/living/body = parent + var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!HAS_TRAIT(body, TRAIT_HULK) || !isliving(target) || !charge?.IsAvailable()) + return + var/distance = get_dist(body, target) + if(distance < 2 || distance > OUTPOST_HULK_CHARGE_RANGE || !can_see(body, target, OUTPOST_HULK_CHARGE_RANGE)) + return + controller.queue_behavior(/datum/ai_behavior/targeted_mob_ability, BB_TARGETED_ACTION, BB_BASIC_MOB_CURRENT_TARGET) + return SUBTREE_RETURN_FINISH_PLANNING + +/// A hulk punch is tg's (the gene answers this signal too); what it did to the target shows a tick later +/datum/component/outpost_experiment_form/hulk/proc/on_punch(mob/living/carbon/human/source, atom/target, proximity, list/modifiers) + SIGNAL_HANDLER + if(!source.combat_mode || !proximity || !isliving(target) || target == source || !HAS_TRAIT(source, TRAIT_HULK)) + return NONE + var/mob/living/victim = target + addtimer(CALLBACK(src, PROC_REF(after_punch), WEAKREF(victim), victim.health), 0, TIMER_DELETE_ME) + return NONE + +/// A blow that landed: a standing prisoner goes down, and anyone else may go flying +/datum/component/outpost_experiment_form/hulk/proc/after_punch(datum/weakref/victim_ref, health_before) + var/mob/living/carbon/human/body = parent + var/mob/living/victim = victim_ref?.resolve() + if(QDELETED(victim) || QDELETED(body) || victim.stat == DEAD || victim.health >= health_before) + return + if(is_outpost_prisoner(victim)) + if(victim.body_position != STANDING_UP) + return + victim.visible_message(span_danger("[body] knocks [victim] flat!")) + playsound(victim, 'sound/items/weapons/punch1.ogg', 60, TRUE) + victim.Knockdown(OUTPOST_HULK_KNOCKDOWN) + return + if(!prob(OUTPOST_HULK_THROW_CHANCE) || victim.anchored || victim.buckled || victim.move_resist >= MOVE_FORCE_OVERPOWERING) + return + victim.visible_message(span_danger("[body] sends [victim] flying!")) + victim.throw_at(get_ranged_target_turf(victim, get_dir(body, victim), OUTPOST_HULK_THROW_RANGE), OUTPOST_HULK_THROW_RANGE, 1, body) + +/// Down into crit, the gene let go of it (tg's hulk): it is an ordinary person again +/datum/component/outpost_experiment_form/hulk/proc/on_hulk_lost(mob/living/carbon/human/source) + SIGNAL_HANDLER + if(source.stat == DEAD) + return + source.visible_message(span_warning("[source] staggers, heaving for breath. [source.p_They()] look[source.p_s()] spent.")) + +/// Charged into a wall with nobody caught: stunned a moment, the way any charger is +/datum/component/outpost_experiment_form/hulk/proc/stagger_on_wall() + var/mob/living/body = parent + body.Stun(OUTPOST_HULK_WALL_STUN, ignore_canstun = TRUE) + +/// Interior walls, windows, grilles, tables, chairs and doors, never the hatch counters or anything protected +/datum/component/outpost_experiment_form/hulk/can_break(atom/thing) + if(!HAS_TRAIT(parent, TRAIT_HULK) || QDELETED(thing) || !outpost_experiment_can_smash(thing, prison)) + return FALSE + if(isturf(thing)) + return iswallturf(thing) && !istype(thing, /turf/closed/wall/r_wall) && !isindestructiblewall(thing) + var/obj/object = thing + if(!istype(object) || (object.resistance_flags & INDESTRUCTIBLE) || HAS_TRAIT(object, TRAIT_OUTPOST_PROPERTY)) + return FALSE + if(istype(object, /obj/structure/table/reinforced/prison_hatch)) + return FALSE + return istype(object, /obj/structure/window) || istype(object, /obj/structure/grille) || istype(object, /obj/structure/table) \ + || istype(object, /obj/structure/chair) || istype(object, /obj/machinery/door) + +/datum/component/outpost_experiment_form/hulk/break_obstacle(atom/thing, datum/ai_controller/controller) + if(!can_break(thing)) + return FALSE + if(istype(thing, /obj/machinery/door/airlock)) + INVOKE_ASYNC(src, PROC_REF(force_door), thing) + return TRUE + if(isturf(thing)) + // tg's hulk smash: a few blows bring an interior wall down, and hurt the arm doing it. + if(!human_npc_ready_empty_hand(parent)) + return FALSE + controller.ai_interact(target = thing, combat_mode = TRUE) + return TRUE + smash(thing) + return TRUE + +/// Batters a door until it gives: bolts sheared, weld broken, forced open +/datum/component/outpost_experiment_form/hulk/proc/force_door(obj/machinery/door/airlock/door) + var/mob/living/body = parent + if(DOING_INTERACTION_WITH_TARGET(body, door) || !door.density) + return FALSE + body.visible_message(span_danger("[body] batters at [door]!")) + playsound(door, 'sound/effects/bang.ogg', 70, TRUE) + door.Shake(2, 2, 0.5 SECONDS) + if(!do_after(body, OUTPOST_HULK_DOOR_TIME, target = door) || QDELETED(door) || !can_break(door)) + return FALSE + if(door.locked) + door.unbolt() + if(door.welded) + door.welded = FALSE + door.update_appearance() + door.visible_message(span_danger("[body] forces [door] open!")) + playsound(door, 'sound/machines/airlock/airlock_alien_prying.ogg', 80, TRUE) + INVOKE_ASYNC(door, TYPE_PROC_REF(/obj/machinery/door, open), BYPASS_DOOR_CHECKS) + prison?.refresh_reach() + return TRUE + +/// Puts something through: windows shatter, tables fold, chairs splinter, window doors break +/datum/component/outpost_experiment_form/hulk/proc/smash(obj/thing) + if(QDELETED(thing)) + return FALSE + var/mob/living/body = parent + body.do_attack_animation(thing, ATTACK_EFFECT_SMASH) + body.visible_message(span_danger("[body] smashes [thing]!")) + playsound(thing, istype(thing, /obj/structure/window) ? 'sound/effects/glass/glassbr1.ogg' : 'sound/effects/woodhit.ogg', 70, TRUE) + thing.take_damage(thing.get_integrity(), BRUTE, MELEE, FALSE, get_dir(thing, body)) + return TRUE + +/// The hulk's charge: a roar, a stamp and a line on the floor, then six tiles of it +/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk + name = "Charge" + desc = "Put your head down and run through whatever is in the way." + cooldown_time = OUTPOST_HULK_CHARGE_COOLDOWN + charge_delay = OUTPOST_HULK_CHARGE_WINDUP + charge_distance = OUTPOST_HULK_CHARGE_RANGE + charge_past = 1 + charge_damage = OUTPOST_HULK_CHARGE_DAMAGE + destroy_objects = FALSE + shake_duration = OUTPOST_HULK_CHARGE_WINDUP + recoil_duration = OUTPOST_HULK_WALL_STUN + knockdown_duration = OUTPOST_HULK_CHARGE_KNOCKDOWN + +/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/do_charge_indicator(atom/charger, atom/charge_target) + . = ..() + playsound(charger, 'sound/mobs/non-humanoids/gorilla/gorilla.ogg', 80, TRUE) + charger.visible_message(span_danger("[charger] roars and stamps, lowering [charger.p_their()] head!")) + var/direction = get_dir(charger, charge_target) + var/turf/step_turf = get_turf(charger) + for(var/i in 1 to charge_distance) + step_turf = get_step(step_turf, direction) + if(!step_turf || isclosedturf(step_turf)) + break + new /obj/effect/temp_visual/telegraphing/outpost_hulk_charge(step_turf) + +/// The hulk's form, if `charger` is a hulk +/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/proc/hulk_form(atom/movable/charger) + return charger.GetComponent(/datum/component/outpost_experiment_form/hulk) + +/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/on_bump(atom/movable/source, atom/target) + var/datum/component/outpost_experiment_form/hulk/hulk = hulk_form(source) + if(hulk && isobj(target) && target.density && !istype(target, /obj/machinery/door/airlock) && hulk.can_break(target)) + INVOKE_ASYNC(hulk, TYPE_PROC_REF(/datum/component/outpost_experiment_form/hulk, smash), target) + return + return ..() + +/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/on_moved(atom/source) + . = ..() + var/datum/component/outpost_experiment_form/hulk/hulk = hulk_form(source) + if(!hulk) + return + for(var/obj/structure/chair/chair in get_turf(source)) + if(hulk.can_break(chair)) + INVOKE_ASYNC(hulk, TYPE_PROC_REF(/datum/component/outpost_experiment_form/hulk, smash), chair) + +/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/hit_target(atom/movable/source, atom/target, damage_dealt) + var/datum/component/outpost_experiment_form/hulk/hulk = hulk_form(source) + if(isliving(target)) + var/mob/living/victim = target + // A prisoner is bowled over; anyone else takes the full charge, through their armour (EX-7). + if(!is_outpost_prisoner(victim)) + outpost_experiment_ability_hit(victim, damage_dealt, attacker = source) + playsound(get_turf(victim), 'sound/effects/meteorimpact.ogg', 80, TRUE) + return ..() + source.visible_message(span_danger("[source] slams into [target] and reels!")) + playsound(get_turf(source), 'sound/effects/bang.ogg', 80, TRUE) + hulk?.stagger_on_wall() + +/// The floor in the charge's path, for the length of the wind-up +/obj/effect/temp_visual/telegraphing/outpost_hulk_charge + duration = OUTPOST_HULK_CHARGE_WINDUP + color = "#9ae05a" + +// ===== FLY PERSON ===== + +/datum/component/outpost_experiment_form/fly + form = "fly" + form_name = "a fly person" + damage_taken = OUTPOST_FLY_DAMAGE_TAKEN + line_context = "fly" + controller_type = /datum/ai_controller/basic_controller/human_npc/outpost_experiment/fly + win_hint = "It never stays still for long." + down_message = "drops to the floor, stunned." + down_emotes = list("buzzes weakly.", "twitches.", "trembles.") + /// world.time of its next random dart, and by when the dart under way must be over + var/next_dart = 0 + var/dart_until = 0 + /// Whoever hit it last, whom it keeps away from until flit_until + var/datum/weakref/flit_from_ref + var/flit_until = 0 + /// REF() of prisoners who watched it throw up -> world.time they may be put off again + var/list/disgusted + +/datum/component/outpost_experiment_form/fly/down_twitch(mob/living/carbon/human/body) + . = ..() + playsound(body, 'sound/mobs/non-humanoids/bee/bee.ogg', 15, TRUE) + +/datum/component/outpost_experiment_form/fly/Destroy(force) + flit_from_ref = null + disgusted = null + return ..() + +/datum/component/outpost_experiment_form/fly/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_ATOM_WAS_ATTACKED, COMSIG_CARBON_VOMITED)) + return ..() + +/datum/component/outpost_experiment_form/fly/change_biology(mob/living/carbon/human/body) + body.set_species(/datum/species/fly) + // Its quickness is real chrome (owner ruling OC-6), which an EMP scrambles and death burns out. + if(ishumannpc(body)) + var/mob/living/carbon/human/npc/npc = body + npc.npc_install_cyberware(/obj/item/organ/cyberimp/cyberware/slipwire) + RegisterSignal(body, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) + RegisterSignal(body, COMSIG_CARBON_VOMITED, PROC_REF(on_vomited)) + +/datum/component/outpost_experiment_form/fly/announce_change(mob/living/carbon/human/body) + playsound(body, 'sound/mobs/non-humanoids/bee/bee_swarm.ogg', 60, TRUE) + body.visible_message(span_danger("[body] folds up, splits open and unfolds into something with bulging eyes and a proboscis!")) + +/// On its feet and free to move: not down, held, cuffed, pulled, buckled or being taken away +/datum/component/outpost_experiment_form/fly/proc/free_to_dart() + var/mob/living/body = parent + if(body.stat != CONSCIOUS || !isturf(body.loc) || body.pulledby || body.buckled || body.body_position != STANDING_UP) + return FALSE + return !HAS_TRAIT(body, TRAIT_IMMOBILIZED) && !HAS_TRAIT(body, TRAIT_INCAPACITATED) && !HAS_TRAIT(body, TRAIT_RESTRAINED) + +/// Hit while it is free to move: it flits away from whoever did it, and keeps away from them for a few seconds +/datum/component/outpost_experiment_form/fly/proc/on_attacked(datum/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(!attacker || attacker == parent || !free_to_dart()) + return + flit_from_ref = WEAKREF(attacker) + flit_until = world.time + OUTPOST_FLY_FLIT_TIME + // Once the blow is over, not in the middle of it. + addtimer(CALLBACK(src, PROC_REF(flit)), 1, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_DELETE_ME) + +/// Whoever hit it within OUTPOST_FLY_FLIT_TIME, if still near enough to keep away from +/datum/component/outpost_experiment_form/fly/proc/flit_threat() + if(world.time > flit_until) + return null + var/mob/living/body = parent + var/atom/movable/attacker = flit_from_ref?.resolve() + if(QDELETED(attacker) || attacker.z != body.z || get_dist(body, attacker) > OUTPOST_FLY_DART_MAX + OUTPOST_FLY_FLEE_RANGE) + return null + return attacker + +/// Darts away from whoever just hit it, at once rather than at its next thought. Returns TRUE if it went. +/datum/component/outpost_experiment_form/fly/proc/flit() + var/mob/living/body = parent + var/atom/attacker = flit_threat() + if(!attacker || !free_to_dart() || !body.ai_controller || !awake()) + return FALSE + var/turf/spot = dart_spot(attacker) + if(!spot) + return FALSE + body.ai_controller.CancelActions() + body.ai_controller.queue_behavior(/datum/ai_behavior/outpost_fly_dart/away, spot) + return TRUE + +/// It goes for food and hits only when cornered: its "targets" are meals, and people it cannot get away from +/datum/component/outpost_experiment_form/fly/can_target(atom/target) + if(istype(target, /obj/item/food)) + return isturf(target.loc) + return ..() + +/// Whoever is closest within OUTPOST_FLY_FLEE_RANGE, to keep away from +/datum/component/outpost_experiment_form/fly/proc/nearest_threat() + var/mob/living/body = parent + var/mob/living/nearest + var/nearest_distance = INFINITY + for(var/mob/living/person in range(OUTPOST_FLY_FLEE_RANGE, body)) + if(person == body || !is_victim(person)) + continue + var/distance = get_dist(body, person) + if(distance < nearest_distance) + nearest = person + nearest_distance = distance + return nearest + +/** + * Somewhere to dart to: an open tile OUTPOST_FLY_DART_MIN to OUTPOST_FLY_DART_MAX tiles off, in a clear + * line, on ground it keeps to (may_dart_to()). Away from `threat`, only somewhere farther from it than it + * is now, and out of its OUTPOST_FLY_FLEE_RANGE if it can. Null if there is nowhere: cornered. + */ +/datum/component/outpost_experiment_form/fly/proc/dart_spot(atom/threat) + var/turf/here = get_turf(parent) + if(!here) + return null + var/threat_distance = threat ? get_dist(here, threat) : 0 + var/list/spots = list() + var/list/clear_spots = list() + for(var/turf/open/tile in RANGE_TURFS(OUTPOST_FLY_DART_MAX, here)) + if(get_dist(here, tile) < OUTPOST_FLY_DART_MIN) + continue + var/from_threat = threat ? get_dist(tile, threat) : INFINITY + if(from_threat <= threat_distance || !may_dart_to(tile) || !clear_line(here, tile)) + continue + spots += tile + if(from_threat > OUTPOST_FLY_FLEE_RANGE) + clear_spots += tile + if(length(clear_spots)) + return pick(clear_spots) + return length(spots) ? pick(spots) : null + +/** + * Whether it keeps to `tile`: free and safe, and in the wing (its cell block, while it is in there). + * Got out of the wing, it darts about the room it is in. + */ +/datum/component/outpost_experiment_form/fly/proc/may_dart_to(turf/tile) + var/mob/living/body = parent + if(isspaceturf(tile) || isopenspaceturf(tile) || tile.is_blocked_turf(FALSE, body) || !tile.can_cross_safely(body)) + return FALSE + var/turf/here = get_turf(body) + if(prison?.wing && here.loc == prison.wing) + return tile.loc == prison.wing && (!prison.in_cell_block(here) || prison.in_cell_block(tile)) + return tile.loc == here.loc + +/// Whether it can go straight from `start` to `finish`: nothing solid or opaque in the way +/datum/component/outpost_experiment_form/fly/proc/clear_line(turf/start, turf/finish) + for(var/turf/step as anything in get_line(start, finish)) + if(step == start) + continue + if(step.opacity || step.is_blocked_turf(TRUE, parent)) + return FALSE + return TRUE + +/** + * Keeps moving: darts away from anyone close or who just hit it, and hits only when cornered with + * someone at it. Otherwise it goes for food in sight, or darts about at random with short pauses. It + * hovers (outpost_fly_hover) rather than planning nothing, so it notices people at once. + */ +/datum/component/outpost_experiment_form/fly/ai_think(datum/ai_controller/controller) + var/mob/living/body = parent + if(!free_to_dart()) + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + return null + var/atom/threat = flit_threat() || nearest_threat() + if(threat) + var/turf/away = dart_spot(threat) + if(away) + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/outpost_fly_dart/away, away) + return SUBTREE_RETURN_FINISH_PLANNING + // Nowhere farther to go: it hits whoever is at it. + var/mob/living/at_it = threat + if(istype(at_it) && body.Adjacent(at_it) && can_target(at_it)) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, at_it) + return null + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/outpost_fly_hover) + return SUBTREE_RETURN_FINISH_PLANNING + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + var/obj/item/food/meal = held_meal() || find_meal() + if(meal) + controller.set_blackboard_key(BB_OUTPOST_FLY_MEAL, meal) + controller.queue_behavior(/datum/ai_behavior/outpost_fly_eat, BB_OUTPOST_FLY_MEAL) + return SUBTREE_RETURN_FINISH_PLANNING + var/turf/spot = world.time >= next_dart ? dart_spot() : null + if(spot) + controller.queue_behavior(/datum/ai_behavior/outpost_fly_dart, spot) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time >= next_dart) + next_dart = world.time + rand(OUTPOST_FLY_DART_GAP_MIN, OUTPOST_FLY_DART_GAP_MAX) + controller.queue_behavior(/datum/ai_behavior/outpost_fly_hover) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Food it has in hand already +/datum/component/outpost_experiment_form/fly/proc/held_meal() + var/mob/living/carbon/human/body = parent + for(var/obj/item/food/meal in body.held_items) + return meal + return null + +/// The nearest food in sight: a serving hatch, a table or the floor +/datum/component/outpost_experiment_form/fly/proc/find_meal() + var/mob/living/body = parent + var/obj/item/food/best + var/best_distance = INFINITY + for(var/obj/item/food/meal in range(5, body)) + if(!isturf(meal.loc) || !can_see(body, meal, 5) || target_unreachable(meal)) + continue + var/distance = get_dist(body, meal) + if(distance < best_distance) + best = meal + best_distance = distance + return best + +/** + * tg's fly stomach throws up what it eats (and the fly is a messy eater). Whoever it gets it on hears + * about it, and prisoners who see it lose a little mood, each at most once in a while. + */ +/datum/component/outpost_experiment_form/fly/proc/on_vomited(mob/living/carbon/source, distance, force) + SIGNAL_HANDLER + var/turf/ahead = get_step(source, source.dir) + var/mob/living/unlucky = ahead ? locate(/mob/living) in ahead : null + if(unlucky && unlucky != source) + source.visible_message(span_danger("[source] throws up all over [unlucky]!")) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher in viewers(5, source)) + if(watcher.stat != CONSCIOUS || LAZYACCESS(disgusted, REF(watcher)) > world.time) + continue + LAZYSET(disgusted, REF(watcher), world.time + OUTPOST_EXPERIMENT_FLY_DISGUST_GAP) + watcher.adjust_mood(-OUTPOST_EXPERIMENT_FLY_DISGUST_MOOD) + +// ===== NIGHTMARE ===== + +/datum/component/outpost_experiment_form/nightmare + form = "nightmare" + form_name = "a nightmare" + damage_taken = OUTPOST_NIGHTMARE_DAMAGE_TAKEN + /// Seconds left of breaking the lights before it hunts + var/opening_left = OUTPOST_NIGHTMARE_OPENING_MIN + /// Mid jaunt: gone into the dark, about to come out beside someone + var/jaunting = FALSE + /// Where it will come out, and at whom + var/turf/jaunt_spot + var/datum/weakref/jaunt_target_ref + COOLDOWN_DECLARE(jaunt_cooldown) + +/datum/component/outpost_experiment_form/nightmare/Destroy(force) + jaunt_spot = null + jaunt_target_ref = null + return ..() + +/datum/component/outpost_experiment_form/nightmare/UnregisterFromParent() + if(jaunting) + end_jaunt() + UnregisterSignal(parent, COMSIG_MOB_FLASH_OVERRIDE_CHECK) + return ..() + +/datum/component/outpost_experiment_form/nightmare/change_biology(mob/living/carbon/human/body) + // tg's nightmare: its brain heals it and slips shots in the dark, its heart grows the light eater, and + // its shadow flesh burns in the light. It can wear nothing but a hat, so the jumpsuit falls off. + body.set_species(/datum/species/shadow/nightmare) + opening_left = rand(OUTPOST_NIGHTMARE_OPENING_MIN, OUTPOST_NIGHTMARE_OPENING_MAX) + RegisterSignal(body, COMSIG_MOB_FLASH_OVERRIDE_CHECK, PROC_REF(on_flashed)) + +/datum/component/outpost_experiment_form/nightmare/announce_change(mob/living/carbon/human/body) + playsound(body, 'sound/effects/hallucinations/wail.ogg', 60, TRUE) + body.visible_message(span_danger("The light seems to drain out of [body], and something else stands where [body.p_they()] stood.")) + +/// Whether `tile` (where it stands, by default) is dark +/datum/component/outpost_experiment_form/nightmare/proc/in_dark(turf/tile = get_turf(parent)) + return tile && tile.get_lumcount() < SHADOW_SPECIES_LIGHT_THRESHOLD + +/// The opening runs down; in the light a jaunt is cut short, and now and then it is seen to suffer +/datum/component/outpost_experiment_form/nightmare/form_life(mob/living/carbon/human/body, seconds_per_tick) + if(opening_left > 0) + opening_left -= seconds_per_tick + if(in_dark()) + return + if(jaunting) + cancel_jaunt() + if(prob(20)) + body.visible_message(span_warning("[body] smokes and shrinks from the light.")) + +/// Whether it is still breaking the lights: for its first half minute, until it is hurt, or until there are none +/datum/component/outpost_experiment_form/nightmare/proc/in_opening() + if(opening_left <= 0) + return FALSE + var/mob/living/body = parent + var/datum/component/experiment_damage_ledger/ledger = body.GetComponent(/datum/component/experiment_damage_ledger) + if(ledger && (ledger.player_damage + ledger.other_damage + ledger.pending) > 0) + opening_left = 0 + return FALSE + return TRUE + +/// Prisoners first, standing or down; it finishes what it starts +/datum/component/outpost_experiment_form/nightmare/target_penalty(mob/living/target) + return is_outpost_prisoner(target) ? -3 : 0 + +/datum/component/outpost_experiment_form/nightmare/chasing_paused() + return jaunting + +/// Lights, during the opening +/datum/component/outpost_experiment_form/nightmare/can_target(atom/target) + if(istype(target, /obj/machinery/light)) + var/obj/machinery/light/fixture = target + return fixture.status != LIGHT_BROKEN && fixture.status != LIGHT_EMPTY + return ..() + +/// It fights with tg's light eater, which breaks lights and puts out torches as it cuts +/datum/component/outpost_experiment_form/nightmare/ready_to_hit(atom/target) + var/mob/living/carbon/human/body = parent + for(var/obj/item/light_eater/blade in body.held_items) + return human_npc_ready_item(body, blade) + return ..() + +/// The nearest working light in sight +/datum/component/outpost_experiment_form/nightmare/proc/find_light() + var/mob/living/body = parent + var/obj/machinery/light/best + var/best_distance = INFINITY + for(var/obj/machinery/light/fixture in range(9, body)) + if(!can_target(fixture) || !can_see(body, fixture, 9) || target_unreachable(fixture)) + continue + var/distance = get_dist(body, fixture) + if(distance < best_distance) + best = fixture + best_distance = distance + return best + +/datum/component/outpost_experiment_form/nightmare/ai_think(datum/ai_controller/controller) + if(jaunting) + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + return SUBTREE_RETURN_FINISH_PLANNING + if(in_opening()) + var/obj/machinery/light/fixture = find_light() + if(fixture) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, fixture) + return null + opening_left = 0 + . = ..() + var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!isliving(target) || get_dist(parent, target) <= 1) + return + var/turf/spot = jaunt_spot_near(target) + if(!spot) + return + start_jaunt(target, spot) + return SUBTREE_RETURN_FINISH_PLANNING + +/// A flash sears it (tg's shadow flesh) and throws it out of a jaunt +/datum/component/outpost_experiment_form/nightmare/proc/on_flashed(datum/source, mob/living/carbon/flashed, flash, deviation) + SIGNAL_HANDLER + if(jaunting) + cancel_jaunt() + return NONE + +/// A dark tile beside `target` it could come out on, if the jaunt is ready and it stands in the dark +/datum/component/outpost_experiment_form/nightmare/proc/jaunt_spot_near(mob/living/target) + if(jaunting || !COOLDOWN_FINISHED(src, jaunt_cooldown) || !in_dark()) + return null + var/list/spots = list() + for(var/turf/open/tile in orange(1, target)) + if(tile.is_blocked_turf(TRUE) || !in_dark(tile) || !prison?.outpost_holds(tile)) + continue + spots += tile + return length(spots) ? pick(spots) : null + +/// Sinks into the dark; a ripple and a whisper warn where it will come out, OUTPOST_NIGHTMARE_JAUNT_RIPPLE later +/datum/component/outpost_experiment_form/nightmare/proc/start_jaunt(mob/living/target, turf/spot) + var/mob/living/body = parent + jaunting = TRUE + jaunt_spot = spot + jaunt_target_ref = WEAKREF(target) + COOLDOWN_START(src, jaunt_cooldown, OUTPOST_NIGHTMARE_JAUNT_COOLDOWN) + ADD_TRAIT(body, TRAIT_IMMOBILIZED, OUTPOST_EXPERIMENT_FORM_TRAIT) + body.ai_controller?.CancelActions() + animate(body, alpha = 0, time = 0.3 SECONDS) + new /obj/effect/temp_visual/telegraphing/outpost_shadow_ripple(spot) + playsound(spot, pick('sound/effects/hallucinations/behind_you1.ogg', 'sound/effects/hallucinations/behind_you2.ogg', 'sound/effects/hallucinations/i_see_you2.ogg'), 35, TRUE) + addtimer(CALLBACK(src, PROC_REF(finish_jaunt)), OUTPOST_NIGHTMARE_JAUNT_RIPPLE, TIMER_DELETE_ME) + +/// Comes out beside the target, if the spot is still dark, and lunges: a blade through the armour on them (EX-7) +/datum/component/outpost_experiment_form/nightmare/proc/finish_jaunt() + if(!jaunting) + return FALSE + var/mob/living/body = parent + var/turf/spot = jaunt_spot + var/mob/living/target = jaunt_target_ref?.resolve() + end_jaunt() + if(body.stat != CONSCIOUS || !spot || !in_dark(spot) || spot.is_blocked_turf(TRUE)) + return FALSE + body.forceMove(spot) + if(!target || !body.Adjacent(target) || !can_target(target)) + return TRUE + body.face_atom(target) + body.do_attack_animation(target, ATTACK_EFFECT_SLASH) + playsound(target, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) + target.visible_message(span_danger("[body] lunges out of the dark at [target]!"), span_userdanger("[body] lunges out of the dark at you!")) + outpost_experiment_ability_hit(target, OUTPOST_NIGHTMARE_JAUNT_DAMAGE, SHARP_EDGED, OUTPOST_NIGHTMARE_ARMOUR_PENETRATION, attacker = body) + target.Knockdown(OUTPOST_NIGHTMARE_JAUNT_KNOCKDOWN) + return TRUE + +/// Thrown out of a jaunt (light, a flash): back where it went in +/datum/component/outpost_experiment_form/nightmare/proc/cancel_jaunt() + if(!jaunting) + return + end_jaunt() + var/mob/living/body = parent + body.visible_message(span_warning("[body] is forced out of the shadows!")) + +/datum/component/outpost_experiment_form/nightmare/proc/end_jaunt() + var/mob/living/body = parent + jaunting = FALSE + jaunt_spot = null + jaunt_target_ref = null + REMOVE_TRAIT(body, TRAIT_IMMOBILIZED, OUTPOST_EXPERIMENT_FORM_TRAIT) + animate(body, alpha = 255, time = 0.2 SECONDS) + +/// Where the nightmare will come out of the dark +/obj/effect/temp_visual/telegraphing/outpost_shadow_ripple + icon = 'icons/mob/telegraphing/telegraph.dmi' + icon_state = "target_circle" + color = "#2a0a3a" + light_range = 0 + duration = OUTPOST_NIGHTMARE_JAUNT_RIPPLE + +// ===== AI ===== + +/** + * A creature's mind: tg's human NPC controller (soft crit, working at cuffs and grabs), then what its form + * wants (ai_think()), breaking what it may out of its way, and real blows with its hands or its blade. + */ +/datum/ai_controller/basic_controller/human_npc/outpost_experiment + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/outpost_experiment_form, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + ) + ai_movement = /datum/ai_movement/jps + idle_behavior = /datum/idle_behavior/idle_random_walk + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/outpost_experiment_form_think, + /datum/ai_planning_subtree/attack_obstacle_in_path/outpost_experiment_form, + /datum/ai_planning_subtree/outpost_experiment_form_melee, + ) + +/// The fly person moves only in its own darts: no aimless steps +/datum/ai_controller/basic_controller/human_npc/outpost_experiment/fly + idle_behavior = null + +/// The creature's form decides what it is after +/datum/ai_planning_subtree/outpost_experiment_form_think + +/datum/ai_planning_subtree/outpost_experiment_form_think/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/form = body.GetComponent(/datum/component/outpost_experiment_form) + if(!form || body.stat != CONSCIOUS) + return + form.track_reach(controller) + return form.ai_think(controller) + +/// Only what the form may break +/datum/ai_planning_subtree/attack_obstacle_in_path/outpost_experiment_form + attack_behaviour = /datum/ai_behavior/attack_obstructions/outpost_experiment_form + +/datum/ai_behavior/attack_obstructions/outpost_experiment_form + +/datum/ai_behavior/attack_obstructions/outpost_experiment_form/attack_in_direction(datum/ai_controller/controller, mob/living/basic/basic_mob, direction) + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/form = body.GetComponent(/datum/component/outpost_experiment_form) + if(!form) + return FALSE + var/turf/next_step = get_step(body, direction) + if(!next_step?.is_blocked_turf(exclude_mobs = TRUE, source_atom = body)) + return FALSE + for(var/obj/object in next_step) + if(!object.density || object.IsObscured() || !form.can_break(object)) + continue + return form.break_obstacle(object, controller) + if(isclosedturf(next_step) && form.can_break(next_step)) + return form.break_obstacle(next_step, controller) + return FALSE + +/// With a target: walks up and hits it, with the hand or blade the form fights with +/datum/ai_planning_subtree/outpost_experiment_form_melee + +/datum/ai_planning_subtree/outpost_experiment_form_melee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/outpost_experiment_form, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + +// tg's basic melee attack already clicks the target through ai_interact(); this readies the hand or blade first. +/datum/ai_behavior/basic_melee_attack/outpost_experiment_form + +/datum/ai_behavior/basic_melee_attack/outpost_experiment_form/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/body = controller.pawn + var/datum/component/outpost_experiment_form/form = body.GetComponent(/datum/component/outpost_experiment_form) + form?.ready_to_hit(controller.blackboard[target_key]) + body.zone_selected = human_npc_pick_aim_zone() + return ..() + +/// A creature's targets are whatever its form's can_target() says, in reach on its level +/datum/targeting_strategy/outpost_experiment_form + +/datum/targeting_strategy/outpost_experiment_form/can_attack(mob/living/living_mob, atom/target, vision_range) + var/datum/component/outpost_experiment_form/form = living_mob.GetComponent(/datum/component/outpost_experiment_form) + if(!form || QDELETED(target) || !isturf(living_mob.loc)) + return FALSE + var/turf/target_turf = get_turf(target) + if(!target_turf || target_turf.z != living_mob.z) + return FALSE + if(vision_range && get_dist(living_mob, target) > vision_range) + return FALSE + return form.can_target(target) + +/// The fly person's darts: straight runs that start at once +/datum/ai_movement/basic_avoidance/outpost_fly + move_flags = MOVEMENT_LOOP_START_FAST + +/** + * A fly person's dart to the turf it picked (dart_spot()): done when it gets there, given up after + * OUTPOST_FLY_DART_TIMEOUT. Planning goes on meanwhile, so a dart away from someone can cut a random one + * short. A random dart is followed by a short pause. + */ +/datum/ai_behavior/outpost_fly_dart + required_distance = 0 + action_cooldown = 0 + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/outpost_fly_dart/setup(datum/ai_controller/controller, turf/destination) + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/fly/fly = body.GetComponent(/datum/component/outpost_experiment_form/fly) + if(!fly || !isturf(destination) || destination.z != body.z) + return FALSE + fly.dart_until = world.time + OUTPOST_FLY_DART_TIMEOUT + set_movement_target(controller, destination, /datum/ai_movement/basic_avoidance/outpost_fly) + return ..() + +/datum/ai_behavior/outpost_fly_dart/perform(seconds_per_tick, datum/ai_controller/controller, turf/destination) + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/fly/fly = body.GetComponent(/datum/component/outpost_experiment_form/fly) + if(!fly || world.time >= fly.dart_until) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + if(get_turf(body) == destination) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/outpost_fly_dart/finish_action(datum/ai_controller/controller, succeeded, turf/destination) + // A dart that took over from this one has set its own movement; leave that be. + var/moving_for_us = controller.movement_target_source == type + . = ..() + if(moving_for_us) + controller.change_ai_movement_type(initial(controller.ai_movement)) + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/fly/fly = body?.GetComponent(/datum/component/outpost_experiment_form/fly) + if(fly) + fly.next_dart = world.time + pause_after() + +/// How long it waits after this dart before the next random one +/datum/ai_behavior/outpost_fly_dart/proc/pause_after() + return rand(OUTPOST_FLY_DART_GAP_MIN, OUTPOST_FLY_DART_GAP_MAX) + +/// Away from someone: no pause after it +/datum/ai_behavior/outpost_fly_dart/away + +/datum/ai_behavior/outpost_fly_dart/away/pause_after() + return 0 + +/// The fly person waiting between darts. Planning goes on, so it notices anyone coming at once. +/datum/ai_behavior/outpost_fly_hover + action_cooldown = 0 + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/outpost_fly_hover/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/fly/fly = body.GetComponent(/datum/component/outpost_experiment_form/fly) + if(!fly || world.time >= fly.next_dart) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/** + * The fly person eats: walks to the food, picks it up and eats it the way anyone does (tg's edible, with + * its do_after per bite); its fly stomach does the rest. Planning goes on, so a threat cuts it short. + */ +/datum/ai_behavior/outpost_fly_eat + action_cooldown = 1 SECONDS + required_distance = 1 + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/outpost_fly_eat/setup(datum/ai_controller/controller, meal_key) + var/obj/item/food/meal = controller.blackboard[meal_key] + if(QDELETED(meal)) + return FALSE + set_movement_target(controller, meal) + return ..() + +/datum/ai_behavior/outpost_fly_eat/perform(seconds_per_tick, datum/ai_controller/controller, meal_key) + var/mob/living/carbon/human/body = controller.pawn + var/obj/item/food/meal = controller.blackboard[meal_key] + if(QDELETED(meal)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + if(LAZYLEN(body.do_afters)) + return AI_BEHAVIOR_DELAY + if(body.is_holding(meal)) + if(!human_npc_ready_item(body, meal)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + // A bite: the food used on itself, out of combat mode, as a player eats. + controller.ai_interact(target = body, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + if(!isturf(meal.loc)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + if(!body.Adjacent(meal)) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_empty_hand(body)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + controller.ai_interact(target = meal, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/outpost_fly_eat/finish_action(datum/ai_controller/controller, succeeded, meal_key) + var/obj/item/food/meal = controller.blackboard[meal_key] + . = ..() + controller.clear_blackboard_key(meal_key) + if(succeeded || QDELETED(meal)) + return + // Couldn't get at it: written off a while, like a target it can't reach. + var/mob/living/body = controller.pawn + var/datum/component/outpost_experiment_form/fly/fly = body?.GetComponent(/datum/component/outpost_experiment_form/fly) + fly?.mark_unreachable(meal) + +#undef OUTPOST_EXPERIMENT_FORM_TRAIT +#undef BB_OUTPOST_FLY_MEAL diff --git a/voidcrew/modules/player_outposts/outpost_prison_experiments.dm b/voidcrew/modules/player_outposts/outpost_prison_experiments.dm index 2d06959b6db..94892810561 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_experiments.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_experiments.dm @@ -10,12 +10,17 @@ * - The serum, the specimen jar and the food it taints (outpost_prison_serum.dm) only work in a * member's hands, on this wing's prisoners, for 10 minutes, and never leave the level. * - A serum subject twitches for a minute, with the form's tells in the last 40 seconds, then - * turns into a hulk, a fly person or a nightmare (outpost_prison_creatures.dm). A specimen host - * goes to the changeling event (outpost_changeling_infect(), outpost_prison_changeling.dm). - * - The wing is paid a fee when a creature shows (experiment_creature_appeared()) and a bonus when - * it is put down (experiment_creature_down()) if the crew did half its damage or more - * (/datum/component/experiment_damage_ledger). Each is paid once per experiment. Put down, it - * lies there, "awaiting pickup" on the console, until Kessler's team beams in for it (kessler_collect()). + * their own body becomes a hulk, a fly person or a nightmare by tg's own genes and species + * (outpost_prison_experiment_forms.dm). A specimen host goes to the changeling event + * (outpost_changeling_infect(), outpost_prison_changeling.dm). + * - The wing is paid a fee when a creature shows (experiment_creature_appeared()). A creature that goes + * down (dead, or in crit, stamcrit, asleep or cuffed: outpost_experiment_down_state()) lies there, + * "awaiting pickup" on the console, until Kessler's team beams in for it (kessler_collect()), keeps it + * down with a real tranquilliser dart, and beams it away. The containment bonus is paid, if the crew + * did half its damage or more (/datum/component/experiment_damage_ledger), when it dies, or for one + * taken alive when Kessler has it, at the subdued rate if it was held. Each is paid once per + * experiment. One that gets back up before Kessler has it (revived, woken, out of its cuffs) is loose + * again. * - A creature stays until the crew puts it down or an admin ends the experiment. One that gets out * of the wing sets off the containment breach alarm. One taken off the outpost (onto a ship, say) * is recovered by Kessler at once, for a fee that becomes debt (experiment_recover()). The one @@ -29,7 +34,7 @@ * Every clock here counts only while a member of the wing is home (crew_home()). */ -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Dosed or carrying a specimen: their death is the experiment's, not staff's var/experiment_subject = FALSE @@ -37,7 +42,7 @@ /// The experiment under way, or just ended (the console shows the result for a while) var/datum/outpost_experiment/experiment /// The researcher, while visiting with an offer - var/mob/living/basic/outpost_kessler_staff/researcher/researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/researcher /// Crew-home seconds until the researcher is due; null before the first arrival and while an offer, item or experiment is out var/researcher_wait /// The researcher's visits have been scheduled: the wing has had its first arrival @@ -114,7 +119,7 @@ * bursts from the body. */ /datum/outpost_prison/proc/subject_tick(seconds, home) - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() if(!subject || subject.prison != src || subject.phase != PRISONER_PRESENT) experiment_failed("the subject is gone") return FALSE @@ -134,14 +139,14 @@ /datum/outpost_prison/proc/experiment_paused() if(!experiment_active() || !crew_home()) return TRUE - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() return subject && subject.stat != DEAD && (get_area(subject) != wing || !in_cell_block(subject)) /// A minute of twitching, then the change. The form's tells show in the last OUTPOST_EXPERIMENT_TELLS seconds. /datum/outpost_prison/proc/twitch_tick(seconds, home) if(!home) return - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() experiment.twitch_left -= seconds experiment.stage = experiment.twitch_left > OUTPOST_EXPERIMENT_TELLS ? "dosed" : "twitching" if(experiment.twitch_left <= 0) @@ -162,21 +167,29 @@ else subject.say_context("experiment_dose") -/// The serum takes: the subject becomes the creature where they stand +/// The serum takes: the subject's body becomes the creature where they stand (outpost_prison_experiment_forms.dm) /datum/outpost_prison/proc/transform_subject() - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() - var/creature_type = outpost_experiment_creature_type(experiment.form) + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() var/turf/spot = get_turf(subject) - if(!subject || !creature_type || !spot) + if(!subject || !outpost_experiment_form_type(experiment.form) || !spot) experiment_failed("the serum did not take") return null - var/mob/living/basic/outpost_experiment/creature = new creature_type(spot, src, subject) - add_log("[subject.real_name] turned into [creature.form_name].") - log_game("PLAYER OUTPOST PRISON: [subject.real_name] turned into [creature.form_name] at '[outpost?.name]' [AREACOORD(spot)]") + var/subject_name = subject.real_name + var/personality = subject.personality experiment.subject_ref = null - qdel(subject) - experiment_creature_appeared(creature, experiment.form) - return creature + // The same body, no longer an inmate: off the roster (its cell waits a refill), the prisoner's own + // reactions off. The form brings its own mind, side and refusals. + subject.stand_down_for_experiment() + forget(subject) + var/mob/living/carbon/human/body = subject + var/datum/component/outpost_experiment_form/form = outpost_experiment_turn(body, experiment.form, src, personality) + if(!form) + experiment_failed("the serum did not take") + return null + add_log("[subject_name] turned into [form.form_name].") + log_game("PLAYER OUTPOST PRISON: [subject_name] turned into [form.form_name] at '[outpost?.name]' [AREACOORD(spot)]") + experiment_creature_appeared(body, experiment.form) + return body /** * The creatures and the leash. A creature off the outpost is recovered at once (for the fee only @@ -196,6 +209,10 @@ return var/out_of_wing = FALSE for(var/mob/living/creature as anything in live) + // A body the serum took is down when tg says so: crit, stamcrit, asleep, cuffed or dead. + if(outpost_experiment_down_state(creature)) + experiment_creature_down(creature) + continue if(!outpost_holds(creature)) // Its own move into space (on_moved()) usually got there first; this catches the rest. var/mob/living/basic/outpost_experiment/horror/spaced = creature @@ -208,6 +225,12 @@ return if(get_area(creature) != wing) out_of_wing = TRUE + // Down and waiting for Kessler, but dragged off the outpost: Kessler's beam takes it at once. + for(var/key in experiment.awaiting.Copy()) + var/datum/weakref/creature_ref = experiment.creature_refs[key] + var/mob/living/creature = creature_ref?.resolve() + if(!QDELETED(creature) && !outpost_holds(creature) && !HAS_TRAIT_FROM(creature, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) + kessler_finish_collect(creature_ref) if(out_of_wing && !experiment.breach_announced) experiment.breach_announced = TRUE add_log("An experiment creature got out of the wing.") @@ -230,7 +253,7 @@ * does the admin panel with `forced`, which skips the cell block rule. One experiment at a time, * on a living prisoner of this wing. Returns TRUE if it started. */ -/datum/outpost_prison/proc/start_experiment(form, mob/living/basic/outpost_prisoner/subject, forced = FALSE) +/datum/outpost_prison/proc/start_experiment(form, mob/living/carbon/human/npc/outpost_prisoner/subject, forced = FALSE) if(!(form in list("hulk", "fly", "nightmare", "changeling"))) return FALSE if(QDELETED(subject) || subject.prison != src || subject.phase != PRISONER_PRESENT || subject.stat == DEAD) @@ -262,7 +285,7 @@ experiment.stage = "dosed" experiment.twitch_left = OUTPOST_EXPERIMENT_TWITCH experiment.effect_left = rand(3, 5) - INVOKE_ASYNC(subject, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "experiment_dose") + INVOKE_ASYNC(subject, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "experiment_dose") return TRUE // ===== CREATURES AND PAY ===== @@ -290,7 +313,10 @@ experiment.creature_refs[key] = WEAKREF(creature) experiment.creature_kinds[key] = kind experiment.revealed = TRUE - creature.AddComponent(/datum/component/experiment_damage_ledger) + var/datum/component/experiment_damage_ledger/ledger = creature.AddComponent(/datum/component/experiment_damage_ledger) + // The crew's lights are their weapon against a nightmare: what the light burns is theirs. + if(kind == "nightmare" && istype(ledger)) + ledger.light_is_players = TRUE RegisterSignal(creature, COMSIG_LIVING_DEATH, PROC_REF(on_creature_death)) RegisterSignal(creature, COMSIG_QDELETING, PROC_REF(on_creature_deleted)) experiment.stage = kind == "horror" ? "horror" : "live" @@ -304,37 +330,87 @@ return TRUE /** - * A creature was put down: killed, or `subdued` alive (worn out on stamina). Pays the containment - * bonus, once per experiment, if players did at least OUTPOST_EXPERIMENT_PLAYER_SHARE of its - * damage. It lies there, and the console shows it awaiting pickup, until Kessler's team comes for - * it OUTPOST_EXPERIMENT_PICKUP seconds later. With no creature left the experiment is over. - * Returns TRUE if it counted. + * A creature went down (outpost_experiment_down_state()): dead, or a body the serum took in crit, + * stamcrit, asleep or cuffed. It lies there, and the console shows it awaiting pickup, until Kessler's + * team comes for it OUTPOST_EXPERIMENT_PICKUP seconds later (kessler_collect()). A dead one earns the + * containment bonus now (once per experiment, so reviving it earns nothing more); one down alive earns it + * when Kessler has it, by the state the team finds it in. With nothing left loose or down alive, the + * experiment is contained. Returns TRUE if it counted. */ -/datum/outpost_prison/proc/experiment_creature_down(mob/living/creature, subdued = FALSE) +/datum/outpost_prison/proc/experiment_creature_down(mob/living/creature) if(!experiment || !creature) return FALSE var/key = REF(creature) if(!experiment.creature_refs[key] || experiment.downed[key]) return FALSE + var/state = outpost_experiment_down_state(creature) || OUTPOST_EXPERIMENT_DOWN_DEAD experiment.downed[key] = TRUE - experiment.awaiting[key] = subdued ? "subdued" : "down" - var/kind = experiment.creature_kinds[key] - if(!experiment.bonus_done && !experiment.resolved) - experiment.bonus_done = TRUE - var/datum/component/experiment_damage_ledger/ledger = creature.GetComponent(/datum/component/experiment_damage_ledger) - var/share = ledger ? ledger.player_share() : 1 - if(share >= OUTPOST_EXPERIMENT_PLAYER_SHARE) - var/bonus = round(outpost_experiment_bonus(kind, subdued) * experiment.multiplier) - if(pay_treasury(bonus, "Kessler Biolabs containment bonus")) - experiment.bonus_paid = bonus - add_log("[creature.name] was [subdued ? "subdued" : "put down"]. Kessler Biolabs paid a [bonus] cr containment bonus.") - else - add_log("[creature.name] was [subdued ? "subdued" : "put down"]. Kessler Biolabs paid no bonus.") - addtimer(CALLBACK(src, PROC_REF(kessler_collect), WEAKREF(creature), FALSE), OUTPOST_EXPERIMENT_PICKUP SECONDS, TIMER_DELETE_ME) - if(!experiment.resolved && !length(experiment.live_creatures())) - resolve_experiment("contained") + experiment.awaiting[key] = state + if(state != OUTPOST_EXPERIMENT_DOWN_DEAD) + var/datum/component/outpost_experiment_form/form = creature.GetComponent(/datum/component/outpost_experiment_form) + creature.visible_message(span_danger("[creature] [form?.down_message || "collapses, spent."]")) + creature.balloon_alert_to_viewers(state) + else + if(ishuman(creature)) + creature.balloon_alert_to_viewers("dead") + pay_containment(creature) + addtimer(CALLBACK(src, PROC_REF(kessler_collect), WEAKREF(creature)), OUTPOST_EXPERIMENT_PICKUP SECONDS, TIMER_DELETE_ME) + experiment_check_contained() return TRUE +/// Contained: nothing of the experiment's is loose, or down alive waiting for Kessler (which could still get up) +/datum/outpost_prison/proc/experiment_check_contained() + if(!experiment || experiment.resolved || length(experiment.live_creatures())) + return FALSE + for(var/key in experiment.awaiting) + if(experiment.awaiting[key] != OUTPOST_EXPERIMENT_DOWN_DEAD) + return FALSE + resolve_experiment("contained") + return TRUE + +/// Whether `creature` lies down waiting for Kessler's team +/datum/outpost_prison/proc/experiment_awaiting_pickup(mob/living/creature) + if(!experiment) + return FALSE + return !!experiment.awaiting[REF(creature)] + +/** + * A creature that went down is back on its feet before Kessler had it (revived, woken, out of its + * cuffs): it is loose again, and the experiment goes on. One the experiment has already been settled + * for (contained, paid) is Kessler's: the beam takes it as it stands. + */ +/datum/outpost_prison/proc/experiment_creature_back_up(mob/living/creature) + if(!experiment) + return + var/key = REF(creature) + experiment.awaiting -= key + if(experiment.resolved) + kessler_take(creature) + return + experiment.downed -= key + add_log("[creature.name] got back up before Kessler Biolabs came for [creature.p_them()].") + +/** + * The containment bonus, once per experiment, for the creature Kessler is taking: if players did at least + * OUTPOST_EXPERIMENT_PLAYER_SHARE of its damage, at the subdued rate when it was taken alive and held. + */ +/datum/outpost_prison/proc/pay_containment(mob/living/creature) + var/key = REF(creature) + if(experiment.bonus_done || experiment.resolved) + return + experiment.bonus_done = TRUE + var/subdued = experiment.awaiting[key] == OUTPOST_EXPERIMENT_DOWN_SUBDUED + var/kind = experiment.creature_kinds[key] + var/datum/component/experiment_damage_ledger/ledger = creature.GetComponent(/datum/component/experiment_damage_ledger) + var/share = ledger ? ledger.player_share() : 1 + if(share < OUTPOST_EXPERIMENT_PLAYER_SHARE) + add_log("[creature.name] was [subdued ? "subdued" : "put down"]. Kessler Biolabs paid no bonus.") + return + var/bonus = round(outpost_experiment_bonus(kind, subdued) * experiment.multiplier) + if(pay_treasury(bonus, "Kessler Biolabs containment bonus")) + experiment.bonus_paid = bonus + add_log("[creature.name] was [subdued ? "subdued" : "put down"]. Kessler Biolabs paid a [bonus] cr containment bonus.") + /** * A tracked creature died: that is it put down. Gibbed or dusted (a bomb, a shuttle), it never took * the health it still had as damage, so that goes on its ledger as damage that was not the players'. @@ -342,14 +418,14 @@ */ /datum/outpost_prison/proc/on_creature_death(mob/living/creature, gibbed) SIGNAL_HANDLER - var/unspent = creature.health + var/unspent = max(0, creature.health) var/mob/living/basic/outpost_experiment/experiment_creature = creature if(istype(experiment_creature)) unspent = experiment_creature.unspent_health() if(gibbed && unspent > 0) var/datum/component/experiment_damage_ledger/ledger = creature.GetComponent(/datum/component/experiment_damage_ledger) ledger?.add_damage(unspent, FALSE) - INVOKE_ASYNC(src, PROC_REF(experiment_creature_down), creature, FALSE) + INVOKE_ASYNC(src, PROC_REF(experiment_creature_down), creature) /// A tracked creature is gone. A serum creature deleted before it was put down or recovered takes the experiment with it. /datum/outpost_prison/proc/on_creature_deleted(mob/living/creature) @@ -378,7 +454,8 @@ return FALSE for(var/mob/living/creature as anything in experiment.live_creatures()) experiment.downed[REF(creature)] = TRUE - kessler_collect(WEAKREF(creature), FALSE) + kessler_take(creature) + take_awaiting_creatures() add_log("The experiment failed: [reason].") log_game("PLAYER OUTPOST PRISON: experiment failed at '[outpost?.name]' ([reason])") resolve_experiment("failed") @@ -399,7 +476,8 @@ fee = max(fee, outpost_experiment_recovery_fee(experiment.creature_kinds[key])) names += creature.name experiment.downed[key] = TRUE - kessler_collect(WEAKREF(creature), TRUE) + outpost_kessler_team(creature, "kessler_recovery", tranquilise = FALSE) + kessler_take(creature) var/charged = 0 if(charge && fee > 0) charged = charge_fine(fee, "Kessler Biolabs recovery fee") @@ -450,8 +528,9 @@ qdel(creature) for(var/mob/living/creature as anything in experiment.live_creatures()) experiment.downed[REF(creature)] = TRUE - kessler_collect(WEAKREF(creature), FALSE) - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + kessler_take(creature) + take_awaiting_creatures() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() if(subject) subject.experiment_subject = FALSE outpost_experiment_clear_tells(subject) @@ -482,7 +561,7 @@ experiment.resolved = TRUE experiment.stage = outcome experiment.result_left = OUTPOST_EXPERIMENT_RESULT_SHOWN - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() if(subject && subject.stat != DEAD) subject.experiment_subject = FALSE outpost_experiment_clear_tells(subject) @@ -492,7 +571,7 @@ // absorbed who was not a prisoner. Its witnesses join the experiment's, once each. if(!QDELETED(experiment.changeling)) for(var/datum/weakref/witness_ref as anything in experiment.changeling.witnesses) - var/mob/living/basic/outpost_prisoner/witness = witness_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/witness = witness_ref?.resolve() if(witness) experiment.witnesses[REF(witness)] = witness_ref #endif @@ -510,7 +589,7 @@ /datum/outpost_prison/proc/experiment_aftermath() for(var/key in experiment.witnesses) var/datum/weakref/witness_ref = experiment.witnesses[key] - var/mob/living/basic/outpost_prisoner/witness = witness_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/witness = witness_ref?.resolve() if(!witness || witness.prison != src || witness.stat == DEAD || witness.phase != PRISONER_PRESENT) continue witness.adjust_mood(-OUTPOST_EXPERIMENT_SAW_DEATH_MOOD) @@ -520,37 +599,138 @@ experiment.creature_kills = 0 /** - * Kessler takes a creature away: a team beams in around it, and OUTPOST_KESSLER_TEAM_TIME later - * the beam takes it. One that was put down is collected where it lies; one still on its feet (a - * `recovery` off the outpost, or an experiment called off) is tranquilised and drops first. It is - * held still and cannot be hurt meanwhile. + * Kessler's team comes for a creature that went down (experiment_creature_down()): two agents beam in + * around it, a living one gets a real chloral hydrate dart to keep it down (owner ruling OC-3), and + * OUTPOST_KESSLER_TEAM_TIME later, if it is still down, the containment bonus is paid and the beam takes + * it. The bonus goes by the state it was found in. One already back on its feet is loose again. */ -/datum/outpost_prison/proc/kessler_collect(datum/weakref/creature_ref, recovery = FALSE) +/datum/outpost_prison/proc/kessler_collect(datum/weakref/creature_ref) var/mob/living/creature = creature_ref?.resolve() - if(QDELETED(creature) || HAS_TRAIT_FROM(creature, TRAIT_GODMODE, OUTPOST_KESSLER_TRAIT)) + if(QDELETED(creature) || !experiment || HAS_TRAIT_FROM(creature, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) + return + var/key = REF(creature) + if(!experiment.awaiting[key]) + return + var/state = outpost_experiment_down_state(creature) + if(!state) + experiment_creature_back_up(creature) + return + experiment.awaiting[key] = state + outpost_kessler_team(creature, "kessler_collect", tranquilise = FALSE) + if(creature.stat != DEAD) + outpost_kessler_dart(creature) + addtimer(CALLBACK(src, PROC_REF(kessler_finish_collect), creature_ref), OUTPOST_KESSLER_TEAM_TIME, TIMER_DELETE_ME) + +/// The team's beam takes a creature still down, and Kessler pays for it if it hasn't yet. With nothing left loose or down alive, the experiment is contained. +/datum/outpost_prison/proc/kessler_finish_collect(datum/weakref/creature_ref) + var/mob/living/creature = creature_ref?.resolve() + if(QDELETED(creature) || !experiment || HAS_TRAIT_FROM(creature, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) + return + var/key = REF(creature) + if(!experiment.awaiting[key]) + return + if(!outpost_experiment_down_state(creature)) + experiment_creature_back_up(creature) return - var/already_down = creature.stat == DEAD || creature.body_position == LYING_DOWN - ADD_TRAIT(creature, TRAIT_GODMODE, OUTPOST_KESSLER_TRAIT) - ADD_TRAIT(creature, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) - ADD_TRAIT(creature, TRAIT_INCAPACITATED, OUTPOST_KESSLER_TRAIT) - ADD_TRAIT(creature, TRAIT_HANDS_BLOCKED, OUTPOST_KESSLER_TRAIT) - ADD_TRAIT(creature, TRAIT_FLOORED, OUTPOST_KESSLER_TRAIT) - creature.ai_controller?.CancelActions() + pay_containment(creature) + add_log("Kessler Biolabs collected [creature.name].") + experiment.awaiting -= key + kessler_take(creature) + experiment_check_contained() + +/** + * Kessler's beam takes a creature away for good: held still and harmless for the beam, then gone. A + * transporter, never godmode (defaults 1.8): nothing that happens during it changes what was paid. The + * crew's restraints stay behind. + */ +/datum/outpost_prison/proc/kessler_take(mob/living/creature) + if(QDELETED(creature) || HAS_TRAIT_FROM(creature, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT)) + return + creature.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_INCAPACITATED, TRAIT_HANDS_BLOCKED), OUTPOST_KESSLER_TRAIT) + creature.ai_controller?.set_ai_status(AI_STATUS_OFF) creature.pulledby?.stop_pulling() - if(recovery) - outpost_kessler_team(creature, "kessler_recovery", tranquilise = TRUE) - else - outpost_kessler_team(creature, "kessler_collect", tranquilise = !already_down) - if(already_down && !recovery) - add_log("Kessler Biolabs collected [creature.name].") - addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(outpost_kessler_beam_away), creature), OUTPOST_KESSLER_TEAM_TIME, TIMER_DELETE_ME) + creature.stop_pulling() + creature.buckled?.unbuckle_mob(creature, force = TRUE) + var/turf/spot = get_turf(creature) + if(spot) + creature.visible_message(span_notice("A transporter beam takes [creature] away.")) + playsound(spot, 'sound/effects/magic/teleport_diss.ogg', 40, TRUE) + new /obj/effect/temp_visual/transporter_beam(spot, OUTPOST_KESSLER_BEAM_TIME + 0.5 SECONDS) + transporter_dematerialise(creature, OUTPOST_KESSLER_BEAM_TIME) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(outpost_kessler_finish_take), WEAKREF(creature)), OUTPOST_KESSLER_BEAM_TIME, TIMER_DELETE_ME) + +/// Everything down and waiting for Kessler goes with the beam now, unpaid: the experiment is over before the team came +/datum/outpost_prison/proc/take_awaiting_creatures() + for(var/key in experiment.awaiting.Copy()) + var/datum/weakref/creature_ref = experiment.creature_refs[key] + experiment.awaiting -= key + kessler_take(creature_ref?.resolve()) + +/// The end of Kessler's beam: restraints the crew put on it stay, and the rest goes +/proc/outpost_kessler_finish_take(datum/weakref/creature_ref) + var/mob/living/creature = creature_ref?.resolve() + if(QDELETED(creature)) + return + var/turf/spot = get_turf(creature) + var/mob/living/carbon/held = creature + if(spot && istype(held)) + for(var/obj/item/restraints/worn in list(held.handcuffed, held.legcuffed)) + held.transferItemToLoc(worn, spot, force = TRUE) + if(spot) + new /obj/effect/temp_visual/transporter_flash/departure(spot) + transporter_sparks(spot) + playsound(spot, 'sound/effects/magic/teleport_app.ogg', 50, TRUE) + qdel(creature) + +/** + * A Kessler agent's tranquilliser: a real dart of chloral hydrate, fired from beside `creature`, which tg's + * reagent and the target's clothing decide the rest of. + */ +/proc/outpost_kessler_dart(mob/living/creature) + var/turf/target_turf = get_turf(creature) + if(!target_turf) + return null + var/turf/from = target_turf + for(var/turf/open/tile in shuffle(orange(1, target_turf))) + if(!tile.is_blocked_turf(TRUE)) + from = tile + break + var/obj/projectile/bullet/dart/dart = new(from) + dart.reagents.add_reagent(/datum/reagent/toxin/chloralhydrate, OUTPOST_KESSLER_DART_DOSE) + dart.original = creature + playsound(from, 'sound/items/syringeproj.ogg', 50, TRUE) + dart.aim_projectile(creature, from) + dart.fire(direct_target = creature) + return dart + +/** + * Whether a creature is down for Kessler, and how, by tg's own states: OUTPOST_EXPERIMENT_DOWN_DEAD; + * OUTPOST_EXPERIMENT_DOWN_SUBDUED for a body alive on the floor (crit, stamcrit, asleep) and held (cuffed + * or asleep); OUTPOST_EXPERIMENT_DOWN_DOWN for one on the floor or cuffed but not both. Null while it is + * up and free. The horror and the headslug, monsters, are down only once dead. + */ +/proc/outpost_experiment_down_state(mob/living/creature) + if(QDELETED(creature)) + return null + if(creature.stat == DEAD) + return OUTPOST_EXPERIMENT_DOWN_DEAD + if(!iscarbon(creature)) + return null + var/asleep = creature.stat == UNCONSCIOUS || creature.IsSleeping() || creature.IsUnconscious() + var/floored = asleep || creature.stat >= SOFT_CRIT || HAS_TRAIT_FROM(creature, TRAIT_INCAPACITATED, STAMINA) + var/held = asleep || HAS_TRAIT(creature, TRAIT_RESTRAINED) + if(floored && held) + return OUTPOST_EXPERIMENT_DOWN_SUBDUED + if(floored || held) + return OUTPOST_EXPERIMENT_DOWN_DOWN + return null // ===== PRISONERS ===== /// Prisoners who could be the researcher's subjects: in the wing, alive, not loose and not dosed already /datum/outpost_prison/proc/researcher_prisoner_count() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && prisoner.trouble != PRISONER_TROUBLE_LOOSE && !prisoner.experiment_subject) count++ return count @@ -560,7 +740,7 @@ * researcher says so. A changeling host dying is the changeling event's to handle. Anyone else a * creature (not staff) killed counts toward the aftermath, with everyone in sight of it a witness. */ -/datum/outpost_prison/on_prisoner_death(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/on_prisoner_death(mob/living/carbon/human/npc/outpost_prisoner/prisoner) . = ..() if(!experiment_active()) return @@ -575,12 +755,12 @@ if(!creature_live() || prisoner.death_blamed) return experiment.creature_kills++ - for(var/mob/living/basic/outpost_prisoner/witness in viewers(OUTPOST_EXPERIMENT_WITNESS_RANGE, get_turf(prisoner))) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in viewers(OUTPOST_EXPERIMENT_WITNESS_RANGE, get_turf(prisoner))) if(witness != prisoner && witness.prison == src && witness.stat != DEAD) experiment.witnesses[REF(witness)] = WEAKREF(witness) /// A dosed subject earns nothing: their pay and release bonus are forfeit -/datum/outpost_prison/pay_factor(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/pay_factor(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner?.experiment_subject) return 0 return ..() @@ -592,7 +772,7 @@ * of the cell block stays on the roster (body_tick()). * Once the experiment is over the usual rules apply again. */ -/datum/outpost_prison/proc/held_for_experiment(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/held_for_experiment(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner || !experiment_active()) return FALSE if(prisoner == experiment.subject()) @@ -604,12 +784,12 @@ return FALSE /// A dosed subject is not released at the end of their sentence -/datum/outpost_prison/check_release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/check_release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(held_for_experiment(prisoner)) return return ..() -/datum/outpost_prison/release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(held_for_experiment(prisoner)) return 0 return ..() @@ -740,7 +920,7 @@ var/list/witnesses = list() var/creature_kills = 0 -/datum/outpost_experiment/New(datum/outpost_prison/owner, form, mob/living/basic/outpost_prisoner/subject) +/datum/outpost_experiment/New(datum/outpost_prison/owner, form, mob/living/carbon/human/npc/outpost_prisoner/subject) . = ..() prison = owner src.form = form @@ -972,6 +1152,9 @@ /// While set, the damage it takes is the players' whoever struck last: fire a player is credited /// with (the horror's take_fire_damage()). It is not a blow, so recent_player() keeps its time. var/crediting_players = FALSE + /// A nightmare's: burns it takes standing in the light, with nobody striking it, are the players' + /// (the crew's lights are their weapon) + var/light_is_players = FALSE /datum/component/experiment_damage_ledger/Initialize() if(!isliving(parent)) @@ -1021,6 +1204,11 @@ if(crediting_players) add_damage(damage, TRUE) return + if(light_is_players && damagetype == BURN && last_attack_time != world.time) + var/turf/lit = get_turf(parent) + if(lit && lit.get_lumcount() >= SHADOW_SPECIES_LIGHT_THRESHOLD) + add_damage(damage, TRUE) + return if(last_attack_time == world.time) if(!last_attack_prisoner) add_damage(damage, last_attack_player) @@ -1095,7 +1283,7 @@ return TRUE /// Starts a changeling event in `host`, a prisoner of `prison`, and returns it -/proc/outpost_changeling_infect(mob/living/basic/outpost_prisoner/host, datum/outpost_prison/prison) +/proc/outpost_changeling_infect(mob/living/carbon/human/npc/outpost_prisoner/host, datum/outpost_prison/prison) var/datum/outpost_changeling_event/event = new event.prison = prison event.host_ref = WEAKREF(host) diff --git a/voidcrew/modules/player_outposts/outpost_prison_extras.dm b/voidcrew/modules/player_outposts/outpost_prison_extras.dm index d0df5077f73..16f7f332f9c 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_extras.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_extras.dm @@ -61,14 +61,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", // ===== THE PRISONER ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Values for extra placeholders ("{staff}" = "Isaac") while one line is picked and said var/list/extra_line_values /// The last person or borg (never a machine) whose hit on them counted as staff's var/datum/weakref/last_staff_attacker_ref /// Hooks up every package's own handlers; called once from Initialize() -/mob/living/basic/outpost_prisoner/proc/setup_extras() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_extras() setup_ambience() setup_warden_tools() setup_pastimes() @@ -78,13 +78,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", * Says a line for `context` with values for extra placeholders, list("{place}" = "the Meridian"). * Lines naming a placeholder without a value are skipped. Returns TRUE if they said something. */ -/mob/living/basic/outpost_prisoner/proc/say_context_with(context, list/values, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/say_context_with(context, list/values, mob/living/carbon/human/npc/outpost_prisoner/other) extra_line_values = values . = say_context(context, other) extra_line_values = null /// Says a line for `context` to `person`, filling {staff} with the name the yard knows them by -/mob/living/basic/outpost_prisoner/proc/say_to_staff(context, mob/person) +/mob/living/carbon/human/npc/outpost_prisoner/proc/say_to_staff(context, mob/person) var/staff_name = prison?.staff_greeting_name(person) return say_context_with(context, staff_name ? list("{staff}" = staff_name) : null) @@ -123,13 +123,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", guards_abandon() /// A prisoner was booked in (admit()), with their cell and mood set -/datum/outpost_prison/proc/extras_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/extras_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) on_prisoner_admitted(prisoner) contraband_prisoner_admitted(prisoner) leads_prisoner_admitted(prisoner) /// A prisoner is leaving the roster (forget()), still holding their prison and cell -/datum/outpost_prison/proc/extras_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/extras_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) on_prisoner_leaving(prisoner) contraband_prisoner_leaving(prisoner) mail_prisoner_leaving(prisoner) @@ -146,7 +146,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", // ===== THE PRISON: SPEECH, EXAMINE AND THE TALK MENU ===== /// Something a package wants a prisoner to say now: list(context, other[, values]), or null -/datum/outpost_prison/proc/extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) // A bounty prisoner who sees someone off the ship that caught them (outpost_prison_bounty.dm): rare and gated var/list/choice = bounty_extra_speech(prisoner) if(choice) @@ -163,7 +163,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", return leads_extra_speech(prisoner) /// Extra sentences for a prisoner's examine text, from every package that has one; XB's examine() shows them -/datum/outpost_prison/proc/examine_extra_lines(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/examine_extra_lines(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) var/list/lines = list() for(var/line in list(relationship_examine(prisoner), contraband_examine(prisoner, user), leads_examine(prisoner, user), wildcard_examine(prisoner), bounty_examine(prisoner, user))) if(istext(line) && length(line)) @@ -171,7 +171,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", return lines /// Extra choices for the talk menu (XC's radial): name -> image, from the packages that add one -/datum/outpost_prison/proc/talk_menu_extra_choices(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/talk_menu_extra_choices(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) var/list/choices = list() for(var/list/extra as anything in list(contraband_talk_choices(prisoner, user), leads_talk_choices(prisoner, user))) for(var/key in extra) @@ -179,7 +179,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", return choices /// Runs an extra talk menu choice; TRUE if a package handled it. May sleep (the menu is async). -/datum/outpost_prison/proc/talk_menu_extra_act(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, choice) +/datum/outpost_prison/proc/talk_menu_extra_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, choice) if(contraband_talk_act(prisoner, user, choice)) return TRUE return leads_talk_act(prisoner, user, choice) @@ -187,7 +187,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", // ===== THE PRISON: TROUBLE ===== /// What the packages do to the chance two prisoners start a fight: a multiplier, 1 for no change -/datum/outpost_prison/proc/extras_fight_mult(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/extras_fight_mult(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) return fight_chance_mult(one, two) * contraband_fight_mult(one, two) // ===== THE PRISON: CONSOLES ===== diff --git a/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm b/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm index 0ca544dd12f..a76de68e62c 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm @@ -10,7 +10,9 @@ /// Whether this is an outpost prisoner /proc/is_outpost_prisoner(atom/thing) - return istype(thing, /mob/living/basic/outpost_prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = thing + // A body the serum turned is an experiment's creature now (outpost_prison_experiment_forms.dm) + return istype(prisoner) && !prisoner.experiment_turned // ===== UNIFORM ===== diff --git a/voidcrew/modules/player_outposts/outpost_prison_guard_combat_sense.dm b/voidcrew/modules/player_outposts/outpost_prison_guard_combat_sense.dm new file mode 100644 index 00000000000..91cb195988e --- /dev/null +++ b/voidcrew/modules/player_outposts/outpost_prison_guard_combat_sense.dm @@ -0,0 +1,30 @@ +/** + * Combat sense for a prison's NPC guards (voidcrew/modules/human_npcs/human_npc_combat_sense.dm). + * + * Guards never strike players and carry no stimulant, so theirs is the cover half only: on duty and shot + * with stun rounds by anyone but a fellow guard, a guard gets out of the line of fire, inside the wing, + * keeps down a moment, then goes back to whatever it was doing (its routine plans again). It never + * charges anyone (close_in_reach 0). The prison's own turrets aren't a threat it reacts to: only people + * and mechs are. + */ +/datum/npc_combat_sense/prison_guard + use_stims = FALSE + close_in_reach = 0 + +/mob/living/carbon/human/npc/outpost_prison_guard + npc_combat_sense = /datum/npc_combat_sense/prison_guard + +/datum/npc_combat_sense/prison_guard/can_act(mob/living/carbon/human/npc, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = npc + return istype(guard) && guard.on_duty() && ..() + +// Players share the guards' faction: only another guard's shot is friendly fire. +/datum/npc_combat_sense/prison_guard/allied(mob/living/carbon/human/npc, atom/shooter) + return is_outpost_prison_guard(shooter) + +/datum/npc_combat_sense/prison_guard/tile_ok(mob/living/carbon/human/npc, datum/ai_controller/controller, turf/tile) + if(!..()) + return FALSE + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = npc + var/area/wing = istype(guard) ? guard.prison?.wing : null + return isnull(wing) || get_area(tile) == wing diff --git a/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm b/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm index a2fa01ac29f..096d8392a8e 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm @@ -2,8 +2,8 @@ * # Prison guards: routine * * Owner: XA (extras-plan.md 4.1). The guards' AI controller, their peaceful activities (post, - * rounds, watching the yard, a drink at the cooler, a chat, checking on a prisoner) and the - * response datum the dispatcher in outpost_prison_guards.dm hands out. + * rounds, watching the yard, a drink at the cooler, a chat, checking on a prisoner, patching + * themselves up) and the response datum the dispatcher in outpost_prison_guards.dm hands out. * * As with the prisoners' routine (outpost_prison_routine.dm), the AI only walks: to the goal of * the guard's response when there is one, else to their activity's. The dispatcher deals with @@ -86,7 +86,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) // ===== THE GUARD'S SIDE ===== /// Where their AI should walk them now, or null to stay put -/mob/living/basic/outpost_prison_guard/proc/walk_goal() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/walk_goal() if(phase == OUTPOST_GUARD_DISMISSED) return (leave_spot && loc != leave_spot) ? leave_spot : null if(phase != OUTPOST_GUARD_PRESENT) @@ -98,7 +98,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return (goal && !at_goal(goal)) ? goal : null /// A walk to `goal` failed: a response gets another try until it expires; an activity gets one more, then a rest -/mob/living/basic/outpost_prison_guard/proc/walk_failed(atom/goal) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/walk_failed(atom/goal) if(response || !activity || goal != activity.goal()) return if(++activity.failed_moves < 2) @@ -107,7 +107,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) end_activity(cancel_ai = FALSE) /// Ends what they are doing on routine -/mob/living/basic/outpost_prison_guard/proc/end_activity(cancel_ai = TRUE) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/end_activity(cancel_ai = TRUE) if(!activity) return var/datum/outpost_guard_activity/old = activity @@ -119,16 +119,16 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) ai_controller?.CancelActions() /// Starts `new_activity` in place of whatever they were doing on routine -/mob/living/basic/outpost_prison_guard/proc/start_activity(datum/outpost_guard_activity/new_activity) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/start_activity(datum/outpost_guard_activity/new_activity) end_activity() activity = new_activity return new_activity -/mob/living/basic/outpost_prison_guard/proc/activity_on_cooldown(activity_type) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/activity_on_cooldown(activity_type) return LAZYACCESS(activity_cooldowns, activity_type) > world.time /// Picks their next routine activity by weight. Returns it, already set up, or null. -/mob/living/basic/outpost_prison_guard/proc/choose_activity() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/choose_activity() if(!prison || !on_duty()) return null var/list/candidates = list() @@ -161,7 +161,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) * A two-guard exchange: they open with a line from the file's guard_conversations, and `partner` * answers a few seconds later. Returns TRUE if it started. */ -/mob/living/basic/outpost_prison_guard/proc/start_guard_conversation(mob/living/basic/outpost_prison_guard/partner) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/start_guard_conversation(mob/living/carbon/human/npc/outpost_prison_guard/partner) if(!on_duty() || !partner?.on_duty()) return FALSE var/list/conversations = outpost_guard_dialogue("guard_conversations") @@ -179,8 +179,8 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return TRUE /// The second half of a guards' exchange -/mob/living/basic/outpost_prison_guard/proc/reply_to_guard(line, datum/weakref/opener_ref) - var/mob/living/basic/outpost_prison_guard/opener = opener_ref?.resolve() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/reply_to_guard(line, datum/weakref/opener_ref) + var/mob/living/carbon/human/npc/outpost_prison_guard/opener = opener_ref?.resolve() if(!on_duty() || !opener || get_dist(src, opener) > 5 || !istext(line)) return FALSE face_atom(opener) @@ -191,7 +191,9 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) // ===== AI ===== /datum/ai_controller/basic_controller/outpost_prison_guard - blackboard = list() + blackboard = list( + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + ) ai_traits = PASSIVE_AI_FLAGS ai_movement = /datum/ai_movement/jps // The routine plans everything, idle time included. @@ -200,13 +202,17 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) can_idle = FALSE // No finding targets: guards only ever strike through the dispatcher (outpost_prison_guards.dm), // so turrets never read them as hostile. - planning_subtrees = list(/datum/ai_planning_subtree/outpost_prison_guard) + // Combat sense first: out of a line of stun fire before anything else (outpost_prison_guard_combat_sense.dm) + planning_subtrees = list( + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/outpost_prison_guard, + ) /// Walks to the response's goal, else the activity's, or ticks the activity once there /datum/ai_planning_subtree/outpost_prison_guard /datum/ai_planning_subtree/outpost_prison_guard/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn if(!istype(guard) || guard.stat != CONSCIOUS || HAS_TRAIT(guard, TRAIT_IMMOBILIZED)) return if(guard.phase == OUTPOST_GUARD_DISMISSED || guard.response) @@ -235,7 +241,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return SUBTREE_RETURN_FINISH_PLANNING /// Queues the walk to `goal`: onto a tile, or up beside a mob -/mob/living/basic/outpost_prison_guard/proc/queue_walk(datum/ai_controller/controller, atom/goal) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/queue_walk(datum/ai_controller/controller, atom/goal) controller.set_blackboard_key(BB_OUTPOST_GUARD_GOAL, goal) controller.queue_behavior(isturf(goal) ? /datum/ai_behavior/outpost_prison_guard_travel : /datum/ai_behavior/outpost_prison_guard_travel/approach, BB_OUTPOST_GUARD_GOAL) @@ -253,7 +259,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) set_movement_target(controller, goal) /datum/ai_behavior/outpost_prison_guard_travel/perform(seconds_per_tick, datum/ai_controller/controller, goal_key) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn var/atom/goal = controller.blackboard[goal_key] if(QDELETED(goal) || !istype(guard)) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -269,7 +275,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) . = ..() controller.clear_blackboard_key(goal_key) if(!succeeded) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn if(istype(guard)) guard.walk_failed(goal) @@ -282,11 +288,11 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) action_cooldown = 1 SECONDS /datum/ai_behavior/outpost_prison_guard_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn return istype(guard) && !!guard.activity /datum/ai_behavior/outpost_prison_guard_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn var/datum/outpost_guard_activity/doing = guard.activity if(!doing || guard.response || !guard.on_duty()) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -320,14 +326,14 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) var/max_duration = 90 SECONDS /// Whether rounds or the other guard's chat may take its place var/interruptible = TRUE - var/mob/living/basic/outpost_prison_guard/guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard /// Where they are headed: a tile, or a mob to stand beside; null once there var/datum/weakref/goal_ref var/started = FALSE var/ends_at = 0 var/failed_moves = 0 -/datum/outpost_guard_activity/New(mob/living/basic/outpost_prison_guard/doer) +/datum/outpost_guard_activity/New(mob/living/carbon/human/npc/outpost_prison_guard/doer) . = ..() guard = doer @@ -377,7 +383,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /// The other guard of the wing on the payroll, if any is here /datum/outpost_guard_activity/proc/other_guard() - for(var/mob/living/basic/outpost_prison_guard/other in guard.prison?.guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/other in guard.prison?.guard_mobs) if(other != guard && other.on_duty()) return other return null @@ -534,8 +540,8 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/outpost_guard_activity/watch_yard/tick(seconds) var/obj/structure/hoop/hoop = hoop_ref?.resolve() - var/mob/living/basic/outpost_prisoner/player - for(var/mob/living/basic/outpost_prisoner/prisoner in guard.prison?.prisoners) + var/mob/living/carbon/human/npc/outpost_prisoner/player + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in guard.prison?.prisoners) if(istype(prisoner.activity, /datum/prisoner_activity/basketball) && prisoner.stat == CONSCIOUS) player = prisoner break @@ -600,7 +606,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) var/next_exchange = 0 /datum/outpost_guard_activity/chat/setup() - var/mob/living/basic/outpost_prison_guard/other = other_guard() + var/mob/living/carbon/human/npc/outpost_prison_guard/other = other_guard() if(!can_join(other)) return FALSE partner_ref = WEAKREF(other) @@ -608,13 +614,13 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return TRUE /// Whether the other guard is free to talk -/datum/outpost_guard_activity/chat/proc/can_join(mob/living/basic/outpost_prison_guard/other) +/datum/outpost_guard_activity/chat/proc/can_join(mob/living/carbon/human/npc/outpost_prison_guard/other) if(!other?.on_duty() || !other.ai_running() || other.response) return FALSE return !other.activity || (other.activity.interruptible && !istype(other.activity, /datum/outpost_guard_activity/chat)) /datum/outpost_guard_activity/chat/arrive() - var/mob/living/basic/outpost_prison_guard/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/partner = partner_ref?.resolve() if(!partner || !guard.Adjacent(partner) || !can_join(partner)) return FALSE . = ..() @@ -624,7 +630,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) next_exchange = world.time + 1 SECONDS /datum/outpost_guard_activity/chat/tick(seconds) - var/mob/living/basic/outpost_prison_guard/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/partner = partner_ref?.resolve() var/datum/outpost_guard_activity/chat/listen/listening = partner?.activity if(!partner || !guard.Adjacent(partner) || !istype(listening) || listening.partner_ref?.resolve() != guard) return GUARD_ACTIVITY_DONE @@ -636,7 +642,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return ..() /datum/outpost_guard_activity/chat/finish() - var/mob/living/basic/outpost_prison_guard/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/partner = partner_ref?.resolve() var/datum/outpost_guard_activity/chat/listen/listening = partner?.activity if(istype(listening) && listening.partner_ref?.resolve() == guard) partner.end_activity() @@ -647,7 +653,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) leisure = FALSE weight = 0 -/datum/outpost_guard_activity/chat/listen/New(mob/living/basic/outpost_prison_guard/doer, mob/living/basic/outpost_prison_guard/talker) +/datum/outpost_guard_activity/chat/listen/New(mob/living/carbon/human/npc/outpost_prison_guard/doer, mob/living/carbon/human/npc/outpost_prison_guard/talker) . = ..(doer) partner_ref = WEAKREF(talker) started = TRUE @@ -661,7 +667,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return TRUE /datum/outpost_guard_activity/chat/listen/tick(seconds) - var/mob/living/basic/outpost_prison_guard/talker = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/talker = partner_ref?.resolve() var/datum/outpost_guard_activity/chat/talking = talker?.activity if(!talker || !guard.Adjacent(talker) || !istype(talking) || talking.partner_ref?.resolve() != guard) return GUARD_ACTIVITY_DONE @@ -688,7 +694,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/outpost_guard_activity/check_on/setup() var/datum/outpost_prison/prison = guard.prison var/list/options = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner.phase != PRISONER_PRESENT || !prisoner.routine_allowed() || prisoner.activity?.sleeping || !prison.in_cell_block(prisoner)) continue if(prisoner.guard_noticed_at && world.time - prisoner.guard_noticed_at < OUTPOST_GUARD_CHECK_ON_GAP) @@ -698,7 +704,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) options += prisoner if(!length(options)) return FALSE - var/mob/living/basic/outpost_prisoner/chosen = pick(options) + var/mob/living/carbon/human/npc/outpost_prisoner/chosen = pick(options) prisoner_ref = WEAKREF(chosen) need = chosen.bubble chosen.guard_noticed_at = world.time @@ -707,7 +713,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/outpost_guard_activity/check_on/begin() . = ..() - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() if(!prisoner) return guard.face_atom(prisoner) @@ -716,12 +722,132 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) guard.prison?.note_speech() /datum/outpost_guard_activity/check_on/tick(seconds) - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() if(!prisoner || !guard.Adjacent(prisoner)) return GUARD_ACTIVITY_DONE guard.face_atom(prisoner) return ..() +// ----- patching up ----- + +/** + * Hurt (OUTPOST_GUARD_TREAT_ABOVE brute and burn) with nothing going on: they treat themselves the way + * anyone does, with a bruise pack or suture on themselves (tg's medical stacks, their do_after and all). + * Their own pack first, else one from the wing's first-aid kit in the office, which the crew keeps stocked. + * Nothing heals them otherwise. + */ +/datum/outpost_guard_activity/patch_up + name = "patching up" + leisure = TRUE + weight = 0 + min_duration = 20 SECONDS + max_duration = 30 SECONDS + interruptible = FALSE + /// The first-aid kit they are going to, if they have nothing of their own + var/datum/weakref/kit_ref + +/datum/outpost_guard_activity/patch_up/get_weight() + if(!guard.needs_patching() || guard.prison?.riot_active) + return 0 + return 100 + +/datum/outpost_guard_activity/patch_up/setup() + if(guard.carried_treatment()) + return TRUE + var/obj/item/storage/medkit/kit = guard.prison?.guard_first_aid_kit() + if(!kit) + return FALSE + kit_ref = WEAKREF(kit) + set_goal(isturf(kit.loc) && guard.prison.guard_office_tile(kit.loc) ? kit.loc : guard.prison.guard_spot_beside(kit, guard)) + return !!goal() + +/datum/outpost_guard_activity/patch_up/begin() + . = ..() + var/obj/item/storage/medkit/kit = kit_ref?.resolve() + if(kit && !guard.carried_treatment() && get_dist(guard, kit) <= 1) + guard.take_treatment_from(kit) + INVOKE_ASYNC(guard, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prison_guard, treat_self)) + +/datum/outpost_guard_activity/patch_up/tick(seconds) + if(!guard.treating && !guard.needs_patching()) + return GUARD_ACTIVITY_DONE + if(!guard.treating && guard.carried_treatment()) + INVOKE_ASYNC(guard, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prison_guard, treat_self)) + return ..() + +/datum/outpost_guard_activity/patch_up/Destroy() + kit_ref = null + return ..() + +/mob/living/carbon/human/npc/outpost_prison_guard + /// Busy putting a dressing on themselves + var/treating = FALSE + +/// Hurt enough to stop and patch up +/mob/living/carbon/human/npc/outpost_prison_guard/proc/needs_patching() + return getBruteLoss() + getFireLoss() >= OUTPOST_GUARD_TREAT_ABOVE + +/// A bruise pack, suture, ointment or mesh they carry that would do some good now +/mob/living/carbon/human/npc/outpost_prison_guard/proc/carried_treatment() + for(var/obj/item/stack/medical/stack in get_all_contents_type(/obj/item/stack/medical)) + if(QDELETED(stack) || !stack.amount) + continue + if((stack.heal_brute && getBruteLoss()) || (stack.heal_burn && getFireLoss())) + return stack + return null + +/// Takes a pack out of the wing's first-aid kit into a free hand, as anyone would +/mob/living/carbon/human/npc/outpost_prison_guard/proc/take_treatment_from(obj/item/storage/medkit/kit) + for(var/obj/item/stack/medical/stack in kit) + if(!stack.amount || !((stack.heal_brute && getBruteLoss()) || (stack.heal_burn && getFireLoss()))) + continue + if(!put_in_hands(stack)) + return null + // Used up on them, never loot (human_npc_gear.dm) + npc_lock_item(stack) + return stack + return null + +/** + * Puts a dressing on themselves: a medical stack they carry, in a free hand, used on their own body + * through tg's medical code (the do_after, the limb it treats, the stack used up). Sleeps. + */ +/mob/living/carbon/human/npc/outpost_prison_guard/proc/treat_self() + if(treating || stat != CONSCIOUS || !needs_patching()) + return FALSE + var/obj/item/stack/medical/stack = carried_treatment() + if(!stack) + return FALSE + if(!is_holding(stack) && !human_npc_take_in_hand(src, stack)) + set_baton(FALSE) + if(!human_npc_take_in_hand(src, stack)) + return FALSE + if(!human_npc_ready_item(src, stack)) + return FALSE + treating = TRUE + stack.interact_with_atom(src, src) + treating = FALSE + if(!QDELETED(stack) && is_holding(stack)) + stack.equip_to_best_slot(src) + return TRUE + +/// The wing's first-aid kit in the office with something in it, if there is one +/datum/outpost_prison/proc/guard_first_aid_kit() + for(var/turf/tile as anything in staff_ground) + if(tile.loc != wing) + continue + for(var/obj/item/storage/medkit/kit in tile) + if(locate(/obj/item/stack/medical) in kit) + return kit + return null + +/// A free office tile beside `thing`, for a guard to stand at, or null +/datum/outpost_prison/proc/guard_spot_beside(atom/thing, mob/living/except) + for(var/turf/tile in orange(1, get_turf(thing))) + if(guard_office_tile(tile) && !guard_spot_taken(tile, except)) + return tile + return null + #undef GUARD_ACTIVITY_CONTINUE #undef GUARD_ACTIVITY_DONE #undef GUARD_ACTIVITY_MOVE diff --git a/voidcrew/modules/player_outposts/outpost_prison_guards.dm b/voidcrew/modules/player_outposts/outpost_prison_guards.dm index 17a559bba63..48f53e41c70 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_guards.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_guards.dm @@ -3,20 +3,27 @@ * * Owner: XA (extras-plan.md 4.1). NPC guards the wing's managers hire at the warden's console: * named people on the payroll who keep a peaceful routine, step in on spats, arguments, fights, - * threats and hatch climbs, hold the staff doors in a riot, go down instead of dying and come - * back free. Paid per minute while a member is home. Their routine and activities are in + * threats and hatch climbs, and hold the staff doors in a riot. Paid per minute while a member is home. Their routine and activities are in * outpost_prison_guard_routine.dm; numbers in voidcrew/_DEFINES/outpost_prison_guards.dm. * - * Each hired guard is a record on the prison (name, rank, personality, when they are back). The + * A guard is a real human body (voidcrew/modules/human_npcs/, owner ruling OC-2): stuns, stamina, + * batons, bleeding, crit and death work on them as on anyone, and nothing tops their health up. Hurt, + * they patch themselves up with the office first-aid kit (outpost_prison_guard_routine.dm). In crit + * for OUTPOST_GUARD_RECALL_DELAY the corrections service medevacs the real body; a dead guard's body + * is collected OUTPOST_GUARD_BODY_COLLECT_DELAY after they die. Either way their record waits + * OUTPOST_GUARD_RETURN_TIME, then sends them back (the same face, from the record). Their kit is + * locked to them (D3): it never becomes loot. + * + * Each hired guard is a record on the prison (name, rank, personality, face, when they are back). The * guard on the wing is a mob made from the record; when a guard goes down or is called away the * mob beams out and the record waits to send them back. A slot's wait belongs to the prison, so * dismissing a downed guard and hiring another does not skip it. * * Guards are security only: they never feed, clothe, treat, clean or light anything, so they * cannot raise pay. They never strike players, pets, bots or other NPCs, and they strike a - * prisoner only for violence under way (fighting, rioting, swinging, climbing out, loose) with a - * stun baton that does stamina damage and nothing else. The strike never goes through - * melee_attack(), so no hit is put down to staff. + * prisoner only for violence under way (fighting, rioting, swinging, climbing out, loose) with the + * real stun baton off their belt, through their own hands: tg's stamina, knockdown and cell charge. + * A prisoner never holds a guard's blow against staff (hit_by_staff()). * * The dispatcher (dispatch_guards()) runs every second from guards_tick() and only for guards whose * AI is running, which it is only while a player is on the level. Every response has a proc a test @@ -31,7 +38,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Whether `thing` is one of a prison's NPC guards; with `on_duty`, only one present, arrived and not down /proc/is_outpost_prison_guard(atom/thing, on_duty = FALSE) - var/mob/living/basic/outpost_prison_guard/guard = thing + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = thing if(!istype(guard)) return FALSE return !on_duty || guard.on_duty() @@ -42,34 +49,17 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== THE GUARD ===== -/mob/living/basic/outpost_prison_guard +/mob/living/carbon/human/npc/outpost_prison_guard name = "guard" desc = "A corrections officer on the prison wing's payroll." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - // No sentience potions, no lazarus. - sentience_type = SENTIENCE_HUMANOID - maxHealth = OUTPOST_GUARD_HEALTH - health = OUTPOST_GUARD_HEALTH - speed = OUTPOST_GUARD_SPEED_WALK - // Nobody drags or shoves them about. - move_resist = MOVE_FORCE_VERY_STRONG - density = TRUE - basic_mob_flags = NONE - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - // No stuns or knockdowns: a guard is up or down (go_down()), nothing between. - status_flags = NONE - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 0, STAMINA = 0, OXY = 0) - // A breach or a fire in the wing must not kill them. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - response_help_continuous = "pats" - response_help_simple = "pat" + faction = list(FACTION_NEUTRAL) ai_controller = /datum/ai_controller/basic_controller/outpost_prison_guard + npc_outfit = /datum/outfit/outpost_prison_guard + // They stop once a prisoner is down (defaults 1.13), and only ever go for prisoners. + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // The corrections service collects a guard's body (guards_tick()); the base never cleans it up. + npc_corpse_lifetime = 0 /// The prison that hired them var/datum/outpost_prison/prison @@ -91,10 +81,14 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/response_skips /// Whether the stun baton is out in their hand var/baton_out = FALSE + /// world.time they went down (crit) or died, for the medevac and the body's collection + var/down_since = 0 /// The last thing they said, so they don't repeat it at once var/last_line /// Down: seconds until they are beamed out var/down_left = 0 + /// Killed rather than downed: the body is collected, and the record's wait starts after + var/died_on_duty = FALSE /// Dismissed: seconds left to walk to the office before they beam out wherever they are var/dismiss_left = 0 /// Dismissed: where they walk to before beaming out @@ -107,10 +101,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Between "Hey. Don't." lines COOLDOWN_DECLARE(warned_cooldown) -/mob/living/basic/outpost_prison_guard/Initialize(mapload) - gender = pick(MALE, FEMALE) - . = ..() - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) +/mob/living/carbon/human/npc/outpost_prison_guard/Initialize(mapload) + . = ..(mapload) AddElement(/datum/element/relay_attackers) RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) // Nothing moves a guard but their own feet and the prison's beam. @@ -118,13 +110,12 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) RegisterSignal(src, COMSIG_MOUSEDROP_ONTO, PROC_REF(block_being_dragged)) - RegisterSignal(src, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(refuse_pull)) - ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) - // Next tick: spawn_guard() calls join() right after new, which sets the record's gender and face. - addtimer(CALLBACK(src, PROC_REF(guard_build_look)), 1) + // On their feet nobody boxes or bags them; down or dead they are a body like anyone's (set_guard_upright()). + // Anyone can drag a guard who is down. A guard on their feet shakes off anyone but the wing's members (guards_tick()). + set_guard_upright(TRUE) + set_prison_pace(hurry = FALSE) -/mob/living/basic/outpost_prison_guard/Destroy() +/mob/living/carbon/human/npc/outpost_prison_guard/Destroy() end_response(cancel_ai = FALSE) end_activity(cancel_ai = FALSE) if(record?.guard == src) @@ -138,120 +129,158 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" leave_spot = null return ..() -/// Dresses them as their own person in the guard's outfit (outpost_npc_looks.dm): the record's face and gender. Can sleep. -/mob/living/basic/outpost_prison_guard/proc/guard_build_look() - set_outpost_npc_look(src, /datum/outfit/outpost_prison_guard, gender, record ? record.look_number : 1) - if(QDELETED(src)) - return - update_appearance(UPDATE_OVERLAYS) - -/// Takes on the name, rank, gender and personality of the guard on the payroll -/mob/living/basic/outpost_prison_guard/proc/join(datum/outpost_prison/owner, datum/outpost_guard_record/hired) +/// Takes on the name, rank, face and personality of the guard on the payroll. The first one sent sets the record's face. +/mob/living/carbon/human/npc/outpost_prison_guard/proc/join(datum/outpost_prison/owner, datum/outpost_guard_record/hired) prison = owner record = hired hired.guard = src owner.guard_mobs |= src - gender = hired.gender + if(length(hired.look)) + outpost_prison_apply_look(src, hired.look) + else + hired.look = outpost_prison_save_look(src) + hired.gender = gender personality = hired.personality - real_name = hired.full_name() - name = real_name + fully_replace_character_name(real_name, hired.full_name()) -/// On the wing and on duty: arrived, not down, not dismissed, and not paid by the kingpin to stand back (outpost_prison_boss_moves.dm) -/mob/living/basic/outpost_prison_guard/proc/on_duty() - return !QDELETED(src) && phase == OUTPOST_GUARD_PRESENT && stat == CONSCIOUS && !boss_bribed() +/// On the wing and on duty: arrived, not down, cuffed or stunned, not dismissed, and not paid by the kingpin to stand back (outpost_prison_boss_moves.dm) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/on_duty() + return !QDELETED(src) && phase == OUTPOST_GUARD_PRESENT && stat == CONSCIOUS && !human_npc_out_of_fight(src) && !boss_bribed() /// Whether their AI is running, which it is only while someone is on the level to see them (see outpost_prison_ai_running()) -/mob/living/basic/outpost_prison_guard/proc/ai_running() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/ai_running() return outpost_prison_ai_running(src) -/mob/living/basic/outpost_prison_guard/update_overlays() - . = ..() - if(!baton_out || phase == OUTPOST_GUARD_DOWN) - return - // The lit baton in hand, the way a security officer holds one, turning with them - var/mutable_appearance/baton = mutable_appearance('icons/mob/inhands/equipment/security_righthand.dmi', "stunbaton_active") - baton.plane = FLOAT_PLANE - baton.layer = FLOAT_LAYER - . += baton - -/// Draws or puts away the baton -/mob/living/basic/outpost_prison_guard/proc/set_baton(out) +/// Their stun baton, wherever on them it is +/mob/living/carbon/human/npc/outpost_prison_guard/proc/get_baton() + return locate(/obj/item/melee/baton/security) in get_all_contents() + +/// Draws the baton off the belt into a hand and switches it on, or puts it away again, as an officer does +/mob/living/carbon/human/npc/outpost_prison_guard/proc/set_baton(out) out = !!out - if(baton_out == out) + var/obj/item/melee/baton/security/baton = get_baton() + if(!baton) + baton_out = FALSE + return + if(out) + if(!is_holding(baton) && !human_npc_take_in_hand(src, baton)) + return + if(!baton.active && baton.cell?.charge >= baton.cell_hit_cost) + baton.attack_self(src) + baton_out = TRUE return - baton_out = out - update_appearance(UPDATE_OVERLAYS) + baton_out = FALSE + if(!is_holding(baton)) + return + if(baton.active) + baton.attack_self(src) + if(!equip_to_slot_if_possible(baton, ITEM_SLOT_BELT, disable_warning = TRUE)) + baton.equip_to_best_slot(src) // ===== REFUSALS ===== -/mob/living/basic/outpost_prison_guard/proc/refuse_teleport(datum/source, atom/destination, channel) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER if(isturf(loc)) visible_message(span_notice("[src] flickers for a moment, but stays where [p_they()] [p_are()].")) return TRUE -/mob/living/basic/outpost_prison_guard/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER visible_message(span_notice("[src] shimmers for a moment, then looks the same as before.")) return STOP_WABBAJACK -/mob/living/basic/outpost_prison_guard/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/mob/living/basic/outpost_prison_guard/proc/block_being_dragged(atom/over, mob/user) - SIGNAL_HANDLER - return COMPONENT_CANCEL_MOUSEDROP_ONTO +/// On their feet they refuse lockers and bags; down or dead, a body can be bagged like anyone's +/mob/living/carbon/human/npc/outpost_prison_guard/proc/set_guard_upright(upright) + if(upright) + add_traits(list(TRAIT_NO_CONTAINMENT, TRAIT_NO_STORAGE_INSERT), OUTPOST_GUARD_UPRIGHT_TRAIT) + else + remove_traits(list(TRAIT_NO_CONTAINMENT, TRAIT_NO_STORAGE_INSERT), OUTPOST_GUARD_UPRIGHT_TRAIT) -/mob/living/basic/outpost_prison_guard/proc/refuse_pull(datum/source, mob/living/puller) +/// Not dragged onto things while on their feet +/mob/living/carbon/human/npc/outpost_prison_guard/proc/block_being_dragged(atom/over, mob/user) SIGNAL_HANDLER - return COMSIG_ATOM_CANT_PULL + return stat == CONSCIOUS && phase == OUTPOST_GUARD_PRESENT ? COMPONENT_CANCEL_MOUSEDROP_ONTO : NONE -// ===== HURT AND DOWN ===== +/// On their feet, a guard won't be led about by anyone but the wing's members: they resist it, as anyone resists a pull +/mob/living/carbon/human/npc/outpost_prison_guard/proc/shake_off_pull() + if(!pulledby || !on_duty() || world.time < next_move) + return + if(isliving(pulledby) && prison?.is_member(pulledby)) + return + execute_resist() + +// ===== HURT, DOWN AND DEAD ===== /** - * Damage never takes a guard below OUTPOST_GUARD_DOWN_AT: at that point they go down instead, - * and while down nothing hurts them (TRAIT_GODMODE). Forced damage is held to the same floor. + * Into crit: they go down. The corrections service medevacs the real body after + * OUTPOST_GUARD_RECALL_DELAY (guards_tick()); treated and back out of crit before then, they are back on duty. */ -/mob/living/basic/outpost_prison_guard/adjust_health(amount, updating_health = TRUE, forced = FALSE) - var/floor_loss = maxHealth - OUTPOST_GUARD_DOWN_AT - if(amount > 0) - if(phase == OUTPOST_GUARD_DOWN || phase == OUTPOST_GUARD_LEAVING || bruteloss >= floor_loss) - return 0 - amount = min(amount, floor_loss - bruteloss) +/mob/living/carbon/human/npc/outpost_prison_guard/npc_on_downed() . = ..() - if(bruteloss >= floor_loss && prison) - INVOKE_ASYNC(src, PROC_REF(go_down)) + go_down() -/// Guards never die: whatever would kill one puts them down instead -/mob/living/basic/outpost_prison_guard/death(gibbed) - if(!gibbed && prison && phase != OUTPOST_GUARD_LEAVING) - go_down() +/// Back out of crit before the medevac came: on their feet and on duty again +/mob/living/carbon/human/npc/outpost_prison_guard/npc_on_recovered() + . = ..() + if(phase != OUTPOST_GUARD_DOWN || died_on_duty || stat == DEAD || HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION)) return - return ..() + phase = OUTPOST_GUARD_PRESENT + down_left = 0 + down_since = 0 + set_guard_upright(TRUE) + prison?.add_log("[real_name] is back on [p_their()] feet.") + +/// The prison's guards speak from their own file (say_guard()); a few of tg's changes map onto their lines. +/mob/living/carbon/human/npc/outpost_prison_guard/npc_bark(kind, force = FALSE) + switch(kind) + if(HUMAN_NPC_BARK_DOWNED) + return say_guard("downed") + return FALSE + +/mob/living/carbon/human/npc/outpost_prison_guard/death(gibbed) + var/was_alive = stat != DEAD + . = ..() + if(!was_alive || stat != DEAD) + return + died_on_duty = TRUE + set_guard_upright(FALSE) + if(phase == OUTPOST_GUARD_LEAVING || !prison) + return + end_response() + end_activity() + baton_out = FALSE + phase = OUTPOST_GUARD_DOWN + down_since = world.time + down_left = OUTPOST_GUARD_BODY_COLLECT_DELAY / (1 SECONDS) + prison.guard_killed(src) /** - * Down: lying there, nothing more can hurt them, and OUTPOST_GUARD_RECALL_DELAY later they are - * beamed out, to come back free OUTPOST_GUARD_RETURN_TIME after that (guards_tick()). + * Down in crit: lying there, and OUTPOST_GUARD_RECALL_DELAY later the corrections service medevacs the + * body, to send them back OUTPOST_GUARD_RETURN_TIME after that (guards_tick()). Nothing protects them + * meanwhile: they can bleed out, or be finished off. */ -/mob/living/basic/outpost_prison_guard/proc/go_down() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/go_down() if(QDELETED(src) || phase == OUTPOST_GUARD_DOWN || phase == OUTPOST_GUARD_LEAVING) return FALSE var/was_leaving = phase == OUTPOST_GUARD_DISMISSED phase = OUTPOST_GUARD_DOWN end_response() end_activity() - set_baton(FALSE) - ADD_TRAIT(src, TRAIT_GODMODE, OUTPOST_GUARD_DOWN_TRAIT) - add_traits(list(TRAIT_FLOORED, TRAIT_IMMOBILIZED, TRAIT_INCAPACITATED), OUTPOST_GUARD_DOWN_TRAIT) + baton_out = FALSE + set_guard_upright(FALSE) + down_since = world.time down_left = was_leaving ? 0 : OUTPOST_GUARD_RECALL_DELAY / (1 SECONDS) visible_message(span_danger("[src] goes down!")) - say_guard("downed") prison?.add_log("[real_name] went down.") return TRUE /// Someone took a swing at them. Prisoners are the dispatcher's business; anyone else gets a warning and some room. -/mob/living/basic/outpost_prison_guard/proc/on_attacked(datum/source, atom/attacker, attack_flags) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/on_attacked(datum/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) return @@ -260,7 +289,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" INVOKE_ASYNC(src, PROC_REF(warn_off), attacker) /// "Hey. Don't." and a step back. Guards never strike players. -/mob/living/basic/outpost_prison_guard/proc/warn_off(atom/attacker) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/warn_off(atom/attacker) if(!on_duty() || QDELETED(attacker)) return FALSE if(!COOLDOWN_FINISHED(src, warned_cooldown)) @@ -273,12 +302,6 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" log_combat(attacker, src, "attacked a prison guard") return TRUE -/// Healing slowly while nothing is going on -/mob/living/basic/outpost_prison_guard/proc/heal_tick(seconds) - if(phase != OUTPOST_GUARD_PRESENT || bruteloss <= 0 || prison?.riot_active) - return - adjust_health(-OUTPOST_GUARD_HEAL_PER_MINUTE * seconds / 60) - // ===== BEAMING IN AND OUT ===== /** @@ -286,7 +309,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * the whole beam. They stay OUTPOST_GUARD_ARRIVING (held still, no routine, no responses, no * speech) until finish_beam_in() as the beam ends. */ -/mob/living/basic/outpost_prison_guard/proc/beam_in() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/beam_in() phase = OUTPOST_GUARD_ARRIVING ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_GUARD_BEAM_TRAIT) var/turf/spot = get_turf(src) @@ -298,7 +321,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" addtimer(CALLBACK(src, PROC_REF(finish_beam_in)), OUTPOST_PRISON_BEAM_TIME) /// Fully there as the beam ends: the flash, and only now are they on duty, and say so -/mob/living/basic/outpost_prison_guard/proc/finish_beam_in() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/finish_beam_in() if(phase != OUTPOST_GUARD_ARRIVING) return FALSE var/turf/spot = get_turf(src) @@ -318,7 +341,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * Dematerialises them and deletes them at the end of the beam. Their record, if they are still on * the payroll, sends them back after `away_seconds` (guards_tick()). */ -/mob/living/basic/outpost_prison_guard/proc/beam_out(away_seconds = 0) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/beam_out(away_seconds = 0) if(phase == OUTPOST_GUARD_LEAVING) return FALSE phase = OUTPOST_GUARD_LEAVING @@ -339,7 +362,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" addtimer(CALLBACK(src, PROC_REF(finish_beam_out)), OUTPOST_PRISON_BEAM_TIME) return TRUE -/mob/living/basic/outpost_prison_guard/proc/finish_beam_out() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/finish_beam_out() var/turf/spot = get_turf(src) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) @@ -348,7 +371,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" qdel(src) /// Called back from somewhere they should not be (off the wing's level, or out of the wing with nobody about) -/mob/living/basic/outpost_prison_guard/proc/recall(away_seconds = OUTPOST_GUARD_OFF_LEVEL_AWAY) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/recall(away_seconds = OUTPOST_GUARD_OFF_LEVEL_AWAY) if(phase != OUTPOST_GUARD_PRESENT) return FALSE say_guard("recalled") @@ -359,7 +382,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * Off the payroll: they say `context`, walk back to the office and beam out there, or wherever * they are after OUTPOST_GUARD_DISMISS_WALK seconds. Down or arriving, they beam out at once. */ -/mob/living/basic/outpost_prison_guard/proc/leave(context = "dismissed") +/mob/living/carbon/human/npc/outpost_prison_guard/proc/leave(context = "dismissed") if(phase == OUTPOST_GUARD_LEAVING || phase == OUTPOST_GUARD_DISMISSED) return FALSE if(phase != OUTPOST_GUARD_PRESENT || stat != CONSCIOUS) @@ -368,7 +391,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" end_response() end_activity() set_baton(FALSE) - set_varspeed(OUTPOST_GUARD_SPEED_WALK) + set_prison_pace(hurry = FALSE) say_guard(context) dismiss_left = OUTPOST_GUARD_DISMISS_WALK leave_spot = prison?.guard_office_spot(src) @@ -377,7 +400,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== SPEECH ===== /// A line for `context` in their personality (or anyone's), with `values` filled in, or null -/mob/living/basic/outpost_prison_guard/proc/pick_guard_line(context, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/pick_guard_line(context, list/values) var/list/guard_lines = outpost_guard_dialogue("guard_lines") var/list/by_personality = guard_lines[context] if(!islist(by_personality)) @@ -396,7 +419,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return line /// The lines of `pool` that are not the last thing they said and need no placeholder `values` lacks -/mob/living/basic/outpost_prison_guard/proc/usable_guard_lines(list/pool, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/usable_guard_lines(list/pool, list/values) var/list/usable = list() for(var/line in pool) if(!istext(line) || line == last_line) @@ -411,7 +434,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return usable /// Says a line for `context`. Returns TRUE if they said something. Never while beaming in or out, invisible or half there. -/mob/living/basic/outpost_prison_guard/proc/say_guard(context, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/say_guard(context, list/values) if(QDELETED(src) || stat == DEAD || phase == OUTPOST_GUARD_ARRIVING || phase == OUTPOST_GUARD_LEAVING) return FALSE var/line = pick_guard_line(context, values) @@ -422,7 +445,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// An idle line: waits for the wing to be quiet and for their own pause, and counts toward the wing's -/mob/living/basic/outpost_prison_guard/proc/idle_say(context, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/idle_say(context, list/values) if(!prison?.wing_can_speak() || !COOLDOWN_FINISHED(src, speech_cooldown)) return FALSE if(!say_guard(context, values)) @@ -438,7 +461,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * and making violent trouble right now (rioting, breaking out, fighting with blows, swinging at * someone or having just struck someone, climbing out, or loose). Never a calm or downed prisoner. */ -/mob/living/basic/outpost_prison_guard/proc/may_strike(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/may_strike(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!on_duty() || !is_outpost_prisoner(prisoner) || QDELETED(prisoner) || !prison || prisoner.prison != prison) return FALSE if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.can_be_dragged() || !Adjacent(prisoner)) @@ -451,29 +474,47 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return prisoner.guard_struck_recently() /** - * One stun baton strike: OUTPOST_GUARD_BATON_STAMINA stamina, their blows stop, and every prisoner - * who sees it loses a little mood. Not an attack in tg's sense (no relay_attackers, no - * hit_by_staff()), so it is never held against staff and never hurts. Returns TRUE if it landed. + * One strike with the real stun baton off their belt, through their own hands: tg's baton does its + * stamina damage and knockdown, against the prisoner's armour, off the cell's charge. Their blows stop, + * and every prisoner who sees it loses a little mood. A prisoner never holds a guard's blow against staff + * (hit_by_staff()). Returns TRUE if they swung. */ -/mob/living/basic/outpost_prison_guard/proc/strike(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/strike(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!COOLDOWN_FINISHED(src, baton_cooldown) || !may_strike(prisoner)) return FALSE - COOLDOWN_START(src, baton_cooldown, OUTPOST_GUARD_BATON_COOLDOWN) + var/obj/item/melee/baton/security/baton = get_baton() + // A flat cell would make it a club, and guards never hurt anyone: they go to change it (baton_flat()). + if(!baton || !baton.cell || baton.cell.charge < baton.cell_hit_cost) + baton_flat() + return FALSE set_baton(TRUE) - face_atom(prisoner) - do_attack_animation(prisoner) - playsound(prisoner, 'sound/items/weapons/egloves.ogg', 50, TRUE) - prisoner.visible_message(span_danger("[src] strikes [prisoner] with a stun baton!"), span_userdanger("[src] strikes you with a stun baton!")) - prisoner.apply_damage(OUTPOST_GUARD_BATON_STAMINA, STAMINA) + // The baton recharges between stuns, as anyone's does. + if(!is_holding(baton) || !baton.active || !COOLDOWN_FINISHED(baton, cooldown_check)) + return FALSE + COOLDOWN_START(src, baton_cooldown, OUTPOST_GUARD_BATON_COOLDOWN) + if(!outpost_prison_npc_hit(src, prisoner, baton)) + return FALSE prisoner.stop_blows() prison.guard_strike_seen(prisoner, src) return TRUE +/** + * Their baton's cell is flat. The office recharger can't take a baton that is locked to its guard + * (human_npc_gear.dm), so the corrections service swaps the shift: they say so and beam out, and the + * record sends them back with a charged baton after OUTPOST_GUARD_SHIFT_SWAP_TIME. Their wages stop meanwhile. + */ +/mob/living/carbon/human/npc/outpost_prison_guard/proc/baton_flat() + if(phase != OUTPOST_GUARD_PRESENT || !prison) + return FALSE + say_guard("recalled") + prison.add_log("[real_name]'s baton went flat. [p_They()] went to swap shifts.") + return beam_out(OUTPOST_GUARD_SHIFT_SWAP_TIME / (1 SECONDS)) + /// Strikes a rioter beside them. Returns TRUE if they struck. -/mob/living/basic/outpost_prison_guard/proc/strike_adjacent_rioter() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/strike_adjacent_rioter() if(!COOLDOWN_FINISHED(src, baton_cooldown)) return FALSE - for(var/mob/living/basic/outpost_prisoner/rioter in orange(1, src)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter in orange(1, src)) if(rioter.is_rioting() && strike(rioter)) return TRUE return FALSE @@ -481,29 +522,29 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== RESPONSES ===== /// Sends them to deal with something, dropping their routine -/mob/living/basic/outpost_prison_guard/proc/give_response(kind, priority, key, atom/target, turf/spot) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/give_response(kind, priority, key, atom/target, turf/spot) end_response(cancel_ai = FALSE) end_activity(cancel_ai = FALSE) response = new(kind, key, priority, target, spot) response.expires_at = world.time + OUTPOST_GUARD_RESPONSE_TIMEOUT - set_varspeed(OUTPOST_GUARD_SPEED_HURRY) + set_prison_pace(hurry = TRUE) set_baton(TRUE) ai_controller?.CancelActions() return response /// Back to routine -/mob/living/basic/outpost_prison_guard/proc/end_response(cancel_ai = TRUE) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/end_response(cancel_ai = TRUE) if(!response) return QDEL_NULL(response) if(!QDELETED(src)) - set_varspeed(OUTPOST_GUARD_SPEED_WALK) + set_prison_pace(hurry = FALSE) set_baton(FALSE) if(cancel_ai) ai_controller?.CancelActions() /// Whether they gave up on an incident recently -/mob/living/basic/outpost_prison_guard/proc/skipping(key) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/skipping(key) return LAZYACCESS(response_skips, key) > world.time /** @@ -511,7 +552,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * the AI walks them to) and, once there, acts. Called by the dispatcher every second. Returns * TRUE while the response goes on. */ -/mob/living/basic/outpost_prison_guard/proc/perform_response() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/perform_response() var/datum/outpost_guard_response/job = response if(!job) return FALSE @@ -545,7 +586,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// Whether they stand where a response wants them: beside a mob, or on a tile -/mob/living/basic/outpost_prison_guard/proc/at_goal(atom/goal) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/at_goal(atom/goal) if(!goal) return TRUE if(isturf(goal)) @@ -557,13 +598,13 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * it down (the prison's guard_talkdown_chance); once blows fly, "Break it up!", then the baton on * the first fighter every OUTPOST_GUARD_BATON_COOLDOWN until the fight is over. */ -/mob/living/basic/outpost_prison_guard/proc/fight_response(datum/outpost_guard_response/job) - var/mob/living/basic/outpost_prisoner/fighter = job.target() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/fight_response(datum/outpost_guard_response/job) + var/mob/living/carbon/human/npc/outpost_prisoner/fighter = job.target() var/datum/outpost_prison_fight/brawl = fighter?.fight if(QDELETED(brawl)) return TRUE - var/mob/living/basic/outpost_prisoner/other = brawl.opponent_of(fighter) - var/mob/living/basic/outpost_prisoner/beside = Adjacent(fighter) ? fighter : ((other && Adjacent(other)) ? other : null) + var/mob/living/carbon/human/npc/outpost_prisoner/other = brawl.opponent_of(fighter) + var/mob/living/carbon/human/npc/outpost_prisoner/beside = Adjacent(fighter) ? fighter : ((other && Adjacent(other)) ? other : null) if(!beside) job.set_goal(fighter) return FALSE @@ -595,8 +636,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * decided to swing, or struck someone in the last OUTPOST_GUARD_STRUCK_RECENT, gets the baton * after the warning instead, until they stop. */ -/mob/living/basic/outpost_prison_guard/proc/threat_response(datum/outpost_guard_response/job) - var/mob/living/basic/outpost_prisoner/prisoner = job.target() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/threat_response(datum/outpost_guard_response/job) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = job.target() if(!prisoner || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.can_be_dragged() || prisoner.trouble) return TRUE var/violent = prisoner.swing_ref || prisoner.guard_struck_recently() @@ -622,8 +663,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return FALSE /// A prisoner climbing over a serving hatch: to the hatch's office side, "Get down off that!", and down they slide -/mob/living/basic/outpost_prison_guard/proc/climb_response(datum/outpost_guard_response/job) - var/mob/living/basic/outpost_prisoner/climber = job.target() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/climb_response(datum/outpost_guard_response/job) + var/mob/living/carbon/human/npc/outpost_prisoner/climber = job.target() var/obj/structure/table/reinforced/prison_hatch/hatch = climber?.climb_ref?.resolve() if(!hatch) return TRUE @@ -647,7 +688,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * OUTPOST_GUARD_FOLLOW_RANGE of them and go for any rioter at them. Otherwise they hold the staff * door from the office. They baton any rioter beside them whatever they are doing. */ -/mob/living/basic/outpost_prison_guard/proc/riot_response(datum/outpost_guard_response/job) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/riot_response(datum/outpost_guard_response/job) if(!prison.riot_active) return TRUE job.reached = TRUE @@ -657,7 +698,9 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" job.spot = prison.guard_fallback_spot(src) say_guard("fallback") job.set_goal((job.spot && loc != job.spot) ? job.spot : null) - strike_adjacent_rioter() + if(!strike_adjacent_rioter() && (!job.spot || loc == job.spot)) + // Back in the corner, they patch themselves up from the office's first-aid kit (outpost_prison_guard_routine.dm). + INVOKE_ASYNC(src, PROC_REF(treat_self)) return FALSE var/mob/living/member = prison.guard_member_in_cell_block() if(member) @@ -665,7 +708,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" job.mode = "follow" job.spot = null say_guard("follow_member", list("{boss}" = prison.guard_boss_name(member))) - var/mob/living/basic/outpost_prisoner/attacker = prison.rioter_beside(member) + var/mob/living/carbon/human/npc/outpost_prisoner/attacker = prison.rioter_beside(member) if(attacker && !Adjacent(attacker)) job.set_goal(attacker) else if(get_dist(src, member) > OUTPOST_GUARD_FOLLOW_RANGE) @@ -690,7 +733,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * cell block that the other guard is not holding, the tiles to its sides first so the doorway * itself stays clear for the crew. Sets it as their response's spot. Returns it, or null. */ -/mob/living/basic/outpost_prison_guard/proc/take_hold_position() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/take_hold_position() if(!prison) return null for(var/turf/spot as anything in prison.guard_hold_spots()) @@ -705,7 +748,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * it is dead, taken or shut in. They never strike the creatures. The rest of an experiment (the dose, * the incubation, the slug in the vents) is no reason to leave the yard to itself. */ -/mob/living/basic/outpost_prison_guard/proc/shelter_response(datum/outpost_guard_response/job) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/shelter_response(datum/outpost_guard_response/job) if(!prison.creature_out()) return TRUE job.reached = TRUE @@ -715,7 +758,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return FALSE /// Wandered out of the wing: back to the office -/mob/living/basic/outpost_prison_guard/proc/leash_response(datum/outpost_guard_response/job) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/leash_response(datum/outpost_guard_response/job) if(get_area(src) == prison.wing) outside_seconds = 0 return TRUE @@ -728,14 +771,14 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== THE PRISONER'S SIDE ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// world.time a guard last checked on them, or they last said hello to a guard var/guard_noticed_at = 0 /// world.time they last lost mood seeing a guard's baton var/guard_onlooker_at = 0 /// Whether they struck someone (not another prisoner) in the last OUTPOST_GUARD_STRUCK_RECENT -/mob/living/basic/outpost_prisoner/proc/guard_struck_recently() +/mob/living/carbon/human/npc/outpost_prisoner/proc/guard_struck_recently() return struck_at && world.time - struck_at <= OUTPOST_GUARD_STRUCK_RECENT && !isnull(struck_ref?.resolve()) // ===== THE PAYROLL ===== @@ -744,28 +787,28 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_guard_record var/datum/outpost_prison/prison /// The guard on the wing now, if any - var/mob/living/basic/outpost_prison_guard/guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard /// "Officer" or "Sergeant" var/rank = "Officer" var/surname = "Hale" var/gender = MALE var/personality = "by_the_book" - /// Which face they have (set_outpost_npc_look()) - var/look_number = 1 + /// Their face, saved from the first body sent (outpost_prison_save_look()), so the same person comes back + var/list/look /// An admin's guard: no fee, no wage, not counted against guard_max() var/free = FALSE var/dismissed = FALSE /// Seconds until they are sent back while away (down, recalled); they come only while a member is home var/away_left = 0 /// What the guard is made as (tests use a subtype) - var/guard_type = /mob/living/basic/outpost_prison_guard + var/guard_type = /mob/living/carbon/human/npc/outpost_prison_guard /datum/outpost_guard_record/New(datum/outpost_prison/owner, rank, free = FALSE, guard_type) . = ..() prison = owner src.rank = rank src.free = free - if(ispath(guard_type, /mob/living/basic/outpost_prison_guard)) + if(ispath(guard_type, /mob/living/carbon/human/npc/outpost_prison_guard)) src.guard_type = guard_type gender = pick(MALE, FEMALE) if(length(GLOB.last_names)) @@ -773,7 +816,6 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/personalities = outpost_guard_dialogue("guard_personalities") if(length(personalities)) personality = pick(personalities) - look_number = random_outpost_npc_look_number() /datum/outpost_guard_record/Destroy() if(guard?.record == src) @@ -789,7 +831,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Seconds before this slot could have a guard on the wing again: the down time and the return, or the time still away /datum/outpost_guard_record/proc/pending_wait() if(guard) - return guard.phase == OUTPOST_GUARD_DOWN ? guard.down_left + OUTPOST_GUARD_RETURN_TIME / (1 SECONDS) : 0 + return guard.phase == OUTPOST_GUARD_DOWN ? max(0, guard.down_left) + OUTPOST_GUARD_RETURN_TIME / (1 SECONDS) : 0 return max(0, away_left) /// What the console calls them: arriving, post, rounds, routine, responding, riot, down or away @@ -816,7 +858,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Every guard on the payroll var/list/datum/outpost_guard_record/guard_records = list() /// Every guard mob of this prison, on the wing or leaving it - var/list/mob/living/basic/outpost_prison_guard/guard_mobs = list() + var/list/mob/living/carbon/human/npc/outpost_prison_guard/guard_mobs = list() /// Seconds each slot still waits for a dismissed guard's return, passed on to the next hire var/list/guard_slot_waits = list() /// Wages earned and not yet charged, in credits, and the seconds into the charging interval @@ -910,8 +952,12 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/turf/spot = guard_office_spot() if(!spot) return null + // Nobody is sent into an office with air nobody can breathe; the slot waits for it to be fixed. + if(outpost_prison_unsafe_air(spot.return_air())) + return null var/datum/npc_spawn_context/context = new(outpost, role = "exempt") - var/mob/living/basic/outpost_prison_guard/guard = spawn_npc_in_zone(record.guard_type, spot, context, FALSE) + // One at a time, as they are hired or sent back: no need for the human NPC spawn queue. + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = spawn_npc_in_zone(record.guard_type, spot, context, FALSE) guard.join(src, record) context.finalize() record.away_left = 0 @@ -933,7 +979,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_slot_waits += wait record.dismissed = TRUE guard_records -= record - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard record.guard = null if(guard) guard.record = null @@ -967,20 +1013,26 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" record.away_left = max(0, record.away_left - seconds) if(record.away_left <= 0 && home) spawn_guard(record) - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs.Copy()) if(QDELETED(guard)) continue switch(guard.phase) if(OUTPOST_GUARD_DOWN) guard.down_left -= seconds if(guard.down_left <= 0) - guard.beam_out(OUTPOST_GUARD_RETURN_TIME / (1 SECONDS)) + guard_medevac(guard) if(OUTPOST_GUARD_DISMISSED) guard.dismiss_left -= seconds if(guard.dismiss_left <= 0 || (guard.leave_spot && guard.loc == guard.leave_spot) || !guard.ai_running()) guard.beam_out() if(OUTPOST_GUARD_PRESENT) - guard.heal_tick(seconds) + guard.shake_off_pull() + // Bad air in the wing (a breach, a fire): guards carry no air of their own, so the corrections + // service calls them off it until the office is fit to stand in again (spawn_guard()). + if(guard.stat == CONSCIOUS && outpost_prison_unsafe_air(guard.loc?.return_air())) + guard.say_guard("recalled") + add_log("[guard.real_name] was called off the wing: bad air.") + guard.beam_out(OUTPOST_GUARD_SHIFT_SWAP_TIME / (1 SECONDS)) guard_rounds_tick(seconds) guard_check_clock += seconds if(guard_check_clock >= OUTPOST_GUARD_CHECK_SECONDS) @@ -990,6 +1042,38 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_yard_chatter() dispatch_guards() +/** + * The corrections service takes a downed guard's real body away (a transport: the same beam as + * everyone's, with no heal), or a dead guard's body once it has lain for OUTPOST_GUARD_BODY_COLLECT_DELAY. + * Their wages stopped when they went down (guard_payroll()); their record sends them back after + * OUTPOST_GUARD_RETURN_TIME. The service locks on to its own staff wherever the body is: zipped in a bag, + * shut in a locker, carried off. Otherwise a body stashed out of sight would hold the slot for the rest + * of the round, and lie there for anyone to cut up. + */ +/datum/outpost_prison/proc/guard_medevac(mob/living/carbon/human/npc/outpost_prison_guard/guard) + if(QDELETED(guard) || guard.phase != OUTPOST_GUARD_DOWN) + return FALSE + if(!isturf(guard.loc)) + var/turf/out_of_it = get_turf(guard) + if(isnull(out_of_it)) + return FALSE + guard.forceMove(out_of_it) + guard.pulledby?.stop_pulling() + guard.buckled?.unbuckle_mob(guard, force = TRUE) + add_log(guard.stat == DEAD ? "The corrections service collected [guard.real_name]'s body." : "The corrections service took [guard.real_name] away for treatment.") + return guard.beam_out(OUTPOST_GUARD_RETURN_TIME / (1 SECONDS)) + +/** + * A guard was killed on duty. Logged with whoever last hurt them (the warden's log and the game log), + * and the yard hears of it. The slot waits out the body's collection and the return before anyone is sent. + */ +/datum/outpost_prison/proc/guard_killed(mob/living/carbon/human/npc/outpost_prison_guard/guard) + var/mob/killer = guard.lastattacker ? get_mob_by_ckey(guard.lastattackerckey) : null + add_log("[guard.real_name] was killed on duty.") + log_game("PLAYER OUTPOST PRISON: '[outpost?.name]': guard [guard.real_name] was killed[killer ? " (last hit by [key_name(killer)])" : ""]") + if(killer && !is_member(killer)) + announce("Prison wing: [guard.real_name] was killed on duty.", SHIP_NOTIFY_WARNING) + /** * Wages: OUTPOST_GUARD_WAGE a minute for each hired guard on the wing, only while a member is * home, charged every OUTPOST_PRISON_DEPOSIT_INTERVAL. @@ -1050,7 +1134,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_rounds_left -= seconds if(guard_rounds_left > 0) return - for(var/mob/living/basic/outpost_prison_guard/guard in shuffle(guard_mobs)) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in shuffle(guard_mobs)) if(!guard.on_duty() || !guard.ai_running() || guard.response) continue if(guard.activity && (!guard.activity.interruptible || istype(guard.activity, /datum/outpost_guard_activity/rounds))) @@ -1069,7 +1153,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" */ /datum/outpost_prison/proc/guard_leash_check() var/z = wing_z() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard.phase != OUTPOST_GUARD_PRESENT) continue var/turf/here = get_turf(guard) @@ -1101,7 +1185,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" */ /datum/outpost_prison/proc/dispatch_guards() var/list/on_duty = list() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard.on_duty() && guard.ai_running()) on_duty += guard if(!riot_active) @@ -1112,7 +1196,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(!length(on_duty)) return if(creature_about) - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response?.kind == "shelter") continue guard.give_response("shelter", OUTPOST_GUARD_PRIORITY_SHELTER, "shelter", null, guard_office_spot(guard)) @@ -1120,7 +1204,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_shelter_called = TRUE guard.say_guard("creature_flee") else if(riot_active) - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response?.kind == "riot") continue guard.give_response("riot", OUTPOST_GUARD_PRIORITY_RIOT, "riot", null, null) @@ -1129,7 +1213,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" assign_guard_incidents(on_duty) guard_stop_spat(on_duty) guard_watch_loose(on_duty) - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) guard.perform_response() /** @@ -1139,7 +1223,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" */ /datum/outpost_prison/proc/guard_incidents() var/list/found = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS || prisoner.can_be_dragged()) continue if(prisoner.climb_ref) @@ -1166,7 +1250,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" for(var/list/incident as anything in guard_incidents()) var/key = incident[1] var/handled = FALSE - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response?.key == key) handled = TRUE // A fight's priority rises once blows fly. @@ -1174,15 +1258,15 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" break if(handled) continue - var/mob/living/basic/outpost_prisoner/prisoner = incident[4] + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = incident[4] var/atom/where = prisoner var/turf/spot if(incident[2] == "climb") var/obj/structure/table/reinforced/prison_hatch/hatch = prisoner.climb_ref?.resolve() spot = hatch?.staff_side_turf() where = spot || prisoner - var/mob/living/basic/outpost_prison_guard/best - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + var/mob/living/carbon/human/npc/outpost_prison_guard/best + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response && guard.response.priority >= incident[3]) continue if(guard.skipping(key)) @@ -1195,13 +1279,13 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_prison/proc/guard_stop_spat(list/on_duty) if(spat_lines_left <= 0) return FALSE - var/mob/living/basic/outpost_prisoner/one = spat_first_ref?.resolve() - var/mob/living/basic/outpost_prisoner/two = spat_second_ref?.resolve() - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + var/mob/living/carbon/human/npc/outpost_prisoner/one = spat_first_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/two = spat_second_ref?.resolve() + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response) continue var/list/seen = view(OUTPOST_GUARD_SPAT_VIEW, guard) - var/mob/living/basic/outpost_prisoner/speaker = (one && (one in seen)) ? one : ((two && (two in seen)) ? two : null) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = (one && (one in seen)) ? one : ((two && (two in seen)) ? two : null) if(!speaker) continue guard.face_atom(speaker) @@ -1213,19 +1297,19 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Loose prisoners: one guard calls each one out, once, and any guard they come beside batons them. No chase. /datum/outpost_prison/proc/guard_watch_loose(list/on_duty) var/list/called = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.trouble != PRISONER_TROUBLE_LOOSE || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue var/key = REF(prisoner) called[key] = TRUE if(!guard_loose_called[key]) // Said aloud, and noted in the warden's log - var/mob/living/basic/outpost_prison_guard/crier = pick(on_duty) + var/mob/living/carbon/human/npc/outpost_prison_guard/crier = pick(on_duty) if(crier.say_guard("loose_call", list("{place}" = get_area_name(prisoner)))) add_log("[crier.real_name]: \"[crier.last_line]\"") if(prisoner.can_be_dragged()) continue - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.strike(prisoner)) break guard_loose_called = called @@ -1241,18 +1325,18 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/first_names = list() var/list/full_names = list() var/list/places = list() - for(var/mob/living/basic/outpost_prisoner/runner as anything in runners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/runner as anything in runners) first_names += runner.speech_name() full_names += runner.real_name places |= get_area_name(runner) // Every line says where they were last seen var/list/values = list("{prisoner}" = english_list(first_names), "{place}" = english_list(places)) var/list/on_duty = list() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard.on_duty()) on_duty += guard if(length(on_duty)) - var/mob/living/basic/outpost_prison_guard/crier = pick(on_duty) + var/mob/living/carbon/human/npc/outpost_prison_guard/crier = pick(on_duty) if(crier.say_guard("loose_nearly", values)) add_log("[crier.real_name]: \"[crier.last_line]\"") announce("[crier.real_name]: \"[crier.last_line]\"", SHIP_NOTIFY_DANGER) @@ -1261,7 +1345,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// The riot hold is called out once a riot: said aloud, and noted in the warden's log -/datum/outpost_prison/proc/announce_riot_hold(mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/announce_riot_hold(mob/living/carbon/human/npc/outpost_prison_guard/guard) if(guard_riot_announced) return FALSE guard_riot_announced = TRUE @@ -1271,9 +1355,9 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// Every prisoner who sees a baton strike loses OUTPOST_GUARD_ONLOOKER_MOOD, at most once per OUTPOST_GUARD_ONLOOKER_GAP each; one may say so -/datum/outpost_prison/proc/guard_strike_seen(mob/living/basic/outpost_prisoner/struck, mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/guard_strike_seen(mob/living/carbon/human/npc/outpost_prisoner/struck, mob/living/carbon/human/npc/outpost_prison_guard/guard) var/list/watchers = list() - for(var/mob/living/basic/outpost_prisoner/onlooker in view(OUTPOST_GUARD_ONLOOKER_RANGE, struck)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in view(OUTPOST_GUARD_ONLOOKER_RANGE, struck)) if(onlooker == struck || onlooker.prison != src || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT) continue if(onlooker.guard_onlooker_at && world.time - onlooker.guard_onlooker_at < OUTPOST_GUARD_ONLOOKER_GAP) @@ -1282,8 +1366,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" onlooker.adjust_mood(-OUTPOST_GUARD_ONLOOKER_MOOD) watchers += onlooker if(length(watchers) && prob(50)) - var/mob/living/basic/outpost_prisoner/speaker = pick(watchers) - INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context_with), "guard_batoned", list("{staff}" = guard.real_name)) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(watchers) + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context_with), "guard_batoned", list("{staff}" = guard.real_name)) return watchers // ===== MEMBERS, GREETINGS AND REPORTS ===== @@ -1310,7 +1394,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// A rioter on their feet beside `member`, if any /datum/outpost_prison/proc/rioter_beside(mob/living/member) - for(var/mob/living/basic/outpost_prisoner/rioter in orange(1, member)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter in orange(1, member)) if(rioter.prison == src && rioter.is_rioting() && rioter.stat == CONSCIOUS && !rioter.can_be_dragged()) return rioter return null @@ -1335,7 +1419,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/causes = restless_causes() if(length(causes)) return list("report_restless", list("{cause}" = causes[1])) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(counts_for_tension(prisoner) && ("hurt" in prisoner.bubble_needs())) return list("report_hurt", list("{prisoner}" = prisoner.speech_name())) var/list/dark = conditions_payload()["dark_cells"] @@ -1349,7 +1433,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(!length(members)) return for(var/mob/living/member as anything in members) - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(!guard.on_duty() || guard.response || get_dist(guard, member) > OUTPOST_GUARD_REPORT_RANGE || !(member in view(OUTPOST_GUARD_REPORT_RANGE, guard))) continue if(guard.greet_member(member)) @@ -1359,7 +1443,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * Greets `member` if it is time, else reports to them if it is time. Returns TRUE if they said * something. */ -/mob/living/basic/outpost_prison_guard/proc/greet_member(mob/living/member) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/greet_member(mob/living/member) if(!prison || !on_duty()) return FALSE var/key = member.ckey || REF(member) @@ -1390,10 +1474,10 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_prison/proc/guard_yard_chatter() if(!wing_can_speak() || !prob(OUTPOST_GUARD_NOTICE_CHANCE)) return FALSE - for(var/mob/living/basic/outpost_prison_guard/guard in shuffle(guard_mobs)) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in shuffle(guard_mobs)) if(!guard.on_duty() || !guard.ai_running()) continue - for(var/mob/living/basic/outpost_prisoner/prisoner in view(OUTPOST_GUARD_NOTICE_RANGE, guard)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in view(OUTPOST_GUARD_NOTICE_RANGE, guard)) if(prisoner.prison != src || !prisoner.ai_running() || !prisoner.routine_allowed() || prisoner.activity?.sleeping) continue if(prisoner.guard_noticed_at && world.time - prisoner.guard_noticed_at < OUTPOST_GUARD_CHECK_ON_GAP) @@ -1431,7 +1515,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" for(var/mob/living/other in tile) if(other != except && other.density) return TRUE - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard == except) continue if(guard.response?.spot == tile || guard.activity?.goal_turf() == tile) @@ -1547,7 +1631,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_prison/proc/guards_admin_payload() var/list/rows = list() for(var/datum/outpost_guard_record/record as anything in guard_records) - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard var/status = record.status() if(status == "away" || status == "down") var/back = record.back_in() @@ -1581,10 +1665,11 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(action == "prison_guard_remove") dismiss_guard(record, "dismissed", forget_wait = TRUE) return "remove prison guard [guard_name]" - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard if(!guard?.on_duty()) return list("error" = "That guard is not on duty.") - guard.adjust_health(guard.health - OUTPOST_GUARD_DOWN_AT) + // Real damage over their limbs, enough to put them in crit; tg's crit does the rest. + guard.take_overall_damage(brute = max(0, guard.health) + 5) if(guard.phase != OUTPOST_GUARD_DOWN) guard.go_down() return "put prison guard [guard_name] down" @@ -1593,7 +1678,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== LIFE AND DEATH OF THE PRISON ===== /datum/outpost_prison/proc/guards_destroy() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs.Copy()) if(QDELETED(guard)) continue guard.prison = null @@ -1611,7 +1696,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// The outpost was abandoned: every guard is dismissed, no refund, and beams out /datum/outpost_prison/proc/guards_abandon() for(var/datum/outpost_guard_record/record as anything in guard_records.Copy()) - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard dismiss_guard(record, "dismissed", forget_wait = TRUE) guard?.beam_out() guard_slot_waits.Cut() @@ -1621,16 +1706,20 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== LOOK ===== /** - * A corrections officer: pale blue shirt and tie over navy trousers, a navy peaked police cap, - * black gloves and combat boots, and an earpiece. No red, no orange (that's the inmates), no - * armour; the stun baton is drawn in hand only when they use it (update_overlays()). + * A corrections officer: pale blue shirt and tie over navy trousers, a blue-trimmed vest and helmet, + * black gloves and combat boots, and an earpiece. No red, no orange (that's the inmates). The stun + * baton rides on the belt and is drawn only when they use it (set_baton()); a bruise pack in a pocket + * for their own scrapes. Locked to them (D3): none of it is ever loot. */ /datum/outfit/outpost_prison_guard name = "Outpost prison guard" uniform = /obj/item/clothing/under/rank/security/officer/blueshirt - head = /obj/item/clothing/head/hats/warden/police + suit = /obj/item/clothing/suit/armor/vest/blueshirt + head = /obj/item/clothing/head/helmet/blueshirt gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/jackboots ears = /obj/item/radio/headset + belt = /obj/item/melee/baton/security/loaded + r_pocket = /obj/item/stack/medical/bruise_pack #undef OUTPOST_GUARD_DIALOGUE_FILE diff --git a/voidcrew/modules/player_outposts/outpost_prison_horror.dm b/voidcrew/modules/player_outposts/outpost_prison_horror.dm index 9f977ec2bcc..afcc6265a0e 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_horror.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_horror.dm @@ -736,7 +736,7 @@ if(victim.stat != CONSCIOUS) return TRUE if(is_outpost_prisoner(victim)) - var/mob/living/basic/outpost_prisoner/prisoner = victim + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim return prisoner.can_be_dragged() return HAS_TRAIT_FROM(victim, TRAIT_INCAPACITATED, STAMINA) @@ -785,8 +785,8 @@ if(watcher.client) shake_camera(watcher, 3, 1) if(is_outpost_prisoner(victim)) - var/mob/living/basic/outpost_prisoner/prisoner = victim - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "absorbed") + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "absorbed") addtimer(CALLBACK(src, PROC_REF(absorb_proboscis), serial), OUTPOST_HORROR_ABSORB_PROBOSCIS, TIMER_DELETE_ME) addtimer(CALLBACK(src, PROC_REF(absorb_drained), serial), OUTPOST_HORROR_ABSORB_TIME, TIMER_DELETE_ME) return TRUE @@ -836,16 +836,20 @@ set_busy("digest", OUTPOST_HORROR_DIGEST_TIME) return victim -/// Husked and dead +/// Dead and husked the way tg's changeling absorb leaves its victims: death, then tg's Drain() on a body /mob/living/basic/outpost_experiment/horror/proc/husk(mob/living/victim) victim.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) - victim.become_husk(CHANGELING_DRAIN) - if(!ishuman(victim)) - // A basic mob has no husk sprite: grey and dried out - victim.add_atom_colour(list(0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0,0,0,1, -0.05,-0.05,-0.05,0), FIXED_COLOUR_PRIORITY) - victim.blood_volume = 0 if(victim.stat != DEAD) + victim.investigate_log("has died from being absorbed by an outpost prison horror.", INVESTIGATE_DEATHS) victim.death() + if(iscarbon(victim)) + var/mob/living/carbon/body = victim + body.Drain() + return + // A basic mob has no husk sprite: grey and dried out + victim.become_husk(CHANGELING_DRAIN) + victim.add_atom_colour(list(0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0,0,0,1, -0.05,-0.05,-0.05,0), FIXED_COLOUR_PRIORITY) + victim.blood_volume = 0 /// Admins hear about every non-member it absorbs; the game log gets every absorb /mob/living/basic/outpost_experiment/horror/proc/log_absorb(mob/living/victim) @@ -1049,7 +1053,7 @@ /// Whether a prisoner is shut in a bolted cell, which it leaves for last /mob/living/basic/outpost_experiment/horror/proc/bolted_away(mob/living/target) - var/mob/living/basic/outpost_prisoner/prisoner = target + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = target return istype(prisoner) && prisoner.in_bolted_cell() /** @@ -1133,7 +1137,7 @@ best_distance = distance if(best) return best - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in event?.prison?.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in event?.prison?.prisoners) if(!valid_quarry(prisoner) || bolted_away(prisoner)) continue var/distance = get_dist(src, prisoner) diff --git a/voidcrew/modules/player_outposts/outpost_prison_incidents.dm b/voidcrew/modules/player_outposts/outpost_prison_incidents.dm index bf54e8e5320..93a95cd4f00 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_incidents.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_incidents.dm @@ -66,7 +66,7 @@ /// Prisoners who count for wildcards: present, alive, not loose, in the cell block /datum/outpost_prison/proc/wildcard_prisoner_count() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(counts_for_tension(prisoner) && in_cell_block(prisoner)) count++ return count @@ -146,7 +146,7 @@ /// The kinds and their weights: a stabbing is likelier while someone who could do it has a shiv stashed /datum/outpost_prison/proc/wildcard_kind_weights() var/stab_weight = PRISON_INCIDENT_WEIGHT_STAB - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.cell?.stash_shiv && wildcard_can_act(prisoner)) stab_weight *= PRISON_INCIDENT_STASH_KIND_MULT break @@ -171,7 +171,7 @@ * block, out of trouble and not busy with it, not shut in a cell, owing no lockdown, not an * experiment's subject, and not running from a creature */ -/datum/outpost_prison/proc/wildcard_can_act(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_can_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return FALSE if(prisoner.in_trouble() || prisoner.can_be_dragged() || prisoner.pulledby || prisoner.experiment_subject || prisoner.lockdown_left > 0 || prisoner.is_panicking()) @@ -181,7 +181,7 @@ return in_cell_block(prisoner) && !prisoner.is_confined() /// Whether `prisoner` can be the one a stabbing is aimed at: as wildcard_can_act(), but asleep will do -/datum/outpost_prison/proc/wildcard_can_be_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_can_be_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return FALSE if(prisoner.in_trouble() || prisoner.can_be_dragged() || prisoner.pulledby || prisoner.experiment_subject) @@ -189,7 +189,7 @@ return in_cell_block(prisoner) && !prisoner.is_confined() /// Whether `one` can walk to where `two` stands -/datum/outpost_prison/proc/wildcard_can_reach(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/wildcard_can_reach(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(!one.walkable) refresh_prisoner_reach(one) return !!one.walkable?[get_turf(two)] @@ -218,11 +218,11 @@ var/list/weights = list() var/list/rival_pairs = list() var/list/rival_weights = list() - for(var/mob/living/basic/outpost_prisoner/attacker in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/attacker in prisoners) if(!wildcard_can_act(attacker)) continue var/weight = attacker.cell?.stash_shiv ? PRISON_INCIDENT_STASH_PICK_MULT : 1 - for(var/mob/living/basic/outpost_prisoner/victim in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/victim in prisoners) if(victim == attacker || !wildcard_can_be_target(victim) || !wildcard_can_reach(attacker, victim)) continue if(are_rivals(attacker, victim)) @@ -241,7 +241,7 @@ /datum/outpost_prison/proc/pick_snapper() var/list/options = list() var/list/weights = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!wildcard_can_act(prisoner) || !prisoner.can_join_riot()) continue options += prisoner @@ -253,15 +253,15 @@ /// Who fights whom: list(one, two), rivals first, or null /datum/outpost_prison/proc/pick_fight_pair() var/list/able = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(wildcard_can_act(prisoner) && !prisoner.fight) able += prisoner var/list/pairs = list() var/list/rival_pairs = list() for(var/first_index in 1 to length(able) - 1) - var/mob/living/basic/outpost_prisoner/one = able[first_index] + var/mob/living/carbon/human/npc/outpost_prisoner/one = able[first_index] for(var/second_index in first_index + 1 to length(able)) - var/mob/living/basic/outpost_prisoner/two = able[second_index] + var/mob/living/carbon/human/npc/outpost_prisoner/two = able[second_index] if(!wildcard_can_reach(one, two)) continue if(are_rivals(one, two)) @@ -289,7 +289,7 @@ /// A snap: the tell, or with `skip_tell` the riot at once. Returns TRUE if it started. /datum/outpost_prison/proc/start_snap(skip_tell = FALSE) - var/mob/living/basic/outpost_prisoner/snapper = pick_snapper() + var/mob/living/carbon/human/npc/outpost_prisoner/snapper = pick_snapper() if(!snapper) return FALSE if(skip_tell) @@ -300,7 +300,7 @@ return TRUE /// `snapper` riots, alone unless others are unhappy enough to join. Returns TRUE if the riot started. -/datum/outpost_prison/proc/snap(mob/living/basic/outpost_prisoner/snapper) +/datum/outpost_prison/proc/snap(mob/living/carbon/human/npc/outpost_prisoner/snapper) if(!snapper?.can_join_riot()) return FALSE if(!start_riot("[snapper.real_name] snapped", forced = snapper, ignore_quiet = TRUE)) @@ -318,27 +318,27 @@ // ===== STOPPING ONE ===== /// Whether `prisoner` is working up to a stabbing or a snap right now -/datum/outpost_prison/proc/wildcard_brooding(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_brooding(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return !!prisoner && wildcard?.stage == WILDCARD_TELL && wildcard.actor() == prisoner /// The tell for examine, while `prisoner` works up to a stabbing or a snap; or null -/datum/outpost_prison/proc/wildcard_examine(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!wildcard_brooding(prisoner)) return null if(wildcard.kind != "stab") return "Paces and mutters, fists clenched." - var/mob/living/basic/outpost_prisoner/target = wildcard.target() + var/mob/living/carbon/human/npc/outpost_prisoner/target = wildcard.target() var/stare = target ? "Keeps staring at [target.speech_name()]" : "Keeps staring at someone" return wildcard.last_tell_shown ? "[stare], one hand under [prisoner.p_their()] shirt." : "[stare]." /// A member talked `prisoner` down (talk_down()): a tell under way is called off. Returns TRUE if one was. -/datum/outpost_prison/proc/wildcard_talked_down(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/wildcard_talked_down(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!wildcard_brooding(prisoner)) return FALSE return wildcard.stopped_by_staff(user, "talk") /// Staff batoned `prisoner` (a signal handler's call: nothing here sleeps): a tell under way is called off -/datum/outpost_prison/proc/wildcard_batoned(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/wildcard_batoned(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!wildcard_brooding(prisoner)) return FALSE return wildcard.stopped_by_staff(user, "baton") @@ -346,7 +346,7 @@ // ===== COMINGS AND GOINGS ===== /// A prisoner is leaving the roster: a wildcard they are part of ends, and a stabber drops the shiv -/datum/outpost_prison/proc/wildcard_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!wildcard || (wildcard.actor() != prisoner && wildcard.target() != prisoner)) return if(wildcard.stage == WILDCARD_ATTACK && wildcard.actor() != prisoner) @@ -360,8 +360,8 @@ /// The admin panel's wildcard numbers, with the wing events' (outpost_prison_wing_events.dm) /datum/outpost_prison/proc/incidents_admin_payload() - var/mob/living/basic/outpost_prisoner/actor = wildcard?.actor() - var/mob/living/basic/outpost_prisoner/target = wildcard?.target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = wildcard?.actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = wildcard?.target() return list( "chance" = round(outpost_prison_wildcard_chance(tension), 0.1), "gap_left" = round(wildcard_gap_left), @@ -397,7 +397,7 @@ /// A stabbing's fight var/datum/weakref/fight_ref -/datum/outpost_prison_wildcard/New(datum/outpost_prison/owner, kind, mob/living/basic/outpost_prisoner/actor, mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison_wildcard/New(datum/outpost_prison/owner, kind, mob/living/carbon/human/npc/outpost_prisoner/actor, mob/living/carbon/human/npc/outpost_prisoner/target) . = ..() prison = owner src.kind = kind @@ -406,7 +406,7 @@ tell_left = rand(PRISON_INCIDENT_TELL_MIN, PRISON_INCIDENT_TELL_MAX) /datum/outpost_prison_wildcard/Destroy() - var/mob/living/basic/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() if(actor && is_tell_activity(actor.activity)) actor.end_activity() if(prison?.wildcard == src) @@ -425,13 +425,13 @@ return istype(activity, /datum/prisoner_activity/wildcard_shadow) || istype(activity, /datum/prisoner_activity/pace/wildcard) /// Whether a stabbing's victim is still there to go for -/datum/outpost_prison_wildcard/proc/target_ok(mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison_wildcard/proc/target_ok(mob/living/carbon/human/npc/outpost_prisoner/target) return !QDELETED(target) && target.prison == prison && (target in prison.prisoners) && target.phase == PRISONER_PRESENT && target.stat != DEAD && target.trouble != PRISONER_TROUBLE_LOOSE && !target.is_rioting() /// The tell begins: a stare and the shadowing, or the pacing /datum/outpost_prison_wildcard/proc/begin_tell() - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() if(!actor) return actor.end_activity() @@ -445,7 +445,7 @@ log_game("PLAYER OUTPOST PRISON: [actor.real_name] is working up to [kind == "stab" ? "stabbing [target?.real_name]" : "snapping"] at '[prison?.outpost?.name]'") /// Starts the tell's activity again if something ended it -/datum/outpost_prison_wildcard/proc/restart_activity(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/restart_activity(mob/living/carbon/human/npc/outpost_prisoner/actor) if(is_tell_activity(actor.activity) || !actor.routine_allowed()) return var/datum/prisoner_activity/tell_activity @@ -460,8 +460,8 @@ /// Advances the tell, or watches the attack until its fight is over /datum/outpost_prison_wildcard/proc/tick(seconds) - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() if(stage == WILDCARD_ATTACK) var/datum/outpost_prison_fight/brawl = fight_ref?.resolve() if(!QDELETED(brawl) && actor?.fight == brawl) @@ -504,7 +504,7 @@ qdel(src) /// A stabbing's tell: the stare, a mutter, the hand under the shirt -/datum/outpost_prison_wildcard/proc/tell_stab(mob/living/basic/outpost_prisoner/actor, mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison_wildcard/proc/tell_stab(mob/living/carbon/human/npc/outpost_prisoner/actor, mob/living/carbon/human/npc/outpost_prisoner/target) var/in_sight = (target in view(7, actor)) if(in_sight) actor.face_atom(target) @@ -516,7 +516,7 @@ actor.manual_emote("slips a hand under [actor.p_their()] shirt.") /// A snap's tell: muttering, a blow at the wall, then shouting -/datum/outpost_prison_wildcard/proc/tell_snap(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/tell_snap(mob/living/carbon/human/npc/outpost_prisoner/actor) if(!muttered && tell_elapsed >= PRISON_INCIDENT_MUTTER_AT) muttered = TRUE actor.say_context("incident_pacing") @@ -543,8 +543,8 @@ * victim, a fight past its argument. Returns TRUE if it started. */ /datum/outpost_prison_wildcard/proc/strike() - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() // Nobody goes for a prisoner who is already down, or busy fighting someone else. if(QDELETED(actor) || actor.stat != CONSCIOUS || actor.trouble || actor.can_be_dragged() || !target_ok(target) || target.stat != CONSCIOUS || target.can_be_dragged() || target.fight) return FALSE @@ -553,9 +553,9 @@ if(is_tell_activity(actor.activity)) actor.end_activity() actor.stand_up() - if(!prison.draw_stashed_shiv(actor)) - actor.draw_shiv() - actor.manual_emote("pulls a shiv out from under [actor.p_their()] shirt!") + // A stabbing needs a blade: one stashed in their cell, or one they already carry. Nothing comes from nowhere. + if(!actor.has_shiv() && !prison.draw_stashed_shiv(actor)) + return FALSE var/datum/outpost_prison_fight/brawl = prison.start_fight(actor, target, "stab") if(!brawl) put_away_shiv(actor) @@ -570,13 +570,13 @@ return TRUE /// After a stabbing, the attacker lets go of the shiv, unless they are rioting or loose with it -/datum/outpost_prison_wildcard/proc/put_away_shiv(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/put_away_shiv(mob/living/carbon/human/npc/outpost_prisoner/actor) if(QDELETED(actor) || !actor.has_shiv() || actor.is_rioting() || actor.trouble == PRISONER_TROUBLE_LOOSE) return actor.drop_shiv() /// It came to nothing (the victim out of reach, the snapper shut in): they think better of it -/datum/outpost_prison_wildcard/proc/give_up(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/give_up(mob/living/carbon/human/npc/outpost_prisoner/actor) if(!QDELETED(actor) && actor.stat == CONSCIOUS) actor.say_context("incident_stopped") qdel(src) @@ -589,8 +589,8 @@ /datum/outpost_prison_wildcard/proc/stopped_by_staff(mob/living/user, how) if(stage != WILDCARD_TELL) return FALSE - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() if(actor) actor.note_trouble_ended() var/what = kind == "stab" ? "went for [target?.real_name || "someone"]" : "snapped" @@ -599,7 +599,7 @@ prison?.add_log("[user?.name || "Someone"] talked [who] down before [actor.p_they()] [what].") else prison?.add_log(user ? "[user.name] stopped [who] before [actor.p_they()] [what]." : "[who] was stopped before [actor.p_they()] [what].") - INVOKE_ASYNC(actor, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "incident_stopped") + INVOKE_ASYNC(actor, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "incident_stopped") qdel(src) return TRUE @@ -613,7 +613,7 @@ interruptible = FALSE var/datum/weakref/target_ref -/datum/prisoner_activity/wildcard_shadow/New(mob/living/basic/outpost_prisoner/doer, mob/living/basic/outpost_prisoner/target) +/datum/prisoner_activity/wildcard_shadow/New(mob/living/carbon/human/npc/outpost_prisoner/doer, mob/living/carbon/human/npc/outpost_prisoner/target) . = ..() target_ref = target ? WEAKREF(target) : null diff --git a/voidcrew/modules/player_outposts/outpost_prison_leads.dm b/voidcrew/modules/player_outposts/outpost_prison_leads.dm index 2a77d84b737..ab38f2fd799 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_leads.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_leads.dm @@ -32,7 +32,7 @@ /// Lies given anywhere this round, so every lie's waypoint key is unique on any ship's helm GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Knows where something is, until they tell it (leads_prisoner_admitted()) var/carries_lead = FALSE /// A member is putting a question about leads to them right now @@ -83,11 +83,11 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) // ===== CARRIERS ===== /// A prisoner was booked in: some carry a lead -/datum/outpost_prison/proc/leads_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/leads_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.carries_lead = prob(OUTPOST_PRISON_LEAD_CHANCE) /// Their tips stay on the helms and in the tracking by name; a liar who has gone never owns up -/datum/outpost_prison/proc/leads_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/leads_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.carries_lead = FALSE /// Whether the wing's gap between leads is over @@ -100,7 +100,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return !!lead_test_roll return prob(chance) -/datum/outpost_prison/proc/lead_member_in_view(mob/living/basic/outpost_prisoner/prisoner, range) +/datum/outpost_prison/proc/lead_member_in_view(mob/living/carbon/human/npc/outpost_prisoner/prisoner, range) for(var/mob/living/person in view(range, prisoner)) if(person.stat == CONSCIOUS && is_member(person)) return TRUE @@ -109,7 +109,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) // ===== SPEECH AND EXAMINE ===== /// A carrier hints at what they know, while the wing is ready to give a lead and a member is near -/datum/outpost_prison/proc/leads_extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/leads_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.carries_lead || !lead_ready()) return null if(!lead_member_in_view(prisoner, OUTPOST_PRISON_LEAD_HINT_RANGE) || !lead_roll(OUTPOST_PRISON_LEAD_HINT_CHANCE)) @@ -117,13 +117,13 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return list("lead_hint", null) /// Nothing: mood (XB's examine) and the tells as they answer are what there is to read -/datum/outpost_prison/proc/leads_examine(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/leads_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) return null // ===== THE TALK MENU ===== /// "Heard anything?" on every prisoner, and "About that tip" on those who saw a recent one given; members only -/datum/outpost_prison/proc/leads_talk_choices(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/leads_talk_choices(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) var/list/choices = list() if(!prisoner || !user || !is_member(user)) return choices @@ -133,7 +133,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return choices /// Runs a talk menu choice of this package; TRUE if it was one. May sleep. -/datum/outpost_prison/proc/leads_talk_act(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, choice) +/datum/outpost_prison/proc/leads_talk_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, choice) if(choice == LEAD_ASK_CHOICE) lead_ask(prisoner, user) return TRUE @@ -147,12 +147,12 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return FALSE /// The newest tip `prisoner` can be asked about (lead_vouchable()), or null -/datum/outpost_prison/proc/lead_newest_vouchable(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lead_newest_vouchable(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/list/vouchable = lead_vouchable(prisoner) return length(vouchable) ? vouchable[length(vouchable)] : null /// Open leads `prisoner` saw given, recent enough to ask about, that they did not give and have not answered for -/datum/outpost_prison/proc/lead_vouchable(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lead_vouchable(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/list/found = list() var/datum/weakref/prisoner_ref = WEAKREF(prisoner) for(var/datum/outpost_prison_lead/lead as anything in open_leads) @@ -164,14 +164,14 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return found /// Whether `user` may put a question to `prisoner` now; shows or says why not -/datum/outpost_prison/proc/lead_can_ask(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/lead_can_ask(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(QDELETED(prisoner) || QDELETED(user) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return FALSE if(!is_member(user)) prisoner.balloon_alert(user, "not your prisoner") return FALSE // Not talking: the talk menu may hold that while it hands this question over. - if(prisoner.lead_questioned || prisoner.trouble || prisoner.fight || prisoner.threat_ref || prisoner.swing_ref || prisoner.climb_ref || prisoner.beaten_left > 0) + if(prisoner.lead_questioned || prisoner.trouble || prisoner.fight || prisoner.threat_ref || prisoner.swing_ref || prisoner.climb_ref || prisoner.is_beaten()) prisoner.balloon_alert(user, "they're busy") return FALSE if(!prisoner.will_listen()) @@ -182,7 +182,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return TRUE /// A few seconds face to face, as a talk-down takes; FALSE if it was cut short or they stopped listening -/datum/outpost_prison/proc/lead_face_to_face(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, self_message) +/datum/outpost_prison/proc/lead_face_to_face(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, self_message) prisoner.lead_questioned = TRUE // Put back as found: the talk menu may already hold it and clear it itself. var/was_talking = prisoner.talking @@ -204,7 +204,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return TRUE /// "Heard anything?": the checks and the talk, then give_lead() for the asker's ship -/datum/outpost_prison/proc/lead_ask(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/lead_ask(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!lead_can_ask(prisoner, user)) return FALSE if(!get_crew_ship(user)) @@ -226,7 +226,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return TRUE /// "About that tip": the checks and the talk, then lead_vouch() -/datum/outpost_prison/proc/lead_ask_about(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, datum/outpost_prison_lead/lead) +/datum/outpost_prison/proc/lead_ask_about(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, datum/outpost_prison_lead/lead) if(!lead_can_ask(prisoner, user)) return FALSE if(!(lead in lead_vouchable(prisoner))) @@ -240,7 +240,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) // ===== GIVING A LEAD ===== /// Whether `asker` hit `prisoner` unprovoked in the last OUTPOST_PRISON_LEAD_HIT_MEMORY -/datum/outpost_prison/proc/lead_hit_by(mob/living/basic/outpost_prisoner/prisoner, mob/asker) +/datum/outpost_prison/proc/lead_hit_by(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/asker) if(prisoner.last_hit_justified || !prisoner.last_staff_hit) return FALSE if(world.time - prisoner.last_staff_hit > OUTPOST_PRISON_LEAD_HIT_MEMORY) @@ -248,7 +248,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return prisoner.last_staff_attacker_ref?.resolve() == asker /// Percent chance `prisoner` lies to `asker`: none when content, then by mood, the asker's name in the yard and personality -/datum/outpost_prison/proc/lead_lie_chance(mob/living/basic/outpost_prisoner/prisoner, mob/asker) +/datum/outpost_prison/proc/lead_lie_chance(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/asker) if(prisoner.mood >= OUTPOST_PRISON_LEAD_TRUE_MOOD) return 0 var/chance = prisoner.mood < OUTPOST_PRISON_LEAD_HOSTILE_MOOD ? OUTPOST_PRISON_LEAD_LIE_HOSTILE : OUTPOST_PRISON_LEAD_LIE_UNEASY @@ -272,7 +272,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) * lead_lie_chance(), which `force_lie` (tests) decides instead. Returns "invalid", "refuse_hit", * "refuse", "none", "nothing", "truth" or "lie". */ -/datum/outpost_prison/proc/give_lead(mob/living/basic/outpost_prisoner/prisoner, obj/structure/overmap/ship/ship, mob/living/asker, force_lie = null) +/datum/outpost_prison/proc/give_lead(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/structure/overmap/ship/ship, mob/living/asker, force_lie = null) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return "invalid" if(QDELETED(ship) || QDELETED(asker)) @@ -326,7 +326,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) lead.ship_ref = WEAKREF(ship) lead.ship_name = ship.name lead.given_at = world.time - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other != prisoner && other.phase == PRISONER_PRESENT) lead.witnesses += WEAKREF(other) @@ -347,7 +347,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return lead.lie ? "lie" : "truth" /// A tell as they answer: an emote from the dialogue file's "tells", their personality's more often than the shared ones -/datum/outpost_prison/proc/lead_tell_emote(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lead_tell_emote(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/list/tells = outpost_prisoner_extra_dialogue(LEAD_DIALOGUE_FILE, "tells") var/list/own = prisoner.personality ? tells[prisoner.personality] : null var/list/shared = tells["any"] @@ -477,13 +477,13 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) var/datum/outpost_prison_lead/lead = lead_ref?.resolve() if(!lead || !(lead in open_leads)) return FALSE - var/mob/living/basic/outpost_prisoner/teller = lead.teller_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/teller = lead.teller_ref?.resolve() var/mob/living/asker = lead.asker_ref?.resolve() if(QDELETED(teller) || QDELETED(asker) || teller.prison != src) return FALSE if(!lead_roll(OUTPOST_PRISON_LEAD_GOSSIP_CHANCE)) return FALSE - for(var/mob/living/basic/outpost_prisoner/other in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in shuffle(prisoners)) if(other == teller || other.stat != CONSCIOUS || other.phase != PRISONER_PRESENT || other.trouble || other.mood < OUTPOST_PRISON_LEAD_GOSSIP_MOOD) continue var/list/seen = view(OUTPOST_PRISON_LEAD_GOSSIP_RANGE, other) @@ -498,7 +498,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) * middling aren't sure, and the sour and anyone the asker hit refuse (they can be asked again * later). Returns "refuse_hit", "refuse", "unsure", "true" or "lie" (what they said the tip was). */ -/datum/outpost_prison/proc/lead_vouch(mob/living/basic/outpost_prisoner/prisoner, datum/outpost_prison_lead/lead, mob/living/asker) +/datum/outpost_prison/proc/lead_vouch(mob/living/carbon/human/npc/outpost_prisoner/prisoner, datum/outpost_prison_lead/lead, mob/living/asker) prisoner.face_atom(asker) if(lead_hit_by(prisoner, asker)) prisoner.say_context("lead_refuse_hit") @@ -550,7 +550,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) /// A caught liar still in the wing owns up once the asker comes within OUTPOST_PRISON_LEAD_CAUGHT_RANGE /datum/outpost_prison/proc/lead_caught_line(datum/outpost_prison_lead/lead) - var/mob/living/basic/outpost_prisoner/teller = lead.teller_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/teller = lead.teller_ref?.resolve() if(QDELETED(teller) || teller.prison != src) lead.caught_said = TRUE return FALSE @@ -574,7 +574,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) /// The admin panel's leads block: {ready_in, carriers: [ref], open: [{teller, ship, name, lie, exposed, age}]}; times in seconds /datum/outpost_prison/proc/leads_admin_payload() var/list/carriers = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.carries_lead) carriers += REF(prisoner) var/list/open = list() @@ -598,7 +598,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) if(action != "prison_lead") return null var/ref = params ? params["ref"] : null - var/mob/living/basic/outpost_prisoner/prisoner = istext(ref) ? (locate(ref) in prisoners) : null + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = istext(ref) ? (locate(ref) in prisoners) : null if(QDELETED(prisoner)) return list("error" = "No such prisoner.") prisoner.carries_lead = TRUE diff --git a/voidcrew/modules/player_outposts/outpost_prison_life.dm b/voidcrew/modules/player_outposts/outpost_prison_life.dm index 835bf41113c..a1d82660238 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_life.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_life.dm @@ -28,7 +28,7 @@ /// A friend of one of the fighters has had their one try at breaking it up var/breakup_tried = FALSE -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// It is their birthday; `party_done` once the yard has had the cake var/has_birthday = FALSE var/party_done = FALSE @@ -38,19 +38,19 @@ // ===== AFFINITY ===== /// The key a pair of prisoners is kept under, the same whichever way round they come -/datum/outpost_prison/proc/affinity_key(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/affinity_key(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) var/one_ref = REF(one) var/two_ref = REF(two) return (sorttext(one_ref, two_ref) > 0) ? "[one_ref]|[two_ref]" : "[two_ref]|[one_ref]" /// How two prisoners get on, -100 (rivals) to 100 (friends) -/datum/outpost_prison/proc/affinity(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/affinity(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(!one || !two || one == two) return 0 return affinities[affinity_key(one, two)] || 0 /// Sets how two prisoners get on. A wing keeps at most PRISON_AFFINITY_MAX_PAIRS pairs: a new one pushes out the mildest. -/datum/outpost_prison/proc/set_affinity(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, value) +/datum/outpost_prison/proc/set_affinity(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, value) if(!one || !two || one == two) return 0 var/key = affinity_key(one, two) @@ -68,20 +68,20 @@ affinities[key] = value return value -/datum/outpost_prison/proc/adjust_affinity(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, amount) +/datum/outpost_prison/proc/adjust_affinity(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, amount) return set_affinity(one, two, affinity(one, two) + amount) -/datum/outpost_prison/proc/are_friends(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/are_friends(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) return affinity(one, two) >= PRISON_AFFINITY_FRIEND -/datum/outpost_prison/proc/are_rivals(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/are_rivals(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) return affinity(one, two) <= PRISON_AFFINITY_RIVAL /// The prisoner `prisoner` gets on with best, if a friend; with `worst`, the one they get on with worst, if a rival -/datum/outpost_prison/proc/closest_tie(mob/living/basic/outpost_prisoner/prisoner, worst = FALSE) - var/mob/living/basic/outpost_prisoner/found +/datum/outpost_prison/proc/closest_tie(mob/living/carbon/human/npc/outpost_prisoner/prisoner, worst = FALSE) + var/mob/living/carbon/human/npc/outpost_prisoner/found var/found_value = worst ? PRISON_AFFINITY_RIVAL : PRISON_AFFINITY_FRIEND - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == prisoner || other.stat == DEAD || other.phase == PRISONER_LEAVING) continue var/value = affinity(prisoner, other) @@ -93,12 +93,12 @@ return found /// Picks, removes from `options` and returns the prisoner `prisoner` goes to chat with, friends by preference -/datum/outpost_prison/proc/take_chat_partner(mob/living/basic/outpost_prisoner/prisoner, list/options) +/datum/outpost_prison/proc/take_chat_partner(mob/living/carbon/human/npc/outpost_prisoner/prisoner, list/options) if(!length(options)) return null var/list/weights = list() var/total = 0 - for(var/mob/living/basic/outpost_prisoner/other as anything in options) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other as anything in options) var/weight = clamp(1 + affinity(prisoner, other) / 25, PRISON_CHAT_WEIGHT_MIN, PRISON_CHAT_WEIGHT_MAX) weights += weight total += weight @@ -106,7 +106,7 @@ for(var/index in 1 to length(options)) roll -= weights[index] if(roll <= 0) - var/mob/living/basic/outpost_prisoner/chosen = options[index] + var/mob/living/carbon/human/npc/outpost_prisoner/chosen = options[index] options.Cut(index, index + 1) return chosen return pick_n_take(options) @@ -114,7 +114,7 @@ // ===== WHAT CHANGES IT ===== /// A chat between two prisoners finished -/datum/outpost_prison/proc/note_chat(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/note_chat(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) adjust_affinity(one, two, PRISON_AFFINITY_CHAT) /// Prisoners who shared a meal and got its lift: every pair of them @@ -123,16 +123,16 @@ for(var/second_index in first_index + 1 to length(diners)) adjust_affinity(diners[first_index], diners[second_index], PRISON_AFFINITY_SHARED_MEAL) -/datum/outpost_prison/proc/note_spat(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/note_spat(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) adjust_affinity(one, two, PRISON_AFFINITY_SPAT) -/datum/outpost_prison/proc/note_fight(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/note_fight(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) adjust_affinity(one, two, PRISON_AFFINITY_FIGHT) /// Each full minute two prisoners spend playing basketball together brings them a little closer /datum/outpost_prison/proc/coplay_tick(seconds) var/list/players = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == CONSCIOUS && prisoner.phase == PRISONER_PRESENT && istype(prisoner.activity, /datum/prisoner_activity/basketball) && prisoner.activity.started) players += prisoner for(var/first_index in 1 to length(players) - 1) @@ -147,7 +147,7 @@ // ===== WHAT IT CHANGES ===== /// Rivals fight more readily, friends less -/datum/outpost_prison/proc/fight_chance_mult(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/fight_chance_mult(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(are_rivals(one, two)) return PRISON_FIGHT_MULT_RIVAL if(are_friends(one, two)) @@ -155,7 +155,7 @@ return 1 /// A fight cause beyond food, ball and bed: an old grudge between two who can't stand each other -/datum/outpost_prison/proc/fight_cause_extra(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/fight_cause_extra(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(affinity(one, two) <= PRISON_AFFINITY_GRUDGE) return "grudge" return null @@ -181,7 +181,7 @@ * The two-person conversation `speaker` opens with `partner`: friends and rivals mostly open with * one of their own (conversations_friendly, conversations_hostile), falling back to the main pool. */ -/datum/outpost_prison/proc/pick_conversation(mob/living/basic/outpost_prisoner/speaker, mob/living/basic/outpost_prisoner/partner) +/datum/outpost_prison/proc/pick_conversation(mob/living/carbon/human/npc/outpost_prisoner/speaker, mob/living/carbon/human/npc/outpost_prisoner/partner) var/value = affinity(speaker, partner) var/pool_key if(value >= PRISON_AFFINITY_FRIEND) @@ -197,9 +197,9 @@ return length(pool) ? pick(pool) : null /// "Seems to get on with Rosa." for examine, or null -/datum/outpost_prison/proc/relationship_examine(mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/friend = closest_tie(prisoner) - var/mob/living/basic/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) +/datum/outpost_prison/proc/relationship_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/friend = closest_tie(prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) if(friend && rival) return "Seems to get on with [friend.speech_name()], and keeps away from [rival.speech_name()]." if(friend) @@ -209,8 +209,8 @@ return null /// The first name of `prisoner`'s worst rival in the wing, or null -/datum/outpost_prison/proc/worst_rival_name(mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) +/datum/outpost_prison/proc/worst_rival_name(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) return rival?.speech_name() // ===== A FRIEND BREAKS IT UP ===== @@ -224,28 +224,28 @@ for(var/datum/outpost_prison_fight/brawl in fights.Copy()) if(brawl.fighting || brawl.breakup_tried) continue - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) - var/mob/living/basic/outpost_prisoner/friend = peacemaker_for(fighter, brawl) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) + var/mob/living/carbon/human/npc/outpost_prisoner/friend = peacemaker_for(fighter, brawl) if(!friend) continue brawl.breakup_tried = TRUE var/chance = isnull(breakup_chance_override) ? PRISON_BREAKUP_CHANCE : breakup_chance_override if(!prob(chance)) break - var/mob/living/basic/outpost_prisoner/opponent = brawl.opponent_of(fighter) + var/mob/living/carbon/human/npc/outpost_prisoner/opponent = brawl.opponent_of(fighter) friend.face_atom(fighter) friend.manual_emote(opponent ? "steps in between [fighter] and [opponent]." : "puts a hand on [fighter]'s shoulder.") end_fight(brawl) add_log("[friend.real_name] talked [fighter.real_name] and [opponent?.real_name] out of a fight.") - INVOKE_ASYNC(friend, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "friend_breaks_up", fighter) + INVOKE_ASYNC(friend, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "friend_breaks_up", fighter) return friend return null /// A friend of `fighter` who could step in: close by, awake, in a good enough mood and out of trouble -/datum/outpost_prison/proc/peacemaker_for(mob/living/basic/outpost_prisoner/fighter, datum/outpost_prison_fight/brawl) +/datum/outpost_prison/proc/peacemaker_for(mob/living/carbon/human/npc/outpost_prisoner/fighter, datum/outpost_prison_fight/brawl) if(QDELETED(fighter)) return null - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == brawl.first || other == brawl.second || !are_friends(fighter, other)) continue // Down or cuffed, they can't step in between anyone @@ -258,13 +258,13 @@ // ===== ARRIVALS ===== /// A prisoner was booked in. Whether they know anyone inside, and their birthday, wait until they beam in. -/datum/outpost_prison/proc/on_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) // admit_next() beams them in straight after admit(); anyone booked in any other way stays put. addtimer(CALLBACK(src, PROC_REF(greet_arrival), WEAKREF(prisoner)), 1) /// An arrival beaming in through intake: a crewmate or a rival already inside, a birthday, marks on the wall /datum/outpost_prison/proc/greet_arrival(datum/weakref/prisoner_ref) - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() if(!prisoner || prisoner.prison != src || prisoner.phase != PRISONER_ARRIVING) return FALSE roll_crew_tie(prisoner) @@ -279,9 +279,9 @@ * (-PRISON_CREW_TIE). `forced` is "friend" or "rival" to skip the roll. The arrival says so once * they have beamed in. Returns the other prisoner, or null. */ -/datum/outpost_prison/proc/roll_crew_tie(mob/living/basic/outpost_prisoner/prisoner, forced) +/datum/outpost_prison/proc/roll_crew_tie(mob/living/carbon/human/npc/outpost_prisoner/prisoner, forced) var/list/others = list() - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other != prisoner && other.phase == PRISONER_PRESENT && other.stat != DEAD && other.trouble != PRISONER_TROUBLE_LOOSE) others += other if(!length(others)) @@ -294,7 +294,7 @@ tie = "rival" if(tie != "friend" && tie != "rival") return null - var/mob/living/basic/outpost_prisoner/other = pick(others) + var/mob/living/carbon/human/npc/outpost_prisoner/other = pick(others) set_affinity(prisoner, other, tie == "friend" ? PRISON_CREW_TIE : -PRISON_CREW_TIE) var/delay = (prisoner.phase == PRISONER_ARRIVING ? OUTPOST_PRISON_BEAM_TIME / (1 SECONDS) : 0) + PRISON_CREW_LINE_DELAY queue_life_line(prisoner, tie == "friend" ? "arrival_crew_friend" : "arrival_crew_rival", other, delay) @@ -305,7 +305,7 @@ return prob(PRISON_BIRTHDAY_CHANCE) /// It is their birthday: they say so once they are in, and the yard can throw them a party (outpost_prison_pastimes.dm) -/datum/outpost_prison/proc/give_birthday(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/give_birthday(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.has_birthday = TRUE prisoner.party_done = FALSE var/delay = (prisoner.phase == PRISONER_ARRIVING ? OUTPOST_PRISON_BEAM_TIME / (1 SECONDS) : 0) + PRISON_BIRTHDAY_LINE_DELAY @@ -314,12 +314,12 @@ // ===== LINES DUE ===== /// Queues a line for `prisoner` to say to `other` in `seconds` -/datum/outpost_prison/proc/queue_life_line(mob/living/basic/outpost_prisoner/prisoner, context, mob/living/basic/outpost_prisoner/other, seconds) +/datum/outpost_prison/proc/queue_life_line(mob/living/carbon/human/npc/outpost_prisoner/prisoner, context, mob/living/carbon/human/npc/outpost_prisoner/other, seconds) LAZYADD(prisoner.life_lines, list(list(seconds, context, other ? WEAKREF(other) : null))) /// Counts down queued lines and says the ones that are due, once the speaker has beamed in /datum/outpost_prison/proc/life_lines_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!length(prisoner.life_lines)) continue for(var/list/entry as anything in prisoner.life_lines.Copy()) @@ -330,7 +330,7 @@ if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue var/datum/weakref/other_ref = entry[3] - var/mob/living/basic/outpost_prisoner/other = other_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = other_ref?.resolve() if(other && other.stat == CONSCIOUS && (other in view(7, prisoner))) prisoner.face_atom(other) else @@ -339,10 +339,10 @@ /// Says a line for a prisoner who may have gone by now; for timers /datum/outpost_prison/proc/say_life_line(datum/weakref/speaker_ref, context, datum/weakref/other_ref) - var/mob/living/basic/outpost_prisoner/speaker = speaker_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = speaker_ref?.resolve() if(!speaker || speaker.stat != CONSCIOUS) return FALSE - var/mob/living/basic/outpost_prisoner/other = other_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = other_ref?.resolve() if(other) speaker.face_atom(other) return speaker.say_context(context, other) @@ -350,7 +350,7 @@ // ===== LEAVING ===== /// A prisoner is leaving the roster: their pairs go, and a party for them is off -/datum/outpost_prison/proc/on_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/prisoner_ref = REF(prisoner) for(var/key in affinities.Copy()) if(findtext(key, prisoner_ref)) @@ -368,7 +368,7 @@ * sight say goodbye, a second or so apart, and feel it; the leaver answers the first before the beam * takes them. Returns TRUE if any of that replaced the usual release line. */ -/datum/outpost_prison/proc/on_prisoner_releasing(mob/living/basic/outpost_prisoner/prisoner, average) +/datum/outpost_prison/proc/on_prisoner_releasing(mob/living/carbon/human/npc/outpost_prisoner/prisoner, average) var/spoke = FALSE if(average >= PRISON_RELEASE_THANKS_AVERAGE) var/mob/living/member = nearest_member_in_view(prisoner) @@ -381,7 +381,7 @@ else if(average <= PRISON_RELEASE_BITTER_AVERAGE) spoke = prisoner.say_context("release_bitter") var/list/friends = list() - for(var/mob/living/basic/outpost_prisoner/other in view(PRISON_FAREWELL_RANGE, prisoner)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in view(PRISON_FAREWELL_RANGE, prisoner)) if(other == prisoner || !(other in prisoners) || other.stat != CONSCIOUS || other.phase != PRISONER_PRESENT) continue if(are_friends(prisoner, other)) @@ -391,7 +391,7 @@ // The first goodbye and the leaver's answer come before the beam starts, since nobody talks while // beaming out; later friends call after them anyway, a second or two apart. var/delay = 0 - for(var/mob/living/basic/outpost_prisoner/friend as anything in friends) + for(var/mob/living/carbon/human/npc/outpost_prisoner/friend as anything in friends) friend.adjust_mood(-PRISON_FAREWELL_MOOD) friend.face_atom(prisoner) if(friend == friends[1]) @@ -403,7 +403,7 @@ return TRUE /// The nearest member of the wing awake in sight of `prisoner`, or null -/datum/outpost_prison/proc/nearest_member_in_view(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/nearest_member_in_view(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(7, prisoner)) @@ -433,7 +433,7 @@ /// A birthday prisoner free to talk brings it up now and then, until they get their party /datum/outpost_prison/proc/birthday_chatter() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.has_birthday || prisoner.party_done || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) continue if(!prisoner.ai_running() || prisoner.in_trouble() || prisoner.activity?.sleeping) @@ -459,8 +459,8 @@ var/list/refs = splittext(key, "|") if(length(refs) != 2) continue - var/mob/living/basic/outpost_prisoner/one = locate(refs[1]) in prisoners - var/mob/living/basic/outpost_prisoner/two = locate(refs[2]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/one = locate(refs[1]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/two = locate(refs[2]) in prisoners if(!one || !two) continue pairs += list(list( @@ -471,11 +471,11 @@ "affinity" = round(affinities[key]), )) var/list/birthdays = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.has_birthday && !prisoner.party_done) birthdays += REF(prisoner) var/scene - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(party_stage && host) scene = party_stage == "waiting" ? "cake waiting for [host.real_name]" : "birthday party for [host.real_name]" return list( @@ -488,8 +488,8 @@ /datum/outpost_prison/proc/life_admin_act(action, list/params, mob/user) switch(action) if("prison_affinity") - var/mob/living/basic/outpost_prisoner/one = locate(params?["a_ref"]) in prisoners - var/mob/living/basic/outpost_prisoner/two = locate(params?["b_ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/one = locate(params?["a_ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/two = locate(params?["b_ref"]) in prisoners if(QDELETED(one) || QDELETED(two) || one == two) return list("error" = "Pick two different prisoners.") var/value = params["value"] @@ -500,7 +500,7 @@ set_affinity(one, two, value) return "set how [one.real_name] and [two.real_name] get on to [round(affinity(one, two))]" if("prison_birthday", "prison_party") - var/mob/living/basic/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners if(QDELETED(prisoner) || prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT) return list("error" = "Only a living prisoner in the wing.") if(action == "prison_birthday") diff --git a/voidcrew/modules/player_outposts/outpost_prison_mail.dm b/voidcrew/modules/player_outposts/outpost_prison_mail.dm index c71f90b36c9..3d666bed22b 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_mail.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_mail.dm @@ -41,7 +41,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( // ===== STATE ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Whether a letter has come for them this stay; one each var/mail_had_letter = FALSE @@ -205,7 +205,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return waiting /// The letter waiting for `prisoner`, if any -/datum/outpost_prison/proc/mail_waiting_for(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_waiting_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) if(letter.letter_prisoner_ref?.resolve() == prisoner) return letter @@ -220,7 +220,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/outpost_prison/proc/mail_wave_recipients() var/present = 0 var/list/eligible = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue present++ @@ -251,7 +251,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return 0 var/obj/structure/closet/supplypod/outpost_prison_mail/pod = new() var/obj/structure/closet/crate/mail/crate = new(pod) - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in recipients) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in recipients) mail_make_letter(prisoner, null, null, crate) crate.update_appearance() RegisterSignal(crate, COMSIG_MOVABLE_MOVED, PROC_REF(mail_crate_moved)) @@ -472,7 +472,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * waves (the admin panel's prison_mail): `kind` (by weight when not given), and for a contraband * letter `enclosure_type` (a razor blade or yeast by chance when not given). Returns the letter, or null. */ -/datum/outpost_prison/proc/mail_send(mob/living/basic/outpost_prisoner/prisoner, kind, enclosure_type) +/datum/outpost_prison/proc/mail_send(mob/living/carbon/human/npc/outpost_prisoner/prisoner, kind, enclosure_type) if(QDELETED(prisoner) || !(prisoner in prisoners)) return null var/turf/spot = mail_letter_drop_turf() @@ -492,7 +492,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * not given), and for a contraband letter `enclosure_type` (a razor blade or yeast by chance when not * given). That is their one letter this stay. The caller tracks it (mail_letters). Returns the letter. */ -/datum/outpost_prison/proc/mail_make_letter(mob/living/basic/outpost_prisoner/prisoner, kind, enclosure_type, atom/destination) +/datum/outpost_prison/proc/mail_make_letter(mob/living/carbon/human/npc/outpost_prisoner/prisoner, kind, enclosure_type, atom/destination) if(!(kind in GLOB.outpost_prison_mail_kinds)) kind = pick_weight(GLOB.outpost_prison_mail_kinds) var/obj/item/mail/envelope/outpost_prison/envelope = new(destination) @@ -514,7 +514,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return letter /// Writes a letter of `kind` to `prisoner` from the extras' templates -/datum/outpost_prison/proc/mail_write(obj/item/paper/outpost_prison_letter/letter, mob/living/basic/outpost_prisoner/prisoner, kind) +/datum/outpost_prison/proc/mail_write(obj/item/paper/outpost_prison_letter/letter, mob/living/carbon/human/npc/outpost_prisoner/prisoner, kind) var/list/by_kind = outpost_prisoner_extra_dialogue(MAIL_STRINGS_FILE, "letters") var/list/templates = by_kind[kind] var/list/senders = outpost_prisoner_extra_dialogue(MAIL_STRINGS_FILE, "senders") @@ -538,7 +538,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/outpost_prison/proc/mail_check() var/z = wing_z() for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(QDELETED(addressee) || !(addressee in prisoners)) mail_discard(letter) continue @@ -555,7 +555,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( if(QDELETED(letter)) return mail_letters -= WEAKREF(letter) - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(lost && !QDELETED(addressee) && (addressee in prisoners) && addressee.stat != DEAD) addressee.adjust_mood(-OUTPOST_MAIL_EXPIRED_MOOD) add_log("[addressee.real_name]'s mail was sent back.") @@ -564,7 +564,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /// Someone opened a letter before its prisoner got it /datum/outpost_prison/proc/mail_opened(obj/item/paper/outpost_prison_letter/letter, mob/user) add_log("[user ? user.name : "Someone"] opened [letter.letter_addressee]'s mail.") - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(user && addressee) note_staff_event(user, addressee, "mail_opened") @@ -576,7 +576,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * the level they read it where they stand if the hatch is in their reach, as fend_for_self() does * with food. Returns TRUE if they went for it or read it. */ -/datum/outpost_prison/proc/mail_collect_from_hatch(mob/living/basic/outpost_prisoner/addressee, obj/item/thing) +/datum/outpost_prison/proc/mail_collect_from_hatch(mob/living/carbon/human/npc/outpost_prisoner/addressee, obj/item/thing) if(addressee.stat != CONSCIOUS || addressee.phase != PRISONER_PRESENT || !isturf(thing.loc)) return FALSE if(addressee.ai_running()) @@ -591,7 +591,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return FALSE addressee.start_activity(fetch) return TRUE - if(addressee.in_trouble() || addressee.cuffs) + if(addressee.in_trouble() || addressee.handcuffed) return FALSE if(!addressee.reachable) refresh_prisoner_reach(addressee) @@ -608,7 +608,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( if(!letter) only_mail = FALSE continue - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(!QDELETED(addressee) && (addressee in prisoners)) mail_collect_from_hatch(addressee, thing) return only_mail @@ -618,21 +618,21 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * anyone else says it isn't theirs, and a prisoner who won't listen refuses it. Runs inside a * signal handler: never sleeps. Returns the item interaction result. */ -/datum/outpost_prison/proc/mail_hand_over(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, obj/item/thing, obj/item/paper/outpost_prison_letter/letter) +/datum/outpost_prison/proc/mail_hand_over(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, obj/item/thing, obj/item/paper/outpost_prison_letter/letter) if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) return NONE if(letter.letter_prisoner_ref?.resolve() != prisoner) prisoner.balloon_alert(user, "not theirs") - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "mail_not_mine") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "mail_not_mine") return ITEM_INTERACT_BLOCKING // Cuffed hands take nothing, as with food and uniforms (outpost_prison_capture.dm) - if(prisoner.cuffs) + if(prisoner.handcuffed) prisoner.balloon_alert(user, "cuffed") return ITEM_INTERACT_BLOCKING // Fighting, squaring up, climbing, lying beaten, or already busy with staff: they won't take it if(prisoner.in_trouble() || !prisoner.will_listen()) prisoner.balloon_alert(user, "not listening") - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "talk_refuse") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "talk_refuse") return ITEM_INTERACT_BLOCKING if(!user.temporarilyRemoveItemFromInventory(thing)) return ITEM_INTERACT_BLOCKING @@ -649,7 +649,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * unless `instant`, then the letter takes effect. Bad news sends them to sit in their cell a while. * Returns the kind read, or null. Sleeps unless `instant`. */ -/datum/outpost_prison/proc/mail_read(mob/living/basic/outpost_prisoner/prisoner, obj/item/thing, instant = FALSE) +/datum/outpost_prison/proc/mail_read(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/thing, instant = FALSE) if(QDELETED(prisoner) || QDELETED(thing)) return null mail_reading_emote(prisoner, thing) @@ -667,7 +667,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( mail_start_mull(prisoner) return kind -/datum/outpost_prison/proc/mail_reading_emote(mob/living/basic/outpost_prisoner/prisoner, obj/item/thing) +/datum/outpost_prison/proc/mail_reading_emote(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/thing) if(istype(thing, /obj/item/mail/envelope/outpost_prison)) prisoner.manual_emote("tears open the envelope and reads the letter.") playsound(prisoner, 'sound/items/poster/poster_ripped.ogg', 30, TRUE) @@ -681,7 +681,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * sealed in with it is kept hidden (contraband, 4.14). The letter and its envelope are then gone * (they keep it). Never sleeps. Returns the kind, or null. */ -/datum/outpost_prison/proc/mail_finish_reading(mob/living/basic/outpost_prisoner/prisoner, obj/item/thing) +/datum/outpost_prison/proc/mail_finish_reading(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/thing) var/obj/item/paper/outpost_prison_letter/letter = outpost_prison_letter_in(thing) if(!letter || QDELETED(prisoner)) return null @@ -705,7 +705,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return kind /// Bad news: they go and sit in their cell's chair (or on its bed) for a while, if they can -/datum/outpost_prison/proc/mail_start_mull(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_start_mull(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || !prisoner.ai_running() || !prisoner.routine_allowed()) return FALSE var/datum/prisoner_activity/mull_letter/mull = new(prisoner) @@ -718,7 +718,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( // ===== THE PRISON: SPEECH, CONSOLES, CLEAN-UP ===== /// "Any mail for me?", from a prisoner with a letter waiting and a member in sight, as list(context, other, values), or null -/datum/outpost_prison/proc/mail_extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!mail_waiting_for(prisoner) || !contraband_roll(OUTPOST_MAIL_ASK_CHANCE)) return null for(var/mob/living/person in view(OUTPOST_MAIL_ASK_RANGE, prisoner)) @@ -729,7 +729,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return null /// A prisoner is leaving: their undelivered letters go -/datum/outpost_prison/proc/mail_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) if(letter.letter_prisoner_ref?.resolve() == prisoner) mail_discard(letter) @@ -743,7 +743,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/outpost_prison/proc/mail_admin_payload() var/list/rows = list() for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() rows += list(list( "ref" = REF(letter), "to_ref" = addressee ? REF(addressee) : null, @@ -769,7 +769,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return "call a prison mail pod with [sent] letter\s" if(action != "prison_mail") return null - var/mob/living/basic/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners if(QDELETED(prisoner)) return list("error" = "That prisoner is gone.") var/kind = params["kind"] @@ -791,7 +791,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/element/outpost_prison_mail_handover/Attach(datum/target) . = ..() - if(!istype(target, /mob/living/basic/outpost_prisoner)) + if(!istype(target, /mob/living/carbon/human/npc/outpost_prisoner)) return ELEMENT_INCOMPATIBLE RegisterSignal(target, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_item_interaction)) @@ -799,7 +799,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( UnregisterSignal(source, COMSIG_ATOM_ITEM_INTERACTION) return ..() -/datum/element/outpost_prison_mail_handover/proc/on_item_interaction(mob/living/basic/outpost_prisoner/source, mob/living/user, obj/item/tool, list/modifiers) +/datum/element/outpost_prison_mail_handover/proc/on_item_interaction(mob/living/carbon/human/npc/outpost_prisoner/source, mob/living/user, obj/item/tool, list/modifiers) SIGNAL_HANDLER if(!istype(user) || user.combat_mode || is_outpost_prisoner(user) || !source.prison) return NONE @@ -823,7 +823,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( var/read_for = 0 var/datum/weakref/seat_ref -/datum/prisoner_activity/fetch_mail/New(mob/living/basic/outpost_prisoner/doer, obj/item/thing) +/datum/prisoner_activity/fetch_mail/New(mob/living/carbon/human/npc/outpost_prisoner/doer, obj/item/thing) . = ..(doer) mail_ref = WEAKREF(thing) diff --git a/voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm b/voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm new file mode 100644 index 00000000000..c5e57d0bf43 --- /dev/null +++ b/voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm @@ -0,0 +1,120 @@ +/** + * # Prison NPC bodies + * + * Prisoners, guards and Kessler staff are real human bodies (voidcrew/modules/human_npcs/): they go + * down, bleed, stamcrit, get cuffed, die and come back through tg's own code. This file holds what the + * prison's own AI needs to use those bodies the way a person does: + * - outpost_prison_npc_hit(): a blow is a click with combat mode on, through the body's hands (a fist, + * a held shiv, a stun baton), so tg's damage, armour, wounds, knockdowns and logs apply. + * - Saved faces: a guard on the payroll is the same person each time the record sends them back + * (outpost_prison_save_look(), outpost_prison_apply_look()). + * - Bruising to a health: outpost_prison_bruise() and outpost_prison_set_health(), for a transfer's + * roughing up and the admin tools. + */ + +/// Brute in which a limb is bruised by outpost_prison_bruise(): a multiple of it on a limb (0.75 or 0.5 of +/// the body's health) comes to whole tenths of health +#define OUTPOST_PRISON_BRUISE_STEP 0.4 + +/** + * `hitter` strikes `target` beside it with `weapon` in hand (switched to the active hand), or a fist + * if `weapon` is null, the way a player clicks with combat mode on. The aim spreads over the body as + * other human NPCs' does. Returns TRUE if the blow was thrown: not if they can't swing yet, nor a second + * time in the same tick, which ClickOn() would ignore as it does a player's second click. + */ +/proc/outpost_prison_npc_hit(mob/living/carbon/human/hitter, atom/target, obj/item/weapon) + if(QDELETED(hitter) || QDELETED(target) || hitter.stat != CONSCIOUS || world.time < hitter.next_move || world.time <= hitter.next_click) + return FALSE + if(!hitter.Adjacent(target)) + return FALSE + if(weapon) + if(!human_npc_ready_item(hitter, weapon)) + return FALSE + else if(!human_npc_ready_empty_hand(hitter)) + return FALSE + hitter.face_atom(target) + hitter.zone_selected = human_npc_pick_aim_zone() + var/old_combat_mode = hitter.combat_mode + hitter.set_combat_mode(TRUE, silent = TRUE) + hitter.ClickOn(target) + hitter.set_combat_mode(old_combat_mode, silent = TRUE) + return TRUE + +/// What makes a face: the bits of a random person a guard record keeps, so the same guard comes back +/proc/outpost_prison_save_look(mob/living/carbon/human/person) + if(QDELETED(person)) + return null + return list( + "gender" = person.gender, + "skin_tone" = person.skin_tone, + "hairstyle" = person.hairstyle, + "hair_color" = person.hair_color, + "facial_hairstyle" = person.facial_hairstyle, + "facial_hair_color" = person.facial_hair_color, + "eye_color" = person.eye_color_left, + "real_name" = person.real_name, + ) + +/// Puts a face saved by outpost_prison_save_look() on `person` +/proc/outpost_prison_apply_look(mob/living/carbon/human/person, list/look) + if(QDELETED(person) || !length(look)) + return + person.gender = look["gender"] == FEMALE ? FEMALE : MALE + person.physique = person.gender + if(look["skin_tone"]) + person.skin_tone = look["skin_tone"] + person.set_hairstyle(look["hairstyle"] || "Bald", update = FALSE) + if(look["hair_color"]) + person.set_haircolor(look["hair_color"], update = FALSE) + person.set_facial_hairstyle(look["facial_hairstyle"] || "Shaved", update = FALSE) + if(look["facial_hair_color"]) + person.set_facial_haircolor(look["facial_hair_color"], update = FALSE) + if(look["eye_color"]) + person.set_eye_color(look["eye_color"]) + person.update_body(is_creating = TRUE) + +/** + * Bruises `body` until it has lost `health_loss` health: brute spread over its limbs, no wounds and no + * attacker, as a rough transfer leaves someone, with the chest taking what is left. Arms and legs count + * for less of a body's health than its head and chest (body_damage_coeff), so each limb's share is + * measured in health and bruised in steps of OUTPOST_PRISON_BRUISE_STEP brute, which come to whole + * tenths of health; the chest, which counts in full, then makes the loss exact. Returns the health it + * ended at. + */ +/proc/outpost_prison_bruise(mob/living/carbon/body, health_loss) + if(QDELETED(body) || health_loss <= 0) + return body?.health + var/target_health = body.health - health_loss + var/obj/item/bodypart/chest = body.get_bodypart(BODY_ZONE_CHEST) + var/list/parts = body.get_damageable_bodyparts() + if(length(parts)) + var/share = health_loss / length(parts) + for(var/obj/item/bodypart/part as anything in parts) + if(part == chest || part.body_damage_coeff <= 0) + continue + var/room = FLOOR(part.max_damage - part.get_damage(), OUTPOST_PRISON_BRUISE_STEP) + var/brute = min(round(share / part.body_damage_coeff, OUTPOST_PRISON_BRUISE_STEP), room) + if(brute > 0) + part.receive_damage(brute = brute, forced = TRUE, wound_bonus = CANT_WOUND, updating_health = FALSE) + body.updatehealth() + for(var/i in 1 to 5) + var/short = round(body.health - target_health, DAMAGE_PRECISION) + if(short <= 0 || !chest) + break + chest.receive_damage(brute = short, forced = TRUE, wound_bonus = CANT_WOUND) + body.update_damage_overlays() + return body.health + +/** + * Sets `body`'s health to `target_health` with brute alone: heals the brute it has, then bruises it + * down (outpost_prison_bruise()). Burns, toxins and lost air stay as they are, so a body with those + * can't be brought all the way up. For the admin tools. Returns the health it ended at. + */ +/proc/outpost_prison_set_health(mob/living/carbon/body, target_health) + if(QDELETED(body)) + return null + if(body.getBruteLoss() > 0) + body.heal_overall_damage(brute = body.getBruteLoss(), forced = TRUE) + return outpost_prison_bruise(body, body.health - target_health) + +#undef OUTPOST_PRISON_BRUISE_STEP diff --git a/voidcrew/modules/player_outposts/outpost_prison_panic.dm b/voidcrew/modules/player_outposts/outpost_prison_panic.dm index ea647506707..38b269d00bd 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_panic.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_panic.dm @@ -41,7 +41,7 @@ // ===== THE PRISONER ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Seconds of fright left: full while a creature frightens them, counting down once none does. 0 when calm. var/creature_panic_left = 0 /// The creature that frightened them last @@ -61,11 +61,11 @@ var/running_scared = FALSE /// Whether a creature has them frightened -/mob/living/basic/outpost_prisoner/proc/is_panicking() +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_panicking() return creature_panic_left > 0 /// The creature that frightened them last, while it lives -/mob/living/basic/outpost_prisoner/proc/panic_threat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/panic_threat() var/mob/living/threat = panic_threat_ref?.resolve() if(QDELETED(threat) || threat.stat == DEAD) return null @@ -76,13 +76,13 @@ * (outpost_prison_changeling.dm): they asked for it, so the lock-in clock waits, and so does the * pay it stops (update_locked_in(), confined_unpaid()). */ -/mob/living/basic/outpost_prisoner/proc/sheltering_from_creature() +/mob/living/carbon/human/npc/outpost_prisoner/proc/sheltering_from_creature() if(!is_panicking() && !istype(activity, /datum/prisoner_activity/flee_creature)) return FALSE return !!cell?.contains(src) && is_confined() /// The creature they are going for, while they still are: a menace still, close, and not given up on -/mob/living/basic/outpost_prisoner/proc/creature_foe() +/mob/living/carbon/human/npc/outpost_prisoner/proc/creature_foe() if(!creature_foe_ref) return null var/mob/living/foe = creature_foe_ref.resolve() @@ -92,19 +92,19 @@ return foe /// They run while running from a creature, and walk otherwise -/mob/living/basic/outpost_prisoner/proc/update_flee_speed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/update_flee_speed() var/wanted = stat != DEAD && (istype(activity, /datum/prisoner_activity/creature_panic) || (!!creature_retreat_spot && world.time < creature_retreat_until)) if(wanted == running_scared) return running_scared = wanted // A loose meek bounty prisoner keeps their pace (outpost_prison_bounty.dm). - set_varspeed(wanted ? OUTPOST_PANIC_FLEE_SPEED : bounty_base_speed()) + set_prison_pace(hurry = wanted || bounty_base_speed()) /** * A rioter running from a creature, planned from the trouble subtree: TRUE while they are on the * way. At the spot, out of time or no longer rioting, they stop and riot on. */ -/mob/living/basic/outpost_prisoner/proc/plan_creature_retreat(datum/ai_controller/controller) +/mob/living/carbon/human/npc/outpost_prisoner/proc/plan_creature_retreat(datum/ai_controller/controller) if(!creature_retreat_spot) return FALSE if(loc == creature_retreat_spot || world.time >= creature_retreat_until || !is_rioting()) @@ -115,7 +115,7 @@ controller.queue_behavior(/datum/ai_behavior/outpost_prisoner_creature_retreat) return TRUE -/mob/living/basic/outpost_prisoner/proc/finish_creature_retreat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_creature_retreat() creature_retreat_spot = null creature_retreat_until = 0 update_flee_speed() @@ -127,7 +127,7 @@ action_cooldown = 0.5 SECONDS /datum/ai_behavior/outpost_prisoner_creature_retreat/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/turf/spot = prisoner?.creature_retreat_spot if(!spot) return FALSE @@ -136,7 +136,7 @@ return TRUE /datum/ai_behavior/outpost_prisoner_creature_retreat/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn if(!prisoner?.creature_retreat_spot || prisoner.loc != prisoner.creature_retreat_spot) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED prisoner.finish_creature_retreat() @@ -146,7 +146,7 @@ . = ..() if(!succeeded) // No way through: they riot on from where they are. - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn prisoner?.finish_creature_retreat() /** @@ -249,7 +249,7 @@ * everyone; otherwise it has to be within OUTPOST_PANIC_SIGHT_RANGE and in their sight, or within * OUTPOST_PANIC_DOWNED_RANGE for one that only frightens up close. */ -/datum/outpost_prison/proc/frightens(mob/living/basic/outpost_prisoner/prisoner, mob/living/creature, menace, loose) +/datum/outpost_prison/proc/frightens(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/creature, menace, loose) var/turf/here = get_turf(prisoner) var/turf/there = get_turf(creature) if(!here || !there || here.z != there.z) @@ -261,7 +261,7 @@ return get_dist(here, there) <= OUTPOST_PANIC_SIGHT_RANGE && can_see(here, there, OUTPOST_PANIC_SIGHT_RANGE) /// The nearest of `threats` (creature = menace) that frightens `prisoner`, or null. `loose` marks those loose in the cell block. -/datum/outpost_prison/proc/frightening_creature(mob/living/basic/outpost_prisoner/prisoner, list/threats, list/loose) +/datum/outpost_prison/proc/frightening_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, list/threats, list/loose) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/creature as anything in threats) @@ -276,7 +276,7 @@ // ===== THE PRISON'S TICK ===== /// Whether a creature can frighten `prisoner` now: present and awake in the wing's custody, not loose, and not the experiment's subject -/datum/outpost_prison/proc/can_panic(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/can_panic(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.prison == src && prisoner.phase == PRISONER_PRESENT && prisoner.stat == CONSCIOUS && !prisoner.experiment_subject && prisoner.trouble != PRISONER_TROUBLE_LOOSE /** @@ -289,7 +289,7 @@ for(var/mob/living/creature as anything in threats) loose[creature] = threats[creature] == CREATURE_MENACE_FULL && in_cell_block(creature) && !creature_shut_in(creature) var/shouts = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner)) continue if(!can_panic(prisoner)) @@ -317,7 +317,7 @@ * drops what they were doing (a wreck, squaring up to staff) and runs (start_panic()), or, already * running, rethinks the way (the panic's reconsider()). Returns TRUE if they shouted. */ -/datum/outpost_prison/proc/react_to_creature(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat, seconds, may_shout) +/datum/outpost_prison/proc/react_to_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat, seconds, may_shout) if(prisoner.can_be_dragged() || prisoner.pulledby) // Down, they neither run nor fight. prisoner.creature_foe_ref = null @@ -343,7 +343,7 @@ * `prisoner` breaks and runs (/datum/prisoner_activity/creature_panic), if their routine is free to. * `shout` has them say so. Returns TRUE if they shouted. */ -/datum/outpost_prison/proc/start_panic(mob/living/basic/outpost_prisoner/prisoner, shout = FALSE) +/datum/outpost_prison/proc/start_panic(mob/living/carbon/human/npc/outpost_prisoner/prisoner, shout = FALSE) if(prisoner.prison != src || prisoner.experiment_subject || !prisoner.routine_allowed()) return FALSE if(istype(prisoner.activity, /datum/prisoner_activity/creature_panic)) @@ -357,17 +357,17 @@ if(!shout || !prisoner.ai_running()) return FALSE prisoner.next_panic_line = world.time + OUTPOST_EXPERIMENT_PANIC_GAP * rand(4, 6) / 10 SECONDS - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_flee") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_flee") return TRUE /// Still frightened with nothing in sight: they stay where they ran to, and go back to it if something else took them away -/datum/outpost_prison/proc/keep_hiding(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/keep_hiding(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(istype(prisoner.activity, /datum/prisoner_activity/creature_panic) || istype(prisoner.activity, /datum/prisoner_activity/flee_creature)) return start_panic(prisoner) /// Cuffed, down or held: nowhere to go, so they cower and shout now and then. Returns TRUE if they shouted. -/datum/outpost_prison/proc/cower_in_fright(mob/living/basic/outpost_prisoner/prisoner, may_shout) +/datum/outpost_prison/proc/cower_in_fright(mob/living/carbon/human/npc/outpost_prisoner/prisoner, may_shout) if(!may_shout && world.time < prisoner.next_panic_line) return FALSE prisoner.next_panic_line = world.time + OUTPOST_EXPERIMENT_PANIC_GAP * rand(8, 12) / 10 SECONDS @@ -377,11 +377,11 @@ if(!may_shout && !wing_can_speak()) return FALSE note_speech() - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_panic") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_panic") return TRUE /// Nothing has frightened them for OUTPOST_PANIC_CALM_TIME, or they can no longer be frightened: back to their day -/datum/outpost_prison/proc/calm_from_panic(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/calm_from_panic(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.creature_panic_left = 0 prisoner.panic_threat_ref = null prisoner.creature_reaction = null @@ -399,7 +399,7 @@ * mood and personality (outpost_prisoner_creature_fight_chance()). The roll holds for * OUTPOST_PANIC_ROLL_HOLD. Returns what they did. */ -/datum/outpost_prison/proc/creature_roll(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/creature_roll(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) if(prisoner.creature_reaction && world.time < prisoner.creature_reaction_until) return prisoner.creature_reaction var/reaction = forced_creature_reaction @@ -414,7 +414,7 @@ return reaction /// Goes at the creature, with their shiv if they have one. A fight they were in is forgotten. -/datum/outpost_prison/proc/stand_against_creature(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/stand_against_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) if(prisoner.fight) end_fight(prisoner.fight) prisoner.cancel_threat() @@ -431,7 +431,7 @@ * (flee_spot()) and riot on from there, or back off where they stand with nowhere to go. Returns * TRUE if a rioter ran. */ -/datum/outpost_prison/proc/run_from_creature(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/run_from_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) prisoner.creature_foe_ref = null if(!prisoner.is_rioting()) if(prisoner.fight) @@ -449,7 +449,7 @@ prisoner.creature_retreat_until = world.time + OUTPOST_PANIC_RETREAT_TIME prisoner.update_flee_speed() if(prisoner.ai_running() && prob(50)) - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_flee") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_flee") return TRUE // ===== WHERE TO RUN ===== @@ -478,7 +478,7 @@ return steps /// Where they hide at home: their own cell's chair or bed (home_seat()), or failing that any free tile of their own cell they can walk to, or null -/datum/outpost_prison/proc/panic_home_spot(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/panic_home_spot(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison_cell/home = prisoner.cell if(!home || !prisoner.walkable) return null @@ -496,7 +496,7 @@ * passes within OUTPOST_PANIC_PATH_MARGIN of it and no farther from it than they stand now. Once they * are inside the cell, only it coming in makes home unsafe. */ -/datum/outpost_prison/proc/home_is_safe(mob/living/basic/outpost_prisoner/prisoner, turf/home, mob/living/threat) +/datum/outpost_prison/proc/home_is_safe(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/home, mob/living/threat) var/datum/outpost_prison_cell/own = prisoner.cell if(!own || !home) return FALSE @@ -533,7 +533,7 @@ * passing beside it that is farthest from it, out of its sight if any is, and not within * OUTPOST_PANIC_SPOT_MARGIN of it. Null when there is nowhere: they are cornered. */ -/datum/outpost_prison/proc/flee_spot(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/flee_spot(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) var/turf/here = get_turf(prisoner) var/turf/danger = get_turf(threat) if(!here || !danger || !length(prisoner.walkable)) @@ -718,7 +718,7 @@ /// What they shout: to be bolted in, on the way home or in it; to run, otherwise; about the dark, now and then, with a nightmare about /datum/prisoner_activity/creature_panic/proc/panic_line() var/mob/living/threat = prisoner.panic_threat() - var/nightmare = istype(threat, /mob/living/basic/outpost_experiment/nightmare) || (!threat && prisoner.prison?.experiment?.form == "nightmare") + var/nightmare = outpost_experiment_form_name(threat) == "nightmare" || (!threat && prisoner.prison?.experiment?.form == "nightmare") if(nightmare && prob(50)) return "nightmare_fear" return plan == "home" ? "creature_panic" : "creature_flee" diff --git a/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm b/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm index 52996783238..584decf82c2 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm @@ -70,7 +70,7 @@ /// Seconds since the last look for scrubbed marks var/mark_check_clock = 0 -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Between the lifts a card game gives them COOLDOWN_DECLARE(cards_mood_cooldown) /// Between the lifts a game of basketball with staff gives them @@ -83,7 +83,7 @@ /// Hooks up the prisoner's side of the pastimes; called from setup_extras(). Nothing to hook: the /// pastimes run from activities and the prison's clock. -/mob/living/basic/outpost_prisoner/proc/setup_pastimes() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_pastimes() return /// Advances the party, the game with staff and the marks on the walls by `seconds` @@ -140,7 +140,7 @@ return found /// Whether `prisoner` could sit on `stool` now: they can walk there, and nobody else sits on it, stands on it or has it -/datum/outpost_prison/proc/stool_free_for(obj/structure/chair/stool, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/stool_free_for(obj/structure/chair/stool, mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/turf/seat = get_turf(stool) if(QDELETED(stool) || !seat || !prisoner.walkable?[seat] || claimed_by_other(stool, prisoner)) return FALSE @@ -202,7 +202,7 @@ /// Whether members of the wing sitting at the table are dealt in var/deals_in_members = FALSE -/datum/prisoner_activity/table_game/New(mob/living/basic/outpost_prisoner/doer, datum/prisoner_activity/table_game/host_game) +/datum/prisoner_activity/table_game/New(mob/living/carbon/human/npc/outpost_prisoner/doer, datum/prisoner_activity/table_game/host_game) . = ..(doer) if(host_game) game_ref = WEAKREF(host_game) @@ -284,7 +284,7 @@ return best /// Whether `other` may be called over: free for leisure (the chat's rule), not hungry, and no rival of the host -/datum/prisoner_activity/table_game/proc/can_invite(mob/living/basic/outpost_prisoner/other) +/datum/prisoner_activity/table_game/proc/can_invite(mob/living/carbon/human/npc/outpost_prisoner/other) if(other == prisoner || QDELETED(other) || !other.routine_allowed() || !other.ai_running()) return FALSE var/datum/prisoner_activity/doing = other.activity @@ -303,7 +303,7 @@ free_stools++ if(!free_stools) return FALSE - for(var/mob/living/basic/outpost_prisoner/other in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prison.prisoners) if(can_invite(other)) return TRUE return FALSE @@ -454,7 +454,7 @@ if(own && prisoner.buckled == own) seats += list(list(prisoner, own, prison.table_beside(own))) for(var/datum/weakref/joiner_ref as anything in joiner_refs) - var/mob/living/basic/outpost_prisoner/joiner = joiner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/joiner = joiner_ref.resolve() var/datum/prisoner_activity/table_game/seat = joiner?.activity if(!istype(seat) || seat.game_ref?.resolve() != src || !seat.started) continue @@ -491,7 +491,7 @@ var/datum/outpost_prison/prison = prisoner.prison var/list/live = list() for(var/datum/weakref/joiner_ref as anything in joiner_refs) - var/mob/living/basic/outpost_prisoner/joiner = joiner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/joiner = joiner_ref.resolve() var/datum/prisoner_activity/table_game/seat = joiner?.activity if(istype(seat) && seat.game_ref?.resolve() == src) live += joiner_ref @@ -500,14 +500,14 @@ if(room <= 0) return 0 var/list/candidates = list() - for(var/mob/living/basic/outpost_prisoner/other in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in shuffle(prison.prisoners)) if(can_invite(other)) candidates += other - var/mob/living/basic/outpost_prisoner/first_called + var/mob/living/carbon/human/npc/outpost_prisoner/first_called var/came = 0 while(room > 0 && length(candidates)) - var/mob/living/basic/outpost_prisoner/best - for(var/mob/living/basic/outpost_prisoner/other as anything in candidates) + var/mob/living/carbon/human/npc/outpost_prisoner/best + for(var/mob/living/carbon/human/npc/outpost_prisoner/other as anything in candidates) if(!best || prison.affinity(prisoner, other) > prison.affinity(prisoner, best)) best = other candidates -= best @@ -523,7 +523,7 @@ return came /// Gives `other` a seat at this game and sends them to it. TRUE if they are coming. -/datum/prisoner_activity/table_game/proc/seat_joiner(mob/living/basic/outpost_prisoner/other) +/datum/prisoner_activity/table_game/proc/seat_joiner(mob/living/carbon/human/npc/outpost_prisoner/other) if(!seat_type || QDELETED(other)) return FALSE var/datum/prisoner_activity/table_game/seat = new seat_type(other, src) @@ -537,7 +537,7 @@ /// Sends everyone the host called over back to their own business /datum/prisoner_activity/table_game/proc/release_joiners() for(var/datum/weakref/joiner_ref as anything in joiner_refs) - var/mob/living/basic/outpost_prisoner/joiner = joiner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/joiner = joiner_ref.resolve() var/datum/prisoner_activity/table_game/seat = joiner?.activity if(istype(seat) && seat.game_ref?.resolve() == src) joiner.end_activity() @@ -738,7 +738,7 @@ /datum/prisoner_activity/cards/proc/reward_players(list/in_hand) var/datum/outpost_prison/prison = prisoner.prison for(var/mob/living/player as anything in in_hand) - var/mob/living/basic/outpost_prisoner/inmate = player + var/mob/living/carbon/human/npc/outpost_prisoner/inmate = player if(istype(inmate)) if(COOLDOWN_FINISHED(inmate, cards_mood_cooldown)) COOLDOWN_START(inmate, cards_mood_cooldown, PRISON_CARDS_MOOD_GAP) @@ -755,19 +755,19 @@ if(!prob(PRISON_CARDS_TALK_CHANCE)) return var/list/inmate_losers = list() - for(var/mob/living/basic/outpost_prisoner/loser in in_hand) + for(var/mob/living/carbon/human/npc/outpost_prisoner/loser in in_hand) if(loser != winner) inmate_losers += loser - var/mob/living/basic/outpost_prisoner/inmate_winner = is_outpost_prisoner(winner) ? winner : null + var/mob/living/carbon/human/npc/outpost_prisoner/inmate_winner = is_outpost_prisoner(winner) ? winner : null if(inmate_winner && (!length(inmate_losers) || prob(55))) - var/mob/living/basic/outpost_prisoner/beaten = length(inmate_losers) ? pick(inmate_losers) : null + var/mob/living/carbon/human/npc/outpost_prisoner/beaten = length(inmate_losers) ? pick(inmate_losers) : null if(beaten) inmate_winner.face_atom(beaten) inmate_winner.say_context("cards_win", beaten) return if(!length(inmate_losers)) return - var/mob/living/basic/outpost_prisoner/grumbler = pick(inmate_losers) + var/mob/living/carbon/human/npc/outpost_prisoner/grumbler = pick(inmate_losers) grumbler.face_atom(winner) if(inmate_winner) grumbler.say_context("cards_lose", inmate_winner) @@ -792,8 +792,8 @@ var/list/talkers = seated_prisoners() if(!length(talkers)) return - var/mob/living/basic/outpost_prisoner/speaker = pick(talkers) - var/mob/living/basic/outpost_prisoner/other = length(talkers) > 1 ? pick(talkers - speaker) : null + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(talkers) + var/mob/living/carbon/human/npc/outpost_prisoner/other = length(talkers) > 1 ? pick(talkers - speaker) : null if(speaker.say_context("cards", other)) prison.note_speech() @@ -807,7 +807,7 @@ return FALSE COOLDOWN_START(prison, cards_missing_cooldown, PRISON_CARDS_MISSING_GAP) var/list/talkers = seated_prisoners() - var/mob/living/basic/outpost_prisoner/speaker = length(talkers) ? pick(talkers) : prisoner + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = length(talkers) ? pick(talkers) : prisoner return speaker.say_context("cards_missing") /datum/prisoner_activity/cards/end_game() @@ -815,7 +815,7 @@ /// The admin panel's card game: a prisoner free to play takes a deck to its table and calls others over. A log line, or null. /datum/outpost_prison/proc/admin_start_cards() - for(var/mob/living/basic/outpost_prisoner/host in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/host in shuffle(prisoners)) if(!host.ai_running() || !host.routine_allowed()) continue var/datum/prisoner_activity/doing = host.activity @@ -912,7 +912,7 @@ /// The prisoner rolling against the host, while they still sit at the table /datum/prisoner_activity/dice/proc/partner() - var/mob/living/basic/outpost_prisoner/other = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = partner_ref?.resolve() return (other && front_of(other)) ? other : null /datum/prisoner_activity/dice/play_step() @@ -923,7 +923,7 @@ return ACTIVITY_CONTINUE if(--roll_wait > 0) return ACTIVITY_CONTINUE - var/mob/living/basic/outpost_prisoner/roller = turn == 1 ? prisoner : partner() + var/mob/living/carbon/human/npc/outpost_prisoner/roller = turn == 1 ? prisoner : partner() if(rounds_left <= 0 || !roller || !roll_die(roller)) stage = "tidy" steps = 0 @@ -931,8 +931,8 @@ return ACTIVITY_CONTINUE /// Where `roller` rolls to: the table in front of the other player, else the table two tiles off -/datum/prisoner_activity/dice/proc/roll_target(mob/living/basic/outpost_prisoner/roller) - var/mob/living/basic/outpost_prisoner/other = roller == prisoner ? partner() : prisoner +/datum/prisoner_activity/dice/proc/roll_target(mob/living/carbon/human/npc/outpost_prisoner/roller) + var/mob/living/carbon/human/npc/outpost_prisoner/other = roller == prisoner ? partner() : prisoner var/turf/front = front_of(other) if(front && front != get_turf(roller)) return front @@ -942,7 +942,7 @@ return front_of(roller) /// `roller` picks up the die and rolls it across the table. TRUE if it is rolling. -/datum/prisoner_activity/dice/proc/roll_die(mob/living/basic/outpost_prisoner/roller) +/datum/prisoner_activity/dice/proc/roll_die(mob/living/carbon/human/npc/outpost_prisoner/roller) var/obj/item/dice/die = die_ref?.resolve() if(QDELETED(die)) return FALSE @@ -980,11 +980,11 @@ /// Both have rolled: the higher roll wins and usually says so. Returns the winner, or null for a draw. /datum/prisoner_activity/dice/proc/settle_round() last_winner_ref = null - var/mob/living/basic/outpost_prisoner/other = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = partner_ref?.resolve() if(!other || rolls[1] == rolls[2]) return null - var/mob/living/basic/outpost_prisoner/winner = rolls[1] > rolls[2] ? prisoner : other - var/mob/living/basic/outpost_prisoner/loser = winner == prisoner ? other : prisoner + var/mob/living/carbon/human/npc/outpost_prisoner/winner = rolls[1] > rolls[2] ? prisoner : other + var/mob/living/carbon/human/npc/outpost_prisoner/loser = winner == prisoner ? other : prisoner last_winner_ref = WEAKREF(winner) if(prob(70) && winner.stat == CONSCIOUS) winner.face_atom(loser) @@ -1034,7 +1034,7 @@ /// The prisoner whose birthday it is and who has not had their party, if any is in the wing /datum/outpost_prison/proc/birthday_host() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.has_birthday && !prisoner.party_done && prisoner.stat != DEAD && prisoner.phase == PRISONER_PRESENT) return prisoner return null @@ -1043,7 +1043,7 @@ * Keeps `cake` for `host`'s party: nobody eats it as a meal while it waits (reserved_supply()). * `giver` is whoever put it on the hatch or handed it over. Never sleeps. Returns TRUE if it is kept. */ -/datum/outpost_prison/proc/start_party(mob/living/basic/outpost_prisoner/host, obj/item/food/cake/cake, mob/living/giver) +/datum/outpost_prison/proc/start_party(mob/living/carbon/human/npc/outpost_prisoner/host, obj/item/food/cake/cake, mob/living/giver) if(party_stage || QDELETED(host) || !istype(cake) || QDELETED(cake)) return FALSE if(!host.has_birthday || host.party_done || host.stat == DEAD || host.phase != PRISONER_PRESENT || !(host in prisoners)) @@ -1059,7 +1059,7 @@ /// Sends the host to fetch the cake, once they are free. TRUE if they are on it. /datum/outpost_prison/proc/try_start_party_host() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(!host || party_stage != PARTY_WAITING || !party_cake()) return FALSE if(istype(host.activity, /datum/prisoner_activity/party_host)) @@ -1078,7 +1078,7 @@ return TRUE /// Whether `guest` would come over to the party: awake, free and not busy with a meal or a duty -/datum/outpost_prison/proc/party_guest_free(mob/living/basic/outpost_prisoner/guest) +/datum/outpost_prison/proc/party_guest_free(mob/living/carbon/human/npc/outpost_prisoner/guest) if(QDELETED(guest) || !guest.ai_running() || !guest.routine_allowed()) return FALSE var/datum/prisoner_activity/doing = guest.activity @@ -1093,7 +1093,7 @@ * Returns TRUE if it began. */ /datum/outpost_prison/proc/party_ready(turf/spot) - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(party_stage != PARTY_WAITING || !host || !spot) return FALSE party_spot = spot @@ -1101,14 +1101,14 @@ party_stage_seconds = 0 party_scene_seconds = 0 party_guests = list() - for(var/mob/living/basic/outpost_prisoner/guest in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/guest in prisoners) if(guest != host) invite_party_guest(guest) host.manual_emote("waves everyone over.") return TRUE /// Calls `guest` over to the party. TRUE if they are coming. -/datum/outpost_prison/proc/invite_party_guest(mob/living/basic/outpost_prisoner/guest) +/datum/outpost_prison/proc/invite_party_guest(mob/living/carbon/human/npc/outpost_prisoner/guest) if(!party_spot || !party_guest_free(guest)) return FALSE var/datum/prisoner_activity/party_guest/coming = new(guest) @@ -1123,7 +1123,7 @@ /datum/outpost_prison/proc/present_guests(arrived_only = TRUE) var/list/found = list() for(var/datum/weakref/guest_ref as anything in party_guests) - var/mob/living/basic/outpost_prisoner/guest = guest_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/guest = guest_ref.resolve() var/datum/prisoner_activity/party_guest/attending = guest?.activity if(!istype(attending) || guest.stat != CONSCIOUS || (arrived_only && !attending.started)) continue @@ -1139,7 +1139,7 @@ /datum/outpost_prison/proc/party_tick(seconds) if(!party_stage) return - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(!host || !(host in prisoners) || host.stat == DEAD || host.phase != PRISONER_PRESENT) end_party() return @@ -1174,7 +1174,7 @@ /// The candles go out, and the guests cheer and clap /datum/outpost_prison/proc/blow_candles() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() var/obj/item/food/cake/cake = party_cake() if(!host || !cake) end_party() @@ -1184,7 +1184,7 @@ host.face_atom(cake) host.manual_emote("blows out the candles.") var/cheers = 0 - for(var/mob/living/basic/outpost_prisoner/guest as anything in shuffle(present_guests())) + for(var/mob/living/carbon/human/npc/outpost_prisoner/guest as anything in shuffle(present_guests())) guest.face_atom(cake) if(cheers < 2) addtimer(CALLBACK(src, PROC_REF(say_life_line), WEAKREF(guest), "birthday_party", WEAKREF(host)), (0.5 + 0.8 * cheers) SECONDS) @@ -1199,7 +1199,7 @@ * table. Crumbs are left behind. */ /datum/outpost_prison/proc/serve_party() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() var/obj/item/food/cake/cake = party_cake() var/turf/center = party_spot || get_turf(cake) if(!host || !cake || !center) @@ -1216,11 +1216,11 @@ host.visible_message(span_notice("[host] cuts [cake] and passes the slices round.")) qdel(cake) var/list/diners = list(host) - for(var/mob/living/basic/outpost_prisoner/guest as anything in present_guests()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/guest as anything in present_guests()) if(get_dist(guest, center) <= PRISON_PARTY_REACH) diners += guest party_diners = list() - for(var/mob/living/basic/outpost_prisoner/diner as anything in diners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/diner as anything in diners) diner.drop_held_item() // Beside the table they take theirs off it; further round, the slice is passed along to them. // Nobody picks anything up from further off than they can reach (take_item()). @@ -1237,12 +1237,12 @@ /// Everyone eats their slice; the host thanks whoever brought the cake, and has had their party /datum/outpost_prison/proc/finish_party() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() var/fed = 0 for(var/list/entry as anything in party_diners) var/datum/weakref/diner_ref = entry[1] var/datum/weakref/slice_ref = entry[2] - var/mob/living/basic/outpost_prisoner/diner = diner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/diner = diner_ref.resolve() var/obj/item/food/slice = slice_ref.resolve() if(!diner || !slice || diner.held_item != slice || diner.stat != CONSCIOUS) continue @@ -1268,7 +1268,7 @@ * `diner` eats a slice of the birthday cake. It counts as a cooked meal however the cake was made * (a birthday cake is junk food to outpost_prisoner_food_tier()), as the build plan asks. */ -/datum/outpost_prison/proc/party_feed(mob/living/basic/outpost_prisoner/diner, obj/item/food/slice) +/datum/outpost_prison/proc/party_feed(mob/living/carbon/human/npc/outpost_prisoner/diner, obj/item/food/slice) if(outpost_prisoner_food_tier(slice) == "cooked") diner.eat_food(slice) return @@ -1289,7 +1289,7 @@ /// The party is over or off: everyone goes back to their day, and the cake (if uneaten) is ordinary food /datum/outpost_prison/proc/end_party() var/list/guests = party_guests - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() clear_party_state() dismiss_party_guests(guests) if(host && istype(host.activity, /datum/prisoner_activity/party_host)) @@ -1297,7 +1297,7 @@ /datum/outpost_prison/proc/dismiss_party_guests(list/guests) for(var/datum/weakref/guest_ref as anything in guests) - var/mob/living/basic/outpost_prisoner/guest = guest_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/guest = guest_ref.resolve() if(guest && istype(guest.activity, /datum/prisoner_activity/party_guest)) guest.end_activity() @@ -1314,7 +1314,7 @@ party_diners = list() /// Whether `thing` is the cake kept for a party, which nobody may eat as a meal -/datum/outpost_prison/proc/reserved_supply(obj/item/thing, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/reserved_supply(obj/item/thing, mob/living/carbon/human/npc/outpost_prisoner/prisoner) return !!party_stage && !!thing && thing == party_cake() /** @@ -1322,7 +1322,7 @@ * eaten this way. A whole cake handed to the prisoner whose birthday it is starts their party: they * take it, and fetch it to a table once they are free. Runs in a signal handler: never sleeps. */ -/datum/outpost_prison/proc/pastime_pre_eat(mob/living/basic/outpost_prisoner/prisoner, atom/food, mob/living/feeder) +/datum/outpost_prison/proc/pastime_pre_eat(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/food, mob/living/feeder) if(reserved_supply(food, prisoner)) // Handing the kept cake to the birthday prisoner saves them fetching it. if(party_stage == PARTY_WAITING && prisoner == party_host_ref?.resolve() && istype(feeder) && food.loc == feeder && !prisoner.held_item && feeder.temporarilyRemoveItemFromInventory(food)) @@ -1359,7 +1359,7 @@ if(!QDELETED(whole) && whole.loc == hatch?.loc) cake = whole break - var/mob/living/basic/outpost_prisoner/host = birthday_host() + var/mob/living/carbon/human/npc/outpost_prisoner/host = birthday_host() if(!cake || !host || !start_party(host, cake, user)) return FALSE INVOKE_ASYNC(src, PROC_REF(try_start_party_host)) @@ -1591,7 +1591,7 @@ /// The hoop a prisoner is shooting at now, or null when nobody is playing /datum/outpost_prison/proc/game_hoop() - for(var/mob/living/basic/outpost_prisoner/player in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player in prisoners) var/datum/prisoner_activity/basketball/game = player.activity if(!istype(game) || !game.started || player.stat != CONSCIOUS) continue @@ -1603,7 +1603,7 @@ /// Prisoners playing basketball now /datum/outpost_prison/proc/basketball_players() var/list/players = list() - for(var/mob/living/basic/outpost_prisoner/player in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player in prisoners) if(player.stat == CONSCIOUS && istype(player.activity, /datum/prisoner_activity/basketball) && player.activity.started) players += player return players @@ -1611,7 +1611,7 @@ /// Prisoners watching the game at `hoop` /datum/outpost_prison/proc/game_watchers(obj/structure/hoop/hoop) var/list/watchers = list() - for(var/mob/living/basic/outpost_prisoner/watcher in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher in prisoners) var/datum/prisoner_activity/watch_game/watching = watcher.activity if(istype(watching) && watching.started && watcher.stat == CONSCIOUS && watching.hoop_ref?.resolve() == hoop) watchers += watcher @@ -1668,7 +1668,7 @@ /// Each prisoner playing now cheers up for playing with staff, at most every PRISON_STAFF_GAME_MOOD_GAP /datum/outpost_prison/proc/lift_staff_game_players() - for(var/mob/living/basic/outpost_prisoner/player as anything in basketball_players()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player as anything in basketball_players()) if(!COOLDOWN_FINISHED(player, staff_game_mood_cooldown)) continue COOLDOWN_START(player, staff_game_mood_cooldown, PRISON_STAFF_GAME_MOOD_GAP) @@ -1683,11 +1683,11 @@ return var/mob/living/shooter = shooter_ref?.resolve() var/list/watchers = game_watchers(hoop) - for(var/mob/living/basic/outpost_prisoner/watcher as anything in watchers) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher as anything in watchers) watcher.face_atom(hoop) if(score_event) var/list/announcers = length(watchers) ? watchers : basketball_players() - var/mob/living/basic/outpost_prisoner/announcer = length(announcers) ? pick(announcers) : null + var/mob/living/carbon/human/npc/outpost_prisoner/announcer = length(announcers) ? pick(announcers) : null var/mob/living/staff = staff_ref?.resolve() switch(score_event) if("call") @@ -1698,7 +1698,7 @@ announcer?.say_to_staff("game_lost", staff) if(scored && length(watchers) && COOLDOWN_FINISHED(src, crowd_clap_cooldown)) COOLDOWN_START(src, crowd_clap_cooldown, PRISON_CROWD_CLAP_GAP) - for(var/mob/living/basic/outpost_prisoner/watcher as anything in watchers) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher as anything in watchers) if(prob(70)) watcher.manual_emote("claps.") playsound(watcher, pick('sound/mobs/humanoids/human/clap/clap1.ogg', 'sound/mobs/humanoids/human/clap/clap2.ogg', 'sound/mobs/humanoids/human/clap/clap3.ogg'), 35, TRUE) @@ -1708,7 +1708,7 @@ if(!context) return COOLDOWN_START(src, crowd_line_cooldown, PRISON_CROWD_LINE_GAP) - var/mob/living/basic/outpost_prisoner/speaker = pick(watchers) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(watchers) if(is_outpost_prisoner(shooter)) speaker.say_context(context, shooter) else if(shooter) @@ -1822,7 +1822,7 @@ return initials || "Someone" /// The crayon state for a prisoner's mark: their first initial, or a doodle -/proc/outpost_prisoner_mark_state(mob/living/basic/outpost_prisoner/prisoner) +/proc/outpost_prisoner_mark_state(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/letter = lowertext(copytext(prisoner.real_name, 1, 2)) if(prob(PRISON_MARK_INITIAL_CHANCE) && length(letter) == 1 && text2ascii(letter) >= 97 && text2ascii(letter) <= 122) return letter @@ -1844,7 +1844,7 @@ * "R.D. was here." A cell keeps PRISON_CELL_MARKS_MAX marks; a new one replaces the oldest. * Crayon marks are not mess (outpost_prison_conditions.dm). Returns the mark, or null. */ -/datum/outpost_prison/proc/add_cell_mark(mob/living/basic/outpost_prisoner/prisoner, turf/wall) +/datum/outpost_prison/proc/add_cell_mark(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/wall) var/datum/outpost_prison_cell/home = prisoner?.cell if(!home || !isclosedturf(wall)) return null @@ -1871,7 +1871,7 @@ /// A mark is going for a newer one: whoever made it no longer looks for it /datum/outpost_prison/proc/forget_wall_mark(obj/effect/decal/cleanable/crayon/mark) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.wall_mark_ref?.resolve() == mark) prisoner.wall_mark_ref = null prisoner.wall_mark_spot = null @@ -1882,7 +1882,7 @@ */ /datum/outpost_prison/proc/check_scrubbed_marks() var/noticed = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.wall_mark_ref || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) continue var/obj/effect/decal/cleanable/crayon/mark = prisoner.wall_mark_ref.resolve() diff --git a/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm b/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm index 369d99e208c..a3a2f07fa06 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm @@ -1,61 +1,51 @@ /** * # Outpost prisoner * - * An inmate of a player outpost's prison wing (outpost_prison_core.dm). Each prisoner owns one - * cell, beams into it on arrival and out of it on release, and fills the time in between with the - * routine in outpost_prison_routine.dm. They talk (outpost_prison_dialogue.dm), and when unhappy - * they threaten, fight, riot and escape (outpost_prison_trouble.dm, outpost_prison_riot.dm). + * An inmate of a player outpost's prison wing (outpost_prison_core.dm), and a real human body + * (voidcrew/modules/human_npcs/): they breathe, bleed, bruise, stamcrit, go into crit, die and can be + * revived through tg's own code, like anyone. Each prisoner owns one cell, beams into it on arrival + * and out of it on release, and fills the time in between with the routine in + * outpost_prison_routine.dm. They talk (outpost_prison_dialogue.dm), and when unhappy they threaten, + * fight, riot and escape (outpost_prison_trouble.dm, outpost_prison_riot.dm). * * Needs, on 0-100 scales: `hunger` falls over time and food restores it, by how good the food - * is; `uniform_grime` rises over time, three times as fast at sport, and a cleaner prison uniform - * resets it. Health does not come back on its own; brute medical stacks treat it. Arrivals come in - * hungry, a third in a stained uniform and a fifth roughed up in transfer. A thought bubble with - * the item they need shows when one needs attention, cycling when they need several. They bleed - * when hit, and drip while badly hurt. They take supplies only from a serving hatch or from a - * person's hand (outpost_prison_core.dm). + * is (the prison's own meter: human NPCs have tg's hunger off); `uniform_grime` rises over time, + * three times as fast at sport, shows on the real prison jumpsuit they wear, and a cleaner prison + * uniform resets it. Health does not come back on its own; treatment does. Arrivals come in hungry, a + * third in a stained uniform and a fifth roughed up in transfer (real limb damage). A thought bubble + * with the item they need shows when one needs attention, cycling when they need several. They take + * supplies only from a serving hatch or from a person's hand (outpost_prison_core.dm), into a real hand. * - * Awake, they cannot be dragged onto things or boxed, and only a calm one (calm_for_pull()) can be - * pulled, going along with it (outpost_prison_warden_tools.dm). Knocked down, in stamina crit, - * cuffed or dead, staff can drag them; stamina crit lasts PRISONER_STAMCRIT_TIME after the last - * hit. Cuffs and lockdown are in outpost_prison_capture.dm. + * Owner ruling OC-11: their jumpsuit, contraband and shivs are ordinary items, not locked to them, so + * they can be searched, stripped and disarmed like anyone. Awake and free they keep out of bags and + * lockers (a bluespace bag is a way out of the wing); down or dead they can be bagged. Anyone can + * pull them: a calm one goes along with it (outpost_prison_warden_tools.dm), one making trouble + * resists it, as anyone can. Cuffs (tg's) and lockdown are in outpost_prison_capture.dm. */ /// Trait source for the beam holding a prisoner still #define PRISONER_BEAM_TRAIT "outpost_prisoner_beam" /// Offset source for sitting on a bed edge #define PRISONER_SITTING_OFFSET "outpost_prisoner_sitting" +/// Trait source for what only an awake prisoner on their feet refuses (a bag, a locker) +#define PRISONER_AWAKE_TRAIT "outpost_prisoner_awake" -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner name = "prisoner" desc = "An inmate of the outpost's prison wing." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - maxHealth = 100 - health = 100 - speed = 2 - // Heavy while awake, so nobody shoves them about; see update_drag_resistance(). - move_resist = MOVE_FORCE_VERY_STRONG - density = TRUE - basic_mob_flags = NONE - // tg's human deathgasp - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - // They lie down on beds, when knocked down and when dead. - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - blood_volume = BLOOD_VOLUME_NORMAL - // Batons knock them down, as they do people. - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - stamina_regen_time = PRISONER_STAMCRIT_TIME - // No air needs yet: a breach must not kill the inmates. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - response_help_continuous = "pats" - response_help_simple = "pat" + faction = list(FACTION_NEUTRAL) ai_controller = /datum/ai_controller/basic_controller/outpost_prisoner + npc_outfit = /datum/outfit/outpost_prisoner + // Owner ruling OC-11: their jumpsuit, contraband and shivs are ordinary items, so shakedowns, + // confiscation and uniform swaps work. Searched and stripped like anyone. + npc_gear_locked = FALSE + // They stop once someone is down (defaults 1.13). + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // The prison owns the body: it lies in the cell block until carried out (outpost_prison_economy.dm). + npc_corpse_lifetime = 0 + // No air tank: the cell block's emergency lockers have the masks and tanks (outpost_prison_conditions.dm). + npc_internals = TRUE /// The prison holding them var/datum/outpost_prison/prison @@ -80,8 +70,6 @@ var/locked_in_seconds = 0 /// Outfit whose look they wear var/outfit_path - /// Which of the people wearing that outfit they look like (outpost_npc_looks.dm) - var/look_number = 1 /// The need their thought bubble is about, if any; see update_bubble() var/bubble /// The bubble that is up now, if one is; it pops up now and then rather than staying @@ -93,7 +81,7 @@ var/bubble_timer /// Which grime overlay they show: 0 none, 1 dirty, 2 filthy var/grime_stage = 0 - /// What they are carrying, held in their contents + /// What they are carrying in their hand for the prison's routine (take_item()), a real item in a real hand var/obj/item/held_item /// What they are holding a hand out for, one activity tick before they take it (see reach_for()) var/datum/weakref/reaching_ref @@ -117,7 +105,7 @@ var/well_fed_left = 0 /// Came in wearing a stained transfer uniform var/arrived_stained = FALSE - /// Brute damage they carry from transfer, applied when they beam in, and whether they came in hurt + /// Health they lose to a rough transfer, as bruising when they beam in, and whether they came in hurt var/arrival_brute = 0 var/arrived_hurt = FALSE /// Seconds the thought bubble has shown the current set of needs, for cycling through them @@ -129,6 +117,8 @@ var/last_cared_at = 0 /// world.time they last got the lift of a shared meal var/shared_meal_at = 0 + /// The serum turned them into an experiment's creature: off the roster, no longer treated as an inmate + var/experiment_turned = FALSE /// The criminal they were, when a bounty hunter caught them (outpost_prison_bounty.dm); null for an ordinary prisoner var/datum/bounty_record/bounty_record COOLDOWN_DECLARE(speech_cooldown) @@ -142,97 +132,135 @@ /// Running while what they last said is up over their head: no thought bubble (hush_bubble()) COOLDOWN_DECLARE(bubble_hush) -/mob/living/basic/outpost_prisoner/Initialize(mapload, datum/bounty_record/record) - gender = pick(MALE, FEMALE) - // A caught bounty criminal (admit_next()) keeps their body and who they were (outpost_prison_bounty.dm). +/** + * The second argument is a caught criminal's record (admit_next(), outpost_prison_bounty.dm), never a + * human NPC spawn order: a bounty prisoner keeps the face the record says they were caught with (the + * base dresses a /datum/bounty_record look). Anyone else is a random person in one of the prison's + * jumpsuits. Prisoners are made one at a time as they are admitted, so they don't go through the + * human NPC spawn queue. + */ +/mob/living/carbon/human/npc/outpost_prisoner/Initialize(mapload, datum/bounty_record/record) + if(!istype(record)) + record = null bounty_record = record if(record) apply_bounty_body(record) - . = ..() - real_name = generate_random_name_species_based(gender, TRUE, /datum/species/human) - name = real_name + npc_look = record + // The plain jumpsuit: the glasses and beanie variants would hide their hair or fake their eyewear + npc_outfit = /datum/outfit/outpost_prisoner + else + npc_outfit = pick(/datum/outfit/outpost_prisoner, /datum/outfit/outpost_prisoner/glasses, /datum/outfit/outpost_prisoner/beanie) + outfit_path = npc_outfit + . = ..(mapload) + // They stroll; a runner or a rioter breaks into a run (set_prison_pace()). + set_prison_pace(hurry = FALSE) roll_arrival() var/list/personalities = outpost_prisoner_dialogue("personalities") personality = length(personalities) ? pick(personalities) : "quiet" var/list/crimes = outpost_prisoner_dialogue("crimes") crime = length(crimes) ? pick(crimes) : "unpaid docking fees" - outfit_path = pick(/datum/outfit/outpost_prisoner, /datum/outfit/outpost_prisoner/glasses, /datum/outfit/outpost_prisoner/beanie) - look_number = random_outpost_npc_look_number() if(record) apply_bounty_record(record) - INVOKE_ASYNC(src, PROC_REF(build_look)) + uniform_grime_to_suit() // One shared list: element arguments are keyed by list reference. var/static/list/edible_types = list(/obj/item/food) AddElement(/datum/element/basic_eating, food_types = edible_types) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) RegisterSignal(src, COMSIG_MOB_PRE_EAT, PROC_REF(on_pre_eat)) RegisterSignal(src, COMSIG_MOB_ATE, PROC_REF(on_ate)) RegisterSignal(src, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_item_interaction)) + // Whatever lands in their hands by tg's own means (a catch, a hand-over) is what they hold too + RegisterSignal(src, COMSIG_MOB_EQUIPPED_ITEM, PROC_REF(on_equipped_item)) RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) RegisterSignal(src, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(on_health_update)) // Talking puts the thought bubble away (manual_emote() below covers the prison's own emotes). RegisterSignals(src, list(COMSIG_MOB_SAY, COMSIG_MOB_EMOTE), PROC_REF(on_speech)) - // Dragging only while they are down or cuffed (can_be_dragged()); pulling also while calm (pull_allowed()). - RegisterSignal(src, COMSIG_MOUSEDROP_ONTO, PROC_REF(block_being_dragged)) - RegisterSignal(src, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(check_pullable)) - // Changes to being down reach update_drag_resistance() through the living trait handlers - // overridden below; registering those trait signals here would replace living's own. - ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) + // Members only put cuffs on them (outpost_prison_capture.dm); tg's handcuffs do the rest. + RegisterSignal(src, COMSIG_CARBON_CUFF_ATTEMPTED, PROC_REF(on_cuff_attempt)) + // Cuffs going on and off, and going down and getting up (crit, stamcrit, knockdowns), reach + // update_drag_resistance() through the living trait handlers overridden below; registering those + // trait signals here would replace living's own. setup_trouble() setup_containment() setup_extras() last_health = health + update_drag_resistance() -/mob/living/basic/outpost_prisoner/Destroy() +/mob/living/carbon/human/npc/outpost_prisoner/Destroy() end_activity(cancel_ai = FALSE) clear_trouble() - if(held_item) - held_item.forceMove(drop_location()) - remove_cuffs() prison?.forget(src) prison = null cell = null walkable = null reachable = null + held_item = null deltimer(bubble_timer) if(thought) vis_contents -= thought QDEL_NULL(thought) return ..() -/mob/living/basic/outpost_prisoner/Exited(atom/movable/gone, direction) +/mob/living/carbon/human/npc/outpost_prisoner/Exited(atom/movable/gone, direction) . = ..() if(gone == held_item) held_item = null - update_appearance(UPDATE_OVERLAYS) - else if(gone == cuffs) - // Cuffs taken or destroyed some other way than remove_cuffs() - cuffs = null - uncuffed() - -/// Dresses them as their own person in their outfit, with a body to match their gender; a bounty prisoner keeps the face they were caught with (bounty_looks.dm). Can sleep. -/mob/living/basic/outpost_prisoner/proc/build_look() - if(bounty_record) - apply_bounty_look(src, bounty_record, outfit_path) - else - set_outpost_npc_look(src, outfit_path, gender, look_number) - if(QDELETED(src)) - return - update_appearance(UPDATE_OVERLAYS) + +/// Something went into their hands without take_item(): tg caught a thrown ball for them, or someone handed it over. They hold it now. +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_equipped_item(datum/source, obj/item/equipped, slot) + SIGNAL_HANDLER + if(!held_item && (slot & ITEM_SLOT_HANDS)) + held_item = equipped + +/** + * The serum took them (outpost_prison_experiment_forms.dm): the same body, no longer an inmate. Whatever + * they were doing and whatever trouble they were in stops, their thought bubble goes, and the prisoner's + * own reactions (needs and treatment, cuffing rules, blame, turret rules, teleport and polymorph refusals) + * are switched off; the form brings its own. The caller takes them off the roster (forget()). + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/stand_down_for_experiment() + experiment_turned = TRUE + // The prison no longer owns the body: if Kessler never takes it, it goes like any NPC's + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + end_activity(cancel_ai = FALSE) + clear_trouble() + bubble = null + if(popped_bubble) + fade_bubble() + UnregisterSignal(src, list( + COMSIG_MOB_PRE_EAT, + COMSIG_MOB_ATE, + COMSIG_ATOM_ITEM_INTERACTION, + COMSIG_MOB_AFTER_APPLY_DAMAGE, + COMSIG_LIVING_HEALTH_UPDATE, + COMSIG_MOB_SAY, + COMSIG_MOB_EMOTE, + COMSIG_CARBON_CUFF_ATTEMPTED, + COMSIG_ATOM_WAS_ATTACKED, + COMSIG_MOB_BATONED, + COMSIG_ATOM_BULLET_ACT, + COMSIG_MOVABLE_TELEPORTING, + COMSIG_LIVING_PRE_WABBAJACKED, + COMSIG_PRE_MOB_CHANGED_TYPE, + COMSIG_LIVING_REVIVE, + )) + +/// Walking pace, or a run for a runner, a rioter or someone fleeing, by the move intent a player uses. +/mob/living/carbon/human/npc/proc/set_prison_pace(hurry) + if((move_intent == MOVE_INTENT_RUN) != !!hurry) + toggle_move_intent() /** * How they come in: hungry, a third in a stained transfer uniform, a fifth roughed up in transfer. * The injury waits for beam_in(), so a prisoner made any other way (an admin, a test) starts whole. */ -/mob/living/basic/outpost_prisoner/proc/roll_arrival() +/mob/living/carbon/human/npc/outpost_prisoner/proc/roll_arrival() hunger = rand(PRISONER_ARRIVAL_HUNGER_MIN, PRISONER_ARRIVAL_HUNGER_MAX) arrived_stained = prob(PRISONER_STAINED_CHANCE) uniform_grime = arrived_stained ? rand(PRISONER_STAINED_GRIME_MIN, PRISONER_STAINED_GRIME_MAX) : rand(0, PRISONER_ARRIVAL_GRIME_MAX) arrival_brute = prob(PRISONER_HURT_ARRIVAL_CHANCE) ? round(maxHealth * (100 - rand(PRISONER_HURT_ARRIVAL_MIN, PRISONER_HURT_ARRIVAL_MAX)) / 100) : 0 /// Their first name, for dialogue. (Not called first_name(): inside it, that would call itself.) -/mob/living/basic/outpost_prisoner/proc/speech_name() +/mob/living/carbon/human/npc/outpost_prisoner/proc/speech_name() return first_name(real_name) // ===== BEAMING IN AND OUT ===== @@ -243,11 +271,11 @@ * PRISONER_ARRIVING, and so do nothing at all (no walking, routine, trouble, speech or thought * bubble), until finish_beam_in() as the beam ends. */ -/mob/living/basic/outpost_prisoner/proc/beam_in() +/mob/living/carbon/human/npc/outpost_prisoner/proc/beam_in() phase = PRISONER_ARRIVING if(arrival_brute > 0) - // Roughed up in transfer: no attacker, so no blame, no blood and no collapse. - adjustBruteLoss(arrival_brute) + // Roughed up in transfer: real bruising spread over their limbs, with no attacker, so no blame. + outpost_prison_bruise(src, arrival_brute) arrival_brute = 0 arrived_hurt = TRUE ADD_TRAIT(src, TRAIT_IMMOBILIZED, PRISONER_BEAM_TRAIT) @@ -264,7 +292,7 @@ addtimer(CALLBACK(src, PROC_REF(finish_beam_in)), OUTPOST_PRISON_BEAM_TIME) /// Fully there as the beam ends: the flash, and only now are they present, free to move, and say hello -/mob/living/basic/outpost_prisoner/proc/finish_beam_in() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_beam_in() if(phase != PRISONER_ARRIVING) return var/turf/spot = get_turf(src) @@ -291,7 +319,7 @@ * Dematerialises them where they stand and deletes them at the end of the beam. From the start * they are PRISONER_LEAVING: held still, with no routine, trouble, speech or thought bubble. */ -/mob/living/basic/outpost_prisoner/proc/beam_out() +/mob/living/carbon/human/npc/outpost_prisoner/proc/beam_out() if(phase == PRISONER_LEAVING) return phase = PRISONER_LEAVING @@ -312,7 +340,7 @@ transporter_dematerialise(src, OUTPOST_PRISON_BEAM_TIME) addtimer(CALLBACK(src, PROC_REF(finish_beam_out)), OUTPOST_PRISON_BEAM_TIME) -/mob/living/basic/outpost_prisoner/proc/finish_beam_out() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_beam_out() var/turf/spot = get_turf(src) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) @@ -321,7 +349,7 @@ qdel(src) /// What the warden's roster calls them: present, arriving, leaving or dead -/mob/living/basic/outpost_prisoner/proc/console_status() +/mob/living/carbon/human/npc/outpost_prisoner/proc/console_status() if(stat == DEAD) return "dead" if(phase == PRISONER_ARRIVING) @@ -330,75 +358,86 @@ return "leaving" return "present" -// ===== DRAGGING ===== +// ===== DOWN, CUFFED AND PULLED ===== -/// Whether they are down: knocked down, in stamina crit, beaten, out or dead -/mob/living/basic/outpost_prisoner/proc/is_down() - // Floored but not buckled is on the floor; a bed floors them too, and that doesn't count. - return stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_INCAPACITATED) || (HAS_TRAIT(src, TRAIT_FLOORED) && !buckled) +/// Whether they are down: in crit, in stamina crit, knocked down, stunned, out cold, lying on the floor or dead (tg's own states) +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_down() + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_INCAPACITATED)) + return TRUE + // On the floor but not on a bed: lying in bed is no more down than sitting in a chair. + return (HAS_TRAIT(src, TRAIT_FLOORED) || body_position == LYING_DOWN) && !buckled -/// Staff may drag them only while they are down or cuffed (outpost_prison_capture.dm) -/mob/living/basic/outpost_prisoner/proc/can_be_dragged() - return is_down() || !!cuffs +/// Down or in handcuffs: whatever trouble they were in stops, and staff can take them by the arm (outpost_prison_capture.dm) +/mob/living/carbon/human/npc/outpost_prisoner/proc/can_be_dragged() + return is_down() || !!handcuffed /** * Calm enough to go along with a pull: awake, present, and in no trouble of their own (rioting, * loose, fighting, wrecking, squaring up, swinging, climbing, beaten down or hitting back). * Someone talking to them or working on their cuffs does not count. */ -/mob/living/basic/outpost_prisoner/proc/calm_for_pull() - return stat == CONSCIOUS && phase == PRISONER_PRESENT && !trouble && !threat_ref && !swing_ref && !climb_ref && beaten_left <= 0 && !retaliating() +/mob/living/carbon/human/npc/outpost_prisoner/proc/calm_for_pull() + return stat == CONSCIOUS && phase == PRISONER_PRESENT && !trouble && !threat_ref && !swing_ref && !climb_ref && !retaliating() -/// Anyone may pull them while they are down or cuffed, or while they are calm -/mob/living/basic/outpost_prisoner/proc/pull_allowed() +/// Whether they go along with a pull, or let it be: down, cuffed or calm. Anyone else resists it the way a person does. +/mob/living/carbon/human/npc/outpost_prisoner/proc/pull_allowed() return can_be_dragged() || calm_for_pull() -/// Their move_resist: ordinary while they can be dragged or are going along with a pull, else too heavy to shove or pull -/mob/living/basic/outpost_prisoner/proc/pull_weight() - return (can_be_dragged() || (pulledby && calm_for_pull())) ? MOVE_RESIST_DEFAULT : MOVE_FORCE_VERY_STRONG +/mob/living/carbon/human/npc/outpost_prisoner/on_floored_trait_gain(datum/source) + . = ..() + update_drag_resistance() -/mob/living/basic/outpost_prisoner/proc/check_pullable(datum/source, mob/living/puller) - SIGNAL_HANDLER - return pull_allowed() ? NONE : COMSIG_ATOM_CANT_PULL +/mob/living/carbon/human/npc/outpost_prisoner/on_floored_trait_loss(datum/source) + . = ..() + update_drag_resistance() -// Calm, they go along with a pull however heavy they are against shoves; pull_weight() lightens them once it has hold. -/mob/living/basic/outpost_prisoner/can_be_pulled(user, force) - if(!can_be_dragged() && calm_for_pull()) - force = max(force, move_resist * MOVE_FORCE_PULL_RATIO) - return ..(user, force) +/mob/living/carbon/human/npc/outpost_prisoner/on_incapacitated_trait_gain(datum/source) + . = ..() + update_drag_resistance() -/mob/living/basic/outpost_prisoner/proc/block_being_dragged(atom/over, mob/user) - SIGNAL_HANDLER - return can_be_dragged() ? NONE : COMPONENT_CANCEL_MOUSEDROP_ONTO +/mob/living/carbon/human/npc/outpost_prisoner/on_incapacitated_trait_loss(datum/source) + . = ..() + update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_floored_trait_gain(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/on_restrained_trait_gain(datum/source) . = ..() + on_restraint_changed() update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_floored_trait_loss(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/on_restrained_trait_loss(datum/source) . = ..() + on_restraint_changed() update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_incapacitated_trait_gain(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/set_stat(new_stat) . = ..() update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_incapacitated_trait_loss(datum/source) +// Getting up takes a moment once a knockdown or stamina crit is over (tg's get_up()): they count as down +// until they are on their feet, so standing up (and lying down) is a change too. +/mob/living/carbon/human/npc/outpost_prisoner/on_standing_up() . = ..() update_drag_resistance() -/mob/living/basic/outpost_prisoner/set_stat(new_stat) +/mob/living/carbon/human/npc/outpost_prisoner/on_lying_down(new_lying_angle) . = ..() update_drag_resistance() -/// Heavy while awake and free, so nobody shoves them about; ordinary while they are down or cuffed, or calm and being pulled -/mob/living/basic/outpost_prisoner/proc/update_drag_resistance() +/** + * Going down, getting up, cuffs on and off. Awake and free they keep out of bags and lockers (a + * bluespace bag is a way out of the wing); down or dead they can be bagged like anyone. Down or cuffed, + * whatever they were doing and whatever trouble they were making stops. Up and free again, a rioter + * riots on. + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/update_drag_resistance() var/draggable = can_be_dragged() - move_resist = pull_weight() + if(stat == CONSCIOUS && !is_down()) + ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, PRISONER_AWAKE_TRAIT) + ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, PRISONER_AWAKE_TRAIT) + else + REMOVE_TRAIT(src, TRAIT_NO_STORAGE_INSERT, PRISONER_AWAKE_TRAIT) + REMOVE_TRAIT(src, TRAIT_NO_CONTAINMENT, PRISONER_AWAKE_TRAIT) if(!draggable) - // A calm prisoner goes along with a pull (outpost_prison_warden_tools.dm); anyone else shakes it off. - if(!calm_for_pull()) - pulledby?.stop_pulling() if(is_rioting() && stat == CONSCIOUS) // Up and free again: a rioter riots on until the riot is over. INVOKE_ASYNC(src, PROC_REF(back_to_rioting)) @@ -416,7 +455,7 @@ * Time passing: hunger falls (not while well fed after cooked food) and the uniform gets dirtier, * three times as fast at sport, where they sometimes get hurt. */ -/mob/living/basic/outpost_prisoner/proc/adjust_needs(seconds) +/mob/living/carbon/human/npc/outpost_prisoner/proc/adjust_needs(seconds) if(stat == DEAD) return var/hungry_seconds = seconds @@ -434,7 +473,7 @@ sport_injury() /// Shooting hoops or working out: sweaty, and now and then painful -/mob/living/basic/outpost_prisoner/proc/playing_sport() +/mob/living/carbon/human/npc/outpost_prisoner/proc/playing_sport() if(!activity?.started) return FALSE if(istype(activity, /datum/prisoner_activity/basketball)) @@ -443,49 +482,69 @@ return istype(workout) && workout.exercising /** - * Hurt at sport: a turned ankle or the ball in the face, PRISONER_SPORT_INJURY_MIN to _MAX brute, - * and they stop playing. No attacker, so no blame and no blood. Never while already badly hurt, - * and never enough to put them down. + * Hurt at sport, as anyone gets hurt at sport: a turned ankle (a real sprain on a leg), the ball in + * the face or a pulled back, PRISONER_SPORT_INJURY_MIN to _MAX brute on the limb it happened to, and + * they stop playing. No attacker, so no blame. Not while already badly hurt. */ -/mob/living/basic/outpost_prisoner/proc/sport_injury() +/mob/living/carbon/human/npc/outpost_prisoner/proc/sport_injury() if(stat != CONSCIOUS || health_factor() <= PRISONER_SPORT_INJURY_ABOVE) return FALSE - var/damage = min(rand(PRISONER_SPORT_INJURY_MIN, PRISONER_SPORT_INJURY_MAX), health - 1) - if(damage < 1) - return FALSE - if(istype(activity, /datum/prisoner_activity/basketball)) - manual_emote(pick("goes down clutching an ankle.", "takes the ball to the face.", "lands badly and hops off the court.")) + var/damage = rand(PRISONER_SPORT_INJURY_MIN, PRISONER_SPORT_INJURY_MAX) + var/ankle = prob(50) + var/zone = ankle ? pick(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) : (istype(activity, /datum/prisoner_activity/basketball) ? BODY_ZONE_HEAD : BODY_ZONE_CHEST) + if(ankle) + manual_emote(pick("goes down clutching an ankle.", "lands badly and hops off the court.")) + else if(istype(activity, /datum/prisoner_activity/basketball)) + manual_emote("takes the ball to the face.") else - manual_emote(pick("goes down clutching an ankle.", "pulls something and sits down hard.", "grabs at [p_their()] back mid push-up.")) - adjustBruteLoss(damage) + manual_emote(pick("pulls something and sits down hard.", "grabs at [p_their()] back mid push-up.")) + apply_damage(damage, BRUTE, zone, wound_bonus = CANT_WOUND) + if(ankle) + var/obj/item/bodypart/leg = get_bodypart(zone) + if(leg) + var/datum/wound/blunt/bone/moderate/sprain = new + sprain.apply_wound(leg) end_activity() return TRUE -/mob/living/basic/outpost_prisoner/proc/set_hunger(amount) +/mob/living/carbon/human/npc/outpost_prisoner/proc/set_hunger(amount) hunger = clamp(amount, 0, 100) update_bubble() -/mob/living/basic/outpost_prisoner/proc/set_uniform_grime(amount) +/mob/living/carbon/human/npc/outpost_prisoner/proc/set_uniform_grime(amount) uniform_grime = clamp(amount, 0, 100) + uniform_grime_to_suit() update_bubble() +/// The prison jumpsuit they wear, if it is one of the outpost's (a stripped or redressed prisoner may have none) +/mob/living/carbon/human/npc/outpost_prisoner/proc/worn_prison_uniform() + var/obj/item/clothing/under/rank/prisoner/outpost/suit = w_uniform + return istype(suit) ? suit : null + +/// Their grime shows on the real jumpsuit they wear, which keeps it when it comes off +/mob/living/carbon/human/npc/outpost_prisoner/proc/uniform_grime_to_suit() + var/obj/item/clothing/under/rank/prisoner/outpost/suit = worn_prison_uniform() + if(suit && suit.grime != uniform_grime) + suit.set_grime(uniform_grime) + update_worn_undersuit() + /// Fed, 0-100: full marks until they are hungry, then down to nothing once they are starving -/mob/living/basic/outpost_prisoner/proc/fed_factor() +/mob/living/carbon/human/npc/outpost_prisoner/proc/fed_factor() return clamp(100 * (hunger - PRISONER_HUNGER_STARVING) / (PRISONER_HUNGER_HUNGRY - PRISONER_HUNGER_STARVING), 0, 100) /// Clean, 0-100: full marks until the uniform is dirty, then down to nothing once it is filthy -/mob/living/basic/outpost_prisoner/proc/clean_factor() +/mob/living/carbon/human/npc/outpost_prisoner/proc/clean_factor() return clamp(100 * (PRISONER_GRIME_FILTHY - uniform_grime) / (PRISONER_GRIME_FILTHY - PRISONER_GRIME_DIRTY), 0, 100) -/mob/living/basic/outpost_prisoner/proc/health_factor() +/mob/living/carbon/human/npc/outpost_prisoner/proc/health_factor() return stat == DEAD ? 0 : clamp(100 * health / maxHealth, 0, 100) /// Care, 0-100: the mean of fed, clean and health -/mob/living/basic/outpost_prisoner/proc/care() +/mob/living/carbon/human/npc/outpost_prisoner/proc/care() return (fed_factor() + clean_factor() + health_factor()) / 3 /// What their needs do to their mood per minute: list(gain, loss), before personality -/mob/living/basic/outpost_prisoner/proc/needs_mood_per_minute() +/mob/living/carbon/human/npc/outpost_prisoner/proc/needs_mood_per_minute() var/loss = 0 if(hunger < PRISONER_HUNGER_STARVING) loss += PRISONER_MOOD_STARVING @@ -501,21 +560,21 @@ loss += PRISONER_MOOD_HURT * (PRISONER_HURT_MOOD_BELOW - health_percent) / PRISONER_HURT_MOOD_BELOW return list(0, loss) -/mob/living/basic/outpost_prisoner/proc/wants_food() +/mob/living/carbon/human/npc/outpost_prisoner/proc/wants_food() return stat == CONSCIOUS && hunger < PRISONER_HUNGER_SEEK && well_fed_left <= 0 -/mob/living/basic/outpost_prisoner/proc/wants_clean_uniform() +/mob/living/carbon/human/npc/outpost_prisoner/proc/wants_clean_uniform() return stat == CONSCIOUS && uniform_grime >= PRISONER_GRIME_DIRTY /// Whether they would take this off the floor or the hatch and change into it -/mob/living/basic/outpost_prisoner/proc/would_change_into(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/would_change_into(obj/item/thing) var/obj/item/clothing/under/rank/prisoner/outpost/fresh = thing return istype(fresh) && wants_clean_uniform() && fresh.grime < PRISONER_GRIME_DIRTY && fresh.grime < uniform_grime // ===== THOUGHT BUBBLE ===== /// The needs that want attention now, most urgent first: hungry, hurt, dirty -/mob/living/basic/outpost_prisoner/proc/bubble_needs() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bubble_needs() var/list/needs = list() if(hunger < PRISONER_HUNGER_HUNGRY) needs += "hungry" @@ -530,7 +589,7 @@ * or loose (a shiv) and being an experiment's subject (a syringe) show on their own. Otherwise it * cycles through their needs every PRISONER_BUBBLE_CYCLE, most urgent first. */ -/mob/living/basic/outpost_prisoner/proc/wanted_bubble(list/needs) +/mob/living/carbon/human/npc/outpost_prisoner/proc/wanted_bubble(list/needs) if(stat == DEAD || phase != PRISONER_PRESENT) return null if(is_rioting() || trouble == PRISONER_TROUBLE_LOOSE) @@ -549,7 +608,7 @@ * starts at the most urgent. The bubble itself pops up now and then (see schedule_bubble()); a need that * has just come up, a riot or a dose pops it soon. */ -/mob/living/basic/outpost_prisoner/proc/update_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/update_bubble() var/list/needs = bubble_needs() var/needs_text = jointext(needs, ",") var/fresh = FALSE @@ -576,7 +635,7 @@ * Pops the bubble when it is due, but not while what they last said is still up over their head * (hush_bubble()). One that no longer holds fades early, and a riot takes over at once. */ -/mob/living/basic/outpost_prisoner/proc/schedule_bubble(list/needs, fresh) +/mob/living/carbon/human/npc/outpost_prisoner/proc/schedule_bubble(list/needs, fresh) if(popped_bubble && popped_bubble != bubble && (!(popped_bubble in needs) || bubble == "riot")) fade_bubble() if(!bubble) @@ -587,11 +646,11 @@ pop_bubble() /// They said or emoted something: see hush_bubble() -/mob/living/basic/outpost_prisoner/proc/on_speech(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_speech(datum/source) SIGNAL_HANDLER hush_bubble() -/mob/living/basic/outpost_prisoner/manual_emote(text) +/mob/living/carbon/human/npc/outpost_prisoner/manual_emote(text) . = ..() if(.) hush_bubble() @@ -601,7 +660,7 @@ * sinks away out of the text's way, and none pops until the words are gone; then a need that * still holds comes back within PRISONER_BUBBLE_FRESH_DELAY. */ -/mob/living/basic/outpost_prisoner/proc/hush_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/hush_bubble() COOLDOWN_START(src, bubble_hush, PRISONER_BUBBLE_HUSH) if(!popped_bubble) return @@ -609,7 +668,7 @@ bubble_next_pop = min(bubble_next_pop, world.time + PRISONER_BUBBLE_HUSH + rand(0, PRISONER_BUBBLE_FRESH_DELAY)) /// Pops the bubble up with a little bounce; it bobs, then fades on its own -/mob/living/basic/outpost_prisoner/proc/pop_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/pop_bubble() if(!bubble || QDELETED(src)) return popped_bubble = bubble @@ -623,7 +682,7 @@ bubble_timer = addtimer(CALLBACK(src, PROC_REF(end_bubble)), PRISONER_BUBBLE_SHOW + PRISONER_BUBBLE_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) /// Fades the bubble out early, sinking with `sink` (see /obj/effect/abstract/outpost_thought/proc/fade()) -/mob/living/basic/outpost_prisoner/proc/fade_bubble(sink = FALSE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/fade_bubble(sink = FALSE) popped_bubble = null if(!thought) return @@ -632,56 +691,22 @@ bubble_timer = addtimer(CALLBACK(src, PROC_REF(end_bubble)), PRISONER_BUBBLE_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) /// Takes the faded bubble down -/mob/living/basic/outpost_prisoner/proc/end_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/end_bubble() bubble_timer = null popped_bubble = null if(thought) vis_contents -= thought /// Takes any bubble down at once, with no fade: for when they beam in or out -/mob/living/basic/outpost_prisoner/proc/drop_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/drop_bubble() deltimer(bubble_timer) end_bubble() -/mob/living/basic/outpost_prisoner/update_overlays() +/mob/living/carbon/human/npc/outpost_prisoner/update_overlays() . = ..() - if(grime_stage) - var/mutable_appearance/grime = mutable_appearance('icons/effects/blood.dmi', "uniformblood") - grime.color = "#5a4630" - grime.alpha = grime_stage == 2 ? 200 : 130 - . += grime + // Their grime is on the jumpsuit itself (uniform_grime_to_suit()); filth draws flies. if(grime_stage == 2) . += mutable_appearance('icons/effects/effects.dmi', "fly-surrounding", ABOVE_MOB_LAYER) - if(cuffs) - // tg's own cuff overlay, as people wear it - . += mutable_appearance('icons/mob/simple/mob.dmi', "handcuff1") - if(held_item) - // In hand the way a player holds it, turning with them (npc_inhand_look() in bounty_ai.dm) - var/list/inhand = npc_inhand_look(held_item) - if(inhand) - var/mutable_appearance/carried = mutable_appearance(inhand[1], inhand[2]) - carried.plane = FLOAT_PLANE - carried.layer = FLOAT_LAYER - . += carried - else - // No in-hand sprite: the item itself, small, in the same hand whichever way they face - var/mutable_appearance/carried = new(held_item.appearance) - carried.plane = FLOAT_PLANE - carried.layer = FLOAT_LAYER - carried.dir = SOUTH - carried.pixel_x = 0 - carried.pixel_y = 0 - carried.pixel_w = ((dir & WEST) || dir == NORTH) ? -7 : 7 - carried.pixel_z = -4 - carried.transform = matrix().Scale(0.6) - . += carried - -/// Turning moves what they carry to the other side -/mob/living/basic/outpost_prisoner/setDir(newdir) - var/old_dir = dir - . = ..() - if(held_item && dir != old_dir) - update_appearance(UPDATE_OVERLAYS) /** * tg's thought bubble, as a point uses, with the needed item's own sprite inset. A prisoner's pops up @@ -775,7 +800,7 @@ * turn to it, lean in, and it visibly goes into their hand. With `announce`, the room is told what * they took and from where. Returns TRUE if they hold it. */ -/mob/living/basic/outpost_prisoner/proc/take_item(obj/item/thing, announce = FALSE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/take_item(obj/item/thing, announce = FALSE) if(held_item || QDELETED(thing)) return FALSE if(thing.loc != src) @@ -793,15 +818,14 @@ var/obj/structure/table/reinforced/prison_hatch/hatch = locate() in from var/message = hatch ? "[src] takes [thing] off [hatch]." : "[src] picks up [thing]." visible_message(span_notice(message)) - thing.forceMove(src) - if(thing.loc != src) + // Into a real hand: tg draws it there, and anyone can knock it out of it. + if(!put_in_hands(thing)) return FALSE held_item = thing - update_appearance(UPDATE_OVERLAYS) return TRUE /// Leans towards `target` for a moment, as a hand goes out to it -/mob/living/basic/outpost_prisoner/proc/reach_animation(atom/target) +/mob/living/carbon/human/npc/outpost_prisoner/proc/reach_animation(atom/target) var/direction = get_dir(src, target) if(!direction) return @@ -811,18 +835,20 @@ animate(pixel_x = pixel_x - shift_x, pixel_y = pixel_y - shift_y, time = 2, easing = SINE_EASING, flags = ANIMATION_PARALLEL) /// Puts down whatever they carry, on `where` or their own tile -/mob/living/basic/outpost_prisoner/proc/drop_held_item(atom/where) +/mob/living/carbon/human/npc/outpost_prisoner/proc/drop_held_item(atom/where) if(!held_item) return null var/obj/item/dropped = held_item - dropped.forceMove(where || drop_location()) + if(!(where ? transferItemToLoc(dropped, where, force = TRUE) : dropItemToGround(dropped, force = TRUE))) + return null + held_item = null return dropped /** * Whether they can reach `thing` from where they stand. At a serving hatch they open their side * first: PRISONER_REACH_WAIT means it is opening. */ -/mob/living/basic/outpost_prisoner/proc/try_reach(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/try_reach(obj/item/thing) if(QDELETED(thing) || !isturf(thing.loc)) return PRISONER_REACH_FAILED if(thing.loc == loc) @@ -840,7 +866,7 @@ * first time it is in reach they turn to it and hold out a hand (PRISONER_REACH_WAIT), and only on * the next call, about a second later, is it PRISONER_REACH_OK to take it. */ -/mob/living/basic/outpost_prisoner/proc/reach_for(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/reach_for(obj/item/thing) var/result = try_reach(thing) if(result != PRISONER_REACH_OK) reaching_ref = null @@ -853,7 +879,7 @@ return PRISONER_REACH_WAIT /// Sits on a chair, stool or toilet, facing `facing` if given -/mob/living/basic/outpost_prisoner/proc/sit_on(obj/structure/seat, facing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/sit_on(obj/structure/seat, facing) if(buckled != seat) if(loc != seat.loc) return FALSE @@ -865,7 +891,7 @@ return TRUE /// Gets up from whatever they sit or lie on -/mob/living/basic/outpost_prisoner/proc/stand_up() +/mob/living/carbon/human/npc/outpost_prisoner/proc/stand_up() remove_offsets(PRISONER_SITTING_OFFSET) buckled?.unbuckle_mob(src, force = TRUE) @@ -873,7 +899,7 @@ * Settles on the bed under them. There is no sitting pose for a bed, and a standing sprite shifted down * reads as someone standing on it, so they lie on it, awake. With no bed to lie on, they crouch. */ -/mob/living/basic/outpost_prisoner/proc/sit_on_edge(facing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/sit_on_edge(facing) var/obj/structure/bed/bed = locate() in loc if(bed && buckled != bed) stand_up() @@ -884,7 +910,7 @@ setDir(facing) /// Whether they crouch where they are, as sit_on_edge() leaves them with no bed under them -/mob/living/basic/outpost_prisoner/proc/is_crouching() +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_crouching() return !!has_offset(PRISONER_SITTING_OFFSET) /** @@ -893,7 +919,7 @@ * way it faces; `facing` is for the bed and the crouch. The bed is for lying down, so any sitting in * the cell goes to the chair first (home_seat()). */ -/mob/living/basic/outpost_prisoner/proc/sit_in_cell(facing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/sit_in_cell(facing) var/obj/structure/chair/chair = locate() in loc if(chair && (buckled == chair || !chair.has_buckled_mobs()) && sit_on(chair)) return @@ -903,7 +929,7 @@ * Where they sit down in their own cell: its chair if they can use it (seat_usable()), else its bed * on the same terms, lying on it. Null if the cell has neither they can use. */ -/mob/living/basic/outpost_prisoner/proc/home_seat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/home_seat() if(!cell) return null var/obj/structure/chair/chair = cell.chair() @@ -915,7 +941,7 @@ return null /// Whether they can walk to `seat` and use it: nobody else on its tile or in it, and nobody else has claimed it -/mob/living/basic/outpost_prisoner/proc/seat_usable(obj/structure/seat) +/mob/living/carbon/human/npc/outpost_prisoner/proc/seat_usable(obj/structure/seat) var/turf/tile = get_turf(seat) if(!tile || !walkable?[tile] || (tile != loc && tile_taken(tile))) return FALSE @@ -949,11 +975,9 @@ return "snack" return "poor" -/mob/living/basic/outpost_prisoner/proc/on_pre_eat(datum/source, atom/food, mob/living/feeder) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_pre_eat(datum/source, atom/food, mob/living/feeder) SIGNAL_HANDLER - if(cuffs) - if(feeder) - balloon_alert(feeder, "cuffed") + if(handcuffed && !feeder) return COMSIG_MOB_CANCEL_EAT if(prison?.pastime_pre_eat(src, food, feeder)) return COMSIG_MOB_CANCEL_EAT @@ -967,7 +991,7 @@ * Eats `meal`: hunger and mood by how good it is, and cooked food keeps them full for * PRISONER_WELL_FED_TIME. Returns the tier. */ -/mob/living/basic/outpost_prisoner/proc/eat_food(obj/item/food/meal) +/mob/living/carbon/human/npc/outpost_prisoner/proc/eat_food(obj/item/food/meal) var/tier = outpost_prisoner_food_tier(meal) switch(tier) if("ration") @@ -985,7 +1009,7 @@ return tier /// What they say about a meal: praise for cooking, a complaint about poor food, else thanks to whoever fed them -/mob/living/basic/outpost_prisoner/proc/react_to_food(tier, mob/living/feeder) +/mob/living/carbon/human/npc/outpost_prisoner/proc/react_to_food(tier, mob/living/feeder) switch(tier) if("cooked") if(thank("good_food") || !feeder) @@ -997,7 +1021,7 @@ thank("thanks_food") /// Fed by hand -/mob/living/basic/outpost_prisoner/proc/on_ate(datum/source, atom/food, mob/living/feeder) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_ate(datum/source, atom/food, mob/living/feeder) SIGNAL_HANDLER var/tier = eat_food(food) if(isturf(loc) && prob(50)) @@ -1011,7 +1035,7 @@ * `table_turf` is null when they ate standing up. Returns the wrapper when they mean to take it * to the bin, else null. */ -/mob/living/basic/outpost_prisoner/proc/finish_meal(obj/item/food/meal, turf/seat_turf, turf/table_turf) +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_meal(obj/item/food/meal, turf/seat_turf, turf/table_turf) if(QDELETED(meal)) return null var/tier = eat_food(meal) @@ -1030,7 +1054,7 @@ * unhappy one (below PRISONER_LITTER_MOOD) drops it on the floor; anyone else leaves it where * they ate. */ -/mob/living/basic/outpost_prisoner/proc/leave_meal_mess(obj/item/food/meal, turf/floor, turf/table_turf) +/mob/living/carbon/human/npc/outpost_prisoner/proc/leave_meal_mess(obj/item/food/meal, turf/floor, turf/table_turf) if(isopenturf(floor) && (!table_turf || prob(PRISONER_TABLE_CRUMB_CHANCE))) new /obj/effect/decal/cleanable/food/crumbs(floor) var/trash_type @@ -1057,7 +1081,7 @@ return trash /// Puts `trash` in `bin` (or the nearest bin in reach) straight away. Returns TRUE if it went in. -/mob/living/basic/outpost_prisoner/proc/bin_litter(obj/item/trash, obj/structure/closet/crate/bin/bin) +/mob/living/carbon/human/npc/outpost_prisoner/proc/bin_litter(obj/item/trash, obj/structure/closet/crate/bin/bin) if(QDELETED(trash)) return FALSE bin = bin || prison?.find_bin(src) @@ -1085,7 +1109,7 @@ return count < bin.storage_capacity /// Someone fed, clothed or treated them by hand -/mob/living/basic/outpost_prisoner/proc/note_carer(mob/living/carer) +/mob/living/carbon/human/npc/outpost_prisoner/proc/note_carer(mob/living/carer) if(!istype(carer) || is_outpost_prisoner(carer)) return last_carer_ref = WEAKREF(carer) @@ -1094,11 +1118,9 @@ // ===== UNIFORMS ===== -/// Cuffs go on them (outpost_prison_capture.dm); handed a cleaner prison uniform, they change and hand the old one back -/mob/living/basic/outpost_prisoner/proc/on_item_interaction(datum/source, mob/living/user, obj/item/tool, list/modifiers) +/// Treated by hand, they notice; handed a cleaner prison uniform, they change and hand the old one back. Cuffs are tg's own (outpost_prison_capture.dm). +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_item_interaction(datum/source, mob/living/user, obj/item/tool, list/modifiers) SIGNAL_HANDLER - if(istype(tool, /obj/item/restraints/handcuffs)) - return on_cuffs_used(user, tool) if(istype(tool, /obj/item/stack/medical)) // Treatment only shows as health coming back once the dressing is on. COOLDOWN_START(src, treatment_window, 20 SECONDS) @@ -1108,7 +1130,7 @@ // Not while they beam in or out, invisible or half there. if(!istype(offered) || user.combat_mode || stat != CONSCIOUS || phase != PRISONER_PRESENT) return NONE - if(cuffs) + if(handcuffed) balloon_alert(user, "cuffed") return ITEM_INTERACT_BLOCKING if(offered.grime >= uniform_grime) @@ -1128,7 +1150,7 @@ * Picks up a cleaner uniform within arm's reach (their own tile or beside them) and changes, leaving * the old one in its place. They turn to it, lean in, and it visibly leaves the floor or the hatch. */ -/mob/living/basic/outpost_prisoner/proc/take_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh) +/mob/living/carbon/human/npc/outpost_prisoner/proc/take_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh) if(!would_change_into(fresh) || !isturf(fresh.loc) || !(fresh.loc == loc || Adjacent(fresh))) return FALSE var/turf/spot = fresh.loc @@ -1143,32 +1165,38 @@ return TRUE /** - * Changes into `fresh` and returns their old uniform, created at `drop_spot`. The clean one goes - * before the old one appears, so a swap on a full serving hatch never pushes it over capacity. + * Changes into `fresh`, the way anyone changes: the jumpsuit they wear comes off onto `drop_spot` + * and the clean one goes on. Returns the old one (null if what they wore wasn't a prison jumpsuit). */ -/mob/living/basic/outpost_prisoner/proc/swap_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh, atom/drop_spot) +/mob/living/carbon/human/npc/outpost_prisoner/proc/swap_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh, atom/drop_spot) var/fresh_grime = fresh.grime - var/old_grime = uniform_grime - qdel(fresh) - var/obj/item/clothing/under/rank/prisoner/outpost/old = new(drop_spot) - old.set_grime(old_grime) + // Off the counter before the old one goes down, so a swap on a full serving hatch never pushes it over capacity. + fresh.forceMove(src) + var/obj/item/clothing/under/rank/prisoner/outpost/old = worn_prison_uniform() + if(old) + old.set_grime(uniform_grime) + if(!transferItemToLoc(old, drop_spot, force = TRUE)) + return null + else if(w_uniform) + // Someone else's clothes: off they come, as they are + var/obj/item/other_clothes = w_uniform + if(!transferItemToLoc(other_clothes, drop_spot, force = TRUE)) + return null + if(!equip_to_slot_if_possible(fresh, ITEM_SLOT_ICLOTHING, disable_warning = TRUE)) + fresh.forceMove(drop_spot) set_uniform_grime(fresh_grime) adjust_mood(PRISONER_MOOD_CLEAN_UNIFORM) return old // ===== HURT AND TREATED ===== -/// Blood on the floor from any real brute hit -/mob/living/basic/outpost_prisoner/proc/on_damaged(datum/source, damage_dealt, damagetype) +/// A real hit that puts them down is a collapse (outpost_prison_trouble.dm). tg does the blood. +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_damaged(datum/source, damage_dealt, damagetype) SIGNAL_HANDLER - if((damagetype == BRUTE || damagetype == BURN) && damage_dealt > 0) - // Low enough and they collapse (outpost_prison_trouble.dm). + if(damage_dealt > 0 && (damagetype == BRUTE || damagetype == BURN || damagetype == STAMINA)) INVOKE_ASYNC(src, PROC_REF(check_beaten)) - if(damagetype != BRUTE || damage_dealt < 3 || !isturf(loc)) - return - add_splatter_floor(loc, damage_dealt < 10) -/mob/living/basic/outpost_prisoner/proc/on_health_update(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_health_update(datum/source) SIGNAL_HANDLER var/was = last_health last_health = health @@ -1178,15 +1206,6 @@ adjust_mood(PRISONER_MOOD_TREATED) INVOKE_ASYNC(src, PROC_REF(thank), "thanks_treatment") -/// Badly hurt and untreated: now and then a drop of blood -/mob/living/basic/outpost_prisoner/proc/maybe_drip(seconds) - if(stat == DEAD || !isturf(loc) || health_factor() >= PRISONER_BLEED_BELOW) - return FALSE - if(!SPT_PROB(3, seconds)) - return FALSE - add_splatter_floor(loc, small_drip = TRUE) - return TRUE - // ===== LEFT ALONE ===== /** @@ -1197,7 +1216,7 @@ * moments tg switches the AI off after a plan that queued nothing (ai_running()), they walk over * and take things by hand instead. */ -/mob/living/basic/outpost_prisoner/proc/fend_for_self() +/mob/living/carbon/human/npc/outpost_prisoner/proc/fend_for_self() if(!routine_allowed() || ai_running()) return if(wants_food()) @@ -1213,7 +1232,7 @@ swap_uniform(fresh, fresh.loc) /// Leaves some dirt or a wrapper where they stand -/mob/living/basic/outpost_prisoner/proc/make_mess() +/mob/living/carbon/human/npc/outpost_prisoner/proc/make_mess() var/turf/open/floor = loc if(!istype(floor)) return @@ -1223,13 +1242,12 @@ var/trash_type = pick(/obj/item/trash/candy, /obj/item/trash/chips, /obj/item/trash/raisins) new trash_type(floor) -/mob/living/basic/outpost_prisoner/death(gibbed) +/mob/living/carbon/human/npc/outpost_prisoner/death(gibbed) var/was_alive = stat != DEAD . = ..() + // The body keeps what it held and wore, cuffs too, as anyone's does. if(was_alive && stat == DEAD) end_activity() - drop_held_item() - remove_cuffs() prison?.on_prisoner_death(src) update_bubble() @@ -1237,7 +1255,7 @@ /datum/outfit/outpost_prisoner name = "Outpost prisoner" - uniform = /obj/item/clothing/under/rank/prisoner + uniform = /obj/item/clothing/under/rank/prisoner/outpost shoes = /obj/item/clothing/shoes/sneakers/orange /datum/outfit/outpost_prisoner/glasses @@ -1250,3 +1268,4 @@ #undef PRISONER_BEAM_TRAIT #undef PRISONER_SITTING_OFFSET +#undef PRISONER_AWAKE_TRAIT diff --git a/voidcrew/modules/player_outposts/outpost_prison_researcher.dm b/voidcrew/modules/player_outposts/outpost_prison_researcher.dm index 163c4718879..52fd2faa699 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_researcher.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_researcher.dm @@ -155,7 +155,7 @@ if(!spot) return null roll_offer() - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = new(spot, src) + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = new(spot, src) researcher = doctor researcher_stay = 0 pitch_left = OUTPOST_KESSLER_BEAM_TIME / (1 SECONDS) + 2 @@ -184,7 +184,7 @@ * checked again once it returns. Returns what they got: the item, TRUE for a decline, or FALSE. */ /datum/outpost_prison/proc/present_offer(mob/living/user) - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor) || doctor.leaving || doctor.beaming || !outpost?.can_manage(user)) return FALSE var/mob/holder = offer_claim?.resolve() @@ -217,7 +217,7 @@ * to 0, and the researcher leaves. Refused if the wing could not run it now. Returns the item. */ /datum/outpost_prison/proc/accept_offer(mob/living/user) - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor) || doctor.leaving || !outpost?.can_manage(user)) return FALSE var/needed = offer_kind == "specimen" ? 2 : 1 @@ -243,7 +243,7 @@ /// `user` turns the offer down: the next one pays more, and the researcher leaves /datum/outpost_prison/proc/decline_offer(mob/living/user) - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor) || doctor.leaving) return FALSE sweetener = min(sweetener + OUTPOST_EXPERIMENT_SWEETENER, OUTPOST_EXPERIMENT_SWEETENER_MAX) @@ -255,7 +255,7 @@ /// Nobody took the offer in time: the researcher leaves, and the next offer pays more /datum/outpost_prison/proc/researcher_lapse() - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor)) researcher = null return FALSE @@ -265,21 +265,37 @@ send_researcher_away(doctor) return TRUE -/// Someone hurt the researcher: gone at once, with no sweetener for the next visit +/** + * Someone hurt the researcher: "I'm leaving!", and the corporate beam takes them a couple of seconds + * later (OUTPOST_KESSLER_ASSAULT_BEAM_DELAY), with no sweetener for the next visit. The hit was real; + * the beam is only a way out. + */ /datum/outpost_prison/proc/researcher_assaulted() - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor)) return FALSE // Hit while still beaming in: they just go, without a word from thin air. - if(!doctor.beaming && !doctor.departing) + if(!doctor.beaming && !doctor.departing && doctor.stat == CONSCIOUS) doctor.say("I'm leaving!") add_log("[doctor.real_name] was attacked and left.") log_game("PLAYER OUTPOST PRISON: the Kessler researcher at '[outpost?.name]' was attacked and left") - send_researcher_away(doctor, 0) + send_researcher_away(doctor, OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) return TRUE +/** + * The researcher was killed before Kessler's beam got them out. Kessler takes the body back, and + * doesn't come again for a long while (OUTPOST_KESSLER_KILLED_WAIT_MIN to _MAX). + */ +/datum/outpost_prison/proc/researcher_killed(mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor, mob/killer) + add_log("[doctor.real_name] of Kessler Biolabs was killed. Kessler wants a word about that.") + log_game("PLAYER OUTPOST PRISON: the Kessler researcher at '[outpost?.name]' was killed[killer ? " (last hit by [key_name(killer)])" : ""]") + if(researcher == doctor) + researcher = null + offer_claim = null + researcher_wait = max(researcher_wait || 0, rand(OUTPOST_KESSLER_KILLED_WAIT_MIN, OUTPOST_KESSLER_KILLED_WAIT_MAX)) + /// The researcher leaves after `delay`; the next visit is 35-50 minutes off -/datum/outpost_prison/proc/send_researcher_away(mob/living/basic/outpost_kessler_staff/researcher/doctor, delay = 2 SECONDS) +/datum/outpost_prison/proc/send_researcher_away(mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor, delay = 2 SECONDS) researcher = null offer_claim = null researcher_wait = rand(OUTPOST_EXPERIMENT_GAP_MIN, OUTPOST_EXPERIMENT_GAP_MAX) @@ -291,39 +307,28 @@ if(!spot) return null // Not the wing's researcher: nobody can take an offer from them, and they hold no reference to the prison. - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = new(spot, null) + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = new(spot, null) doctor.beam_in() // A second after they are all the way in, not while they are still knitting together. - addtimer(CALLBACK(doctor, TYPE_PROC_REF(/mob/living/basic/outpost_kessler_staff/researcher, refuse_wing)), OUTPOST_KESSLER_BEAM_TIME + 1 SECONDS, TIMER_DELETE_ME) + addtimer(CALLBACK(doctor, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_kessler_staff/researcher, refuse_wing)), OUTPOST_KESSLER_BEAM_TIME + 1 SECONDS, TIMER_DELETE_ME) add_log("[doctor.real_name] of Kessler Biolabs came, but would not work in the wing as it is.") return doctor // ===== KESSLER STAFF ===== -/// Someone from Kessler Biolabs: beams in and out, cannot be hurt, boxed, teleported or taken over -/mob/living/basic/outpost_kessler_staff +/** + * Someone from Kessler Biolabs, a real human body (owner ruling OC-3): they can be hurt, bled, cuffed + * and killed like anyone. The first real hit sends them away: "I'm leaving!" and Kessler's beam a + * couple of seconds later. Teleporters, polymorph and bluespace bags don't take them while they are + * alive. Their kit is locked to them (D3). They have no AI: they stand where they beamed in. + */ +/mob/living/carbon/human/npc/outpost_kessler_staff name = "Kessler Biolabs employee" desc = "Someone from Kessler Biolabs." - icon = 'icons/mob/simple/simple_human.dmi' - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - basic_mob_flags = NONE - status_flags = NONE - move_resist = INFINITY - // tg's human deathgasp - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - density = TRUE - maxHealth = 100 - health = 100 - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 0, STAMINA = 0, OXY = 0) - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - speak_emote = list("says") - response_help_continuous = "taps" - response_help_simple = "tap" - /// What they wear - var/outfit = /datum/outfit/outpost_kessler_researcher + faction = list(FACTION_NEUTRAL) + ai_controller = null + npc_outfit = /datum/outfit/outpost_kessler_researcher + npc_target_stat = HUMAN_NPC_SPARES_DOWNED /// The prison they came to var/datum/outpost_prison/prison /// On the way out @@ -335,52 +340,78 @@ /// A line of dialogue to say once they have fully materialised, if any var/arrival_line -/mob/living/basic/outpost_kessler_staff/Initialize(mapload, datum/outpost_prison/owner) - gender = pick(MALE, FEMALE) - . = ..() - prison = owner - ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) +/** + * The second argument is the prison they come to (or null for a visit that isn't the wing's), never a + * human NPC spawn order. Made one at a time as they visit, so they don't go through the spawn queue. + */ +/mob/living/carbon/human/npc/outpost_kessler_staff/Initialize(mapload, datum/outpost_prison/owner) + . = ..(mapload) + prison = istype(owner) ? owner : null + ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, OUTPOST_KESSLER_TRAIT) + ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, OUTPOST_KESSLER_TRAIT) ADD_TRAIT(src, TRAIT_OUTPOST_EXPERIMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_PUSHIMMUNE, INNATE_TRAIT) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) + AddElement(/datum/element/relay_attackers) + RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) + RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - apply_dynamic_human_appearance(src, outfit, /datum/species/human) -/mob/living/basic/outpost_kessler_staff/Destroy() +/mob/living/carbon/human/npc/outpost_kessler_staff/Destroy() if(prison?.researcher == src) prison.researcher = null prison.offer_claim = null prison = null return ..() -/// Nothing Kessler sends can be hurt: a blow makes them leave instead -/mob/living/basic/outpost_kessler_staff/adjust_health(amount, updating_health = TRUE, forced = FALSE) - if(amount > 0 && !forced) +/// A real blow from someone: they leave +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/on_attacked(datum/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK)) INVOKE_ASYNC(src, PROC_REF(hurt_by_someone)) - return 0 - return ..() -/mob/living/basic/outpost_kessler_staff/proc/hurt_by_someone() - leave() +/// Hurt any other way (a fire, a blast, a stray shot): they leave too +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/on_damaged(datum/source, damage_dealt, damagetype) + SIGNAL_HANDLER + if(damage_dealt > 0 && damagetype != OXY) + INVOKE_ASYNC(src, PROC_REF(hurt_by_someone)) + +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/hurt_by_someone() + leave(OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) + +/mob/living/carbon/human/npc/outpost_kessler_staff/death(gibbed) + var/datum/outpost_prison/visited = prison + var/was_alive = stat != DEAD + . = ..() + if(!was_alive || stat != DEAD) + return + REMOVE_TRAIT(src, TRAIT_NO_CONTAINMENT, OUTPOST_KESSLER_TRAIT) + REMOVE_TRAIT(src, TRAIT_NO_STORAGE_INSERT, OUTPOST_KESSLER_TRAIT) + killed_on_visit(visited) + // Kessler takes its own people back, alive or not. + leave(OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) -/mob/living/basic/outpost_kessler_staff/proc/refuse_teleport(datum/source, atom/destination, channel) +/// Killed while visiting `visited` +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/killed_on_visit(datum/outpost_prison/visited) + return + +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER + if(stat == DEAD) + return FALSE return TRUE -/mob/living/basic/outpost_kessler_staff/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER return STOP_WABBAJACK -/mob/living/basic/outpost_kessler_staff/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/// Says a line of `context` from the dialogue file. Never while beaming in or out, invisible or half there. -/mob/living/basic/outpost_kessler_staff/proc/say_line(context) - if(beaming || departing) +/// Says a line of `context` from the dialogue file. Never while beaming in or out, invisible or half there, or unable to talk. +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/say_line(context) + if(beaming || departing || !human_npc_can_talk(src)) return null var/line = outpost_experiment_line(context) if(line) @@ -391,7 +422,7 @@ * Materialises where they stand: they knit together inside the transporter's column over the * whole beam. They stay `beaming` (held still, silent) until finish_beam_in() as the beam ends. */ -/mob/living/basic/outpost_kessler_staff/proc/beam_in() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/beam_in() beaming = TRUE ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) var/turf/spot = get_turf(src) @@ -404,7 +435,7 @@ addtimer(CALLBACK(src, PROC_REF(finish_beam_in)), OUTPOST_KESSLER_BEAM_TIME, TIMER_DELETE_ME) /// Fully there as the beam ends: the flash, and only now do they talk -/mob/living/basic/outpost_kessler_staff/proc/finish_beam_in() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/finish_beam_in() if(!beaming || departing) return var/turf/spot = get_turf(src) @@ -420,7 +451,7 @@ say_line(arrival_line) /// Leaves after `delay`: beams out and is gone -/mob/living/basic/outpost_kessler_staff/proc/leave(delay = 0) +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/leave(delay = 0) if(leaving) return leaving = TRUE @@ -433,9 +464,13 @@ else beam_out() -/mob/living/basic/outpost_kessler_staff/proc/beam_out() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/beam_out() + if(departing || QDELETED(src)) + return beaming = FALSE departing = TRUE + pulledby?.stop_pulling() + buckled?.unbuckle_mob(src, force = TRUE) ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) var/turf/spot = get_turf(src) if(spot) @@ -444,7 +479,7 @@ transporter_dematerialise(src, OUTPOST_KESSLER_BEAM_TIME) addtimer(CALLBACK(src, PROC_REF(finish_beam_out)), OUTPOST_KESSLER_BEAM_TIME, TIMER_DELETE_ME) -/mob/living/basic/outpost_kessler_staff/proc/finish_beam_out() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/finish_beam_out() var/turf/spot = get_turf(src) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) @@ -454,34 +489,36 @@ // ----- the researcher ----- -/mob/living/basic/outpost_kessler_staff/researcher +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher name = "researcher" desc = "A researcher from Kessler Biolabs, with a clipboard and a labcoat that has never seen a stain." - outfit = /datum/outfit/outpost_kessler_researcher + npc_outfit = /datum/outfit/outpost_kessler_researcher COOLDOWN_DECLARE(brush_off_cooldown) -/mob/living/basic/outpost_kessler_staff/researcher/Initialize(mapload, datum/outpost_prison/owner) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/Initialize(mapload, datum/outpost_prison/owner) . = ..() - real_name = "Dr. [pick(GLOB.last_names)]" - name = real_name + fully_replace_character_name(real_name, "Dr. [pick(GLOB.last_names)]") RegisterSignal(src, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_hand)) -/mob/living/basic/outpost_kessler_staff/researcher/hurt_by_someone() - if(prison?.researcher == src) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/hurt_by_someone() + if(prison?.researcher == src && !leaving) prison.researcher_assaulted() return - leave() + leave(OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) + +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/killed_on_visit(datum/outpost_prison/visited) + visited?.researcher_killed(src, lastattackerckey ? get_mob_by_ckey(lastattackerckey) : null) -/// An empty hand, not in combat mode: a word with the researcher instead of a pat -/mob/living/basic/outpost_kessler_staff/researcher/proc/on_hand(datum/source, mob/living/user, list/modifiers) +/// An empty hand, not in combat mode: a word with the researcher instead of a pat. Down or out cold, tg's own help. +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/on_hand(datum/source, mob/living/user, list/modifiers) SIGNAL_HANDLER - if(!istype(user) || user.combat_mode || LAZYACCESS(modifiers, RIGHT_CLICK)) + if(!istype(user) || user.combat_mode || LAZYACCESS(modifiers, RIGHT_CLICK) || stat != CONSCIOUS || on_fire) return NONE INVOKE_ASYNC(src, PROC_REF(talk_to), user) return COMPONENT_CANCEL_ATTACK_CHAIN /// Managers get the offer; everyone else gets told who the researcher deals with -/mob/living/basic/outpost_kessler_staff/researcher/proc/talk_to(mob/living/user) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/talk_to(mob/living/user) if(leaving || beaming || !prison || prison.researcher != src) return FALSE face_atom(user) @@ -493,17 +530,17 @@ return prison.present_offer(user) /// The offer card as a prompt: "Accept", "Decline", or null if it was closed -/mob/living/basic/outpost_kessler_staff/researcher/proc/ask_offer(mob/living/user, text, timeout) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/ask_offer(mob/living/user, text, timeout) return tgui_alert(user, text, "[real_name], Kessler Biolabs", list("Accept", "Decline"), timeout) /// A pitch to whoever is about, pushier after declines -/mob/living/basic/outpost_kessler_staff/researcher/proc/pitch(sweetened = FALSE) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/pitch(sweetened = FALSE) if(leaving || beaming) return null return say_line(sweetened ? "researcher_sweetened" : "researcher_offer") /// A short visit to a wing in no state for an experiment -/mob/living/basic/outpost_kessler_staff/researcher/proc/refuse_wing() +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/refuse_wing() if(QDELETED(src) || leaving) return say_line("researcher_pigsty") @@ -511,15 +548,30 @@ // ----- the recovery team ----- -/mob/living/basic/outpost_kessler_staff/agent +/mob/living/carbon/human/npc/outpost_kessler_staff/agent name = "Kessler recovery agent" desc = "A Kessler Biolabs recovery agent in a hazmat suit, carrying a tranquilliser gun." - outfit = /datum/outfit/outpost_kessler_agent + npc_outfit = /datum/outfit/outpost_kessler_agent + +/// Fires the real chloral hydrate dart in its syringe gun at `target`, the way anyone fires one. Returns TRUE if it fired. +/mob/living/carbon/human/npc/outpost_kessler_staff/agent/proc/dart(mob/living/target) + if(QDELETED(target) || stat != CONSCIOUS || departing) + return FALSE + var/obj/item/gun/syringe/gun = locate() in held_items + if(!gun || !length(gun.syringes) || !human_npc_ready_item(src, gun)) + return FALSE + face_atom(target) + var/old_combat_mode = combat_mode + set_combat_mode(TRUE, silent = TRUE) + ClickOn(target) + set_combat_mode(old_combat_mode, silent = TRUE) + return TRUE /** * Two agents beam in around `creature`, one says a line of `context` once they are all the way in, - * and they all beam out after OUTPOST_KESSLER_TEAM_TIME. A creature still on its feet is - * `tranquilise`d; one already down is simply collected. + * and they all beam out after OUTPOST_KESSLER_TEAM_TIME. With `tranquilise`, the first agent fires the + * real chloral hydrate dart in its syringe gun at the creature once it is all the way in, to keep it + * down while they work (owner ruling OC-3: they collect only what is already down). */ /proc/outpost_kessler_team(mob/living/creature, context = "kessler_recovery", tranquilise = TRUE) var/turf/center = get_turf(creature) @@ -529,9 +581,9 @@ for(var/turf/open/tile in orange(1, center)) if(!tile.is_blocked_turf(TRUE) && !(locate(/mob/living) in tile)) spots += tile - var/mob/living/basic/outpost_kessler_staff/agent/speaker + var/mob/living/carbon/human/npc/outpost_kessler_staff/agent/speaker for(var/i in 1 to min(2, length(spots))) - var/mob/living/basic/outpost_kessler_staff/agent/agent = new(pick_n_take(spots), null) + var/mob/living/carbon/human/npc/outpost_kessler_staff/agent/agent = new(pick_n_take(spots), null) agent.face_atom(creature) agent.beam_in() agent.leave(OUTPOST_KESSLER_TEAM_TIME) @@ -543,21 +595,11 @@ if(tranquilise) creature.visible_message(span_warning("Kessler Biolabs agents beam in around [creature], and a tranquilliser dart drops [creature.p_them()].")) playsound(center, 'sound/items/syringeproj.ogg', 50, TRUE) + if(speaker) + addtimer(CALLBACK(speaker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_kessler_staff/agent, dart), creature), OUTPOST_KESSLER_BEAM_TIME + 0.2 SECONDS, TIMER_DELETE_ME) else creature.visible_message(span_notice("Kessler Biolabs agents beam in to collect [creature].")) -/// Kessler's beam takes `target` away for good -/proc/outpost_kessler_beam_away(mob/living/target) - if(QDELETED(target)) - return - var/turf/spot = get_turf(target) - if(spot) - target.visible_message(span_notice("A transporter beam takes [target] away.")) - playsound(spot, 'sound/effects/magic/teleport_diss.ogg', 40, TRUE) - new /obj/effect/temp_visual/transporter_beam(spot, OUTPOST_KESSLER_BEAM_TIME + 0.5 SECONDS) - transporter_dematerialise(target, OUTPOST_KESSLER_BEAM_TIME) - QDEL_IN(target, OUTPOST_KESSLER_BEAM_TIME) - // ===== LOOKS ===== /datum/outfit/outpost_kessler_researcher @@ -578,3 +620,16 @@ gloves = /obj/item/clothing/gloves/latex shoes = /obj/item/clothing/shoes/sneakers/white r_hand = /obj/item/gun/syringe + +/datum/outfit/outpost_kessler_agent/post_equip(mob/living/carbon/human/agent, visuals_only = FALSE) + . = ..() + if(visuals_only) + return + var/obj/item/gun/syringe/gun = locate() in agent.held_items + if(!gun) + return + var/obj/item/reagent_containers/syringe/dart = new(gun) + dart.reagents.add_reagent(/datum/reagent/toxin/chloralhydrate, dart.volume) + dart.name = "tranquilliser syringe" + gun.syringes += dart + gun.update_appearance() diff --git a/voidcrew/modules/player_outposts/outpost_prison_riot.dm b/voidcrew/modules/player_outposts/outpost_prison_riot.dm index c531de4ec84..0468d022a1a 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_riot.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_riot.dm @@ -140,10 +140,10 @@ * mean mood, so a neglected wing sours its newcomers. `newcomer` is left out of the mean, as is * anyone who has not served a moment yet. */ -/datum/outpost_prison/proc/arrival_mood(mob/living/basic/outpost_prisoner/newcomer) +/datum/outpost_prison/proc/arrival_mood(mob/living/carbon/human/npc/outpost_prisoner/newcomer) var/total = 0 var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner == newcomer || prisoner.served_seconds <= 0 || !counts_for_tension(prisoner)) continue total += prisoner.mood @@ -159,7 +159,7 @@ * PRISONER_LOCKED_IN_RECOVERY seconds a second, so letting them out for a moment does not reset it. * A rioter just shut in a cell is in custody, and owes lockdown for it (outpost_prison_capture.dm). */ -/datum/outpost_prison/proc/update_locked_in(mob/living/basic/outpost_prisoner/prisoner, seconds) +/datum/outpost_prison/proc/update_locked_in(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) var/confined = prisoner.is_confined() if(confined) if(!prisoner.was_confined && prisoner.is_rioting()) @@ -181,7 +181,7 @@ return riot_active || runners_at_large() > 0 /// A prisoner is let out after a long lock-in: some relief, and they say so -/datum/outpost_prison/proc/on_unbolted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_unbolted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.trouble) return FALSE prisoner.adjust_mood(PRISONER_MOOD_UNBOLTED) @@ -209,7 +209,7 @@ * Staff killed a prisoner. No fine: the cost is the worst spark there is, the pay and bonus they * forfeit, and a body to deal with (body_tick()). */ -/datum/outpost_prison/proc/blame_death(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/blame_death(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.death_blamed) return FALSE prisoner.death_blamed = TRUE @@ -219,14 +219,14 @@ return trouble_event(PRISON_SPIKE_KILLED, "[prisoner.real_name] was killed by staff") /// Whether a prisoner's mood counts toward the wing's tension: present, alive and not loose -/datum/outpost_prison/proc/counts_for_tension(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/counts_for_tension(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && prisoner.trouble != PRISONER_TROUBLE_LOOSE /// 100 minus the mean mood of the prisoners in the cell block, plus the spike and any shivs hidden in the wing /datum/outpost_prison/proc/compute_tension() var/total = 0 var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!counts_for_tension(prisoner)) continue total += prisoner.mood @@ -289,7 +289,7 @@ var/hurt = 0 var/locked = 0 var/cuffed = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!counts_for_tension(prisoner)) continue if(prisoner.hunger < PRISONER_HUNGER_HUNGRY) @@ -338,7 +338,7 @@ add_log("A riot is brewing in the yard.") announce("Prison wing: a riot is brewing.", SHIP_NOTIFY_DANGER) var/shouts = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.routine_allowed() || cell_at(get_turf(prisoner))) continue // Leisure makes way, so the gathering (three times as likely now) gets picked. @@ -355,7 +355,7 @@ /datum/outpost_prison/proc/trouble_tick(seconds) tension_spike = max(0, tension_spike - PRISON_SPIKE_DECAY * seconds) subdued_left = max(0, subdued_left - seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue prisoner.trouble_counters(seconds) @@ -373,7 +373,7 @@ check_incident_over() /// A prisoner's own trouble timers -/mob/living/basic/outpost_prisoner/proc/trouble_counters(seconds) +/mob/living/carbon/human/npc/outpost_prisoner/proc/trouble_counters(seconds) threat_cooldown = max(0, threat_cooldown - seconds) fight_cooldown = max(0, fight_cooldown - seconds) if(swing_ref) @@ -382,10 +382,8 @@ if(swing_left <= 0 || !target || target.stat == DEAD) swing_ref = null swing_left = 0 - if(beaten_left > 0) - beaten_left -= seconds - if(beaten_left <= 0 || health_factor() > PRISONER_RECOVER_ABOVE) - recover() + // Down until they are up again, by tg's own clock (crit, stamina, the knockdown), or a yield's lie-down by choice + beaten_tick() if(climb_ref) climb_tick(seconds) @@ -396,7 +394,7 @@ * their AI runs, and never at someone who just helped them. A talk with staff holds the threat. */ /datum/outpost_prison/proc/threats_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) // Someone in trouble keeps a swing only while hitting back at a player (outpost_prison_trouble.dm). // Running from a creature, staff are the least of their worries (outpost_prison_panic.dm). if(prisoner.phase != PRISONER_PRESENT || (prisoner.trouble && !prisoner.retaliating()) || !prisoner.trouble_can_act() || !prisoner.ai_running() || !in_cell_block(prisoner) || prisoner.is_panicking()) @@ -427,8 +425,8 @@ /// Two prisoners squaring off: an argument, then blows until one yields /datum/outpost_prison_fight var/datum/outpost_prison/prison - var/mob/living/basic/outpost_prisoner/first - var/mob/living/basic/outpost_prisoner/second + var/mob/living/carbon/human/npc/outpost_prisoner/first + var/mob/living/carbon/human/npc/outpost_prisoner/second /// What it is about, for the argument's lines: "food", "ball", "bed" or "none" var/cause = "none" /// Past the argument: blows are being thrown @@ -440,7 +438,7 @@ /// Seconds it has lasted var/elapsed = 0 -/datum/outpost_prison_fight/New(datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, cause = "none") +/datum/outpost_prison_fight/New(datum/outpost_prison/owner, mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, cause = "none") . = ..() prison = owner first = one @@ -457,7 +455,7 @@ prison = null return ..() -/datum/outpost_prison_fight/proc/opponent_of(mob/living/basic/outpost_prisoner/fighter) +/datum/outpost_prison_fight/proc/opponent_of(mob/living/carbon/human/npc/outpost_prisoner/fighter) if(fighter == first) return second if(fighter == second) @@ -465,7 +463,7 @@ return null /// Still up for it: awake, on their feet, in this fight -/datum/outpost_prison_fight/proc/fighter_ok(mob/living/basic/outpost_prisoner/fighter) +/datum/outpost_prison_fight/proc/fighter_ok(mob/living/carbon/human/npc/outpost_prisoner/fighter) return !QDELETED(fighter) && fighter.stat == CONSCIOUS && fighter.phase == PRISONER_PRESENT && fighter.fight == src && fighter.trouble == PRISONER_TROUBLE_FIGHT && !fighter.can_be_dragged() /// The argument, then the fight. FALSE once it is over. @@ -476,7 +474,7 @@ if(elapsed >= PRISONER_FIGHT_MAX_TIME) return FALSE if(fighting) - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(first, second)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(first, second)) if(fighter.health_factor() <= PRISONER_FIGHT_YIELD) fighter.yield_fight() return FALSE @@ -485,8 +483,8 @@ line_left -= seconds if(line_left <= 0) line_left = 3 - var/mob/living/basic/outpost_prisoner/speaker = first_speaks ? first : second - var/mob/living/basic/outpost_prisoner/listener = opponent_of(speaker) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = first_speaks ? first : second + var/mob/living/carbon/human/npc/outpost_prisoner/listener = opponent_of(speaker) first_speaks = !first_speaks speaker.face_atom(listener) speaker.say_context_or("fight_argue_[cause]", "fight_argue", listener) @@ -494,17 +492,17 @@ fighting = TRUE first.manual_emote("goes for [second]!") // A scuffle on the floor is dirty work. - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(first, second)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(first, second)) fighter.set_uniform_grime(fighter.uniform_grime + PRISONER_FIGHT_GRIME) return TRUE /// Starts a fight between two prisoners over `cause`, telling everyone in sight. Returns the fight, or null. -/datum/outpost_prison/proc/start_fight(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, cause = "none") +/datum/outpost_prison/proc/start_fight(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, cause = "none") if(!trouble_enabled || one == two || one.fight || two.fight || riot_active) return null var/datum/outpost_prison_fight/brawl = new(src, one, two, cause) fights += brawl - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(one, two)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(one, two)) fighter.cancel_threat() fighter.end_activity() fighter.stand_up() @@ -513,7 +511,7 @@ one.face_atom(two) two.face_atom(one) add_tension_spike(PRISON_SPIKE_FIGHT) - for(var/mob/living/basic/outpost_prisoner/onlooker in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in prisoners) if(onlooker == one || onlooker == two || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT) continue if((onlooker in view(7, one)) || (onlooker in view(7, two))) @@ -528,7 +526,7 @@ return fights -= brawl fight_gap_left = PRISON_FIGHT_GAP - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) if(QDELETED(fighter)) continue if(fighter.fight == brawl) @@ -541,15 +539,15 @@ qdel(brawl) /// Whether they are angry enough, free and able to start a fight, with someone around to see it, and no creature frightening them -/mob/living/basic/outpost_prisoner/proc/can_start_fight() +/mob/living/carbon/human/npc/outpost_prisoner/proc/can_start_fight() return prison && mood < PRISONER_FIGHT_MOOD && !in_trouble() && fight_cooldown <= 0 && trouble_can_act() && health_factor() > PRISONER_FIGHT_YIELD && !activity?.sleeping && ai_running() && prison.in_cell_block(src) && !is_panicking() /// Chasing the ball, or holding it -/mob/living/basic/outpost_prisoner/proc/after_ball() +/mob/living/carbon/human/npc/outpost_prisoner/proc/after_ball() return istype(activity, /datum/prisoner_activity/basketball) || istype(held_item, /obj/item/toy/basketball) /// What two prisoners would fight about: both hungry ("food"), both after the ball ("ball"), one in the other's cell ("bed"), or nothing ("none") -/datum/outpost_prison/proc/fight_cause(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/fight_cause(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(one.hunger < PRISONER_HUNGER_HUNGRY && two.hunger < PRISONER_HUNGER_HUNGRY) return "food" if(one.after_ball() && two.after_ball()) @@ -585,14 +583,14 @@ if(!trouble_enabled || riot_active || subdued_left > 0 || length(fights) || fight_gap_left > 0) return null var/list/angry = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.can_start_fight()) angry += prisoner var/list/pairs = list() for(var/i in 1 to length(angry) - 1) - var/mob/living/basic/outpost_prisoner/one = angry[i] + var/mob/living/carbon/human/npc/outpost_prisoner/one = angry[i] for(var/j in i + 1 to length(angry)) - var/mob/living/basic/outpost_prisoner/two = angry[j] + var/mob/living/carbon/human/npc/outpost_prisoner/two = angry[j] if(get_dist(one, two) <= PRISONER_FIGHT_RANGE && one.walkable?[get_turf(two)]) pairs += list(list(one, two)) if(!length(pairs)) @@ -609,7 +607,7 @@ // ===== SPATS ===== /// Whether a prisoner is sour enough and free to argue, and no creature is frightening them -/datum/outpost_prison/proc/can_spat(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/can_spat(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.phase == PRISONER_PRESENT && prisoner.mood < PRISONER_SPAT_MOOD && !prisoner.in_trouble() && prisoner.trouble_can_act() && !prisoner.activity?.sleeping && prisoner.ai_running() && !prisoner.is_panicking() /** @@ -620,14 +618,14 @@ if(!trouble_enabled || riot_active || spat_lines_left > 0 || spat_gap_left > 0 || !prob(PRISON_SPAT_CHANCE * contraband_spat_mult())) return FALSE var/list/sour = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(can_spat(prisoner)) sour += prisoner var/list/pairs = list() for(var/i in 1 to length(sour) - 1) - var/mob/living/basic/outpost_prisoner/one = sour[i] + var/mob/living/carbon/human/npc/outpost_prisoner/one = sour[i] for(var/j in i + 1 to length(sour)) - var/mob/living/basic/outpost_prisoner/two = sour[j] + var/mob/living/carbon/human/npc/outpost_prisoner/two = sour[j] if(get_dist(one, two) <= PRISONER_FIGHT_RANGE && (two in view(PRISONER_FIGHT_RANGE, one))) pairs += list(list(one, two)) if(!length(pairs)) @@ -651,13 +649,13 @@ spat_line_left -= seconds if(spat_line_left > 0) return - var/mob/living/basic/outpost_prisoner/one = spat_first_ref?.resolve() - var/mob/living/basic/outpost_prisoner/two = spat_second_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/one = spat_first_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/two = spat_second_ref?.resolve() if(!one || !two || one.stat != CONSCIOUS || two.stat != CONSCIOUS || one.phase != PRISONER_PRESENT || two.phase != PRISONER_PRESENT || one.in_trouble() || two.in_trouble()) spat_lines_left = 0 return - var/mob/living/basic/outpost_prisoner/speaker = (spat_lines_said % 2) ? two : one - var/mob/living/basic/outpost_prisoner/listener = speaker == one ? two : one + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = (spat_lines_said % 2) ? two : one + var/mob/living/carbon/human/npc/outpost_prisoner/listener = speaker == one ? two : one speaker.face_atom(listener) speaker.say_context("spat", listener) spat_lines_said++ @@ -667,7 +665,7 @@ // ===== RIOTS ===== /// Whether they are shut in a cell with its door bolted -/mob/living/basic/outpost_prisoner/proc/in_bolted_cell() +/mob/living/carbon/human/npc/outpost_prisoner/proc/in_bolted_cell() var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(src)) return holding?.is_bolted() @@ -676,11 +674,11 @@ * door bolted open does not count). Down or cuffed, they are still at large, though the riot may * count them dealt with (riot_handled()). */ -/mob/living/basic/outpost_prisoner/proc/riot_at_large() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_at_large() return phase == PRISONER_PRESENT && stat != DEAD && is_rioting() && !is_confined() /// A rioter at large and free to act: on their feet, uncuffed, and not given up at a turret's warning (outpost_prison_security.dm) -/mob/living/basic/outpost_prisoner/proc/riot_free() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_free() return riot_at_large() && stat == CONSCIOUS && !can_be_dragged() && !surrendered_to_turret() /** @@ -689,10 +687,10 @@ * unable to act (in crit, stamina crit, collapsed or stunned). A knockdown they are straight back * up from is not enough. The riot is over once every rioter is (riot_tick()). */ -/mob/living/basic/outpost_prisoner/proc/riot_handled() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_handled() if(QDELETED(src) || isnull(get_turf(src))) return TRUE - if(!riot_at_large() || cuffs) + if(!riot_at_large() || handcuffed) return TRUE return stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_INCAPACITATED) @@ -700,11 +698,11 @@ * Whether they can join a riot now. Not from a cell they are shut in: a riot that nobody can take * out of a cell would be over as soon as it began, so they bang on the door and shout instead. */ -/mob/living/basic/outpost_prisoner/proc/can_join_riot() - return prison && stat == CONSCIOUS && phase == PRISONER_PRESENT && !can_be_dragged() && beaten_left <= 0 && !climb_ref && (!trouble || trouble == PRISONER_TROUBLE_FIGHT) && prison.in_cell_block(src) && !is_confined() +/mob/living/carbon/human/npc/outpost_prisoner/proc/can_join_riot() + return prison && stat == CONSCIOUS && phase == PRISONER_PRESENT && !can_be_dragged() && !beaten && !climb_ref && (!trouble || trouble == PRISONER_TROUBLE_FIGHT) && prison.in_cell_block(src) && !is_confined() /// Below this mood they join a riot, by personality, raised by a shiv under their mattress (outpost_prison_contraband.dm) -/mob/living/basic/outpost_prisoner/proc/riot_join_mood() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_join_mood() var/line = PRISON_RIOT_JOIN_CHATTY switch(personality) if("grumpy") @@ -721,10 +719,10 @@ * Who joins a riot: everyone below their personality's line (everyone able, for an admin riot), * plus `forced`, else the unhappiest alone. */ -/datum/outpost_prison/proc/riot_candidates(everyone = FALSE, mob/living/basic/outpost_prisoner/forced) +/datum/outpost_prison/proc/riot_candidates(everyone = FALSE, mob/living/carbon/human/npc/outpost_prisoner/forced) var/list/joining = list() - var/mob/living/basic/outpost_prisoner/unhappiest - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + var/mob/living/carbon/human/npc/outpost_prisoner/unhappiest + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.can_join_riot()) continue if(everyone || prisoner == forced || prisoner.mood < prisoner.riot_join_mood()) @@ -741,7 +739,7 @@ * does. `ignore_quiet` starts it in the quiet after a riot too, for a prisoner let out of lockdown * early. An experiment under way stops nothing. */ -/datum/outpost_prison/proc/start_riot(reason, everyone = FALSE, mob/living/basic/outpost_prisoner/forced, ignore_quiet = FALSE, alone = FALSE) +/datum/outpost_prison/proc/start_riot(reason, everyone = FALSE, mob/living/carbon/human/npc/outpost_prisoner/forced, ignore_quiet = FALSE, alone = FALSE) if(!trouble_enabled || riot_active) return FALSE if(!everyone && subdued_left > 0 && !ignore_quiet) @@ -766,7 +764,7 @@ open_incident() var/shouts = 0 // A Most Wanted ringleader is among the first to shout (outpost_prison_bounty.dm). - for(var/mob/living/basic/outpost_prisoner/rioter as anything in bounty_ringleaders_first(joining)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter as anything in bounty_ringleaders_first(joining)) rioter.start_rioting(shouts++ < 2) send_bystanders_home(joining) add_log("Riot in the yard: [length(joining)] prisoner\s.") @@ -777,7 +775,7 @@ return TRUE /// Joins the riot: a shiv out, everything else dropped -/mob/living/basic/outpost_prisoner/proc/start_rioting(shout = TRUE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_rioting(shout = TRUE) cancel_threat() end_activity() stand_up() @@ -786,16 +784,15 @@ riot_target_ref = null riot_target_hits = 0 riot_victim_ref = null - // A shiv they hid in their cell is the one they draw (outpost_prison_contraband.dm). - if(!prison?.draw_stashed_shiv(src)) - draw_shiv() + // A shiv they hid in their cell is the one they draw (outpost_prison_contraband.dm); without one, their fists. + prison?.draw_stashed_shiv(src) update_bubble() if(shout) say_context("riot") /// Prisoners who stay out of a riot go back to their own cells and sit it out on the bed. Anyone running from a creature keeps running (outpost_prison_panic.dm). /datum/outpost_prison/proc/send_bystanders_home(list/rioters) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if((prisoner in rioters) || prisoner.phase != PRISONER_PRESENT || !prisoner.routine_allowed() || prisoner.is_confined() || prisoner.is_panicking()) continue var/datum/prisoner_activity/hide/hide = new(prisoner) @@ -816,7 +813,7 @@ riot_windup_left = max(0, riot_windup_left - seconds) var/unhandled = 0 var/free = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.riot_handled()) continue unhandled++ @@ -862,7 +859,7 @@ riot_windup_left = 0 tension_spike = 0 subdued_left = PRISON_SUBDUED_TIME / (1 SECONDS) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.is_rioting()) prisoner.calm_down() stage = outpost_prison_stage_for(compute_tension()) @@ -884,7 +881,7 @@ /// The riot went on too long: the rioters go all out for the exits, and each one's loose clock starts /datum/outpost_prison/proc/begin_breakout() breaking_out = TRUE - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) // Rioters who gave up at a turret's warning stay where they are (outpost_prison_security.dm) if(prisoner.trouble != PRISONER_TROUBLE_RIOT || prisoner.surrendered_to_turret()) continue @@ -901,7 +898,7 @@ * once a riot turns into a breakout. Anyone else in a riot already on carries on as before. Returns * TRUE if they are breaking out. */ -/datum/outpost_prison/proc/start_breakout(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/start_breakout(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!trouble_enabled || QDELETED(prisoner) || !(prisoner in prisoners)) return FALSE if(!prisoner.is_rioting()) @@ -928,7 +925,7 @@ */ /datum/outpost_prison/proc/transfer_rioters() var/list/rioters = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.riot_at_large() && !held_for_experiment(prisoner)) rioters += prisoner if(!length(rioters)) @@ -937,7 +934,7 @@ return 0 open_incident() var/charged = 0 - for(var/mob/living/basic/outpost_prisoner/rioter as anything in rioters) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter as anything in rioters) close_bounty_record(rioter, BOUNTY_RECORD_CLOSED) charged += charge_fine(OUTPOST_PRISON_TRANSFER_FEE, "Prisoner transfer: [rioter.real_name]", TRUE) note_prisoner_lost(rioter, "transferred") @@ -951,7 +948,7 @@ return length(rioters) /// A serving hatch left open on both sides that this prisoner can reach from the yard side -/datum/outpost_prison/proc/open_hatch_for(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/open_hatch_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/obj/structure/table/reinforced/prison_hatch/hatch in fixtures_of("hatch")) if(QDELETED(hatch) || !hatch.both_sides_open()) continue @@ -969,7 +966,7 @@ * were smashing for a few more blows, or a new target (pick_smash_target(), * outpost_prison_breakout.dm). Turret warnings are in outpost_prison_security.dm. */ -/mob/living/basic/outpost_prisoner/proc/riot_target() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_target() if(!prison) return null if(prison.riot_windup_left > 0 || surrendered_to_turret() || is_confined()) @@ -1004,7 +1001,7 @@ return next /// The nearest member of staff in sight that they can get to, and that no more than PRISON_RIOT_MAX_ATTACKERS - 1 others are going for -/mob/living/basic/outpost_prisoner/proc/staff_in_reach() +/mob/living/carbon/human/npc/outpost_prisoner/proc/staff_in_reach() var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(7, src)) @@ -1020,9 +1017,9 @@ return nearest /// How many rioters other than `except` are going for `person` -/datum/outpost_prison/proc/attackers_on(mob/living/person, mob/living/basic/outpost_prisoner/except) +/datum/outpost_prison/proc/attackers_on(mob/living/person, mob/living/carbon/human/npc/outpost_prisoner/except) var/count = 0 - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == except || !other.is_rioting() || !other.trouble_can_act()) continue if(other.riot_victim_ref?.resolve() == person) @@ -1030,7 +1027,7 @@ return count /// Whether a rioter's target is still worth hitting -/datum/outpost_prison/proc/still_smashable(atom/target, mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/still_smashable(atom/target, mob/living/carbon/human/npc/outpost_prisoner/rioter) if(QDELETED(target) || !rioter.reachable?[get_turf(target)] || LAZYACCESS(rioter.riot_skips, REF(target)) > world.time) return FALSE if(istype(target, /obj/machinery/light)) @@ -1078,7 +1075,7 @@ * wrecking their cells; wrecks under way go on. Nothing happens while their AI sleeps. */ /datum/outpost_prison/proc/wreck_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue if(prisoner.trouble == PRISONER_TROUBLE_WRECK) @@ -1091,7 +1088,7 @@ * and gets dirty, and they hammer on the door. The warden's log says so. Also the admin panel's hook. * Returns TRUE if they started. */ -/datum/outpost_prison/proc/start_wreck(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/start_wreck(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS || prisoner.trouble) return FALSE var/datum/outpost_prison_cell/holding = cell_at(get_turf(prisoner)) @@ -1122,7 +1119,7 @@ return TRUE /// A wreck goes on: bangs on the door every few seconds, and after PRISONER_WRECK_TIME the bolts shear -/datum/outpost_prison/proc/wreck_step(mob/living/basic/outpost_prisoner/prisoner, seconds) +/datum/outpost_prison/proc/wreck_step(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) var/datum/outpost_prison_cell/holding = cell_at(get_turf(prisoner)) if(!holding || !prisoner.is_confined()) // Let out, or no longer in a cell: the wreck is over. @@ -1144,7 +1141,7 @@ shear_bolts(prisoner, holding) /// A wreck ends without the bolts shearing: let out, knocked down or gone -/datum/outpost_prison/proc/stop_wreck(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/stop_wreck(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.trouble != PRISONER_TROUBLE_WRECK) return prisoner.trouble = null @@ -1153,7 +1150,7 @@ prisoner.update_bubble() /// The cell door gives, bolted or welded: out they come, and unless the wing is subdued they riot -/datum/outpost_prison/proc/shear_bolts(mob/living/basic/outpost_prisoner/prisoner, datum/outpost_prison_cell/holding) +/datum/outpost_prison/proc/shear_bolts(mob/living/carbon/human/npc/outpost_prisoner/prisoner, datum/outpost_prison_cell/holding) stop_wreck(prisoner) var/obj/machinery/door/airlock/door = holding.door() if(door && (door.locked || door.welded)) @@ -1199,7 +1196,7 @@ /// Prisoners on the patrol AI, still in the wing's custody /datum/outpost_prison/proc/loose_count() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD) count++ return count @@ -1207,8 +1204,8 @@ /// Loose prisoners nobody has caught yet: not cuffed. Caught ones, left cuffed somewhere, keep no protective custody going. /datum/outpost_prison/proc/runners_at_large() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) - if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && !prisoner.cuffs) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) + if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && !prisoner.handcuffed) count++ return count @@ -1219,7 +1216,7 @@ */ /datum/outpost_prison/proc/alarm_state() var/list/sightings = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD) sightings += "[prisoner.real_name] ([get_area_name(prisoner)])" var/loose_text @@ -1242,7 +1239,7 @@ /// The trouble block for the admin panel and tests: the stage, tension, the riot's clocks and who is on a loose clock where /datum/outpost_prison/proc/trouble_payload() var/list/loose = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD || prisoner.loose_left <= 0) continue if(prisoner.trouble != PRISONER_TROUBLE_LOOSE && prisoner.trouble != PRISONER_TROUBLE_BREAKOUT) @@ -1269,7 +1266,7 @@ * or cuffed (can_be_dragged()). */ /datum/outpost_prison/proc/check_escapes(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue var/inside = in_cell_block(prisoner) @@ -1300,13 +1297,13 @@ /datum/outpost_prison/proc/loose_tick(seconds) var/list/still_loose = list() var/list/nearly_away = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD || prisoner.loose_left <= 0) continue if(prisoner.trouble != PRISONER_TROUBLE_LOOSE && prisoner.trouble != PRISONER_TROUBLE_BREAKOUT) prisoner.loose_left = 0 continue - if(prisoner.trouble == PRISONER_TROUBLE_LOOSE ? prisoner.cuffs : !prisoner.riot_free()) + if(prisoner.trouble == PRISONER_TROUBLE_LOOSE ? prisoner.handcuffed : !prisoner.riot_free()) continue if(prisoner.loose_left <= seconds && held_for_experiment(prisoner)) continue @@ -1327,7 +1324,7 @@ * Out of the cell block: loose on the outpost patrol AI, with their clock running. `breakout` * marks it as part of a breakout even if they were not rioting (the admin button). */ -/datum/outpost_prison/proc/prisoner_escaped(mob/living/basic/outpost_prisoner/prisoner, breakout = FALSE) +/datum/outpost_prison/proc/prisoner_escaped(mob/living/carbon/human/npc/outpost_prisoner/prisoner, breakout = FALSE) if(prisoner.trouble == PRISONER_TROUBLE_LOOSE) return FALSE var/from_riot = breakout || prisoner.is_rioting() @@ -1353,7 +1350,7 @@ return TRUE /// Goes loose: the outpost patrol AI takes over, and the clock starts, or carries on from the breakout -/mob/living/basic/outpost_prisoner/proc/go_loose() +/mob/living/carbon/human/npc/outpost_prisoner/proc/go_loose() end_activity() stand_up() stop_climb() @@ -1366,11 +1363,8 @@ trouble = PRISONER_TROUBLE_LOOSE if(loose_left <= 0) loose_left = OUTPOST_PRISON_LOOSE_TIME - // Wanted and Most Wanted bounty prisoners hit harder, and a meek one runs faster (outpost_prison_bounty.dm). - obj_damage = PRISONER_LOOSE_OBJ_DAMAGE * bounty_breakout_mult() - if(bounty_loose_speed()) - set_varspeed(bounty_base_speed()) - update_melee() + // A runner runs. + set_prison_pace(hurry = bounty_base_speed()) update_bubble() // The outpost patrol AI (voidcrew/modules/npc_ships/code/outpost_patrol.dm). Finding the doors // yields on a big outpost, and this runs in the prison's tick on SSprocessing. @@ -1378,7 +1372,7 @@ assign_mob_to_outpost_patrol_async(src, prison?.outpost) /// Back in the cell block, down or cuffed: in custody again, in a foul mood, and owing lockdown if they got out during a riot -/datum/outpost_prison/proc/recapture(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/recapture(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.trouble != PRISONER_TROUBLE_LOOSE) return FALSE var/owes_lockdown = prisoner.escaped_rioting @@ -1391,21 +1385,17 @@ update_riot_lights() return TRUE -/mob/living/basic/outpost_prisoner/proc/back_in_custody() +/mob/living/carbon/human/npc/outpost_prisoner/proc/back_in_custody() clear_outpost_patrol(src) trouble = null loose_left = 0 escaped_rioting = FALSE note_trouble_ended() - obj_damage = initial(obj_damage) - // A meek bounty prisoner slows back down (outpost_prison_bounty.dm). - if(bounty_loose_speed()) - set_varspeed(bounty_base_speed()) + set_prison_pace(hurry = bounty_base_speed()) drop_shiv() set_mood(PRISONER_RECAPTURED_MOOD) swap_basic_ai_controller(src, /datum/ai_controller/basic_controller/outpost_prisoner) prison?.refresh_prisoner_reach(src) - update_melee() update_bubble() say_context("recaptured") @@ -1413,7 +1403,7 @@ * Out of time: gone for good, walls or no walls. No bonus; the treasury is fined * OUTPOST_PRISON_ESCAPE_FINE as part of the incident, and the corrections service counts them lost. */ -/datum/outpost_prison/proc/escaped_for_good(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/escaped_for_good(mob/living/carbon/human/npc/outpost_prisoner/prisoner) close_bounty_record(prisoner, BOUNTY_RECORD_ESCAPED) var/fine = charge_fine(OUTPOST_PRISON_ESCAPE_FINE, "Prison escape fine: [prisoner.real_name]", TRUE) note_prisoner_lost(prisoner, "escaped") @@ -1434,14 +1424,14 @@ * rioting or not, and treats a loose prisoner outside the wing as fair game until they are down, so * a runner is stopped rather than killed. Turrets players build follow outpost_prison_security.dm. */ -/mob/living/basic/outpost_prisoner/proc/turret_target() +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_target() if(stat == DEAD || trouble != PRISONER_TROUBLE_LOOSE || phase != PRISONER_PRESENT || can_be_dragged()) return FALSE return !prison || get_area(src) != prison.wing /proc/is_loose_outpost_prisoner(mob/living/creature) - var/mob/living/basic/outpost_prisoner/prisoner = creature - return istype(prisoner) && prisoner.turret_target() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = creature + return istype(prisoner) && !prisoner.experiment_turned && prisoner.turret_target() // ===== SERVING HATCH ===== diff --git a/voidcrew/modules/player_outposts/outpost_prison_routine.dm b/voidcrew/modules/player_outposts/outpost_prison_routine.dm index 0732c7fa3e9..086d0a15b13 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_routine.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_routine.dm @@ -38,11 +38,11 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) // ===== PRISONER SIDE ===== /// Whether their routine may run now: not while down, cuffed, pulled, in trouble or held by the talk menu (outpost_prison_warden_tools.dm) -/mob/living/basic/outpost_prisoner/proc/routine_allowed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/routine_allowed() return prison && stat == CONSCIOUS && phase == PRISONER_PRESENT && !can_be_dragged() && !pulledby && !in_trouble() && !held_by_talk_menu() /// Ends what they are doing, putting things down and getting up -/mob/living/basic/outpost_prisoner/proc/end_activity(cancel_ai = TRUE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/end_activity(cancel_ai = TRUE) if(!activity) return var/datum/prisoner_activity/old = activity @@ -55,13 +55,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) ai_controller?.CancelActions() /// Starts `new_activity` in place of whatever they were doing -/mob/living/basic/outpost_prisoner/proc/start_activity(datum/prisoner_activity/new_activity) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_activity(datum/prisoner_activity/new_activity) end_activity() activity = new_activity return new_activity /// A walk failed: try once more, then give up on it for a while -/mob/living/basic/outpost_prisoner/proc/activity_move_failed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/activity_move_failed() if(!activity) return if(++activity.failed_moves < 2) @@ -70,14 +70,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) end_activity(cancel_ai = FALSE) /// Whether they gave up on this kind of activity recently -/mob/living/basic/outpost_prisoner/proc/activity_on_cooldown(activity_type) +/mob/living/carbon/human/npc/outpost_prisoner/proc/activity_on_cooldown(activity_type) return LAZYACCESS(activity_cooldowns, activity_type) > world.time /** * Picks their next activity: duties and needs first (heading home at release, eating, changing, * calling for the hatch), then leisure by weight. Returns the activity, already set up. */ -/mob/living/basic/outpost_prisoner/proc/choose_activity() +/mob/living/carbon/human/npc/outpost_prisoner/proc/choose_activity() if(!prison) return null if(!walkable) @@ -131,7 +131,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return null /// The nearest free tile beside `target` they can stand on, or its own tile when walkable -/mob/living/basic/outpost_prisoner/proc/approach_turf(atom/target) +/mob/living/carbon/human/npc/outpost_prisoner/proc/approach_turf(atom/target) var/turf/target_turf = get_turf(target) if(!target_turf || !walkable) return null @@ -150,19 +150,19 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return best /// Somewhere to hang about: the yard, or their own cell, not someone else's -/mob/living/basic/outpost_prisoner/proc/may_loiter(turf/tile) +/mob/living/carbon/human/npc/outpost_prisoner/proc/may_loiter(turf/tile) var/datum/outpost_prison_cell/holder = prison?.cell_at(tile) return !holder || holder == cell || holder == prison.cell_at(get_turf(src)) /// Whether someone else stands on this tile -/mob/living/basic/outpost_prisoner/proc/tile_taken(turf/tile) +/mob/living/carbon/human/npc/outpost_prisoner/proc/tile_taken(turf/tile) for(var/mob/living/other in tile) if(other != src && other.density) return TRUE return FALSE /// Their bed if they can get to it, else any free bed they can -/mob/living/basic/outpost_prisoner/proc/find_bed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/find_bed() var/obj/structure/bed/own = cell?.bed() if(own && walkable?[get_turf(own)] && !prison.claimed_by_other(own, src)) return own @@ -172,7 +172,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return null /// A fixture of `category` they can walk up to and nobody else is using, own cell's first, never in another's cell -/mob/living/basic/outpost_prisoner/proc/find_fixture(category, stand_on = FALSE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/find_fixture(category, stand_on = FALSE) var/list/options = list() for(var/atom/fixture as anything in prison.fixtures_of(category)) if(QDELETED(fixture) || prison.claimed_by_other(fixture, src)) @@ -189,7 +189,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return pick(options) /// Whether `person` is a member of the wing awake in the cell block, close by, holding dressings -/mob/living/basic/outpost_prisoner/proc/is_medic(mob/living/person) +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_medic(mob/living/person) if(!istype(person) || QDELETED(person) || person.stat != CONSCIOUS || is_outpost_prisoner(person)) return FALSE if(get_dist(src, person) > PRISONER_SICK_CALL_RANGE || !prison?.in_cell_block(person) || !prison.is_member(person)) @@ -197,7 +197,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return !!person.is_holding_item_of_type(/obj/item/stack/medical) /// The nearest medic they can see and walk up to -/mob/living/basic/outpost_prisoner/proc/find_medic() +/mob/living/carbon/human/npc/outpost_prisoner/proc/find_medic() var/mob/living/best var/best_distance = INFINITY for(var/mob/living/person in view(PRISONER_SICK_CALL_RANGE, src)) @@ -213,13 +213,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) * Hurt, with a medic in sight: drops whatever idle thing they were doing and goes over. Called * every few seconds by the prison while anyone is on the level. Returns TRUE if they went. */ -/mob/living/basic/outpost_prisoner/proc/check_sick_call() +/mob/living/carbon/human/npc/outpost_prisoner/proc/check_sick_call() if(!ai_running()) return FALSE return start_sick_call() /// Starts a sick call if they are hurt, free and someone holding dressings is near. Returns TRUE if it started. -/mob/living/basic/outpost_prisoner/proc/start_sick_call() +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_sick_call() if(!routine_allowed() || health_factor() >= PRISONER_INJURED_BELOW || !COOLDOWN_FINISHED(src, sick_call_cooldown)) return FALSE // A need, a duty or sleep comes first; anything idle can wait. @@ -236,7 +236,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) * A ball thrown at them: caught by anyone in a game, and by an idle prisoner when staff throw it, * who then starts shooting hoops. Returns TRUE if they caught it. */ -/mob/living/basic/outpost_prisoner/proc/try_catch_ball(atom/movable/thrown_thing, datum/thrownthing/throwing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/try_catch_ball(atom/movable/thrown_thing, datum/thrownthing/throwing) var/obj/item/toy/basketball/ball = thrown_thing if(!istype(ball) || held_item || !routine_allowed() || activity?.sleeping) return FALSE @@ -257,7 +257,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) qdel(game) return TRUE -/mob/living/basic/outpost_prisoner/hitby(atom/movable/AM, skipcatch, hitpush = TRUE, blocked = FALSE, datum/thrownthing/throwingdatum) +/mob/living/carbon/human/npc/outpost_prisoner/hitby(atom/movable/AM, skipcatch, hitpush = TRUE, blocked = FALSE, datum/thrownthing/throwingdatum) if(try_catch_ball(AM, throwingdatum)) return TRUE return ..() @@ -284,7 +284,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /datum/ai_planning_subtree/outpost_prisoner_routine /datum/ai_planning_subtree/outpost_prisoner_routine/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn if(!istype(prisoner) || !prisoner.routine_allowed()) return var/datum/prisoner_activity/activity = prisoner.activity || prisoner.choose_activity() @@ -310,7 +310,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) action_cooldown = 0.5 SECONDS /datum/ai_behavior/outpost_prisoner_travel/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/turf/spot = prisoner.activity?.spot if(!spot) return FALSE @@ -319,7 +319,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return TRUE /datum/ai_behavior/outpost_prisoner_travel/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/datum/prisoner_activity/activity = prisoner.activity if(!activity || prisoner.loc != activity.spot) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -330,7 +330,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /datum/ai_behavior/outpost_prisoner_travel/finish_action(datum/ai_controller/controller, succeeded) . = ..() if(!succeeded) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn prisoner?.activity_move_failed() /// Ticks the activity about once a second until it wants to move or is done @@ -338,11 +338,11 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) action_cooldown = 1 SECONDS /datum/ai_behavior/outpost_prisoner_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn return !!prisoner.activity /datum/ai_behavior/outpost_prisoner_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/datum/prisoner_activity/activity = prisoner.activity if(!activity || !prisoner.routine_allowed()) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -381,7 +381,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) var/mood_activity = FALSE /// Asleep: no chatting, only sleep talk var/sleeping = FALSE - var/mob/living/basic/outpost_prisoner/prisoner + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner /// Where they are headed; null once there var/turf/spot /// When it ends, once started @@ -392,7 +392,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /// REF() keys of what this activity has claimed in the prison var/list/claimed -/datum/prisoner_activity/New(mob/living/basic/outpost_prisoner/doer) +/datum/prisoner_activity/New(mob/living/carbon/human/npc/outpost_prisoner/doer) . = ..() prisoner = doer @@ -1062,13 +1062,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /datum/prisoner_activity/chat/setup() var/list/options = list() - for(var/mob/living/basic/outpost_prisoner/other as anything in prisoner.prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other as anything in prisoner.prison.prisoners) if(other == prisoner || !can_join(other)) continue options += other while(length(options)) // Friends first (outpost_prison_life.dm); the pick comes out of options either way. - var/mob/living/basic/outpost_prisoner/other = prisoner.prison.take_chat_partner(prisoner, options) + var/mob/living/carbon/human/npc/outpost_prisoner/other = prisoner.prison.take_chat_partner(prisoner, options) var/turf/beside = prisoner.approach_turf(other) if(!beside) continue @@ -1078,7 +1078,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return FALSE /// Whether `other` is free to talk -/datum/prisoner_activity/chat/proc/can_join(mob/living/basic/outpost_prisoner/other) +/datum/prisoner_activity/chat/proc/can_join(mob/living/carbon/human/npc/outpost_prisoner/other) if(QDELETED(other) || !other.routine_allowed() || other.activity?.sleeping) return FALSE if(other.activity && (!other.activity.interruptible || istype(other.activity, /datum/prisoner_activity/chat))) @@ -1086,7 +1086,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return TRUE /datum/prisoner_activity/chat/arrive() - var/mob/living/basic/outpost_prisoner/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = partner_ref?.resolve() if(!partner || get_dist(prisoner, partner) > 1 || !can_join(partner)) return FALSE . = ..() @@ -1096,7 +1096,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) next_exchange = world.time + 1 SECONDS /datum/prisoner_activity/chat/tick(seconds) - var/mob/living/basic/outpost_prisoner/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = partner_ref?.resolve() if(!partner || get_dist(prisoner, partner) > 1 || partner.activity?.chat_partner() != prisoner) return ACTIVITY_DONE prisoner.face_atom(partner) @@ -1107,7 +1107,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return ..() /datum/prisoner_activity/chat/finish() - var/mob/living/basic/outpost_prisoner/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = partner_ref?.resolve() if(partner && istype(partner.activity, /datum/prisoner_activity/chat/listen) && partner.activity.chat_partner() == prisoner) partner.end_activity() if(started && partner) @@ -1119,7 +1119,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) leisure = FALSE weight = 0 -/datum/prisoner_activity/chat/listen/New(mob/living/basic/outpost_prisoner/doer, mob/living/basic/outpost_prisoner/talker) +/datum/prisoner_activity/chat/listen/New(mob/living/carbon/human/npc/outpost_prisoner/doer, mob/living/carbon/human/npc/outpost_prisoner/talker) . = ..(doer) partner_ref = WEAKREF(talker) started = TRUE @@ -1133,7 +1133,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return TRUE /datum/prisoner_activity/chat/listen/tick(seconds) - var/mob/living/basic/outpost_prisoner/talker = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/talker = partner_ref?.resolve() if(!talker || get_dist(prisoner, talker) > 1 || talker.activity?.chat_partner() != prisoner) return ACTIVITY_DONE prisoner.face_atom(talker) diff --git a/voidcrew/modules/player_outposts/outpost_prison_security.dm b/voidcrew/modules/player_outposts/outpost_prison_security.dm index 636154072b0..f8f5351b6e9 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_security.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_security.dm @@ -15,9 +15,9 @@ * A shot at a prisoner is always a stun shot: the turret's own if its gun has a stamina or * electrode setting that goes through glass, else a disabler beam, whatever the mode. Before the first shot at a prisoner * it has not warned lately, the turret warns them and holds fire for a moment, and the prisoner - * decides what to do about it (react_to_turret()). Its shots pass through anyone the rule spares, - * and a lethal shot passes through every prisoner. Rioters go for turrets they can reach before - * any fixture (priority_smash_target()). + * decides what to do about it (react_to_turret()). The rule is the turret's choice of target: a shot + * that goes wide lands on whoever is in the way, as anyone's would. Rioters go for turrets they can + * reach before any fixture (priority_smash_target()). * * On a player outpost a turret's controls, and a turret control panel's, answer only to the * outpost's members, as a hull turret's do there (ship_defense_turret.dm). @@ -39,10 +39,13 @@ /proc/outpost_prison_turret_verdict(mob/living/target, obj/machinery/porta_turret/turret) if(!isliving(target)) return OUTPOST_PRISON_TURRET_NOT_MINE + // A body the serum took is the creature now, whoever it was (outpost_prison_experiment_forms.dm) + if(is_outpost_experiment_creature(target)) + return (target.stat != DEAD && is_hostile_creature(target)) ? OUTPOST_PRISON_TURRET_SHOOT : OUTPOST_PRISON_TURRET_SPARE if(is_outpost_prisoner(target)) - var/mob/living/basic/outpost_prisoner/prisoner = target + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = target return prisoner.turret_verdict(turret) - if(is_outpost_prison_guard(target) || istype(target, /mob/living/basic/outpost_kessler_staff)) + if(is_outpost_prison_guard(target) || istype(target, /mob/living/carbon/human/npc/outpost_kessler_staff)) return OUTPOST_PRISON_TURRET_SPARE // The experiments' creatures (outpost_prison_creatures.dm, outpost_prison_changeling.dm): until dead, subdued or in Kessler's hands if(is_outpost_prison_mob(target) || is_outpost_experiment_mob(target)) @@ -58,7 +61,7 @@ var/obj/projectile/shot = projectile_type return initial(shot.damage_type) == STAMINA && initial(shot.damage) > 0 -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// What they did about a turret's warning while rioting (OUTPOST_PRISON_TURRET_GIVE_UP, _BACK_OFF or _DEFY), the turret, and when that runs out var/turret_reaction var/datum/weakref/turret_reaction_ref @@ -68,7 +71,7 @@ var/turret_retreat_until = 0 /// A turret's verdict on them; see outpost_prison_turret_verdict() -/mob/living/basic/outpost_prisoner/proc/turret_verdict(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_verdict(obj/machinery/porta_turret/turret) if(stat != CONSCIOUS || phase != PRISONER_PRESENT || can_be_dragged() || is_confined()) return OUTPOST_PRISON_TURRET_SPARE if(surrendered_to_turret() || backing_off_from_turret() || !turret_trouble()) @@ -76,21 +79,21 @@ return OUTPOST_PRISON_TURRET_SHOOT /// The trouble a turret answers: rioting or breaking out, loose, swinging at staff, climbing a hatch, or fighting past the argument. Threats, arguments and wrecking a cell are not. -/mob/living/basic/outpost_prisoner/proc/turret_trouble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_trouble() if(is_rioting() || trouble == PRISONER_TROUBLE_LOOSE || climb_ref || swing_ref?.resolve()) return TRUE return trouble == PRISONER_TROUBLE_FIGHT && fight?.fighting /// A rioter who gave up at a turret's warning: in the riot until staff bolt them in, but doing nothing -/mob/living/basic/outpost_prisoner/proc/surrendered_to_turret() +/mob/living/carbon/human/npc/outpost_prisoner/proc/surrendered_to_turret() return turret_reaction == OUTPOST_PRISON_TURRET_GIVE_UP && is_rioting() /// Walking away after backing off from a turret's warning -/mob/living/basic/outpost_prisoner/proc/backing_off_from_turret() +/mob/living/carbon/human/npc/outpost_prisoner/proc/backing_off_from_turret() return world.time < turret_retreat_until /// Forgets what a turret's warning made them do: a riot starting or ending clears it -/mob/living/basic/outpost_prisoner/proc/clear_turret_reaction() +/mob/living/carbon/human/npc/outpost_prisoner/proc/clear_turret_reaction() turret_reaction = null turret_reaction_ref = null turret_reaction_until = 0 @@ -156,7 +159,7 @@ update_appearance() /// Whether it may fire at `prisoner` now. The first time in a while it warns them instead, and holds fire for OUTPOST_PRISON_TURRET_WARN_TIME. -/obj/machinery/porta_turret/proc/prison_may_fire_at(mob/living/basic/outpost_prisoner/prisoner) +/obj/machinery/porta_turret/proc/prison_may_fire_at(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(outpost_prison_turret_verdict(prisoner, src) != OUTPOST_PRISON_TURRET_SHOOT) return FALSE var/warned = LAZYACCESS(prison_warned_at, REF(prisoner)) @@ -169,7 +172,7 @@ return !(prisoner.is_rioting() && prisoner.prison?.riot_windup_left > 0) /// The warning: a red line on them, a triple beep and "Step away.", and they decide what to do about it. Returns what they did. -/obj/machinery/porta_turret/proc/warn_prisoner(mob/living/basic/outpost_prisoner/prisoner) +/obj/machinery/porta_turret/proc/warn_prisoner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/key in prison_warned_at?.Copy()) if(world.time - prison_warned_at[key] >= OUTPOST_PRISON_TURRET_REWARN_TIME) LAZYREMOVE(prison_warned_at, key) @@ -180,24 +183,6 @@ say("Step away.") return prisoner.react_to_turret(src) -/// A turret following the prison's rules lands no shot on anyone the rule spares, and no lethal one on a prisoner -/obj/projectile/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - if(isliving(target) && istype(firer, /obj/machinery/porta_turret) && !outpost_prison_turret_shot_lands(src, target)) - return FALSE - return ..() - -/// Whether `shot`, fired by a turret, may land on `target` -/proc/outpost_prison_turret_shot_lands(obj/projectile/shot, mob/living/target) - var/obj/machinery/porta_turret/turret = shot.firer - if(!turret.uses_outpost_turret_rules()) - return TRUE - switch(outpost_prison_turret_verdict(target, turret)) - if(OUTPOST_PRISON_TURRET_NOT_MINE) - return TRUE - if(OUTPOST_PRISON_TURRET_SPARE) - return FALSE - return !is_outpost_prisoner(target) || outpost_prison_stun_projectile(shot.type) - // ===== THE WARNING, FROM THE PRISONER'S SIDE ===== /// Percent chance someone swinging at staff or fighting backs off at a turret's warning: likelier the better their mood, never certain either way @@ -235,7 +220,7 @@ * off (riot on out of the turret's sight), or defy it (go for the turret). * Loose prisoners run on the outpost patrol AI and take no notice. Returns what they did, or null. */ -/mob/living/basic/outpost_prisoner/proc/react_to_turret(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/react_to_turret(obj/machinery/porta_turret/turret) if(QDELETED(turret) || stat != CONSCIOUS || phase != PRISONER_PRESENT || can_be_dragged()) return null var/reaction @@ -269,24 +254,24 @@ return reaction /// Backing off with `chance` percent, else defying. The prison's forced_turret_reaction settles it instead, for tests. -/mob/living/basic/outpost_prisoner/proc/turret_roll(chance) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_roll(chance) var/forced = prison?.forced_turret_reaction if(forced) return forced == OUTPOST_PRISON_TURRET_DEFY ? OUTPOST_PRISON_TURRET_DEFY : OUTPOST_PRISON_TURRET_BACK_OFF return prob(chance) ? OUTPOST_PRISON_TURRET_BACK_OFF : OUTPOST_PRISON_TURRET_DEFY -/mob/living/basic/outpost_prisoner/proc/set_turret_reaction(reaction, obj/machinery/porta_turret/turret, duration) +/mob/living/carbon/human/npc/outpost_prisoner/proc/set_turret_reaction(reaction, obj/machinery/porta_turret/turret, duration) turret_reaction = reaction turret_reaction_ref = WEAKREF(turret) turret_reaction_until = world.time + duration /// Heads for `spot`, if there is one; no turret shoots them for `duration` meanwhile -/mob/living/basic/outpost_prisoner/proc/start_turret_retreat(turf/spot, duration) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_turret_retreat(turf/spot, duration) turret_retreat_spot = spot turret_retreat_until = world.time + duration /// Swinging or fighting, and backing off: they drop it, step away from the turret and whoever they were going for, and don't square up again for the threat cooldown -/mob/living/basic/outpost_prisoner/proc/turret_stand_down(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_stand_down(obj/machinery/porta_turret/turret) var/mob/living/other = swing_ref?.resolve() || fight?.opponent_of(src) cancel_threat() threat_cooldown = max(threat_cooldown, PRISONER_THREAT_COOLDOWN) @@ -297,7 +282,7 @@ start_turret_retreat(pick_turret_retreat(turret, FALSE, other), OUTPOST_PRISON_TURRET_RETREAT_TIME) /// A rioter who gives up: the shiv goes down and they walk back to their own cell. Bolted in, they are out of the riot, owing lockdown (outpost_prison_capture.dm). -/mob/living/basic/outpost_prisoner/proc/turret_give_up(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_give_up(obj/machinery/porta_turret/turret) set_turret_reaction(OUTPOST_PRISON_TURRET_GIVE_UP, turret, 0) cancel_threat() drop_shiv() @@ -310,7 +295,7 @@ prison?.add_log("[real_name] gave up rioting at a turret's warning.") /// A rioter who backs off: still rioting, but out of the turret's sight, and leaving alone what it can see for a while -/mob/living/basic/outpost_prisoner/proc/turret_back_off_riot(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_back_off_riot(obj/machinery/porta_turret/turret) set_turret_reaction(OUTPOST_PRISON_TURRET_BACK_OFF, turret, OUTPOST_PRISON_TURRET_AVOID_TIME) // The turret too: under its cover it does not show up in its own view LAZYSET(riot_skips, REF(turret), turret_reaction_until) @@ -324,13 +309,13 @@ start_turret_retreat(pick_turret_retreat(turret, TRUE), OUTPOST_PRISON_TURRET_RETREAT_TIME) /// A rioter who defies it: they go for the turret before anything else (riot_target()), and get shot on the way -/mob/living/basic/outpost_prisoner/proc/turret_defy(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_defy(obj/machinery/porta_turret/turret) set_turret_reaction(OUTPOST_PRISON_TURRET_DEFY, turret, OUTPOST_PRISON_TURRET_DEFY_TIME) LAZYREMOVE(riot_skips, REF(turret)) ai_controller?.CancelActions() /// The turret a rioter defied, while they still mean to smash it and can get at it -/mob/living/basic/outpost_prisoner/proc/defied_turret() +/mob/living/carbon/human/npc/outpost_prisoner/proc/defied_turret() if(turret_reaction != OUTPOST_PRISON_TURRET_DEFY || world.time >= turret_reaction_until || !is_rioting()) return null var/obj/machinery/porta_turret/turret = turret_reaction_ref?.resolve() @@ -339,7 +324,7 @@ return turret /// Where a rioter who gave up waits: their cell's chair or bed (home_seat()), or any free tile of their own cell they can walk to -/mob/living/basic/outpost_prisoner/proc/own_cell_spot() +/mob/living/carbon/human/npc/outpost_prisoner/proc/own_cell_spot() if(!cell) return null var/obj/structure/seat = home_seat() @@ -355,7 +340,7 @@ * can walk to that it cannot see, or failing that the one farthest from it; anyone else a tile or * two away, as far as they can get from it and from `other`. Null if nowhere will do. */ -/mob/living/basic/outpost_prisoner/proc/pick_turret_retreat(obj/machinery/porta_turret/turret, out_of_sight = FALSE, mob/living/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/pick_turret_retreat(obj/machinery/porta_turret/turret, out_of_sight = FALSE, mob/living/other) if(!length(walkable)) return null var/turf/here = get_turf(src) @@ -387,7 +372,7 @@ * way. At the spot, or out of time, they stop; one who gave up and got to their cell's chair or * bed sits down (sit_in_cell()). */ -/mob/living/basic/outpost_prisoner/proc/plan_turret_retreat(datum/ai_controller/controller) +/mob/living/carbon/human/npc/outpost_prisoner/proc/plan_turret_retreat(datum/ai_controller/controller) if(!turret_retreat_spot) return FALSE if(loc == turret_retreat_spot || world.time >= turret_retreat_until) @@ -398,7 +383,7 @@ controller.queue_behavior(/datum/ai_behavior/outpost_prisoner_turret_retreat) return TRUE -/mob/living/basic/outpost_prisoner/proc/finish_turret_retreat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_turret_retreat() var/turf/spot = turret_retreat_spot turret_retreat_spot = null if(!surrendered_to_turret() || loc != spot || !cell?.contains(src) || !((locate(/obj/structure/chair) in loc) || (locate(/obj/structure/bed) in loc))) @@ -413,7 +398,7 @@ action_cooldown = 0.5 SECONDS /datum/ai_behavior/outpost_prisoner_turret_retreat/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/turf/spot = prisoner?.turret_retreat_spot if(!spot) return FALSE @@ -422,7 +407,7 @@ return TRUE /datum/ai_behavior/outpost_prisoner_turret_retreat/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn if(!prisoner?.turret_retreat_spot || prisoner.loc != prisoner.turret_retreat_spot) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED prisoner.finish_turret_retreat() @@ -442,7 +427,7 @@ * rules that they can reach and have not given up on, or null. A rioter newly going for one may * shout about it. */ -/datum/outpost_prison/proc/priority_smash_target(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/priority_smash_target(mob/living/carbon/human/npc/outpost_prisoner/rioter) if(!rioter?.reachable) return null var/obj/machinery/porta_turret/nearest @@ -457,21 +442,21 @@ nearest_distance = distance if(nearest && rioter.riot_target_ref?.resolve() != nearest && COOLDOWN_FINISHED(src, turret_smash_line_cooldown)) COOLDOWN_START(src, turret_smash_line_cooldown, OUTPOST_PRISON_TURRET_SMASH_LINE_GAP) - INVOKE_ASYNC(rioter, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "turret_smash") + INVOKE_ASYNC(rioter, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "turret_smash") return nearest /// A turret's shot is about to land on `target`: now and then someone watching says something -/datum/outpost_prison/proc/note_turret_hit(mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison/proc/note_turret_hit(mob/living/carbon/human/npc/outpost_prisoner/target) if(!COOLDOWN_FINISHED(src, turret_hit_line_cooldown) || !prob(OUTPOST_PRISON_TURRET_HIT_LINE_CHANCE)) return FALSE - for(var/mob/living/basic/outpost_prisoner/onlooker in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in shuffle(prisoners)) if(onlooker == target || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT || !onlooker.ai_running() || onlooker.in_trouble()) continue if(get_dist(onlooker, target) > 7 || !(target in view(7, onlooker))) continue COOLDOWN_START(src, turret_hit_line_cooldown, OUTPOST_PRISON_TURRET_HIT_LINE_GAP) onlooker.face_atom(target) - INVOKE_ASYNC(onlooker, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "turret_hit") + INVOKE_ASYNC(onlooker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "turret_hit") return TRUE return FALSE diff --git a/voidcrew/modules/player_outposts/outpost_prison_serum.dm b/voidcrew/modules/player_outposts/outpost_prison_serum.dm index eef2c4310ee..af179458ffa 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_serum.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_serum.dm @@ -55,7 +55,7 @@ /obj/item/outpost_experiment/serum/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers) if(!isliving(interacting_with)) return NONE - var/mob/living/basic/outpost_prisoner/subject = interacting_with + var/mob/living/carbon/human/npc/outpost_prisoner/subject = interacting_with var/error = dose_error(user, subject) if(error) balloon_alert(user, error) @@ -79,7 +79,7 @@ return ITEM_INTERACT_SUCCESS /// Why the serum cannot go into `subject` now, or null -/obj/item/outpost_experiment/serum/proc/dose_error(mob/living/user, mob/living/basic/outpost_prisoner/subject) +/obj/item/outpost_experiment/serum/proc/dose_error(mob/living/user, mob/living/carbon/human/npc/outpost_prisoner/subject) var/error = user_error(user) if(error) return error @@ -207,7 +207,7 @@ */ /datum/component/outpost_experiment_item/proc/eaten_by(mob/living/eater) var/datum/outpost_prison/prison = prison() - var/mob/living/basic/outpost_prisoner/host = eater + var/mob/living/carbon/human/npc/outpost_prisoner/host = eater if(istype(host) && prison && host.prison == prison && prison.start_experiment("changeling", host)) log_game("PLAYER OUTPOST PRISON: [host.real_name] ate food with a Kessler specimen in it at '[prison.outpost?.name]'") qdel(src) @@ -250,7 +250,7 @@ qdel(parent) /// A prisoner eats: food with the specimen in it takes them as its host -/mob/living/basic/outpost_prisoner/eat_food(obj/item/food/meal) +/mob/living/carbon/human/npc/outpost_prisoner/eat_food(obj/item/food/meal) var/datum/component/outpost_experiment_item/specimen = meal?.GetComponent(/datum/component/outpost_experiment_item) . = ..() if(specimen && !QDELETED(specimen)) diff --git a/voidcrew/modules/player_outposts/outpost_prison_social.dm b/voidcrew/modules/player_outposts/outpost_prison_social.dm index a22d32f11dd..00aa58585de 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_social.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_social.dm @@ -103,7 +103,7 @@ var/rep_greet_chance = PRISON_REP_GREET_CHANCE var/rep_word_chance = PRISON_REP_WORD_CHANCE -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Record key -> world.time they last greeted that person var/list/rep_greeted /// Record keys already thanked for bringing them mail this stay @@ -200,7 +200,7 @@ // note_staff_care() and note_staff_hit() run inside signal handlers: nothing here may sleep. /// `person` fed, clothed or treated `prisoner` by hand: once per prisoner per PRISON_REP_CARE_GAP -/datum/outpost_prison/proc/note_staff_care(mob/person, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/note_staff_care(mob/person, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner || !ismob(person)) return FALSE // note_carer() runs as a dressing is offered, so a dressing held to someone unhurt is not care. @@ -229,7 +229,7 @@ return TRUE /// `person` talked `prisoner` down -/datum/outpost_prison/proc/note_staff_talk(mob/person, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/note_staff_talk(mob/person, mob/living/carbon/human/npc/outpost_prisoner/prisoner) return rep_adjust(person, PRISON_REP_TALK) /// `person` sank a shot while prisoners played @@ -243,7 +243,7 @@ return rep_adjust(person, min(amount, PRISON_REP_KINDNESS_MAX)) /// An unprovoked hit on `prisoner` that cost them mood (hit_by_staff() has already ruled out deserved ones) -/datum/outpost_prison/proc/note_staff_hit(atom/attacker, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/note_staff_hit(atom/attacker, mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/counted = FALSE for(var/mob/person in rep_people_behind(attacker)) if(rep_adjust(person, PRISON_REP_HIT)) @@ -254,7 +254,7 @@ * Staff got the blame for `prisoner`: `event` is "beaten" (an unprovoked beating down, on top of * the hits) or "killed" (the score goes to PRISON_REP_MIN). The blame goes to whoever last hit them. */ -/datum/outpost_prison/proc/note_staff_blamed(mob/living/basic/outpost_prisoner/prisoner, event) +/datum/outpost_prison/proc/note_staff_blamed(mob/living/carbon/human/npc/outpost_prisoner/prisoner, event) if(event != "beaten" && event != "killed") return FALSE var/atom/attacker = prisoner?.last_staff_attacker_ref?.resolve() @@ -277,7 +277,7 @@ * prisoner per stay), "mail_opened", "search_found", "search_empty", "patdown_found", * "patdown_empty". Events it does not know, and anyone who is not a member, are ignored. */ -/datum/outpost_prison/proc/note_staff_event(mob/person, mob/living/basic/outpost_prisoner/prisoner, event) +/datum/outpost_prison/proc/note_staff_event(mob/person, mob/living/carbon/human/npc/outpost_prisoner/prisoner, event) var/amount switch(event) if("mail_delivered") @@ -307,7 +307,7 @@ // ===== WHAT IT DOES ===== /// Below this mood `prisoner` squares up to `person`: lower for a fair member, higher for a brute, and higher for some bounty prisoners (outpost_prison_bounty.dm) -/datum/outpost_prison/proc/threat_mood_for(mob/living/basic/outpost_prisoner/prisoner, mob/person) +/datum/outpost_prison/proc/threat_mood_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person) . = PRISONER_THREAT_MOOD switch(staff_label(person)) if("fair") @@ -317,29 +317,29 @@ . += prisoner ? prisoner.bounty_threat_bonus() : 0 /// The highest line threat_mood_for() can return (for `prisoner`, when given), so a prisoner above it looks for nobody -/datum/outpost_prison/proc/threat_mood_ceiling(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/threat_mood_ceiling(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return max(PRISONER_THREAT_MOOD, PRISON_REP_THREAT_BRUTE) + (prisoner ? prisoner.bounty_threat_bonus() : 0) /// The mood a talk-down from `person` gives `prisoner` -/datum/outpost_prison/proc/talk_mood_for(mob/living/basic/outpost_prisoner/prisoner, mob/person) +/datum/outpost_prison/proc/talk_mood_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person) return staff_label(person) == "fair" ? PRISON_REP_TALK_MOOD_FAIR : PRISONER_MOOD_TALK /// Whether `prisoner` refuses to be talked down by `person`: a brute, below PRISON_REP_REFUSE_BRUTE_BELOW. It says so itself (async). -/datum/outpost_prison/proc/refuses_talk_from(mob/living/basic/outpost_prisoner/prisoner, mob/person) +/datum/outpost_prison/proc/refuses_talk_from(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person) if(!prisoner || prisoner.mood >= PRISON_REP_REFUSE_BRUTE_BELOW || staff_label(person) != "brute") return FALSE prisoner.balloon_alert(person, "not listening") - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, turn_away_brute), person) + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, turn_away_brute), person) return TRUE /// Won't hear a word from a brute -/mob/living/basic/outpost_prisoner/proc/turn_away_brute(mob/person) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turn_away_brute(mob/person) if(!QDELETED(person)) face_atom(person) say_to_staff("talk_refuse_brute", person) /// How far `person` seems to a rioter picking whom to go for: a brute nearer, a fair member farther -/datum/outpost_prison/proc/riot_victim_distance(mob/living/basic/outpost_prisoner/prisoner, mob/person, distance) +/datum/outpost_prison/proc/riot_victim_distance(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person, distance) switch(staff_label(person)) if("brute") return distance - PRISON_REP_RIOT_BRUTE_NEARER @@ -355,7 +355,7 @@ * PRISON_REP_GREET_RANGE tiles in view gets greet_