diff --git a/code/__DEFINES/ai_pet_commands.dm b/code/__DEFINES/ai_pet_commands.dm new file mode 100644 index 00000000000..8887a3875dd --- /dev/null +++ b/code/__DEFINES/ai_pet_commands.dm @@ -0,0 +1,19 @@ +/// Blackboard field for the most recent command the pet was given +#define BB_ACTIVE_PET_COMMAND "BB_active_pet_command" +/// Follow your normal behaviour +#define PET_COMMAND_NONE "pet_command_none" +/// Don't take any actions at all +#define PET_COMMAND_IDLE "pet_command_idle" +/// Pursue and attack the pointed target +#define PET_COMMAND_ATTACK "pet_command_attack" +/// Pursue the person who made this command +#define PET_COMMAND_FOLLOW "pet_commmand_follow" +/// Use a targetted mob ability +#define PET_COMMAND_USE_ABILITY "pet_command_use_ability" + +/// Blackboard field for what we actually want the pet to target +#define BB_CURRENT_PET_TARGET "BB_current_pet_target" +/// Blackboard field for how we target things, as usually we want to be more permissive than normal +#define BB_PET_TARGETTING_DATUM "BB_pet_targetting" +/// Typecache of weakrefs to mobs this mob is friends with, will follow their instructions and won't attack them +#define BB_FRIENDS_LIST "BB_friends_list" diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm index 954d2a8a24e..282a474fdf6 100644 --- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm +++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm @@ -128,3 +128,6 @@ #define STOP_SACRIFICE (1<<0) /// Don't send a message for sacrificing this thing, we have our own #define SILENCE_SACRIFICE_MESSAGE (1<<1) + +/// From /mob/living/befriend() : (mob/living/new_friend) +#define COMSIG_LIVING_BEFRIENDED "living_befriended" diff --git a/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm b/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm index 231193a277b..64600e03f54 100644 --- a/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm +++ b/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm @@ -9,6 +9,9 @@ var/atom/target = weak_target?.resolve() if(!target) return FALSE + var/datum/targetting_datum/targetting_datum = controller.blackboard[targetting_datum_key] + if (!targetting_datum) + return controller.set_movement_target(target) /datum/ai_behavior/basic_melee_attack/perform(delta_time, datum/ai_controller/controller, target_key, targetting_datum_key, hiding_location_key) diff --git a/code/datums/ai/basic_mobs/pet_commands/pet_command_planning.dm b/code/datums/ai/basic_mobs/pet_commands/pet_command_planning.dm new file mode 100644 index 00000000000..bf1b13b7853 --- /dev/null +++ b/code/datums/ai/basic_mobs/pet_commands/pet_command_planning.dm @@ -0,0 +1,18 @@ +/** + * # Pet Planning + * Perform behaviour based on what pet commands you have received. This is delegated to the pet command datum. + * When a command is set, we blackboard a key to our currently active command. + * The blackboard also has a weak reference to every command datum available to us. + * We use the key to figure out which datum to run, then ask it to figure out how to execute its action. + */ +/datum/ai_planning_subtree/pet_planning + +/datum/ai_planning_subtree/pet_planning/SelectBehaviors(datum/ai_controller/controller, delta_time) + var/active_command_key = controller.blackboard[BB_ACTIVE_PET_COMMAND] + if (!active_command_key) + return // Do something else + var/datum/weakref/weak_command = controller.blackboard[active_command_key] + var/datum/pet_command/command = weak_command?.resolve() + if (!command) + return // We forgot this command at some point + return command.execute_action(controller) diff --git a/code/datums/ai/basic_mobs/pet_commands/pet_follow_friend.dm b/code/datums/ai/basic_mobs/pet_commands/pet_follow_friend.dm new file mode 100644 index 00000000000..244a10332b0 --- /dev/null +++ b/code/datums/ai/basic_mobs/pet_commands/pet_follow_friend.dm @@ -0,0 +1,19 @@ +/// Just keep following the target until the command is interrupted +/datum/ai_behavior/pet_follow_friend + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM + +/datum/ai_behavior/pet_follow_friend/setup(datum/ai_controller/controller, target_key) + . = ..() + var/datum/weakref/weak_target = controller.blackboard[target_key] + var/atom/target = weak_target?.resolve() + if (!target) + return FALSE + controller.current_movement_target = target + +/datum/ai_behavior/pet_follow_friend/perform(delta_time, datum/ai_controller/controller, target_key) + . = ..() + var/datum/weakref/weak_target = controller.blackboard[target_key] + var/atom/target = weak_target?.resolve() + if (!target) + finish_action(controller, FALSE, target_key) + return diff --git a/code/datums/ai/basic_mobs/pet_commands/pet_use_targetted_ability.dm b/code/datums/ai/basic_mobs/pet_commands/pet_use_targetted_ability.dm new file mode 100644 index 00000000000..940993cb393 --- /dev/null +++ b/code/datums/ai/basic_mobs/pet_commands/pet_use_targetted_ability.dm @@ -0,0 +1,27 @@ +/// Pet owners can't see their pet's ability cooldowns so we keep attempting to use an ability until we succeed +/datum/ai_behavior/pet_use_ability + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM + +/datum/ai_behavior/pet_use_ability/setup(datum/ai_controller/controller, ability_key, target_key) + . = ..() + var/datum/weakref/weak_target = controller.blackboard[target_key] + var/mob/living/target = weak_target?.resolve() + if (QDELETED(target)) + return FALSE + controller.current_movement_target = target + +/datum/ai_behavior/pet_use_ability/perform(delta_time, datum/ai_controller/controller, ability_key, target_key) + var/datum/action/cooldown/mob_cooldown/ability = controller.blackboard[ability_key] + var/datum/weakref/weak_target = controller.blackboard[target_key] + var/mob/living/target = weak_target?.resolve() + if (!ability || QDELETED(target)) + finish_action(controller, FALSE, ability_key, target_key) + return + var/mob/pawn = controller.pawn + if (ability.InterceptClickOn(pawn, null, target)) + finish_action(controller, TRUE, ability_key, target_key) + +/datum/ai_behavior/pet_use_ability/finish_action(datum/ai_controller/controller, succeeded, ability_key, target_key) + . = ..() + controller.blackboard[BB_ACTIVE_PET_COMMAND] = PET_COMMAND_IDLE // Wait for further instruction + controller.blackboard[target_key] = null diff --git a/code/datums/ai/basic_mobs/targetting_datums/dont_target_friends.dm b/code/datums/ai/basic_mobs/targetting_datums/dont_target_friends.dm new file mode 100644 index 00000000000..0fcd5dbf422 --- /dev/null +++ b/code/datums/ai/basic_mobs/targetting_datums/dont_target_friends.dm @@ -0,0 +1,30 @@ +/// Don't target an atom in our friends list (or turfs), anything else is fair game +/datum/targetting_datum/not_friends + +///Returns true or false depending on if the target can be attacked by the mob +/datum/targetting_datum/not_friends/can_attack(mob/living/living_mob, atom/target) + if (!target) + return FALSE + if (isturf(target)) + return FALSE + + if (ismob(target)) + var/mob/mob_target = target + if (mob_target.status_flags & GODMODE) + return FALSE + + if (living_mob.see_invisible < target.invisibility) + return FALSE + if (isturf(target.loc) && living_mob.z != target.z) // z check will always fail if target is in a mech + return FALSE + + if (!living_mob.ai_controller) // How did you get here? + return FALSE + + var/list/friends_list = living_mob.ai_controller.blackboard[BB_FRIENDS_LIST] + if (!friends_list) + return TRUE // We don't have any friends, anything's fair game + if (!friends_list[WEAKREF(target)]) + return TRUE // This is not our friend, fire at will + + return FALSE diff --git a/code/datums/components/pet_commands/obeys_commands.dm b/code/datums/components/pet_commands/obeys_commands.dm new file mode 100644 index 00000000000..8dc297e80dc --- /dev/null +++ b/code/datums/components/pet_commands/obeys_commands.dm @@ -0,0 +1,70 @@ +/** + * # Obeys Commands Component + * Manages a list of pet command datums, allowing you to boss it around + * Creates a radial menu of pet commands when this creature is alt-clicked, if it has any + */ +/datum/component/obeys_commands + /// List of commands you can give to the owner of this component + var/list/available_commands = list() + +/// The available_commands parameter should be passed as a list of typepaths +/datum/component/obeys_commands/Initialize(list/command_typepaths = list()) + . = ..() + if (!isliving(parent)) + return COMPONENT_INCOMPATIBLE + var/mob/living/living_parent = parent + if (!living_parent.ai_controller) + return COMPONENT_INCOMPATIBLE + if (!length(command_typepaths)) + CRASH("Initialised obedience component with no commands.") + + for (var/command_path in command_typepaths) + var/datum/pet_command/new_command = new command_path(parent) + available_commands[new_command.command_name] = new_command + +/datum/component/obeys_commands/Destroy(force, silent) + . = ..() + QDEL_NULL(available_commands) + +/datum/component/obeys_commands/RegisterWithParent() + RegisterSignal(parent, COMSIG_LIVING_BEFRIENDED, PROC_REF(add_friend)) + RegisterSignal(parent, COMSIG_CLICK_ALT, PROC_REF(display_menu)) + +/datum/component/obeys_commands/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_LIVING_BEFRIENDED, COMSIG_CLICK_ALT)) + +/datum/component/obeys_commands/proc/add_friend(datum/source, mob/living/new_friend) + SIGNAL_HANDLER + + for (var/command_name as anything in available_commands) + var/datum/pet_command/command = available_commands[command_name] + INVOKE_ASYNC(command, TYPE_PROC_REF(/datum/pet_command, add_new_friend), new_friend) + + +/// Displays a radial menu of commands +/datum/component/obeys_commands/proc/display_menu(datum/source, mob/living/clicker) + SIGNAL_HANDLER + + var/mob/living/living_parent = parent + if (IS_DEAD_OR_INCAP(living_parent)) + return + if (!living_parent.ai_controller) + return + var/list/friends_list = living_parent.ai_controller.blackboard[BB_FRIENDS_LIST] + if (!friends_list || !friends_list[WEAKREF(clicker)]) + return // Not our friend, can't boss us around + + INVOKE_ASYNC(src, PROC_REF(display_radial_menu), clicker) + +/// Actually display the radial menu and then do something with the result +/datum/component/obeys_commands/proc/display_radial_menu(mob/living/clicker) + var/list/radial_options = list() + for (var/command_name as anything in available_commands) + var/datum/pet_command/command = available_commands[command_name] + radial_options += command.provide_radial_data() + + var/pick = show_radial_menu(clicker, clicker, radial_options, require_near = TRUE, tooltips = TRUE) + if (!pick) + return + var/datum/pet_command/picked_command = available_commands[pick] + picked_command.try_activate_command(clicker) diff --git a/code/datums/components/pet_commands/pet_command.dm b/code/datums/components/pet_commands/pet_command.dm new file mode 100644 index 00000000000..b732783f1d9 --- /dev/null +++ b/code/datums/components/pet_commands/pet_command.dm @@ -0,0 +1,143 @@ +/** + * # Pet Command + * Set some AI blackboard commands in response to receiving instructions + * This is abstract and should be extended for actual behaviour + */ +/datum/pet_command + /// Weak reference to who follows this command + var/datum/weakref/weak_parent + /// Key for command applied when you receive an order + var/command_key = PET_COMMAND_NONE + /// Unique name used for radial selection, should not be shared with other commands on one mob + var/command_name + /// Description to display in radial menu + var/command_desc + /// Icon to display in radial menu + var/icon/radial_icon + /// Icon state to display in radial menu + var/radial_icon_state + /// Speech strings to listen out for + var/list/speech_commands + /// Shown above the mob's head when it hears you + var/command_feedback + /// How close a mob needs to be to a target to respond to a command + var/sense_radius = 7 + +/datum/pet_command/New(mob/living/parent) + . = ..() + weak_parent = WEAKREF(parent) + parent.ai_controller.blackboard[command_key] = WEAKREF(src) + +/// Register a new guy we want to listen to +/datum/pet_command/proc/add_new_friend(mob/living/tamer) + RegisterSignal(tamer, COMSIG_MOB_SAY, PROC_REF(respond_to_command)) + +/// Respond to something that one of our friends has asked us to do +/datum/pet_command/proc/respond_to_command(mob/living/speaker, speech_args) + SIGNAL_HANDLER + + var/mob/living/parent = weak_parent.resolve() + if (!parent) + return + if (!can_see(parent, speaker, sense_radius)) // Basically the same rules as hearing + return + + var/spoken_text = speech_args[SPEECH_MESSAGE] + if (!find_command_in_text(spoken_text)) + return + + try_activate_command(speaker) + +/// Returns true if we find any of our spoken commands in the text +/datum/pet_command/proc/find_command_in_text(spoken_text) + for (var/command as anything in speech_commands) + if (!findtext(spoken_text, command)) + continue + return TRUE + return FALSE + +/// Apply a command state if conditions are right, return command if successful +/datum/pet_command/proc/try_activate_command(mob/living/commander) + var/mob/living/parent = weak_parent.resolve() + if (!parent) + return + if (!parent.ai_controller) // We stopped having a brain at some point + return + if (IS_DEAD_OR_INCAP(parent)) // Probably can't hear them if we're dead + return + if (parent.ai_controller.blackboard[BB_ACTIVE_PET_COMMAND] == command_key) // We're already doing it + return + set_command_active(parent, commander) + +/// Activate the command, extend to add visible messages and the like +/datum/pet_command/proc/set_command_active(mob/living/parent, mob/living/commander) + set_command_target(parent, null) + + parent.ai_controller.CancelActions() // Stop whatever you're doing and do this instead + parent.ai_controller.blackboard[BB_ACTIVE_PET_COMMAND] = command_key + if (command_feedback) + parent.balloon_alert_to_viewers("[command_feedback]") // If we get a nicer runechat way to do this, refactor this + +/// Store the target for the AI blackboard +/datum/pet_command/proc/set_command_target(mob/living/parent, atom/target) + parent.ai_controller.blackboard[BB_CURRENT_PET_TARGET] = WEAKREF(target) + +/// Provide information about how to display this command in a radial menu +/datum/pet_command/proc/provide_radial_data() + var/datum/radial_menu_choice/choice = new() + choice.name = command_name + choice.image = icon(icon = radial_icon, icon_state = radial_icon_state) + var/tooltip = command_desc + if (length(speech_commands)) + tooltip += "
Speak this command with the words [speech_commands.Join(", ")]." + choice.info = tooltip + + return list("[command_name]" = choice) + +/** + * Execute an AI action on the provided controller, what we should actually do when this command is active. + * This should basically always be called from a planning subtree which passes its own controller. + * Return SUBTREE_RETURN_FINISH_PLANNING to pass that instruction on to the controller, or don't if you don't want that. + */ +/datum/pet_command/proc/execute_action(datum/ai_controller/controller) + SHOULD_CALL_PARENT(FALSE) + CRASH("Pet command execute action not implemented.") + +/** + * # Point Targetting Pet Command + * As above but also listens for you pointing at something and marks it as a target + */ +/datum/pet_command/point_targetting + /// Text describing an action we perform upon receiving a new target + var/pointed_reaction + /// Blackboard key for targetting datum, this is likely going to need it + var/targetting_datum_key = BB_PET_TARGETTING_DATUM + +/datum/pet_command/point_targetting/add_new_friend(mob/living/tamer) + . = ..() + RegisterSignal(tamer, COMSIG_MOB_POINTED, PROC_REF(look_for_target)) + +/// Target the pointed atom for actions +/datum/pet_command/point_targetting/proc/look_for_target(mob/living/friend, atom/pointed_atom) + SIGNAL_HANDLER + + + var/mob/living/parent = weak_parent.resolve() + if (!parent) + return + if (!parent.ai_controller) + return + if (IS_DEAD_OR_INCAP(parent)) + return + if (parent.ai_controller.blackboard[BB_ACTIVE_PET_COMMAND] != command_key) // We're not listening right now + return + if (parent.ai_controller.blackboard[BB_CURRENT_PET_TARGET] == WEAKREF(pointed_atom)) // That's already our target + return + if (!can_see(parent, pointed_atom, sense_radius)) + return + + parent.ai_controller.CancelActions() + // Deciding if they can actually do anything with this target is the behaviour's job + set_command_target(parent, pointed_atom) + // These are usually hostile actions so should have a record in chat + parent.visible_message(span_warning("[parent] follows [friend]'s gesture towards [pointed_atom] and [pointed_reaction]!")) diff --git a/code/datums/components/pet_commands/pet_commands_basic.dm b/code/datums/components/pet_commands/pet_commands_basic.dm new file mode 100644 index 00000000000..7caad390e06 --- /dev/null +++ b/code/datums/components/pet_commands/pet_commands_basic.dm @@ -0,0 +1,124 @@ +// None of these are really complex enough to merit their own file + +/** + * # Pet Command: Idle + * Tells a pet to resume its idle behaviour, usually staying put where you leave it + */ +/datum/pet_command/idle + command_name = "Stay" + command_desc = "Command your pet to stay idle in this location." + radial_icon = 'icons/testing/turf_analysis.dmi' + radial_icon_state = "red_arrow" + command_key = PET_COMMAND_IDLE + speech_commands = list("sit", "stay", "stop") + command_feedback = "sits" + +/datum/pet_command/idle/execute_action(datum/ai_controller/controller) + return SUBTREE_RETURN_FINISH_PLANNING // This cancels further AI planning + +/** + * # Pet Command: Stop + * Tells a pet to exit command mode and resume its normal behaviour, which includes regular target-seeking and what have you + */ +/datum/pet_command/free + command_name = "Loose" + command_desc = "Allow your pet to resume its natural behaviours." + radial_icon = 'icons/mob/actions/actions_spells.dmi' + radial_icon_state = "repulse" + command_key = PET_COMMAND_NONE + speech_commands = list("free", "loose") + command_feedback = "relaxes" + +/datum/pet_command/free/execute_action(datum/ai_controller/controller) + return // Just move on to the next planning subtree. + +/** + * # Pet Command: Follow + * Tells a pet to follow you until you tell it to do something else + */ +/datum/pet_command/follow + command_name = "Follow" + command_desc = "Command your pet to accompany you." + radial_icon = 'icons/mob/actions/actions_spells.dmi' + radial_icon_state = "summons" + command_key = PET_COMMAND_FOLLOW + speech_commands = list("heel", "follow") + +/datum/pet_command/follow/set_command_active(mob/living/parent, mob/living/commander) + . = ..() + set_command_target(parent, commander) + +/datum/pet_command/follow/execute_action(datum/ai_controller/controller) + controller.queue_behavior(/datum/ai_behavior/pet_follow_friend, BB_CURRENT_PET_TARGET) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * # Pet Command: Attack + * Tells a pet to chase and bite the next thing you point at + */ +/datum/pet_command/point_targetting/attack + command_name = "Attack" + command_desc = "Command your pet to attack things that you point out to it." + radial_icon = 'icons/effects/effects.dmi' + radial_icon_state = "bite" + + command_key = PET_COMMAND_ATTACK + speech_commands = list("attack", "sic", "kill") + command_feedback = "growl" + pointed_reaction = "growls" + /// Balloon alert to display if providing an invalid target + var/refuse_reaction = "shakes head" + /// Attack behaviour to use, generally you will want to override this to add some kind of cooldown + var/attack_behaviour = /datum/ai_behavior/basic_melee_attack + +// Refuse to target things we can't target, chiefly other friends +/datum/pet_command/point_targetting/attack/set_command_target(mob/living/parent, atom/target) + if (!target) + return + var/mob/living/living_parent = parent + if (!living_parent.ai_controller) + return + var/datum/targetting_datum/targeter = living_parent.ai_controller.blackboard[targetting_datum_key] + if (!targeter) + return + if (!targeter.can_attack(living_parent, target)) + refuse_target(parent, target) + return + return ..() + +/// Display feedback about not targetting something +/datum/pet_command/point_targetting/attack/proc/refuse_target(mob/living/parent, atom/target) + var/mob/living/living_parent = parent + living_parent.balloon_alert_to_viewers("[refuse_reaction]") + living_parent.visible_message(span_notice("[living_parent] refuses to attack [target].")) + +/datum/pet_command/point_targetting/attack/execute_action(datum/ai_controller/controller) + controller.queue_behavior(attack_behaviour, BB_CURRENT_PET_TARGET, targetting_datum_key) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * # Pet Command: Targetted Ability + * Tells a pet to use some kind of ability on the next thing you point at + */ +/datum/pet_command/point_targetting/use_ability + command_name = "Use ability" + command_desc = "Command your pet to use one of its special skills on something that you point out to it." + radial_icon = 'icons/mob/actions/actions_spells.dmi' + radial_icon_state = "projectile" + command_key = PET_COMMAND_USE_ABILITY + speech_commands = list("shoot", "blast", "cast") + command_feedback = "growl" + pointed_reaction = "growls" + /// Blackboard key where a reference to some kind of mob ability is stored + var/pet_ability_key + +/datum/pet_command/point_targetting/use_ability/execute_action(datum/ai_controller/controller) + if (!pet_ability_key) + return + var/datum/action/cooldown/using_action = controller.blackboard[pet_ability_key] + if (QDELETED(using_action)) + return + // We don't check if the target exists because we want to 'sit attentively' if we've been instructed to attack but not given one yet + // We also don't check if the cooldown is over because there's no way a pet owner can know that, the behaviour will handle it + controller.queue_behavior(/datum/ai_behavior/pet_use_ability, pet_ability_key, targetting_datum_key) + return SUBTREE_RETURN_FINISH_PLANNING diff --git a/code/datums/components/squeak.dm b/code/datums/components/squeak.dm index 23f6d5b28b8..41bc0c8dcb0 100644 --- a/code/datums/components/squeak.dm +++ b/code/datums/components/squeak.dm @@ -97,6 +97,8 @@ return if(arrived.movement_type & (FLYING|FLOATING) || !arrived.has_gravity()) return + if(ismob(arrived) && !arrived.density) // Prevents 10 overlapping mice from making an unholy sound while moving + return var/atom/current_parent = parent if(isturf(current_parent?.loc)) play_squeak() diff --git a/code/datums/components/tameable.dm b/code/datums/components/tameable.dm index 5c85c61f2d7..b7eb5890af7 100644 --- a/code/datums/components/tameable.dm +++ b/code/datums/components/tameable.dm @@ -56,9 +56,8 @@ after_tame?.Invoke(tamer)//Run custom behavior if needed - if(ishostile(parent) && isliving(tamer)) //Kinda shit check but this only applies to hostiles atm - var/mob/living/simple_animal/hostile/evil_but_now_not_evil = parent - evil_but_now_not_evil.friends = tamer - evil_but_now_not_evil.faction = tamer.faction.Copy() + if (isliving(parent) && isliving(tamer)) + var/mob/living/tamed = parent + INVOKE_ASYNC(tamed, TYPE_PROC_REF(/mob/living, befriend), tamer) qdel(src) diff --git a/code/modules/mob/living/basic/vermin/cockroach.dm b/code/modules/mob/living/basic/vermin/cockroach.dm index 71b1fbf67f2..0d51b92abfb 100644 --- a/code/modules/mob/living/basic/vermin/cockroach.dm +++ b/code/modules/mob/living/basic/vermin/cockroach.dm @@ -53,7 +53,8 @@ /datum/ai_controller/basic_controller/cockroach blackboard = list( - BB_TARGETTING_DATUM = new /datum/targetting_datum/basic() + BB_TARGETTING_DATUM = new /datum/targetting_datum/basic(), + BB_PET_TARGETTING_DATUM = new /datum/targetting_datum/not_friends(), ) ai_traits = STOP_MOVING_WHEN_PULLED @@ -92,6 +93,7 @@ /datum/ai_controller/basic_controller/cockroach/glockroach planning_subtrees = list( + /datum/ai_planning_subtree/pet_planning, /datum/ai_planning_subtree/random_speech/cockroach, /datum/ai_planning_subtree/simple_find_target, /datum/ai_planning_subtree/basic_ranged_attack_subtree/glockroach, //If we are attacking someone, this will prevent us from hunting @@ -137,6 +139,7 @@ return FALSE /datum/ai_controller/basic_controller/cockroach/hauberoach planning_subtrees = list( + /datum/ai_planning_subtree/pet_planning, /datum/ai_planning_subtree/random_speech/cockroach, /datum/ai_planning_subtree/simple_find_target, /datum/ai_planning_subtree/basic_melee_attack_subtree/hauberoach, //If we are attacking someone, this will prevent us from hunting @@ -151,6 +154,7 @@ /datum/ai_controller/basic_controller/cockroach/sewer planning_subtrees = list( + /datum/ai_planning_subtree/pet_planning, /datum/ai_planning_subtree/random_speech/cockroach, /datum/ai_planning_subtree/simple_find_target, /datum/ai_planning_subtree/basic_melee_attack_subtree/sewer, diff --git a/code/modules/mob/living/basic/vermin/mouse.dm b/code/modules/mob/living/basic/vermin/mouse.dm index 0ed42ffb28a..906ad668421 100644 --- a/code/modules/mob/living/basic/vermin/mouse.dm +++ b/code/modules/mob/living/basic/vermin/mouse.dm @@ -346,6 +346,7 @@ /datum/ai_controller/basic_controller/mouse/rat blackboard = list( BB_TARGETTING_DATUM = new /datum/targetting_datum/basic(), + BB_PET_TARGETTING_DATUM = new /datum/targetting_datum/not_friends(), BB_BASIC_MOB_CURRENT_TARGET = null, // heathen BB_CURRENT_HUNTING_TARGET = null, // cheese BB_LOW_PRIORITY_HUNTING_TARGET = null, // cable @@ -355,6 +356,7 @@ ai_movement = /datum/ai_movement/basic_avoidance idle_behavior = /datum/idle_behavior/idle_random_walk planning_subtrees = list( + /datum/ai_planning_subtree/pet_planning, /datum/ai_planning_subtree/simple_find_target, /datum/ai_planning_subtree/basic_melee_attack_subtree/rat, /datum/ai_planning_subtree/find_and_hunt_target/look_for_cheese, diff --git a/code/modules/mob/living/living.dm b/code/modules/mob/living/living.dm index f9314b64d8b..6c9d0cd71f7 100644 --- a/code/modules/mob/living/living.dm +++ b/code/modules/mob/living/living.dm @@ -2404,6 +2404,18 @@ GLOBAL_LIST_EMPTY(fire_appearances) . = ..() add_mood_event("gaming", /datum/mood_event/gaming) +/// Proc for giving a mob a new 'friend', generally used for AI control and targetting +/mob/living/proc/befriend(mob/living/new_friend) + SHOULD_CALL_PARENT(TRUE) + faction |= REF(new_friend) + if (ai_controller) + var/list/friends = ai_controller.blackboard[BB_FRIENDS_LIST] + if (!friends) + friends = list() + friends[WEAKREF(new_friend)] = TRUE + ai_controller.blackboard[BB_FRIENDS_LIST] = friends + SEND_SIGNAL(src, COMSIG_LIVING_BEFRIENDED, new_friend) + /// Admin only proc for making the mob hallucinate a certain thing /mob/living/proc/admin_give_hallucination(mob/admin) if(!admin || !check_rights(NONE)) diff --git a/code/modules/mob/living/simple_animal/hostile/hostile.dm b/code/modules/mob/living/simple_animal/hostile/hostile.dm index f3a0902a1b8..be9cd078bc2 100644 --- a/code/modules/mob/living/simple_animal/hostile/hostile.dm +++ b/code/modules/mob/living/simple_animal/hostile/hostile.dm @@ -664,3 +664,8 @@ target = new_target if(target) RegisterSignal(target, COMSIG_PARENT_QDELETING, PROC_REF(handle_target_del)) + +/mob/living/simple_animal/hostile/befriend(mob/living/new_friend) + . = ..() + friends += new_friend + faction = new_friend.faction.Copy() diff --git a/code/modules/mob/living/simple_animal/hostile/regalrat.dm b/code/modules/mob/living/simple_animal/hostile/regalrat.dm index 9ef954d3aa6..785fbe41272 100644 --- a/code/modules/mob/living/simple_animal/hostile/regalrat.dm +++ b/code/modules/mob/living/simple_animal/hostile/regalrat.dm @@ -229,8 +229,8 @@ StartCooldown() /** - * This action checks all nearby mice, and converts them into hostile rats. - * If no mice are nearby, creates a new one. + * This action checks some nearby maintenance animals and makes them your minions. + * If none are nearby, creates a new mouse. */ /datum/action/cooldown/riot name = "Raise Army" @@ -242,111 +242,158 @@ overlay_icon_state = "bg_clock_border" cooldown_time = 8 SECONDS melee_cooldown_time = 0 SECONDS + /// How close does something need to be for us to recruit it? + var/range = 5 + /// Commands you can give to your mouse army + var/static/list/mouse_commands = list( + /datum/pet_command/idle, + /datum/pet_command/free, + /datum/pet_command/follow, + /datum/pet_command/point_targetting/attack/mouse + ) + /// Commands you can give to glockroaches + var/static/list/glockroach_commands = list( + /datum/pet_command/idle, + /datum/pet_command/free, + /datum/pet_command/follow, + /datum/pet_command/point_targetting/attack/glockroach + ) /datum/action/cooldown/riot/Activate(atom/target) StartCooldown(10 SECONDS) riot() StartCooldown() +/** + * Attempts to, in order and ending at any successful step: + * * Convert nearby mice into aggressive rats. + * * Convert nearby roaches into aggressive roaches. + * * Convert nearby frogs into aggressive frogs. + * * Spawn a single mouse if below the mouse cap. + */ /datum/action/cooldown/riot/proc/riot() - var/cap = CONFIG_GET(number/ratcap) var/uplifted_mice = FALSE - var/uplifted_frog = FALSE + for (var/mob/living/basic/mouse/nearby_mouse in oview(owner, range)) + uplifted_mice = convert_mouse(nearby_mouse) || uplifted_mice + if (uplifted_mice) + owner.visible_message(span_warning("[owner] commands their army to action, mutating them into rats!")) + return + + var/static/list/converted_check_list = list(FACTION_RAT) var/uplifted_roach = FALSE - var/list/converted_check_list = list(FACTION_RAT) - for(var/mob/living/simple_animal/hostile/retaliate/frog/nearby_frog in oview(owner, 5)) - // No need to convert when not on the same team. - if(faction_check(nearby_frog.faction, converted_check_list)) - continue - if(nearby_frog.name == "frog") - nearby_frog.name = "trash frog" - nearby_frog.icon_state += "_trash" - nearby_frog.icon_living += "_trash" - nearby_frog.icon_dead = nearby_frog.icon_state + "_dead" - else if(nearby_frog.name == "rare frog") - nearby_frog.name = "rare trash frog" - nearby_frog.icon_state += "_trash" - nearby_frog.icon_living += "_trash" - nearby_frog.icon_dead = nearby_frog.icon_state + "_dead" - nearby_frog.desc += " ...Except this one lives in a trash bag." - nearby_frog.maxHealth += 10 - nearby_frog.health += 10 - nearby_frog.melee_damage_lower += 1 - nearby_frog.melee_damage_upper += 5 - nearby_frog.faction = owner.faction.Copy() - uplifted_frog = TRUE - break - - if(uplifted_frog) - owner.visible_message(span_warning("[owner] commands their army to action, mutating them trash frogs!")) - else - for(var/mob/living/basic/cockroach/nearby_roach in oview(owner, 5)) - // No need to convert when not on the same team. - if(faction_check(nearby_roach.faction, converted_check_list)) - continue - if(istype(nearby_roach, /mob/living/basic/cockroach/glockroach)) - if(nearby_roach.name == "glockroach") - nearby_roach.name = "sewer glockroach" - nearby_roach.melee_damage_lower += 0.5 - nearby_roach.melee_damage_upper += 2 - else if(istype(nearby_roach, /mob/living/basic/cockroach/hauberoach)) - if(nearby_roach.name == "hauberoach") - nearby_roach.name = "sewer hauberoach" - nearby_roach.melee_damage_lower += 0.5 - nearby_roach.melee_damage_upper += 2 - else - if(nearby_roach.name == "cockroach") - nearby_roach.name = "sewer cockroach" - nearby_roach.melee_damage_lower += 2 - nearby_roach.melee_damage_upper += 4 - nearby_roach.obj_damage += 5 - nearby_roach.ai_controller = new /datum/ai_controller/basic_controller/cockroach/sewer(nearby_roach) - nearby_roach.desc += " ...Except this one looks very robust." - nearby_roach.icon_state += "_sewer" - nearby_roach.maxHealth += 1 - nearby_roach.health += 1 - nearby_roach.faction = owner.faction.Copy() - uplifted_roach = TRUE - break - - if(uplifted_roach) + for (var/mob/living/basic/cockroach/nearby_roach in oview(owner, range)) + uplifted_roach = convert_roach(nearby_roach, converted_check_list) || uplifted_roach + if (uplifted_roach) owner.visible_message(span_warning("[owner] commands their army to action, mutating them into sewer roaches!")) - else if(!uplifted_frog) - for(var/mob/living/basic/mouse/nearby_mouse in oview(owner, 5)) - // This mouse is already rat controlled, let's not bother with it. - if(istype(nearby_mouse.ai_controller, /datum/ai_controller/basic_controller/mouse/rat)) - continue - var/mob/living/basic/mouse/rat/rat_path = /mob/living/basic/mouse/rat + return - // Change name - if(nearby_mouse.name == "mouse") - nearby_mouse.name = initial(rat_path.name) - // Buffs our combat stats to that of a rat - nearby_mouse.melee_damage_lower = initial(rat_path.melee_damage_lower) - nearby_mouse.melee_damage_upper = initial(rat_path.melee_damage_upper) - nearby_mouse.obj_damage = initial(rat_path.obj_damage) - nearby_mouse.maxHealth = initial(rat_path.maxHealth) - nearby_mouse.health = initial(rat_path.health) - // Replace our AI with a rat one - nearby_mouse.ai_controller = new /datum/ai_controller/basic_controller/mouse/rat(nearby_mouse) - // Give a hint in description too - nearby_mouse.desc += " ...Except this one looks corrupted and aggressive." - // Now we share factions! - nearby_mouse.faction = owner.faction.Copy() - uplifted_mice = TRUE - break + var/uplifted_frog = FALSE + for (var/mob/living/simple_animal/hostile/retaliate/frog/nearby_frog in oview(owner, range)) + uplifted_frog = convert_frog(nearby_frog, converted_check_list) || uplifted_frog + if (uplifted_frog) + owner.visible_message(span_warning("[owner] commands their army to action, mutating them into trash frogs!")) + return - if(uplifted_mice) - owner.visible_message(span_warning("[owner] commands their army to action, mutating them into rats!")) + var/rat_cap = CONFIG_GET(number/ratcap) + if (LAZYLEN(SSmobs.cheeserats) >= rat_cap) + to_chat(owner,span_warning("There's too many mice on this station to beckon a new one! Find them first!")) + return + new /mob/living/basic/mouse(owner.loc) + owner.visible_message(span_warning("[owner] commands a rat to their side!")) - else if(LAZYLEN(SSmobs.cheeserats) < cap) - new /mob/living/basic/mouse(owner.loc) - owner.visible_message(span_warning("[owner] commands a rat to their side!")) +/// Makes a passed mob into our minion +/datum/action/cooldown/riot/proc/make_minion(mob/living/new_minion, minion_desc, list/command_list = mouse_commands) + if (isbasicmob(new_minion)) // One day this will work for frogs too + new_minion.AddComponent(/datum/component/obeys_commands, command_list) + new_minion.befriend(owner) + new_minion.faction = owner.faction.Copy() + // Give a hint in description too + new_minion.desc += minion_desc + new_minion.balloon_alert_to_viewers("squeak") - else - to_chat(owner,span_warning("There's too many mice on this station to beckon a new one! Find them first!")) +/// Turns a mouse into an angry mouse +/datum/action/cooldown/riot/proc/convert_mouse(mob/living/basic/mouse/nearby_mouse) + // This mouse is already rat controlled, let's not bother with it. + if (istype(nearby_mouse.ai_controller, /datum/ai_controller/basic_controller/mouse/rat)) + return FALSE - StartCooldown() + var/mob/living/basic/mouse/rat/rat_path = /mob/living/basic/mouse/rat + // Change name + if (nearby_mouse.name == "mouse") + nearby_mouse.name = initial(rat_path.name) + // Buffs our combat stats to that of a rat + nearby_mouse.melee_damage_lower = initial(rat_path.melee_damage_lower) + nearby_mouse.melee_damage_upper = initial(rat_path.melee_damage_upper) + nearby_mouse.obj_damage = initial(rat_path.obj_damage) + nearby_mouse.maxHealth = initial(rat_path.maxHealth) + nearby_mouse.health = initial(rat_path.health) + // Replace our AI with a rat one + nearby_mouse.ai_controller = new /datum/ai_controller/basic_controller/mouse/rat(nearby_mouse) + make_minion(nearby_mouse, " ...Except this one looks corrupted and aggressive.") + return TRUE + +/// Turns a roach into an angry roach +/datum/action/cooldown/riot/proc/convert_roach(mob/living/basic/cockroach/nearby_roach, list/converted_check_list) + // No need to convert when not on the same team. + if (faction_check(nearby_roach.faction, converted_check_list)) + return FALSE + + var/list/minion_commands = mouse_commands + if (!findtext(nearby_roach.name, "sewer")) + nearby_roach.name = "sewer [nearby_roach.name]" + + if (istype(nearby_roach, /mob/living/basic/cockroach/glockroach) || istype(nearby_roach, /mob/living/basic/cockroach/hauberoach)) + if (istype(nearby_roach, /mob/living/basic/cockroach/glockroach)) + minion_commands = glockroach_commands + nearby_roach.melee_damage_lower += 0.5 + nearby_roach.melee_damage_upper += 2 + else + nearby_roach.melee_damage_lower += 2 + nearby_roach.melee_damage_upper += 4 + nearby_roach.obj_damage += 5 + nearby_roach.ai_controller = new /datum/ai_controller/basic_controller/cockroach/sewer(nearby_roach) + + nearby_roach.icon_state += "_sewer" + nearby_roach.maxHealth += 1 + nearby_roach.health += 1 + make_minion(nearby_roach, "
This one looks extra robust.", minion_commands) + return TRUE + +/// Turns a frog into a crazy frog. This doesn't do anything interesting and should when it becomes a basic mob. +/datum/action/cooldown/riot/proc/convert_frog(mob/living/simple_animal/hostile/retaliate/frog/nearby_frog, list/converted_check_list) + // No need to convert when not on the same team. + if(faction_check(nearby_frog.faction, converted_check_list)) + return FALSE + + if (!findtext(nearby_frog.name, "trash")) + nearby_frog.name = replacetext(nearby_frog.name, "frog", "trash frog") + + nearby_frog.icon_state += "_trash" + nearby_frog.icon_living += "_trash" + nearby_frog.icon_dead = nearby_frog.icon_state + "_dead" + nearby_frog.maxHealth += 10 + nearby_frog.health += 10 + nearby_frog.melee_damage_lower += 1 + nearby_frog.melee_damage_upper += 5 + var/crazy_frog_desc = " ...[findtext(nearby_frog.name, "rare") ? "even though" : "perhaps because"] they live in a trash bag." + make_minion(nearby_frog, crazy_frog_desc) + return TRUE + +// Command you can give to a mouse to make it kill someone +/datum/pet_command/point_targetting/attack/mouse + speech_commands = list("attack", "sic", "kill", "cheese em") + command_feedback = "squeak!" // Frogs and roaches can squeak too it's fine + pointed_reaction = "squeaks aggressively!" + refuse_reaction = "quivers" + attack_behaviour = /datum/ai_behavior/basic_melee_attack/rat + +// Command you can give to a mouse to make it kill someone +/datum/pet_command/point_targetting/attack/glockroach + speech_commands = list("attack", "sic", "kill", "cheese em") + command_feedback = "squeak!" + pointed_reaction = "cocks gun" + refuse_reaction = "quivers" + attack_behaviour = /datum/ai_behavior/basic_ranged_attack/glockroach /** *Spittle; harmless reagent that is added by rat king, and makes you disgusted. diff --git a/tgstation.dme b/tgstation.dme index a39550703fb..975ebf04eac 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -33,6 +33,7 @@ #include "code\__DEFINES\admin.dm" #include "code\__DEFINES\adventure.dm" #include "code\__DEFINES\ai.dm" +#include "code\__DEFINES\ai_pet_commands.dm" #include "code\__DEFINES\alarm.dm" #include "code\__DEFINES\alerts.dm" #include "code\__DEFINES\antagonists.dm" @@ -724,7 +725,11 @@ #include "code\datums\ai\basic_mobs\basic_subtrees\speech_subtree.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\target_retaliate.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\tipped_subtree.dm" +#include "code\datums\ai\basic_mobs\pet_commands\pet_command_planning.dm" +#include "code\datums\ai\basic_mobs\pet_commands\pet_follow_friend.dm" +#include "code\datums\ai\basic_mobs\pet_commands\pet_use_targetted_ability.dm" #include "code\datums\ai\basic_mobs\targetting_datums\basic_targetting_datum.dm" +#include "code\datums\ai\basic_mobs\targetting_datums\dont_target_friends.dm" #include "code\datums\ai\cursed\cursed_behaviors.dm" #include "code\datums\ai\cursed\cursed_controller.dm" #include "code\datums\ai\cursed\cursed_subtrees.dm" @@ -960,6 +965,9 @@ #include "code\datums\components\food\decomposition.dm" #include "code\datums\components\food\edible.dm" #include "code\datums\components\food\ice_cream_holder.dm" +#include "code\datums\components\pet_commands\obeys_commands.dm" +#include "code\datums\components\pet_commands\pet_command.dm" +#include "code\datums\components\pet_commands\pet_commands_basic.dm" #include "code\datums\components\plumbing\_plumbing.dm" #include "code\datums\components\plumbing\chemical_acclimator.dm" #include "code\datums\components\plumbing\filter.dm"