mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-24 05:30:05 +01:00
[MIRROR] Replaces outdated AI guide with a new one [MDB IGNORE] (#18743)
* Replaces outdated AI guide with a new one (#72724) ## About The Pull Request replaces and updates the old guide ## Why It's Good For The Game This one's better! * Replaces outdated AI guide with a new one Co-authored-by: tralezab <40974010+tralezab@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
|
||||
# Learn AI
|
||||
|
||||
In ye olde days, we designed mob AI, and we built it into simple animals as they were the "non player controlled" mobs. Made sense at the time. But by coding AI directly into the mob, there was so little ability to make unique or complicated AI, and even when it was pulled off the code was hacky and non-reusable. the datum AI system was made to rectify these problems, and expand AI beyond just mobs.
|
||||
|
||||
## AI Controllers Attach
|
||||
|
||||
Any atom can have an AI controller, I'm choosing a basic mob for this guide, because basic mobs stand as a nice "blank canvas" for AI on mobs. Simple animals come with AI built into the mob, basic mobs don't, which is great for us adding AI on top of it.
|
||||
|
||||
Anyways, we just define the type of AI this mob has on the ai_controller var. It starts as a type, but is turned into an instance once the mob is instantiated.
|
||||
|
||||
```dm
|
||||
/mob/living/basic/butterfly
|
||||
name = "butterfly"
|
||||
desc = "A colorful butterfly, how'd it get up here?"
|
||||
// a lot more variables defining for us what a butterfly is
|
||||
|
||||
ai_controller = /datum/ai_controller/basic/butterfly
|
||||
```
|
||||
|
||||
## Controllers Themselves
|
||||
|
||||
First, let's look at the blackboard.
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic/cow
|
||||
blackboard = list(
|
||||
BB_TARGETTING_DATUM = new /datum/targetting_datum/basic/allow_items(),
|
||||
BB_BASIC_MOB_TIP_REACTING = FALSE,
|
||||
BB_BASIC_MOB_TIPPER = null,
|
||||
)
|
||||
```
|
||||
|
||||
Think of the blackboard as the unique format for variables. They are set initially, or by behaviors, **but never in subtrees.** Because we check `blackboard[BB_SOME_KEY]` instead of a variable, we can wipe out variables and slap new ones onto the AI as it runs. For example, this cow uses BB_BASIC_MOB_TIP_REACTING and BB_BASIC_MOB_TIPPER because cows can get tipped, and the AI needs to know that in the subtrees when it plans behavior. And in fact, those two keys aren't required to be defined initially, it's just for clarity that they are.
|
||||
|
||||
Speaking of subtrees, let's look at that now.
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic/cow
|
||||
|
||||
planning_subtrees = list(
|
||||
/datum/ai_planning_subtree/tip_reaction, //<- goes first
|
||||
/datum/ai_planning_subtree/find_and_eat_food, //<- goes second
|
||||
/datum/ai_planning_subtree/random_speech/cow, //<- goes last! But at any point, a previous subtree can end the chain. If a cow is tipped over, it shouldn't make random noises or try finding food!
|
||||
)
|
||||
//and by the end for however many subtrees ran, each one that did may have planned behavior for the AI to act on.
|
||||
```
|
||||
|
||||
AI's work by planning specific behaviors, and subtrees are datums that bundle the planning of behavior together. From top to bottom they run, and they can cancel future subtrees. As an example, cows have their very first consideration be tip_reaction, a subtree that prevents further subtrees like eating food and random speech, as well as planning out how the cow reacts (looking sad at the person who tipped it).
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic/cow
|
||||
ai_traits = null
|
||||
ai_movement = /datum/ai_movement/basic_avoidance
|
||||
idle_behavior = null
|
||||
|
||||
```
|
||||
|
||||
Finally, we have some more minor things.
|
||||
- ai_traits are flags for the AI, things like "STOP_MOVING_WHEN_PULLED" slightly modifying how the AI acts under some situations.
|
||||
- ai_movement is how the mob moves to its movement target. ranges from simple behaviors like ai_movement/dumb that awlays move in the direction of the target and hope there's nothing in the way, all the way to ai_movement/jps that plans and occasionally recalcuates more complicated paths, at the cost of more lag.
|
||||
- idle_behavior is just some simpler behavior to perform when nothing has been planned at all, like idle_behavior/idle_random_walk making a mob wander passively.
|
||||
|
||||
## Subtrees and Behaviors
|
||||
|
||||
Okay, so we have blackboard variables, which are considered by subtrees to plan behaviors. Let's actually look at a subtree planning behaviors, and behaviors themselves.
|
||||
|
||||
```dm
|
||||
/// this subtree checks if the mob has a target. if it doesn't, it plans looking for food. if it does, it tries to eat the food via attacking it.
|
||||
/datum/ai_planning_subtree/find_and_eat_food/SelectBehaviors(datum/ai_controller/controller, delta_time)
|
||||
//get things out of blackboard
|
||||
var/datum/weakref/weak_target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET]
|
||||
var/atom/target = weak_target?.resolve()
|
||||
var/list/wanted = controller.blackboard[BB_BASIC_FOODS]
|
||||
|
||||
//we see if we have a target (remember, anything can be in that blackboard, it's not a hard reference)
|
||||
if(!target || QDELETED(target))
|
||||
//we need to find some food
|
||||
controller.queue_behavior(/datum/ai_behavior/find_and_set/in_list, BB_BASIC_MOB_CURRENT_TARGET, wanted)
|
||||
return //this allows further subtrees to plan since we're doing a non-invasive behavior like checking the viscinity for food.
|
||||
|
||||
//now we know we have a target but should let a hostile subtree plan attacking humans. let's check if it's actually food
|
||||
if(target in wanted)
|
||||
controller.queue_behavior(/datum/ai_behavior/basic_melee_attack, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETTING_DATUM, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION)
|
||||
return SUBTREE_RETURN_FINISH_PLANNING //this prevents further subtrees from planning since we want to focus on eating the food
|
||||
```
|
||||
|
||||
And one of those behaviors, `basic_melee_attack`. As I have been doing so far, I've dumped in a bunch of comments explaining how this one behavior gets mobs to chase a target and slap it if in range.
|
||||
|
||||
```dm
|
||||
///this behavior makes an AI get close to their movement target, and attack every time perform() is called.
|
||||
/datum/ai_behavior/basic_melee_attack
|
||||
action_cooldown = 0.6 SECONDS
|
||||
//flag tells the AI it needs to have a movement target to work, and since it doesn't have "AI_BEHAVIOR_MOVE_AND_PERFORM", it won't call perform() every 0.6 seconds until it is in melee range. Smart!
|
||||
behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT
|
||||
|
||||
/datum/ai_behavior/basic_melee_attack/setup(datum/ai_controller/controller, target_key, targetting_datum_key, hiding_location_key)
|
||||
. = ..()
|
||||
//all this is doing in setup is setting the movement target. setup is called once when the behavior is first planned, and returning FALSE can cancel the behavior if something isn't right.
|
||||
|
||||
//Hiding location is priority
|
||||
var/datum/weakref/weak_target = controller.blackboard[hiding_location_key] || controller.blackboard[target_key]
|
||||
var/atom/target = weak_target?.resolve()
|
||||
if(!target)
|
||||
return FALSE
|
||||
//now the AI_BEHAVIOR_REQUIRE_MOVEMENT flag will be happy, we have a target to always be moving towards.
|
||||
controller.current_movement_target = target
|
||||
|
||||
///perform will run every "action_cooldown" deciseconds as long as the conditions are good for it to do so (we set "AI_BEHAVIOR_REQUIRE_MOVEMENT", so it won't perform until in range).
|
||||
/datum/ai_behavior/basic_melee_attack/perform(delta_time, datum/ai_controller/controller, target_key, targetting_datum_key, hiding_location_key)
|
||||
. = ..()
|
||||
var/mob/living/basic/basic_mob = controller.pawn
|
||||
//targetting datum will kill the action if not real anymore
|
||||
var/datum/weakref/weak_target = controller.blackboard[target_key]
|
||||
var/atom/target = weak_target?.resolve()
|
||||
var/datum/targetting_datum/targetting_datum = controller.blackboard[targetting_datum_key]
|
||||
|
||||
if(!targetting_datum.can_attack(basic_mob, target))
|
||||
///We have a target that is no longer valid to attack. Remember that returning doesn't end the behavior, JUST this single performance. So we call "finish_action" with whether it succeeded in doing what it wanted to do (it didn't, so FALSE) and the blackboard keys passed into this behavior.
|
||||
finish_action(controller, FALSE, target_key)
|
||||
return //don't forget to end the performance too
|
||||
|
||||
var/hiding_target = targetting_datum.find_hidden_mobs(basic_mob, target) //If this is valid, theyre hidden in something!
|
||||
|
||||
controller.blackboard[hiding_location_key] = hiding_target
|
||||
|
||||
///and finally, we're in range, we have a valid target, we can attack. When they fall into crit, they will no longer be a valid target, to the melee behavior will end.
|
||||
if(hiding_target) //Slap it!
|
||||
basic_mob.melee_attack(hiding_target)
|
||||
else
|
||||
basic_mob.melee_attack(target)
|
||||
|
||||
///and so the action has ended. we can now clean up the AI's blackboard based on the success of the action, and the keys passed in.
|
||||
/datum/ai_behavior/basic_melee_attack/finish_action(datum/ai_controller/controller, succeeded, target_key, targetting_datum_key, hiding_location_key)
|
||||
. = ..()
|
||||
///if the behavior failed, the target is no longer valid, so we should lose aggro of them. We remove the target_key (which could be anything, it's whatever key was passed into the behavior by the subtree) from the blackboard. Couldn't do THAT with normal variables!
|
||||
if(!succeeded)
|
||||
controller.blackboard -= target_key
|
||||
```
|
||||
@@ -1,285 +0,0 @@
|
||||
## Introduction
|
||||
|
||||
This is a step by step guide for making an AI Controller for your atom. It teaches the basics of each part of an AI Controller so the target for this guide is someone who doesn't know anything about Controllers and wants to hop in.
|
||||
|
||||
### Note on examples used
|
||||
|
||||
At the moment the quality of ai datums has some dubious code lying all around, and I wanted to show the best examples. So while I walk through this with the basic cow ai as an example, I do swap to other datums involving items, generic instrument planning, and some other stuff to help explain singular concepts. I make it clear later in the guide when I'm getting back to following along with filling out the cow ai, so watch out for that.
|
||||
|
||||
## Starting out
|
||||
|
||||
We're simply starting out with our definition of what we're modifying. Any atom can have an ai controller.
|
||||
|
||||
```dm
|
||||
/mob/living/basic/cow
|
||||
name = "cow"
|
||||
desc = "Known for their milk, just don't tip them over."
|
||||
```
|
||||
|
||||
## Initial AI Controller Definition
|
||||
|
||||
Next, we'll want to define the AI Controller. This is the "brain" of the AI. It starts as a type, but is turned into an instance once the object is instanced.
|
||||
|
||||
### Object Declaration
|
||||
|
||||
For clarity, i've included all the variables we're going to set up but haven't yet as nulls. In reality, some of these are always expected to be something and you should take a look at the base controller for which.
|
||||
|
||||
```dm
|
||||
/mob/living/basic/cow
|
||||
name = "cow"
|
||||
desc = "Known for their milk, just don't tip them over."
|
||||
|
||||
ai_controller = /datum/ai_controller/basic_controller/cow
|
||||
|
||||
/datum/ai_controller/basic_controller/cow
|
||||
blackboard = list()
|
||||
|
||||
ai_traits = null
|
||||
ai_movement = null
|
||||
idle_behavior = null
|
||||
planning_subtrees = list()
|
||||
|
||||
```
|
||||
|
||||
### AI Movement & Idle Behavior
|
||||
|
||||
AI Movement is a datum that decides how the AI you're making pathfinds. This has to at least be set to dumb movement, it cannot be null. We're making a basic mob, so we're just going to go inbetween complex and simple pathfinding with the `basic_avoidance` type.
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic_controller/cow
|
||||
blackboard = list()
|
||||
|
||||
ai_traits = null
|
||||
ai_movement = /datum/ai_movement/basic_avoidance
|
||||
idle_behavior = null
|
||||
planning_subtrees = list()
|
||||
|
||||
```
|
||||
|
||||
Idle Behavior is very similar, datum that decides what the AI should do when it decides it doesn't need to do anything (No planned behaviors, we'll walk through that later). Cows having some idle movement sounds nice, so we're going to pick that.
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic_controller/cow
|
||||
blackboard = list()
|
||||
|
||||
ai_traits = null
|
||||
ai_movement = /datum/ai_movement/basic_avoidance
|
||||
idle_behavior = /datum/idle_behavior/idle_random_walk
|
||||
planning_subtrees = list()
|
||||
|
||||
```
|
||||
|
||||
### AI Traits
|
||||
|
||||
AI traits are flags you can set to modify generic idle and movement behavior. In this case, we want farm animals to be able to be corralled, so we're going to add the `STOP_MOVING_WHEN_PULLED` flag.
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic_controller/cow
|
||||
blackboard = list()
|
||||
|
||||
ai_traits = STOP_MOVING_WHEN_PULLED
|
||||
ai_movement = /datum/ai_movement/basic_avoidance
|
||||
idle_behavior = /datum/idle_behavior/idle_random_walk
|
||||
planning_subtrees = list()
|
||||
|
||||
```
|
||||
|
||||
### Blackboard?
|
||||
|
||||
The blackboard is the variables of the ai controller. They are set up by the subtrees that use them, or are defaults set by the ai controller that the subtrees read. As we don't have our subtrees set up, we don't know what the blackboard should have! We're going to come back to this.
|
||||
|
||||
## Subtrees
|
||||
|
||||
So we have all the fundamentals of the cow set in stone, but we do not have the actual behaviors that make cows... act like cows! We introduce these through subtrees. They're singletons that ai controllers hold references to that plan out each step of how an AI should act, loading up behaviors.
|
||||
|
||||
Let's take a look at a simple subtree:
|
||||
|
||||
```dm
|
||||
/datum/ai_planning_subtree/item_throw_attack
|
||||
|
||||
/datum/ai_planning_subtree/item_throw_attack/SelectBehaviors(datum/ai_controller/controller, delta_time)
|
||||
var/obj/item/item_pawn = controller.pawn
|
||||
|
||||
if(!controller.blackboard[BB_ITEM_TARGET] || !DT_PROB(ITEM_AGGRO_ATTACK_CHANCE, delta_time))
|
||||
return //no target, or didn't aggro
|
||||
|
||||
controller.queue_behavior(controller.blackboard[BB_ITEM_MOVE_AND_ATTACK_TYPE], BB_ITEM_TARGET, BB_ITEM_THROW_ATTEMPT_COUNT)
|
||||
return SUBTREE_RETURN_FINISH_PLANNING
|
||||
```
|
||||
|
||||
This subtree takes a blackboard named `BB_ITEM_TARGET`, the target of the item set by other subtrees, and if that exists alongside a probability to aggro, the subtree queues the behavior to attack that mob.
|
||||
|
||||
So, neat. When you have a target, queue an attack. This item attack subtree is pretty basic, but a more complicated one may queue different attacks depending on the target. How does this fit into the subtrees?
|
||||
|
||||
Let's look where it's used, specifically in the subtrees variable:
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/haunted
|
||||
planning_subtrees = list(
|
||||
///this applies aggro for picking up the item
|
||||
/datum/ai_planning_subtree/item_ghost_resist,
|
||||
///this picks targets from the aggro list
|
||||
/datum/ai_planning_subtree/item_target_from_aggro_list,
|
||||
///this uses the target to attack.
|
||||
/datum/ai_planning_subtree/item_throw_attack,
|
||||
)
|
||||
```
|
||||
|
||||
As you can see the subtrees go top to bottom on their processing. `SUBTREE_RETURN_FINISH_PLANNING` will prematurely end the subtrees, so we can be sure the ai will focus on the behaviors planned so far in a "priority list" kind of way.
|
||||
|
||||
Let's visualize this in a case where the subtrees should stop prematurely!
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/haunted
|
||||
planning_subtrees = list(
|
||||
///someone is currently holding the item,
|
||||
///preventing it from attacking!
|
||||
///resist and end planning.
|
||||
/datum/ai_planning_subtree/item_ghost_resist,
|
||||
///this does not fire this time around
|
||||
/datum/ai_planning_subtree/item_target_from_aggro_list,
|
||||
///this does not fire this time around
|
||||
/datum/ai_planning_subtree/item_throw_attack,
|
||||
)
|
||||
```
|
||||
|
||||
### Subtree Setup
|
||||
|
||||
Subtrees also have procs for when the mob first starts using them and when they stop. You can use this to make subtrees "react" to events via signals, and this is where we set defaults for blackboards if necessary (we want lists to be empty, not null!)
|
||||
|
||||
Example:
|
||||
|
||||
```dm
|
||||
/datum/ai_planning_subtree/item_ghost_resist/SetupSubtree(datum/ai_controller/controller)
|
||||
RegisterSignal(controller.pawn, COMSIG_ITEM_EQUIPPED, PROC_REF(on_equip))
|
||||
controller.blackboard[BB_LIKES_EQUIPPER] = FALSE
|
||||
controller.blackboard[BB_ITEM_AGGRO_LIST] = list()
|
||||
|
||||
/datum/ai_planning_subtree/item_ghost_resist/ForgetSubtree(datum/ai_controller/controller)
|
||||
UnregisterSignal(controller.pawn, list(COMSIG_ITEM_EQUIPPED, COMSIG_ITEM_DROPPED))
|
||||
```
|
||||
|
||||
### Lil' Subtree Warning
|
||||
|
||||
**Do not set blackboards on the subtree!** Subtrees are there to sort and optimize behavior selection, putting logic for setting blackboards is essentially skipping a behavior. I'm putting this here because unfortunately a lot of our current ai datum code has this exact mistake, and I'm hoping we can move on from it!
|
||||
|
||||
BAD:
|
||||
|
||||
```dm
|
||||
if(prob(50))
|
||||
var/list/possible_targets = list()
|
||||
for(var/atom/thing in view(2, living_pawn))
|
||||
if(!thing.mouse_opacity)
|
||||
continue
|
||||
if(thing.IsObscured())
|
||||
continue
|
||||
possible_targets += thing
|
||||
var/atom/target = pick(possible_targets)
|
||||
if(target)
|
||||
controller.blackboard[BB_MONKEY_CURRENT_PRESS_TARGET] = target
|
||||
controller.queue_behavior(/datum/ai_behavior/use_on_object, BB_MONKEY_CURRENT_PRESS_TARGET)
|
||||
return
|
||||
```
|
||||
|
||||
GOOD:
|
||||
|
||||
```dm
|
||||
if(!controller.blackboard[BB_MONKEY_CURRENT_PRESS_TARGET])
|
||||
controller.queue_behavior(/datum/ai_behavior/find_nearby, BB_MONKEY_CURRENT_PRESS_TARGET)
|
||||
return
|
||||
|
||||
if(prob(50))
|
||||
controller.queue_behavior(/datum/ai_behavior/use_on_object, BB_MONKEY_CURRENT_PRESS_TARGET)
|
||||
return SUBTREE_RETURN_FINISH_PLANNING
|
||||
```
|
||||
|
||||
As you can see we're putting the search behavior... on a behavior! and since the planning subtree passes to other subtrees afterwards, the monkey will still find things to do. The next pass, if the search behavior was successful the action can be completed.
|
||||
|
||||
### Behaviors for subtrees
|
||||
|
||||
Finally, we've reached the final stop on this controller rabbit hole: Behaviors! These are what subtrees are planning, and the AI will do **these** from first planned all the way to the end, just like it runs through subtrees.
|
||||
|
||||
As before, let's take a look at a basic example of one:
|
||||
|
||||
```dm
|
||||
/datum/ai_behavior/follow
|
||||
behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM
|
||||
required_distance = 1
|
||||
|
||||
/datum/ai_behavior/follow/perform(delta_time, datum/ai_controller/controller, follow_key, range_key)
|
||||
. = ..()
|
||||
var/mob/living/living_pawn = controller.pawn
|
||||
if(!istype(living_pawn) || !isturf(living_pawn.loc))
|
||||
return
|
||||
|
||||
var/datum/weakref/follow_ref = controller.blackboard[follow_key]
|
||||
var/atom/movable/follow_target = follow_ref?.resolve()
|
||||
if(!follow_target || get_dist(living_pawn, follow_target) > controller.blackboard[range_key])
|
||||
finish_action(controller, FALSE)
|
||||
return
|
||||
|
||||
var/mob/living/living_target = follow_target
|
||||
if(istype(living_target) && (living_target.stat == DEAD))
|
||||
finish_action(controller, TRUE)
|
||||
return
|
||||
|
||||
controller.set_movement_target(living_target)
|
||||
|
||||
/datum/ai_behavior/follow/finish_action(datum/ai_controller/controller, succeeded, follow_key, range_key)
|
||||
. = ..()
|
||||
controller.blackboard[follow_key] = null
|
||||
```
|
||||
|
||||
This behavior makes the ai move to one tile away and finish the action, only finishing the action if the target is dead (success) or out of range (fail). When the action finishes, the follow target is unset by finish_action() regardless of success. Nice!
|
||||
|
||||
The last important thing to know is that behaviors take the keys from subtree planning as arguments. **They do not search for the blackboards they need themselves.**
|
||||
|
||||
BAD:
|
||||
|
||||
```dm
|
||||
/datum/ai_behavior/play_instrument
|
||||
|
||||
/datum/ai_behavior/play_instrument/perform(delta_time, datum/ai_controller/controller)
|
||||
. = ..()
|
||||
|
||||
//bzzt! using blackboard keys directly! let the subtree pass this in!
|
||||
var/datum/song/song = controller.blackboard[BB_SONG_DATUM]
|
||||
|
||||
song.start_playing(controller.pawn)
|
||||
finish_action(controller, TRUE)
|
||||
```
|
||||
|
||||
GOOD:
|
||||
|
||||
```dm
|
||||
/datum/ai_behavior/play_instrument
|
||||
|
||||
/datum/ai_behavior/play_instrument/perform(delta_time, datum/ai_controller/controller, song_datum_key)
|
||||
. = ..()
|
||||
|
||||
var/datum/song/song = controller.blackboard[song_datum_key]
|
||||
|
||||
song.start_playing(controller.pawn)
|
||||
finish_action(controller, TRUE) //NOTE: you may forget, but this doesn't end the proc! return after it if you have code later
|
||||
```
|
||||
|
||||
## "Okay, back to what we were doing!"
|
||||
|
||||
Wow, what a tangent! But it's important to understand subtree planning as it is the core of our AI. We have a subtree for the cows to occasionally make sounds, which can be interrupted by the tipping subtree (since cows can be tipped!) The Blackboard stays empty for our cows, since the tipped subtree does not have any blackboards it needs to read that could change per-ai controller. The Tipping blackboards are handled by the subtree's setup.
|
||||
|
||||
```dm
|
||||
/datum/ai_controller/basic_controller/cow
|
||||
blackboard = list()
|
||||
|
||||
ai_traits = STOP_MOVING_WHEN_PULLED
|
||||
ai_movement = /datum/ai_movement/basic_avoidance
|
||||
idle_behavior = /datum/idle_behavior/idle_random_walk
|
||||
planning_subtrees = list(
|
||||
/datum/ai_planning_subtree/tip_reaction,
|
||||
/datum/ai_planning_subtree/random_speech/cow,
|
||||
)
|
||||
```
|
||||
|
||||
### Finished Product: A COW.
|
||||
|
||||
And... we're finished! The tip_reaction subtree hooks into signals and runs behaviors when the cow is tipped, the random speech occasionally plans speech, the idle behavior runs when no behaviors are planned, and the cow acts like a cow! We used a mob in this case because everyone knows how a cow works as it's a very simple creature, but AI Controllers work on anything! It's just as valid of a use case to make, say, the staff of animation apply AI Controllers to items.
|
||||
Reference in New Issue
Block a user