ajhahn.de
← Theria commits

Commit

Theria

feat: let bots kite or brawl by their hero's stance

ajhahnde · Jun 2026 · 5c475a7197dfaba0b5cefc1d90141b2ceb6b7163 · parent: 91b57c9 · view on GitHub →

modified CHANGELOG.md
@@ -35,6 +35,12 @@ protocol version.
### Added
- Bots now position to their hero's stance instead of all closing in the same way: the
skirmishers (Cheetah, Chameleon) kite — they hold their ranged form and keep an enemy
inside their skillshot band, backing off a point-blank attacker and closing on a distant
one, so they poke hit-and-run rather than committing to melee. Brawlers keep closing and
shifting toward whichever form can land a hit. Stance is authored per kit and read by the
bot; sim-side only, the netcode protocol is unchanged.
- The Verdani's venom and web are now mechanics, not just names: a venom strike leaves a
damage-over-time that keeps biting for two seconds after it lands, and a web leaves a
movement slow on what it catches. Each venom ability trades part of its instant hit for
modified README.md
@@ -94,11 +94,12 @@ tribe fields your team while the opposing tribe fills the bots, so `--hero snake
on the Verdani against the Solane, and the default lion keeps the Solane against the
Verdani. Bots drive the other five seats. A hosted or joined match is still a
one-hero-per-team duel on the lion until multi-hero play
reaches the wire. Move the hero with **WASD** or the **arrow keys**; the bots close
on the nearest enemy and cast their own kits — healing when hurt, otherwise firing
the reachable ability of their form, and shifting form to keep a hit in reach
(closing into the animal kit when an enemy slips inside the human poke, back to the
human form to poke at range or heal). Cast its abilities with
reaches the wire. Move the hero with **WASD** or the **arrow keys**; the bots fight to their kit's
stance — brawlers close on the nearest enemy and shift into the form that keeps a hit
in reach (into the animal kit when an enemy slips inside the human poke, back to the
human form to poke or heal), while the skirmishers (Cheetah, Chameleon) hold their
poke range and back off rather than melee — and all cast their own kits, healing when
hurt and otherwise firing the reachable ability of their form. Cast its abilities with
**1–4**, aimed at the mouse cursor — the hero shifts between a human and an animal
form (shown by the ring around it, white or amber), each form a different set of
abilities drawing on its own resource (the bar under the health bar). Abilities are
modified src/bot/bot_controller.gd
@@ -2,12 +2,15 @@ class_name BotController
extends RefCounted
## Produces an InputCommand for a bot-controlled entity from the world state.
##
## v0.1 behaviour: walk toward the nearest enemy, stop on contact, and — once the
## entity is a kitted hero — cast its abilities. The bot shifts into the stance that
## suits the fight (closing into its animal kit when an enemy slips inside the human
## poke's reach, falling back to the human form to poke at range or to heal when
## hurt), heals itself when hurt, and otherwise fires the first damaging ability of
## its active form that can actually reach the target.
## v0.1 behaviour: position against the nearest enemy and — once the entity is a kitted
## hero — cast its abilities. Positioning follows the kit's stance (AbilityData.STANCE_*):
## a BRAWLER walks in, stops on contact, and shifts toward whichever form can land a hit
## (closing into its animal kit when an enemy slips inside the human poke's reach, falling
## back to the human form to poke at range or to heal when hurt); a KITER (the skirmishers)
## instead holds its ranged form and keeps the enemy inside its skillshot band — backing
## off a point-blank enemy and closing on a distant one — so it pokes hit-and-run rather
## than committing to melee. Either way it heals when hurt and otherwise fires the first
## damaging ability of its active form that can actually reach the target.
## Deterministic — a pure function of the state — so a bot match replays identically
## and feeds the same simulation core a human would, gating every cast (a transform
## included) on the very `AbilityExecutor.can_cast` the player's casts pass through.
@@ -33,9 +36,12 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
var target := _nearest_enemy(state, bot)
if target == null:
return command
var offset := target.position - bot.position
if offset.length() > STOP_RANGE:
command.move_dir = offset.normalized()
if bot.is_hero and bot.stance == AbilityData.STANCE_KITE:
_kite_move(command, bot, target)
else:
var offset := target.position - bot.position
if offset.length() > STOP_RANGE:
command.move_dir = offset.normalized()
if bot.is_hero:
_choose_cast(command, bot, target)
return command
@@ -84,6 +90,10 @@ func _preferred_form(bot: SimEntity, target: SimEntity) -> int:
and _form_has_ready_heal(bot, AbilitySpec.FORM_HUMAN)
):
return AbilitySpec.FORM_HUMAN
# A kiter does not drop to a shorter-range form to engage: it holds the form whose
# poke reaches farthest and creates distance with its feet instead.
if bot.stance == AbilityData.STANCE_KITE:
return _ranged_form(bot)
if (
not _form_reaches_with_damage(bot, bot.form, target)
and _form_reaches_with_damage(bot, other, target)
@@ -171,6 +181,67 @@ func _reaches(spec: AbilitySpec, dist: float) -> bool:
return false
## Positions a kiter: it holds the enemy inside its skillshot band — backing off when
## the enemy is nearer than the band, closing when it is farther, and holding still
## within it so the poke lands. A kiter whose current form has no skillshot poke (it is
## briefly in the wrong form, about to shift back) just closes like a brawler until the
## stance step returns it to its ranged form. Movement only — the cast step still fires.
func _kite_move(command: InputCommand, bot: SimEntity, target: SimEntity) -> void:
var to_enemy := target.position - bot.position
var dist := to_enemy.length()
if dist <= 0.0:
return
var band := _kite_band(bot)
if band == Vector2.ZERO:
if dist > STOP_RANGE:
command.move_dir = to_enemy / dist
return
if dist < band.x:
command.move_dir = -to_enemy / dist
elif dist > band.y:
command.move_dir = to_enemy / dist
## The distance band a kiter holds — [range - radius, range + radius] of its current
## form's longest-range skillshot, the window in which that poke actually lands. Zero
## when the form holds no skillshot, which tells `_kite_move` to close like a brawler.
func _kite_band(bot: SimEntity) -> Vector2:
var best_range := 0.0
var best_radius := 0.0
for spec in _form_specs(bot, bot.form):
if spec.effect != AbilitySpec.EFFECT_DAMAGE:
continue
if spec.target_kind != AbilitySpec.TARGET_SKILLSHOT:
continue
if spec.range > best_range:
best_range = spec.range
best_radius = spec.radius
if best_range <= 0.0:
return Vector2.ZERO
return Vector2(best_range - best_radius, best_range + best_radius)
## A kiter's preferred form: the one whose longest-reaching damaging ability reaches
## farthest, so the kiter always fights from its poke form. A tie, or a kit with no
## damaging ability at all, falls to the human form.
func _ranged_form(bot: SimEntity) -> int:
if _longest_damage_range(bot, AbilitySpec.FORM_ANIMAL) > _longest_damage_range(
bot, AbilitySpec.FORM_HUMAN
):
return AbilitySpec.FORM_ANIMAL
return AbilitySpec.FORM_HUMAN
## The range of the farthest-reaching damaging ability on `form`'s bar — how far that
## stance can threaten — or 0 when the form holds no damaging ability.
func _longest_damage_range(bot: SimEntity, form: int) -> float:
var best := 0.0
for spec in _form_specs(bot, form):
if spec.effect == AbilitySpec.EFFECT_DAMAGE and spec.range > best:
best = spec.range
return best
func _nearest_enemy(state: SimState, bot: SimEntity) -> SimEntity:
var nearest: SimEntity = null
var nearest_dist := INF
modified src/sim/ability_data.gd
@@ -603,6 +603,15 @@ const ABILITIES := {
},
}
## Bot stance per kit: how a bot positions a hero it drives. BRAWL — the default for
## any kit that names no stance — closes the gap and shifts toward whichever form can
## land a hit. KITE holds the kit's ranged poke and keeps the enemy at arm's length,
## fighting hit-and-run from its skillshot band rather than committing to melee. Read by
## BotController and stamped onto the hero at equip; a player-driven hero ignores it.
const STANCE_BRAWL := 0
const STANCE_KITE := 1
## Hero kits keyed by kit id. A kit names, per form, the resource pool (`max` and
## the regen interval `regen_ticks` — one resource point restored every that many
## ticks, 0 for none) and the slot-to-ability-id bar. A hero equipped with a kit
@@ -643,7 +652,8 @@ const KITS := {
"cheetah":
{
# A skirmisher: a lean pool that refills fast, to chain cheap pokes and the
# low-cooldown Hamstring.
# low-cooldown Hamstring. A kiter — it holds its long Spear Throw range.
"stance": STANCE_KITE,
"resource":
{
AbilitySpec.FORM_HUMAN: {"max": 80, "regen_ticks": 8},
@@ -703,7 +713,9 @@ const KITS := {
"chameleon":
{
# An ambusher: the leanest pool on the fastest regen, to land a burst and
# refill for the next one — the most boom-and-bust economy of either tribe.
# refill for the next one — the most boom-and-bust economy of either tribe. A
# kiter — it darts in and out at its Tongue Lash range rather than brawling.
"stance": STANCE_KITE,
"resource":
{
AbilitySpec.FORM_HUMAN: {"max": 70, "regen_ticks": 7},
modified src/sim/sim_core.gd
@@ -127,6 +127,7 @@ func equip_kit(hero_id: int, kit_id: String) -> void:
var res: Dictionary = kit_def["resource"]
hero.is_hero = true
hero.form = AbilitySpec.FORM_HUMAN
hero.stance = kit_def.get("stance", AbilityData.STANCE_BRAWL)
hero.kit = (kit_def["abilities"] as Dictionary).duplicate(true)
hero.form_resource_max = PackedInt32Array(
[res[AbilitySpec.FORM_HUMAN]["max"], res[AbilitySpec.FORM_ANIMAL]["max"]]
modified src/sim/sim_entity.gd
@@ -48,6 +48,12 @@ var is_hero: bool = false
## abilities of the active form are castable; a TRANSFORM ability flips it.
var form: int = 0
## How a bot positions this hero (AbilityData.STANCE_*), set from the kit at equip.
## BRAWL (the default) closes to land a hit and shifts toward whichever form reaches;
## KITE holds the kit's ranged poke and keeps an enemy at arm's length. Ignored for a
## player-driven hero, which positions itself — a player hero just leaves it at BRAWL.
var stance: int = 0
## The current form's resource pool: `resource` spent to cast (gated against
## `resource_max`), refilled by one point every `resource_regen_ticks` ticks
## (0 = no regen), counted by `resource_regen_counter`. The two forms keep separate
@@ -110,6 +116,7 @@ func clone() -> SimEntity:
copy.waypoint_index = waypoint_index
copy.is_hero = is_hero
copy.form = form
copy.stance = stance
copy.resource = resource
copy.resource_max = resource_max
copy.resource_regen_ticks = resource_regen_ticks
modified test/unit/test_bot_controller.gd
@@ -82,12 +82,12 @@ func test_bot_picks_a_ground_ability_that_can_reach() -> void:
func test_a_unit_ability_locks_the_target() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
# Shift to the animal kit, whose slot 0 (Hamstring) is unit-targeted.
var id := _hero(sim, "snake", Vector2.ZERO) # a brawler, so it commits to its animal kit
# Shift to the animal kit, whose slot 0 (Fang Strike) is unit-targeted.
var beast := InputCommand.new()
beast.ability_slot = 3
sim.step({id: beast})
var enemy := sim.add_entity(1, Vector2(200.0, 0.0), 0.0, 600) # inside Hamstring's 280
var enemy := sim.add_entity(1, Vector2(200.0, 0.0), 0.0, 600) # inside Fang Strike's 360
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, 0, "the unit ability is selected")
assert_eq(command.target_id, enemy, "and locked onto the nearest enemy")
@@ -136,7 +136,8 @@ func test_bot_transforms_back_when_the_enemy_outruns_the_animal_kit() -> void:
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
_to_animal(sim, id) # the animal kit reaches only to 280 (Hamstring / Killing Blow)
# Far outside the animal kit but on the human Spear Throw's 750 range: shift back.
# A kiter holds its ranged form: caught in the animal kit, it shifts back toward the
# longer human Spear Throw rather than fighting from melee.
sim.add_entity(1, Vector2(750.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, TRANSFORM_SLOT, "it shifts back toward the human poke")
@@ -182,3 +183,53 @@ func test_a_bot_cast_lands_in_the_sim() -> void:
sim.step({id: _bot().decide(sim.state, id)})
assert_eq(sim.state.get_entity(enemy).hp, 520, "Spirit Bolt's 80 lands on the enemy")
assert_eq(sim.state.get_entity(id).resource, 80, "and its 20 cost is booked")
# --- Kite stance: a skirmisher holds its poke range -------------------------
func test_equip_stamps_the_kit_stance() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var cheetah := _hero(sim, "cheetah", Vector2.ZERO)
var lion := _hero(sim, "lion", Vector2(50.0, 0.0))
assert_eq(sim.state.get_entity(cheetah).stance, AbilityData.STANCE_KITE, "the cheetah kites")
assert_eq(sim.state.get_entity(lion).stance, AbilityData.STANCE_BRAWL, "the lion brawls")
func test_a_kiter_backs_off_a_point_blank_enemy_instead_of_meleeing() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600) # inside the Spear's dead zone, point-blank
var command := _bot().decide(sim.state, id)
assert_lt(command.move_dir.x, 0.0, "the kiter retreats from the enemy, not into it")
assert_eq(command.ability_slot, -1, "and does not drop to a melee form to engage")
func test_a_kiter_holds_and_pokes_from_its_band() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
sim.add_entity(1, Vector2(750.0, 0.0), 0.0, 600) # at the Spear Throw's landing range
var command := _bot().decide(sim.state, id)
assert_eq(command.move_dir, Vector2.ZERO, "inside its poke band the kiter holds position")
assert_eq(command.ability_slot, 0, "and fires its long skillshot")
func test_a_kiter_closes_on_an_enemy_beyond_its_poke() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
sim.add_entity(1, Vector2(1000.0, 0.0), 0.0, 600) # past the band: it must close to poke
var command := _bot().decide(sim.state, id)
assert_gt(command.move_dir.x, 0.0, "beyond its band the kiter advances to bring the poke in")
func test_a_brawler_closes_a_point_blank_enemy() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "snake", Vector2.ZERO) # a brawler: the contrast to the kiter
sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_gt(command.move_dir.x, 0.0, "a brawler closes the gap rather than kiting away")