[MIRROR] Basic Watchers & Basilisks [MDB IGNORE] (#23137)

* Basic Watchers & Basilisks (#77630)

## About The Pull Request

This one is a double feature because Watchers and Basilisks share the
same typepath. You might see a couple more of those.
As is tradition I decided to fuck with them rather than just port them.
Here's what's up.

**Basilisks**

![image](https://github.com/tgstation/tgstation/assets/7483112/9e4b0115-65dd-4df7-b62a-21c7be8549bf)

![image](https://github.com/tgstation/tgstation/assets/7483112/59162e68-7d73-4659-9531-5078ff751228)

- Have a new soulless sprite which looks less like a living blue hedge.
- Walk at you and shoot you while you are not in range (just like
before).
- Become supercharged if they become "heated" by lava, lasers, or
temperature weapons. This was a feature they also previously had but
they would never encounter lava, so now it also works if you use the
wrong gun on them.
- Lose their supercharge if you cool them down.
- Otherwise pretty normal mobs.

**Watchers**

https://www.youtube.com/watch?v=kOq_Bf78k5A
Here's a traditional video of me intentionally getting hit by mechanics
(trust me its definitely on purpose)

- They glow emmissively a little bit so you can see them from further
away.
- Their eyes light up about 0.5 seconds before they are able to shoot at
you.
- No longer melee attack, instead try to stay out of melee.
- Will occasionally put you into "Overwatch", meaning they will shoot
you rapidly if you move or act while they're staring at you for a brief
time period (after which you become immune for 12 seconds, and during
which other watchers will play fair and stop shooting at you).
- If they start taking damage they will also start using their "Gaze"
attack, look away or suffer some kind of negative effect!
- - Normal watcher gaze flashes and confuses you.
- - Magmawing watcher gaze obviously burns (and briefly stuns) you.
- - Icewing watcher gaze freezes you and throws you backwards.
- Magnetically attract and eat diamonds. They also used to do this, but
just if they happened to coincidentally walk past some.

**Other accompanying changes**

All basic mobs will now adopt the "stop gliding" trait if they get
slowed down too much.
I moved behaviour for "fire a projectile from this atom" into a helper
proc because I was using it in three places and I will probably use it
in more places. There are probably other places in the existing code
which could be using this.
I think I made the basic mob melee attack forecast default a little more
forgiving, they were fucking me up too much and I am the playtester.

## Why It's Good For The Game

Another one off the list.
New tricks for old dogs.
Framework for making mobs with ranged attacks "fairer" (you can see when
they are ready to shoot you).
More (hopefully) versatile AI behaviours which we will reuse later (I
hope I'm not duplicating one someone already made).
If our players "enjoy" them enough we can give more mobs "don't look at
me" mechanics.
Removes some soul sprites.

## Changelog

🆑
refactor: Basilisks and Watchers now use the basic mob framework. Please
bug report any unusual behaviour.
sprite: Basilisks have new sprites.
add: Basilisks will go into a frenzy if heated by energy weapons or
temperature beams as well as by lava.
add: Watcher eyes will be illuminated briefly when they are ready to
fire at you.
add: Watchers can now briefly put you into "Overwatch" and penalise you
for moving while they can see you.
add: Wounded watchers will occasionally punish players who look at them.
balance: Unusual watcher variants are more likely to appear.
/🆑

* Basic Watchers & Basilisks

* Modular paths

---------

Co-authored-by: Jacquerel <hnevard@gmail.com>
Co-authored-by: Giz <13398309+vinylspiders@users.noreply.github.com>
This commit is contained in:
SkyratBot
2023-08-16 20:20:53 -04:00
committed by GitHub
co-authored by Jacquerel Giz
parent 22b0e5c17b
commit 57272c8d22
59 changed files with 1762 additions and 1007 deletions
+7
View File
@@ -205,6 +205,13 @@
add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/simplemob_varspeed, multiplicative_slowdown = speed)
SEND_SIGNAL(src, POST_BASIC_MOB_UPDATE_VARSPEED)
/mob/living/basic/update_movespeed()
. = ..()
if (cached_multiplicative_slowdown > END_GLIDE_SPEED)
ADD_TRAIT(src, TRAIT_NO_GLIDE, SPEED_TRAIT)
else
REMOVE_TRAIT(src, TRAIT_NO_GLIDE, SPEED_TRAIT)
/mob/living/basic/relaymove(mob/living/user, direction)
if(user.incapacitated())
return
@@ -0,0 +1,87 @@
/// Watchers' ground-dwelling cousins, they shoot at you until they get into melee and absorb laser fire to power up.
/mob/living/basic/mining/basilisk
name = "basilisk"
desc = "A territorial beast, covered in a diamond shell which absorbs heat. Its stare causes victims to freeze from the inside."
icon_state = "basilisk"
icon_living = "basilisk"
icon_dead = "basilisk_dead"
speak_emote = list("chimes")
damage_coeff = list(BRUTE = 1, BURN = 0.1, TOX = 1, CLONE = 1, STAMINA = 0, OXY = 1)
speed = 20
maxHealth = 200
health = 200
obj_damage = 60
melee_damage_lower = 12
melee_damage_upper = 12
attack_verb_continuous = "bites into"
attack_verb_simple = "bite into"
throw_blocked_message = "bounces off the shell of"
attack_sound = 'sound/weapons/bladeslice.ogg'
attack_vis_effect = ATTACK_EFFECT_BITE
ai_controller = /datum/ai_controller/basic_controller/basilisk
butcher_results = list(
/obj/item/stack/sheet/bone = 1,
/obj/item/stack/ore/diamond = 2,
/obj/item/stack/sheet/sinew = 2,
)
/// The component we use for making ranged attacks
var/datum/component/ranged_attacks/ranged_attacks
/mob/living/basic/mining/basilisk/Initialize(mapload)
. = ..()
AddComponent(/datum/component/basic_mob_attack_telegraph)
ranged_attacks = AddComponent(/datum/component/ranged_attacks, projectile_type = /obj/projectile/temp/watcher, projectile_sound = 'sound/weapons/pierce.ogg')
RegisterSignal(src, COMSIG_MOVABLE_MOVED, PROC_REF(check_lava))
/mob/living/basic/mining/basilisk/Destroy()
QDEL_NULL(ranged_attacks)
return ..()
/mob/living/basic/mining/basilisk/welder_act(mob/living/user, obj/item/tool)
. = ..()
heat_up() // Who would do this?
/mob/living/basic/mining/basilisk/bullet_act(obj/projectile/bullet, def_zone, piercing_hit)
. = ..()
if (istype(bullet, /obj/projectile/temp))
var/obj/projectile/temp/heat_bullet = bullet
if (heat_bullet.temperature < 0)
return
heat_up()
return
if (bullet.damage == 0 || bullet.damage_type != BURN)
return
heat_up()
/// Are we standing in lava?
/mob/living/basic/mining/basilisk/proc/check_lava()
SIGNAL_HANDLER
var/turf/open/lava/entered_lava = loc
if (!islava(entered_lava) || entered_lava.immunity_trait != TRAIT_LAVA_IMMUNE)
return
heat_up()
/// We got hit by something hot, go into heat mode
/mob/living/basic/mining/basilisk/proc/heat_up()
if (stat != CONSCIOUS || has_status_effect(/datum/status_effect/basilisk_overheat))
return
apply_status_effect(/datum/status_effect/basilisk_overheat)
/// Change what kind of beam we fire
/mob/living/basic/mining/basilisk/proc/set_projectile_type(projectile_type)
ranged_attacks.projectile_type = projectile_type
/datum/ai_controller/basic_controller/basilisk
blackboard = list(
BB_TARGETTING_DATUM = new /datum/targetting_datum/basic,
)
ai_movement = /datum/ai_movement/basic_avoidance
idle_behavior = /datum/idle_behavior/idle_random_walk
planning_subtrees = list(
/datum/ai_planning_subtree/simple_find_target,
/datum/ai_planning_subtree/ranged_skirmish,
/datum/ai_planning_subtree/attack_obstacle_in_path,
/datum/ai_planning_subtree/basic_melee_attack_subtree,
)
@@ -0,0 +1,76 @@
/// Status effect gained by basilisks when they touch something hot
/datum/status_effect/basilisk_overheat
id = "basilisk_overheat"
duration = 3 MINUTES
/// Things which will chill us out if we get hit by them
var/static/list/chilling_reagents = list(
/datum/reagent/medicine/cryoxadone,
/datum/reagent/firefighting_foam,
/datum/reagent/consumable/frostoil,
/datum/reagent/consumable/ice,
/datum/reagent/water,
)
/datum/status_effect/basilisk_overheat/on_apply()
. = ..()
if (!. || !istype(owner, /mob/living/basic/mining/basilisk) || owner.stat != CONSCIOUS)
return FALSE
var/mob/living/basic/mining/basilisk/hot_stuff = owner
hot_stuff.visible_message(span_warning("[hot_stuff] is getting fired up!"))
hot_stuff.fully_heal()
hot_stuff.icon_living = "basilisk_alert"
hot_stuff.icon_state = "basilisk_alert"
hot_stuff.update_appearance(UPDATE_ICON_STATE)
hot_stuff.add_movespeed_modifier(/datum/movespeed_modifier/basilisk_overheat)
hot_stuff.set_projectile_type(/obj/projectile/basilisk_hot)
RegisterSignal(hot_stuff, COMSIG_LIVING_DEATH, PROC_REF(on_death))
RegisterSignal(hot_stuff, COMSIG_ATOM_EXPOSE_REAGENTS, PROC_REF(on_splashed))
RegisterSignal(hot_stuff, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_shot))
/datum/status_effect/basilisk_overheat/on_remove()
. = ..()
var/mob/living/basic/mining/basilisk/hot_stuff = owner
hot_stuff.icon_living = "basilisk"
hot_stuff.icon_state = "basilisk"
hot_stuff.set_projectile_type(/obj/projectile/temp/watcher)
hot_stuff.update_appearance(UPDATE_ICON_STATE)
hot_stuff.remove_movespeed_modifier(/datum/movespeed_modifier/basilisk_overheat)
UnregisterSignal(hot_stuff, list(COMSIG_LIVING_DEATH, COMSIG_ATOM_EXPOSE_REAGENTS, COMSIG_ATOM_BULLET_ACT))
if (hot_stuff.stat != CONSCIOUS)
return
hot_stuff.visible_message(span_notice("[hot_stuff] seems to have cooled down."))
var/obj/effect/particle_effect/fluid/smoke/poof = new(get_turf(hot_stuff))
poof.lifetime = 2 SECONDS
/// Cool down if we die
/datum/status_effect/basilisk_overheat/proc/on_death()
SIGNAL_HANDLER
qdel(src)
/// Cool down if splashed with water
/datum/status_effect/basilisk_overheat/proc/on_splashed(atom/source, list/reagents, datum/reagents/source_reagents, methods, volume_modifier, show_message)
SIGNAL_HANDLER
if(!(methods & (TOUCH|VAPOR)))
return
for (var/datum/reagent in reagents)
if (!is_type_in_list(reagent, chilling_reagents))
continue
qdel(src)
return
/// Cool down if shot with a cryo beam
/datum/status_effect/basilisk_overheat/proc/on_shot(datum/source, obj/projectile/temp/cryo_shot)
SIGNAL_HANDLER
if (!istype(cryo_shot) || cryo_shot.temperature > 0)
return
qdel(src)
/// Projectile basilisks use when hot
/obj/projectile/basilisk_hot
name = "energy blast"
icon_state = "chronobolt"
damage = 40
damage_type = BRUTE
@@ -11,7 +11,6 @@
gender = MALE // Female ones are the bipedal elites
speed = 30
basic_mob_flags = IMMUNE_TO_FISTS
mob_biotypes = MOB_ORGANIC|MOB_BEAST
maxHealth = 300
health = 300
friendly_verb_continuous = "wails at"
@@ -50,7 +49,6 @@
/mob/living/basic/mining/goliath/Initialize(mapload)
. = ..()
ADD_TRAIT(src, TRAIT_NO_GLIDE, INNATE_TRAIT)
ADD_TRAIT(src, TRAIT_TENTACLE_IMMUNE, INNATE_TRAIT)
AddElement(/datum/element/ai_retaliate)
AddElement(/datum/element/footstep, FOOTSTEP_MOB_HEAVY)
@@ -168,16 +166,6 @@
. = ..()
faction = new_friend.faction.Copy()
/// Goliath which sometimes replaces itself with a rare variant
/mob/living/basic/mining/goliath/random
/mob/living/basic/mining/goliath/random/Initialize(mapload)
. = ..()
if(!prob(1))
return
new /mob/living/basic/mining/goliath/ancient/immortal(loc)
return INITIALIZE_HINT_QDEL
/// Legacy Goliath mob with different sprites, largely the same behaviour
/mob/living/basic/mining/goliath/ancient
name = "ancient goliath"
@@ -234,3 +222,15 @@
desc = "This rough saddle will give you a serviceable seat upon a goliath! Provided you can get one to stand still."
icon = 'icons/obj/mining.dmi'
icon_state = "goliath_saddle"
/// For map generation, has a chance to instantiate as a special subtype
/obj/effect/spawner/random/goliath
name = "random goliath"
desc = "Chance to spawn a rare shiny version."
icon = 'icons/mob/simple/lavaland/lavaland_monsters_wide.dmi'
icon_state = "goliath"
pixel_x = -12
loot = list(
/mob/living/basic/mining/goliath = 99,
/mob/living/basic/mining/goliath/ancient/immortal = 1,
)
@@ -6,7 +6,6 @@
icon_state = "arctic_lobstrosity"
icon_living = "arctic_lobstrosity"
icon_dead = "arctic_lobstrosity_dead"
mob_biotypes = MOB_ORGANIC|MOB_BEAST
friendly_verb_continuous = "chitters at"
friendly_verb_simple = "chitters at"
speak_emote = list("chitters")
@@ -1,7 +1,9 @@
///prototype for mining mobs
/mob/living/basic/mining
icon = 'icons/mob/simple/lavaland/lavaland_monsters.dmi'
combat_mode = TRUE
mob_size = MOB_SIZE_LARGE
mob_biotypes = MOB_ORGANIC|MOB_BEAST
faction = list(FACTION_MINING)
unsuitable_atmos_damage = 0
minimum_survivable_temperature = 0
@@ -0,0 +1,120 @@
/// A floating eyeball which keeps its distance and plays red light/green light with you.
/mob/living/basic/mining/watcher
name = "watcher"
desc = "A levitating, monocular creature held aloft by wing-like veins. A sharp spine of crystal protrudes from its body."
icon = 'icons/mob/simple/lavaland/lavaland_monsters_wide.dmi'
icon_state = "watcher"
icon_living = "watcher"
icon_dead = "watcher_dead"
health_doll_icon = "watcher"
pixel_x = -12
base_pixel_x = -12
speak_emote = list("chimes")
speed = 3
maxHealth = 200
health = 200
attack_verb_continuous = "buffets"
attack_verb_simple = "buffet"
crusher_loot = /obj/item/crusher_trophy/watcher_wing
ai_controller = /datum/ai_controller/basic_controller/watcher
butcher_results = list(
/obj/item/stack/sheet/bone = 1,
/obj/item/stack/ore/diamond = 2,
/obj/item/stack/sheet/sinew = 2,
)
/// How often can we shoot?
var/ranged_cooldown = 3 SECONDS
/// What kind of beams we got?
var/projectile_type = /obj/projectile/temp/watcher
/// Icon state for our eye overlay
var/eye_glow = "ice_glow"
/// Sound to play when we shoot
var/shoot_sound = 'sound/weapons/pierce.ogg'
/// Typepath of our gaze ability
var/gaze_attack = /datum/action/cooldown/mob_cooldown/watcher_gaze
// We attract and eat these things for some reason
var/list/wanted_objects = list(
/obj/item/stack/sheet/mineral/diamond,
/obj/item/stack/ore/diamond,
/obj/item/pen/survival,
)
/mob/living/basic/mining/watcher/Initialize(mapload)
. = ..()
AddElement(/datum/element/ai_retaliate)
AddElement(/datum/element/simple_flying)
AddElement(/datum/element/content_barfer)
AddComponent(/datum/component/basic_ranged_ready_overlay, overlay_state = eye_glow)
AddComponent(\
/datum/component/ranged_attacks,\
cooldown_time = ranged_cooldown,\
projectile_type = projectile_type,\
projectile_sound = shoot_sound,\
)
AddComponent(\
/datum/component/magnet,\
attracted_typecache = wanted_objects,\
on_contact = CALLBACK(src, PROC_REF(consume)),\
)
update_appearance(UPDATE_OVERLAYS)
var/datum/action/cooldown/mob_cooldown/watcher_overwatch/overwatch = new(src)
overwatch.Grant(src)
overwatch.projectile_type = projectile_type
ai_controller.set_blackboard_key(BB_WATCHER_OVERWATCH, overwatch)
var/datum/action/cooldown/mob_cooldown/watcher_gaze/gaze = new gaze_attack(src)
gaze.Grant(src)
ai_controller.set_blackboard_key(BB_WATCHER_GAZE, gaze)
/mob/living/basic/mining/watcher/update_overlays()
. = ..()
. += emissive_appearance(icon, "watcher_emissive", src)
/// I love eating diamonds yum
/mob/living/basic/mining/watcher/proc/consume(atom/movable/thing)
visible_message(span_warning("[thing] seems to vanish into [src]'s body!"))
thing.forceMove(src)
/// More durable, burning projectiles
/mob/living/basic/mining/watcher/magmawing
name = "magmawing watcher"
desc = "Presented with extreme temperatures, adaptive watchers absorb heat through their circulatory wings and repurpose it as a weapon."
icon_state = "watcher_magmawing"
icon_living = "watcher_magmawing"
icon_dead = "watcher_magmawing_dead"
eye_glow = "fire_glow"
maxHealth = 215 //Compensate for the lack of slowdown on projectiles with a bit of extra health
health = 215
projectile_type = /obj/projectile/temp/watcher/magma_wing
gaze_attack = /datum/action/cooldown/mob_cooldown/watcher_gaze/fire
crusher_loot = /obj/item/crusher_trophy/blaster_tubes/magma_wing
crusher_drop_chance = 100 // There's only going to be one of these per round throw them a bone
/// Less durable, freezing projectiles
/mob/living/basic/mining/watcher/icewing
name = "icewing watcher"
desc = "Watchers which fail to absorb enough heat during their development become fragile, but share their internal chill with their enemies."
icon_state = "watcher_icewing"
icon_living = "watcher_icewing"
icon_dead = "watcher_icewing_dead"
maxHealth = 170
health = 170
projectile_type = /obj/projectile/temp/watcher/ice_wing
gaze_attack = /datum/action/cooldown/mob_cooldown/watcher_gaze/ice
butcher_results = list(/obj/item/stack/ore/diamond = 5, /obj/item/stack/sheet/bone = 1)
crusher_loot = /obj/item/crusher_trophy/watcher_wing/ice_wing
crusher_drop_chance = 100
/// For map generation, has a chance to instantiate as a special subtype
/obj/effect/spawner/random/watcher
name = "random watcher"
desc = "Chance to spawn a rare shiny version."
icon = 'icons/mob/simple/lavaland/lavaland_monsters_wide.dmi'
icon_state = "watcher"
pixel_x = -12
loot = list(
/mob/living/basic/mining/watcher = 80,
/mob/living/basic/mining/watcher/magmawing = 15,
/mob/living/basic/mining/watcher/icewing = 5,
)
@@ -0,0 +1,49 @@
/datum/ai_controller/basic_controller/watcher
blackboard = list(
BB_TARGETTING_DATUM = new /datum/targetting_datum/basic,
)
ai_movement = /datum/ai_movement/basic_avoidance
idle_behavior = /datum/idle_behavior/idle_random_walk
planning_subtrees = list(
/datum/ai_planning_subtree/target_retaliate,
/datum/ai_planning_subtree/simple_find_target,
/datum/ai_planning_subtree/use_mob_ability/gaze,
/datum/ai_planning_subtree/targeted_mob_ability/overwatch,
/datum/ai_planning_subtree/ranged_skirmish/watcher,
/datum/ai_planning_subtree/maintain_distance,
)
/datum/ai_planning_subtree/targeted_mob_ability/overwatch
ability_key = BB_WATCHER_OVERWATCH
/datum/ai_planning_subtree/targeted_mob_ability/overwatch/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
var/atom/target = controller.blackboard[target_key]
if (QDELETED(target) || HAS_TRAIT(target, TRAIT_OVERWATCH_IMMUNE))
return // We should probably let miners move sometimes
return ..()
/datum/ai_planning_subtree/use_mob_ability/gaze
ability_key = BB_WATCHER_GAZE
finish_planning = TRUE
/datum/ai_planning_subtree/use_mob_ability/gaze/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
var/mob/living/watcher = controller.pawn
if (watcher.health > watcher.maxHealth * 0.66) // When we're a little hurt
return
var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET]
if (!isliving(target))
return // Don't do this if there's nothing hostile around or if our target is a mech
return ..()
/datum/ai_planning_subtree/ranged_skirmish/watcher
attack_behavior = /datum/ai_behavior/ranged_skirmish/watcher
/datum/ai_planning_subtree/ranged_skirmish/watcher/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET]
if (QDELETED(target) || HAS_TRAIT(target, TRAIT_OVERWATCHED))
return // Don't bully people who are playing red light green light
return ..()
/datum/ai_behavior/ranged_skirmish/watcher
min_range = 0
@@ -0,0 +1,119 @@
/**
* Do something nasty to everyone nearby if they're looking at us.
*/
/datum/action/cooldown/mob_cooldown/watcher_gaze
name = "Disorienting Gaze"
desc = "After a delay, flash everyone looking at you."
button_icon = 'icons/mob/actions/actions_animal.dmi'
button_icon_state = "gaze"
background_icon_state = "bg_demon"
overlay_icon_state = "bg_demon_border"
cooldown_time = 30 SECONDS
check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED
click_to_activate = FALSE
shared_cooldown = null
/// At what range do we check for vision?
var/effect_radius = 7
/// How long does it take to play our various animation stages
var/animation_time = 0.8 SECONDS
/// How long after pressing the button do we give people to turn around?
var/wait_delay = 1.6 SECONDS
/// What are we currently displaying?
var/image/current_overlay
/// Timer until we go to the next stage
var/stage_timer
/datum/action/cooldown/mob_cooldown/watcher_gaze/Activate(mob/living/target)
show_indicator_overlay("eye_open")
stage_timer = addtimer(CALLBACK(src, PROC_REF(show_indicator_overlay), "eye_pulse"), animation_time, TIMER_STOPPABLE)
StartCooldown(360 SECONDS, 360 SECONDS)
if (do_after(owner, delay = wait_delay, target = owner))
trigger_effect()
else
deltimer(stage_timer)
clear_current_overlay()
StartCooldown()
return TRUE
/datum/action/cooldown/mob_cooldown/watcher_gaze/Destroy()
deltimer(stage_timer)
clear_current_overlay()
return ..()
/datum/action/cooldown/mob_cooldown/watcher_gaze/Remove(mob/removed_from)
deltimer(stage_timer)
clear_current_overlay()
return ..()
/// Do some effects to whoever is looking at us
/datum/action/cooldown/mob_cooldown/watcher_gaze/proc/trigger_effect()
deltimer(stage_timer)
show_indicator_overlay("eye_flash")
for (var/mob/living/viewer in viewers(effect_radius, owner))
var/view_dir = get_dir(viewer, owner)
if (!(viewer.dir & view_dir) || viewer.stat != CONSCIOUS)
continue
if (!apply_effect(viewer))
continue
var/image/flashed_overlay = image(
icon = 'icons/effects/eldritch.dmi',
loc = viewer,
icon_state = "eye_flash",
pixel_x = -viewer.pixel_x,
pixel_y = -viewer.pixel_y,
)
flick_overlay_global(flashed_overlay, show_to = GLOB.clients, duration = animation_time)
stage_timer = addtimer(CALLBACK(src, PROC_REF(hide_eye)), animation_time, TIMER_STOPPABLE)
/// Do something bad to someone who was looking at us
/datum/action/cooldown/mob_cooldown/watcher_gaze/proc/apply_effect(mob/living/viewer)
if (!viewer.flash_act(intensity = 4, affect_silicon = TRUE, visual = TRUE, length = 3 SECONDS))
return FALSE
viewer.set_confusion_if_lower(12 SECONDS)
return TRUE
/// Animate our effect out
/datum/action/cooldown/mob_cooldown/watcher_gaze/proc/hide_eye()
show_indicator_overlay("eye_close")
stage_timer = addtimer(CALLBACK(src, PROC_REF(clear_current_overlay)), animation_time, TIMER_STOPPABLE)
/// Display an animated overlay over our head to indicate what's going on
/datum/action/cooldown/mob_cooldown/watcher_gaze/proc/show_indicator_overlay(overlay_state)
clear_current_overlay()
current_overlay = image(icon = 'icons/effects/eldritch.dmi', loc = owner, icon_state = overlay_state, pixel_x = -owner.pixel_x, pixel_y = 28)
for(var/client/add_to in GLOB.clients)
add_to.images += current_overlay
/// Hide whatever overlay we are showing
/datum/action/cooldown/mob_cooldown/watcher_gaze/proc/clear_current_overlay()
if (!isnull(current_overlay))
remove_image_from_clients(current_overlay, GLOB.clients)
current_overlay = null
/// Magmawing glare burns you
/datum/action/cooldown/mob_cooldown/watcher_gaze/fire
name = "Searing Glare"
desc = "After a delay, burn and stun everyone looking at you."
/datum/action/cooldown/mob_cooldown/watcher_gaze/fire/apply_effect(mob/living/viewer)
viewer.Paralyze(3 SECONDS)
viewer.adjust_fire_stacks(10)
viewer.ignite_mob()
return TRUE
/// Icewing glare freezes you
/datum/action/cooldown/mob_cooldown/watcher_gaze/ice
name = "Cold Stare"
desc = "After a delay, freeze and repulse everyone looking at you."
/// Max distance to throw people looking at us
var/max_throw = 3
/datum/action/cooldown/mob_cooldown/watcher_gaze/ice/apply_effect(mob/living/viewer)
viewer.apply_status_effect(/datum/status_effect/freon/watcher/extended)
viewer.safe_throw_at(
target = get_edge_target_turf(owner, get_dir(owner, get_step_away(viewer, owner))),
range = max_throw,
speed = 1,
thrower = owner,
force = MOVE_FORCE_EXTREMELY_STRONG,
)
@@ -0,0 +1,161 @@
/**
* Automatically shoot at a target if they do anything while this is active on them.
*/
/datum/action/cooldown/mob_cooldown/watcher_overwatch
name = "Overwatch"
desc = "Keep a close eye on the target's actions, automatically firing upon them if they act."
button_icon = 'icons/mob/actions/actions_ecult.dmi'
button_icon_state = "eye"
background_icon_state = "bg_demon"
overlay_icon_state = "bg_demon_border"
cooldown_time = 20 SECONDS
check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED
click_to_activate = TRUE
shared_cooldown = null
/// Furthest range we can activate ability at
var/max_range = 7
/// Type of projectile to fire
var/projectile_type = /obj/projectile/temp/watcher
/// Sound the projectile we fire makes
var/projectile_sound = 'sound/weapons/pierce.ogg'
/// Time to watch for
var/overwatch_duration = 3 SECONDS
/datum/action/cooldown/mob_cooldown/watcher_overwatch/New(Target, original)
. = ..()
melee_cooldown_time = overwatch_duration
/datum/action/cooldown/mob_cooldown/watcher_overwatch/PreActivate(atom/target)
if (target == owner)
return
if (ismecha(target))
var/obj/vehicle/sealed/mecha/mech = target
var/list/drivers = mech.return_drivers()
if (!length(drivers))
return
target = drivers[1]
if (!isliving(target))
return
if (get_dist(owner, target) > max_range)
return
return ..()
/datum/action/cooldown/mob_cooldown/watcher_overwatch/Activate(mob/living/target)
var/mob/living/living_owner = owner
living_owner.face_atom(target)
living_owner.Stun(overwatch_duration, ignore_canstun = TRUE)
target.apply_status_effect(/datum/status_effect/overwatch, overwatch_duration, owner, projectile_type, projectile_sound)
StartCooldown()
return TRUE
/// Status effect which tracks whether our overwatched mob moves or acts
/datum/status_effect/overwatch
id = "watcher_overwatch"
duration = 5 SECONDS
status_type = STATUS_EFFECT_MULTIPLE
alert_type = /atom/movable/screen/alert/status_effect/overwatch
/// Distance at which we break off the ability
var/watch_range = 9
/// Visual effect to make the status obvious
var/datum/beam/link
/// Which watcher is watching?
var/mob/living/watcher
/// Type of projectile to fire
var/projectile_type
/// Noise to make when we shoot beam
var/projectile_sound
/// Did the overwatch ever trigger during our run?
var/overwatch_triggered = FALSE
/// Signals which trigger a hostile response
var/static/list/forbidden_actions = list(
COMSIG_MOB_ABILITY_FINISHED,
COMSIG_MOB_ATTACK_HAND,
COMSIG_MOB_DROVE_MECH,
COMSIG_MOB_FIRED_GUN,
COMSIG_MOB_ITEM_ATTACK,
COMSIG_MOB_THROW,
COMSIG_MOB_USED_MECH_EQUIPMENT,
COMSIG_MOB_USED_MECH_MELEE,
COMSIG_MOVABLE_MOVED,
)
/datum/status_effect/overwatch/on_creation(mob/living/new_owner, set_duration, mob/living/watcher, projectile_type, projectile_sound)
if (isnull(watcher) || isnull(projectile_type))
return FALSE
if (HAS_TRAIT(new_owner, TRAIT_OVERWATCH_IMMUNE))
return FALSE
src.watcher = watcher
src.projectile_type = projectile_type
src.projectile_sound = projectile_sound
if (!isnull(set_duration))
duration = set_duration
return ..()
/datum/status_effect/overwatch/on_apply()
. = ..()
if (!.)
return FALSE
ADD_TRAIT(owner, TRAIT_OVERWATCHED, TRAIT_STATUS_EFFECT(id))
owner.do_alert_animation()
owner.Immobilize(0.25 SECONDS) // Just long enough that they don't trigger it by mistake
owner.playsound_local(owner, 'sound/machines/chime.ogg', 50, TRUE)
var/atom/beam_origin = ismecha(owner.loc) ? owner.loc : owner
link = beam_origin.Beam(watcher, icon_state = "r_beam", override_target_pixel_x = 0)
RegisterSignals(owner, forbidden_actions, PROC_REF(opportunity_attack))
RegisterSignals(owner, list(COMSIG_QDELETING, COMSIG_LIVING_DEATH), PROC_REF(on_participant_died))
RegisterSignals(watcher, list(COMSIG_QDELETING, COMSIG_LIVING_DEATH), PROC_REF(on_participant_died))
/datum/status_effect/overwatch/on_remove()
UnregisterSignal(owner, forbidden_actions + list(COMSIG_QDELETING, COMSIG_LIVING_DEATH))
QDEL_NULL(link)
REMOVE_TRAIT(owner, TRAIT_OVERWATCHED, TRAIT_STATUS_EFFECT(id))
if (!QDELETED(owner))
owner.apply_status_effect(/datum/status_effect/overwatch_immune)
return ..()
/datum/status_effect/overwatch/Destroy()
QDEL_NULL(link)
if (!isnull(watcher)) // Side effects in Destroy? Well it turns out `on_remove` is also just called on Destroy. But only if the owner isn't deleting.
INVOKE_ASYNC(src, PROC_REF(unregister_watcher), watcher)
watcher = null
return ..()
/// Clean up our association with the caster of this ability.
/datum/status_effect/overwatch/proc/unregister_watcher(mob/living/former_overwatcher)
if (!overwatch_triggered)
former_overwatcher.Stun(2 SECONDS, ignore_canstun = TRUE)
UnregisterSignal(former_overwatcher, list(COMSIG_QDELETING, COMSIG_LIVING_DEATH))
/// Uh oh, you did something within my threat radius, now we're going to shoot you
/datum/status_effect/overwatch/proc/opportunity_attack()
SIGNAL_HANDLER
if (!can_see(watcher, owner, length = watch_range))
qdel(src)
return
overwatch_triggered = TRUE
INVOKE_ASYNC(watcher, TYPE_PROC_REF(/atom/, fire_projectile), projectile_type, owner, projectile_sound)
/// Can't overwatch you if I don't exist
/datum/status_effect/overwatch/proc/on_participant_died()
SIGNAL_HANDLER
qdel(src)
/atom/movable/screen/alert/status_effect/overwatch
name = "Overwatched"
desc = "Freeze! You are being watched!"
icon_state = "aimed"
/// Blocks further applications of the ability for a little while
/datum/status_effect/overwatch_immune
id = "watcher_overwatch_immunity"
duration = 10 SECONDS // To stop watcher tendrils spamming the shit out of you
alert_type = null
/datum/status_effect/overwatch_immune/on_apply()
. = ..()
ADD_TRAIT(owner, TRAIT_OVERWATCH_IMMUNE, TRAIT_STATUS_EFFECT(id))
/datum/status_effect/overwatch_immune/on_remove()
REMOVE_TRAIT(owner, TRAIT_OVERWATCH_IMMUNE, TRAIT_STATUS_EFFECT(id))
return ..()
@@ -0,0 +1,37 @@
/// Chilling projectile, hurts and slows you down
/obj/projectile/temp/watcher
name = "chilling blast"
icon_state = "ice_2"
damage = 10
damage_type = BURN
armor_flag = ENERGY
temperature = -50
/obj/projectile/temp/watcher/on_hit(mob/living/target, blocked = 0)
. = ..()
if (!isliving(target))
return
apply_status(target)
/// Apply an additional on-hit effect
/obj/projectile/temp/watcher/proc/apply_status(mob/living/target)
target.apply_status_effect(/datum/status_effect/freezing_blast)
/// Lava projectile, ignites you
/obj/projectile/temp/watcher/magma_wing
name = "scorching blast"
icon_state = "lava"
damage = 5
temperature = 200
/obj/projectile/temp/watcher/magma_wing/apply_status(mob/living/target)
target.adjust_fire_stacks(0.1)
target.ignite_mob()
/// Freezing projectile, freezes you
/obj/projectile/temp/watcher/ice_wing
name = "freezing blast"
damage = 5
/obj/projectile/temp/watcher/ice_wing/apply_status(mob/living/target)
target.apply_status_effect(/datum/status_effect/freon/watcher)