-
Notifications
You must be signed in to change notification settings - Fork 0
Ranged Example Walkthrough Skeleton
The bundled com.azure.azurecortex.example.skeleton.CortexSkeletonEntity shows the same integration shape as the
zombie in Full Example Walkthrough, applied to a ranged attacker. Read that page first if you haven't —
this page only covers what's different: the bow/melee split inside HUNT_TARGET, and the (deliberately simpler)
planner around it.
Everything about vanilla Skeleton (sunlight burning, drowning, skeleton-specific loot/drops, ...) keeps working
unmodified, only targeting/movement/attack decisions are replaced, exactly as with the zombie.
public enum CortexSkeletonGoal implements Goal {
NONE,
WANDER,
INVESTIGATE,
HUNT_TARGET;
@Override
public boolean isNone() {
return this == NONE;
}
}One fewer state than the zombie, no EAT_TO_HEAL, since this example's point is the ranged-attack split, not
planner sophistication.
CortexSkeletonGoalPlanner is intentionally simpler than the zombie's: hunt a live target, otherwise investigate a
recent sighting, otherwise wander, no emergency branch at all.
public final class CortexSkeletonGoalPlanner implements GoalPlanner<CortexSkeletonEntity, CortexSkeletonGoal> {
private static final int INVESTIGATE_MAX_AGE_TICKS = 100;
private static final int MIN_COMMIT_TICKS = 20;
private static final int MAX_COMMIT_TICKS = 200;
@Override
public PlannedGoal<CortexSkeletonEntity, CortexSkeletonGoal> chooseGoal(
CortexSkeletonEntity agent, Blackboard blackboard, CooldownTracker cooldowns
) {
var currentTick = (int) agent.level().getGameTime();
var failureCooldowns = GoalFailureCooldowns.<CortexSkeletonGoal>getOrCreate(blackboard);
failureCooldowns.evictExpired(currentTick);
var feedback = (PlanFeedback<CortexSkeletonGoal>) blackboard.get(CommonBlackboardKeys.LAST_PLAN_FEEDBACK);
if (feedback != null && feedback.isFresh(currentTick)
&& (feedback.reason() == PlanFailureReason.FAILED_STUCK
|| feedback.reason() == PlanFailureReason.FAILED_NO_PATH)) {
failureCooldowns.recordFailure(CortexSkeletonGoal.HUNT_TARGET, currentTick);
}
var target = blackboard.get(CommonBlackboardKeys.TARGET);
if (target != null && target.isAlive()) {
var score = 50f - failureCooldowns.getPenalty(CortexSkeletonGoal.HUNT_TARGET, currentTick);
return PlannedGoal.of(CortexSkeletonGoal.HUNT_TARGET, score, currentTick,
MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, target, null, GoalUrgency.NORMAL, true,
"Live target acquired");
}
var lastSeenPos = blackboard.get(CommonBlackboardKeys.LAST_SEEN_POS);
var lastSeenTick = blackboard.get(CommonBlackboardKeys.LAST_SEEN_TICK);
if (lastSeenPos != null && lastSeenTick != null
&& currentTick - lastSeenTick <= INVESTIGATE_MAX_AGE_TICKS) {
return PlannedGoal.of(CortexSkeletonGoal.INVESTIGATE, 30f, currentTick,
MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, null, lastSeenPos, GoalUrgency.NORMAL, true,
"Lost sight of a target recently");
}
return PlannedGoal.of(CortexSkeletonGoal.WANDER, 10f, currentTick,
MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, null, null, GoalUrgency.LOW, true,
"Nothing better to do");
}
}Structurally identical to the zombie's planner minus the emergency-eat branch, see GOAP Planning for what each
piece (GoalFailureCooldowns, PlanFeedback) is doing.
This is the interesting part. HUNT_TARGET uses HuntTargetNode with two AttackProfiles instead of one: a bow
(mid-to-long range) and a melee fallback for when the target closes to point-blank range. HuntTargetNode re-evaluates
which of the two is legal every tick via AttackSelector, so the skeleton smoothly switches between shooting and
swinging with no explicit state machine of your own, see Behavior Trees for why HuntTargetNode exists at all.
public final class CortexSkeletonTree {
public static BehaviorNode<CortexSkeletonEntity, CortexSkeletonGoal> create() {
var idle = new IdleAction<CortexSkeletonEntity, CortexSkeletonGoal>();
var wander = new WanderAction<CortexSkeletonEntity, CortexSkeletonGoal>(1.0D, 10.0D, 100);
var investigate = new InvestigateLastSeenTargetAction<CortexSkeletonEntity, CortexSkeletonGoal>(
AStarPathfinder.INSTANCE, 1.0D, 2, 60, 100, 60, 0.02D, 2.0D, 8.0D);
var hunt = getHunt();
return PrioritySelector.of(
new ActionNode<>(idle, 0),
new Condition<>((agent, bb, cd) -> bb.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE)
== CortexSkeletonGoal.WANDER, new ActionNode<>(wander, 5)),
new Condition<>((agent, bb, cd) -> bb.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE)
== CortexSkeletonGoal.INVESTIGATE, new ActionNode<>(investigate, 8)),
hunt
);
}
private static HuntTargetNode<CortexSkeletonEntity, CortexSkeletonGoal> getHunt() {
var bowAttack = new UseItemAction<CortexSkeletonEntity, CortexSkeletonGoal>(
InteractionHand.MAIN_HAND,
agent -> agent.getMainHandItem().is(Items.BOW),
20, // minChargeTicks
40, // maxChargeTicks
(agent, blackboard, ticksCharged) -> { // readyToRelease
var target = blackboard.get(CommonBlackboardKeys.TARGET);
return target != null && target.isAlive() && agent.hasLineOfSight(target);
},
(agent, blackboard, ticksCharged) -> { // onChargeTick, aim while drawing
var target = blackboard.get(CommonBlackboardKeys.TARGET);
if (target != null) {
agent.getLookControl().setLookAt(target, 30.0F, 30.0F);
}
},
(agent, blackboard, ticksCharged) -> { // onRelease, actually fire
var target = blackboard.get(CommonBlackboardKeys.TARGET);
if (target != null && target.isAlive()) {
agent.performRangedAttack(target, BowItem.getPowerForTime(ticksCharged));
}
},
"skeleton_bow_cooldown", 20,
18 // priority
);
var melee = new AttackProfile<CortexSkeletonEntity, CortexSkeletonGoal>(
"melee",
new TimedAttackAction<>("skeleton_melee", 6, 2.0D, "skeleton_melee_cooldown", 20),
"skeleton_melee_cooldown", 0.0D, 2.0D, 25 // minRange 0, maxRange 2, priority 25
);
var bow = new AttackProfile<>(
"bow", bowAttack, "skeleton_bow_cooldown",
4.0D, 15.0D, 20 // minRange 4, maxRange 15, priority 20
);
var chase = new MoveToDestinationAction<CortexSkeletonEntity, CortexSkeletonGoal>(
AStarPathfinder.INSTANCE, 1.0D, 1, 10, 60);
return new HuntTargetNode<>(CortexSkeletonGoal.HUNT_TARGET, chase, List.of(melee, bow));
}
}This is the one genuinely subtle piece of the whole example, and it's worth understanding rather than just copying.
Vanilla's BowItem#releaseUsing only fires an arrow if (entity instanceof Player). For a Mob, calling
Mob.stopUsingItem() alone ends the drawing animation and fires nothing at all. Vanilla's own
RangedBowAttackGoal works around this by calling stopUsingItem() and then separately, explicitly calling
RangedAttackMob#performRangedAttack(target, power), the arrow only exists because of that second call.
UseItemAction doesn't special-case bows (that would defeat the point of being a generic action), so it exposes two
hooks instead:
-
onChargeTick, runs every tick while charging. The skeleton uses this purely to keep aiming (getLookControl().setLookAt(target, ...)) while drawing, mirroring what vanilla's ranged goals do every tick so the mob visibly tracks its target instead of standing frozen mid-draw. -
onRelease, runs once, immediately afterstopUsingItem(). This is where the actual shot happens:agent.performRangedAttack(target, BowItem.getPowerForTime(ticksCharged)). Without this,minChargeTicks/maxChargeTickswould still animate a full draw-and-release with nothing coming out of the bow.
If you're wiring up a crossbow or trident instead of a bow, the same two-hook pattern applies, only the specific
vanilla API you call from onRelease changes.
-
melee: range
0.0–2.0, priority25. -
bow: range
4.0–15.0, priority20.
There's a deliberate 2.0–4.0 gap where neither profile is legal, at that distance AttackSelector.select returns
null for both, so HuntTargetNode falls back to chase (closing the distance) until the target is inside one band
or the other. Melee outranks the bow when both happen to be legal (overlapping ranges never actually occur here
given the gap, but the priority order documents the intended tie-break if you widen the bands later). Tune these bands
to taste, a "kiting" archer that prefers to back away rather than let a target get close enough for melee would need
its own custom node rather than relying on HuntTargetNode's "closest wins" chase fallback.
- Use
AttackProfile/AttackSelector/HuntTargetNodeany time an agent should choose between two or more attacks by range/cooldown rather than a single fixed one, adding a third attack is adding one moreAttackProfileentry, no tree changes needed. - Use
UseItemAction's charge-and-release mode plus theonChargeTick/onReleasehooks for any vanilla "hold-then-release" item (bow, crossbow, trident) on a non-PlayerMob, the same bow gotcha above applies to all of them equally. - Keep the planner as simple as your goals actually need, the skeleton's is proof that you don't need an
EAT_TO_HEAL-style emergency branch just because the zombie example has one.
- Full Example Walkthrough, the melee (zombie) counterpart this page assumes you've read.
- Wall-Climbing Example (Spider), a from-scratch build combining this same GOAP/tree shape with Wall Crawling's movement model.
-
Built-in Actions, full reference for
UseItemAction,AttackProfile, andAttackSelector.