ajhahn.de
← Theria commits

Commit

Theria

feat: author the Solane hero roster (lion, cheetah, hyena)

ajhahnde · Jun 2026 · c91b4e31bf3b253e20875a99b1fa7d97045bf7cd · parent: 57b54bd · view on GitHub →

modified CHANGELOG.md
@@ -35,6 +35,14 @@ protocol version.
### Added
- The first roster of distinct heroes — the **Solane**, a Volk of savanna big-cat
shifters: a **Lion** frontline bruiser (short-range pokes, a heavy melee strike, and
the deepest self-sustain), a **Cheetah** burst skirmisher (long-range pokes and a
fast, repeatable single-target shred), and a **Hyena** zone controller (the widest
ground areas for attrition). Each carries its own human and animal kit, drawn from the
shared ability primitives but set apart by its targeting mix, tuning, and resource
economy. The current match equips the Lion for both sides; the Cheetah and Hyena are
authored and reachable once per-team hero selection lands.
- Ability controls: the player now casts the hero's abilities with the **1–4** keys,
aimed at the mouse cursor, and shifts the hero between its human and animal form to
wield each form's distinct set. The hero shows its current form (a ring around it)
modified README.md
@@ -34,8 +34,9 @@ lanes and an equatorial jungle to break each other's nexus.
The first milestone is a **walking skeleton**: one player-controlled hero and
one bot moving on the 3v3 arena under a server-authoritative, fixed-timestep
simulation. With that authority model proven, networked play over a
listen-server now runs on top of it; heroes, abilities, and the art direction
come next.
listen-server, the hero ability layer, and the first roster of heroes — the
**Solane**, savanna big-cat shifters (lion, cheetah, hyena) — now run on top of
it; the opposing Volk, multi-hero teams, and the art direction come next.
## Architecture
@@ -82,8 +83,9 @@ godot --path .
```
A connect screen opens: choose **Practice** for a single-machine match, **Host** to
start a listen-server, or type an address and **Join** one. Move the hero with
**WASD** or the **arrow keys**; the bot walks toward it. Cast its abilities with
start a listen-server, or type an address and **Join** one. Both sides field the
Solane lion today. Move the hero with **WASD** or the **arrow keys**; the bot
walks toward it. 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/client/main.gd
@@ -71,9 +71,11 @@ const CREEP_HP_BAR_OFFSET := Vector2(-35.0, -55.0)
const HP_BAR_BG := Color(0.0, 0.0, 0.0, 0.6)
const HP_BAR_FG := Color(0.4, 0.85, 0.4)
## The proving kit every hero is equipped with for v0.1 (see AbilityData). The
## distinct per-Volk kits replace it once the roster is authored.
const HERO_KIT := "wildkin"
## The kit every seated hero is equipped with for v0.1 (see AbilityData). Both teams
## mirror-pick the same Solane hero today; per-team hero selection arrives with the
## multi-hero (3v3) slice, when the other authored Solane kits ("cheetah", "hyena")
## become reachable in-game.
const HERO_KIT := "lion"
## Ability bar keys, one per slot (0..3). Movement owns WASD/arrows, so the four
## abilities sit on the number row rather than QWER. A held key recasts the slot as
modified src/sim/ability_data.gd
@@ -6,10 +6,22 @@ extends RefCounted
## executor reads to act. New abilities and heroes are added here, by value, with
## no engine or render coupling, so the whole roster stays unit-testable.
##
## v0.1 ships one proving kit, "wildkin": a generic shapeshifter that exercises the
## whole schema — all four targeting modes, the three effects, a per-form resource,
## and the human/animal transform. The distinct Theria heroes are authored against
## this same catalog in a later slice.
## "wildkin" is the schema-proving kit: a generic shapeshifter that exercises the
## whole catalog — all four targeting modes, the three effects, a per-form resource,
## and the human/animal transform. It stays as the reference the schema tests drive.
##
## The first Volk's roster is authored on top of it: the **Solane** — savanna big-cat
## shifters. Three mirror heroes, each a human kit plus an animal kit, built
## from the same DAMAGE/HEAL/TRANSFORM primitives but given distinct identities through
## their targeting mix, their tuning, and their resource economy:
## - **Lion** — a frontline bruiser: short-range poke, a heavy single-target Maul,
## and the deepest self-sustain, on a generous, slow-spending pool.
## - **Cheetah** — a burst skirmisher: long-range pokes and a repeatable single-target
## shred, on a lean, fast-regenerating pool (hit and run).
## - **Hyena** — a zone controller: the widest ground areas in both forms for
## attrition, on a baseline pool.
## v0.1 is a mirror match, so both teams draw from this one Volk; the opposing Volk
## (the Verdani draft) is authored when the second Volk lands.
## Ability rows keyed by catalog id. Each row is parsed on demand into a typed
## AbilitySpec by `spec`; a sparse row leans on the spec defaults. The dictionary's
@@ -93,6 +105,236 @@ const ABILITIES := {
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- Solane: Lion, human form (a short-range bruiser with deep self-sustain) ---
10:
{
"id": 10,
"name": "Sunfire Lash",
"form": AbilitySpec.FORM_HUMAN,
"slot": 0,
"target_kind": AbilitySpec.TARGET_SKILLSHOT,
"range": 450.0,
"radius": 70.0,
"cost": 25,
"cooldown_ticks": 36,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 65,
},
11:
{
"id": 11,
"name": "Mane Guard",
"form": AbilitySpec.FORM_HUMAN,
"slot": 1,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 35,
"cooldown_ticks": 150,
"effect": AbilitySpec.EFFECT_HEAL,
"power": 150, # the deepest heal in the Volk: the bruiser's staying power
},
12:
{
"id": 12,
"name": "Lion Form",
"form": AbilitySpec.FORM_HUMAN,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- Solane: Lion, animal form (engage area, then a heavy melee burst) --------
13:
{
"id": 13,
"name": "Pounce",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 0,
"target_kind": AbilitySpec.TARGET_GROUND,
"range": 350.0,
"radius": 160.0,
"cost": 25,
"cooldown_ticks": 30,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 70,
},
14:
{
"id": 14,
"name": "Maul",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 2,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 190.0, # melee: the bruiser must close to land its payoff
"cost": 35,
"cooldown_ticks": 48,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 160, # the hardest single hit in the Volk
},
15:
{
"id": 15,
"name": "Human Form",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- Solane: Cheetah, human form (long pokes on a lean, fast pool) ------------
20:
{
"id": 20,
"name": "Spear Throw",
"form": AbilitySpec.FORM_HUMAN,
"slot": 0,
"target_kind": AbilitySpec.TARGET_SKILLSHOT,
"range": 750.0, # the longest reach in the Volk
"radius": 50.0, # but a tight line: it must be aimed
"cost": 20,
"cooldown_ticks": 24,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 90,
},
21:
{
"id": 21,
"name": "Second Wind",
"form": AbilitySpec.FORM_HUMAN,
"slot": 1,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 25,
"cooldown_ticks": 100,
"effect": AbilitySpec.EFFECT_HEAL,
"power": 80, # a skirmisher's top-up, not the Lion's wall
},
22:
{
"id": 22,
"name": "Cheetah Form",
"form": AbilitySpec.FORM_HUMAN,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- Solane: Cheetah, animal form (single-target shred, repeatable) -----------
23:
{
"id": 23,
"name": "Hamstring",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 0,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 280.0,
"cost": 15,
"cooldown_ticks": 18, # the shortest cooldown in the Volk: harass on repeat
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 70,
},
24:
{
"id": 24,
"name": "Killing Blow",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 2,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 220.0,
"cost": 35,
"cooldown_ticks": 50,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 140,
},
25:
{
"id": 25,
"name": "Human Form",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- Solane: Hyena, human form (the widest ground zone for attrition) ---------
30:
{
"id": 30,
"name": "Bone-Hex",
"form": AbilitySpec.FORM_HUMAN,
"slot": 0,
"target_kind": AbilitySpec.TARGET_GROUND,
"range": 600.0,
"radius": 190.0,
"cost": 30,
"cooldown_ticks": 40,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 55,
},
31:
{
"id": 31,
"name": "Scavenge",
"form": AbilitySpec.FORM_HUMAN,
"slot": 1,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 30,
"cooldown_ticks": 120,
"effect": AbilitySpec.EFFECT_HEAL,
"power": 100,
},
32:
{
"id": 32,
"name": "Hyena Form",
"form": AbilitySpec.FORM_HUMAN,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- Solane: Hyena, animal form (a bite, and a wide pack slam) ----------------
33:
{
"id": 33,
"name": "Rending Bite",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 0,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 200.0,
"cost": 20,
"cooldown_ticks": 30,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 90,
},
34:
{
"id": 34,
"name": "Pack Frenzy",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 2,
"target_kind": AbilitySpec.TARGET_GROUND,
"range": 320.0,
"radius": 210.0, # the widest area in the Volk
"cost": 35,
"cooldown_ticks": 44,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 60,
},
35:
{
"id": 35,
"name": "Human Form",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
}
## Hero kits keyed by kit id. A kit names, per form, the resource pool (`max` and
@@ -116,6 +358,51 @@ const KITS := {
AbilitySpec.FORM_ANIMAL: {0: 4, 2: 5, 3: 6},
},
},
# --- Solane (savanna big-cats), the v0.1 mirror Volk ---------------------
"lion":
{
# A bruiser: a generous pool that spends slowly, to back the deep heal and
# the heavy Maul.
"resource":
{
AbilitySpec.FORM_HUMAN: {"max": 120, "regen_ticks": 10},
AbilitySpec.FORM_ANIMAL: {"max": 120, "regen_ticks": 10},
},
"abilities":
{
AbilitySpec.FORM_HUMAN: {0: 10, 1: 11, 3: 12},
AbilitySpec.FORM_ANIMAL: {0: 13, 2: 14, 3: 15},
},
},
"cheetah":
{
# A skirmisher: a lean pool that refills fast, to chain cheap pokes and the
# low-cooldown Hamstring.
"resource":
{
AbilitySpec.FORM_HUMAN: {"max": 80, "regen_ticks": 8},
AbilitySpec.FORM_ANIMAL: {"max": 80, "regen_ticks": 8},
},
"abilities":
{
AbilitySpec.FORM_HUMAN: {0: 20, 1: 21, 3: 22},
AbilitySpec.FORM_ANIMAL: {0: 23, 2: 24, 3: 25},
},
},
"hyena":
{
# A zone controller: a baseline pool feeding the wide ground areas.
"resource":
{
AbilitySpec.FORM_HUMAN: {"max": 100, "regen_ticks": 12},
AbilitySpec.FORM_ANIMAL: {"max": 100, "regen_ticks": 12},
},
"abilities":
{
AbilitySpec.FORM_HUMAN: {0: 30, 1: 31, 3: 32},
AbilitySpec.FORM_ANIMAL: {0: 33, 2: 34, 3: 35},
},
},
}
added test/unit/test_solane.gd
@@ -0,0 +1,144 @@
extends GutTest
## Data checks on the Solane roster — the first Volk's three hero kits (Lion, Cheetah,
## Hyena). The ability executor itself is proven by test_ability.gd against the wildkin
## kit; these tests prove the *content* is wired correctly: the right ability sits in
## the right slot and form, the tuned numbers land, the transforms flip, and the three
## resource economies are tiered as designed. Headless and deterministic, creep waves
## off, so only the ability under test changes the world.
const SOLANE := ["lion", "cheetah", "hyena"]
func _solane(sim: SimCore, kit_id: String, team: int, pos: Vector2) -> int:
var id := sim.add_hero(team, pos, 320.0)
sim.equip_kit(id, kit_id)
return id
## Equips a Solane hero and transforms it to its animal form with a real cast (human
## slot 3 = that hero's beast-form ability), so the animal-kit tests start from the
## form the transform actually produces.
func _solane_animal(sim: SimCore, kit_id: String, team: int, pos: Vector2) -> int:
var id := _solane(sim, kit_id, team, pos)
var beast := InputCommand.new()
beast.ability_slot = 3
sim.step({id: beast})
return id
# --- Roster shape -----------------------------------------------------------
func test_solane_kits_are_well_formed() -> void:
for kit_id in SOLANE:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _solane(sim, kit_id, 0, Vector2.ZERO)
var h := sim.state.get_entity(id)
assert_true(h.is_hero, "%s is an ability caster once equipped" % kit_id)
assert_eq(h.form, AbilitySpec.FORM_HUMAN, "%s starts in human form" % kit_id)
for form in [AbilitySpec.FORM_HUMAN, AbilitySpec.FORM_ANIMAL]:
var slots: Dictionary = h.kit[form]
assert_true(slots.has(3), "%s form %d carries a transform in slot 3" % [kit_id, form])
for slot in slots:
var spec := AbilityData.spec(slots[slot])
assert_true(AbilityData.has_ability(spec.id), "%s slot %d is a real ability" % [kit_id, slot])
assert_eq(spec.form, form, "%s slot %d ability is in its own form" % [kit_id, slot])
assert_eq(spec.slot, slot, "%s ability %d sits in its kit slot" % [kit_id, spec.id])
assert_eq(
AbilityData.spec(slots[3]).effect,
AbilitySpec.EFFECT_TRANSFORM,
"%s slot 3 is the transform" % kit_id,
)
func test_solane_kits_use_disjoint_ability_ids() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var seen := {}
var total := 0
for kit_id in SOLANE:
var id := _solane(sim, kit_id, 0, Vector2.ZERO)
var kit: Dictionary = sim.state.get_entity(id).kit
for form in kit:
for slot in kit[form]:
seen[kit[form][slot]] = true
total += 1
assert_eq(seen.size(), total, "no two Solane kits share an ability id (a copy-paste guard)")
# --- Each hero's signature ability lands its tuning --------------------------
func test_lion_maul_is_the_heaviest_single_hit() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _solane_animal(sim, "lion", 0, Vector2.ZERO)
sim.state.get_entity(id).attack_damage = 0 # isolate Maul from the auto-attack
var enemy := sim.add_entity(1, Vector2(180.0, 0.0), 0.0, 600) # inside Maul's 190 range
var cast := InputCommand.new()
cast.ability_slot = 2 # animal E = Maul
cast.target_id = enemy
sim.step({id: cast})
assert_eq(sim.state.get_entity(enemy).hp, 440, "Maul deals its 160 to the locked target")
func test_cheetah_spear_throw_reaches_the_longest() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _solane(sim, "cheetah", 0, Vector2.ZERO)
var far := sim.add_entity(1, Vector2(750.0, 0.0), 0.0, 600) # at Spear Throw's full 750 range
var cast := InputCommand.new()
cast.ability_slot = 0 # human Q = Spear Throw
cast.target_point = Vector2(100.0, 0.0) # any +x point: the skillshot flies the full range
sim.step({id: cast})
assert_eq(sim.state.get_entity(far).hp, 510, "Spear Throw clips an enemy 750 away")
assert_eq(sim.state.get_entity(id).resource, 60, "and spends its 20 from the lean 80 pool")
func test_hyena_bone_hex_zones_a_wide_area() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _solane(sim, "hyena", 0, Vector2.ZERO)
# Bone-Hex: GROUND, range 600, radius 190. Land it at (600,0).
var inside := sim.add_entity(1, Vector2(600.0, 150.0), 0.0, 600) # 150 from centre -> hit
var outside := sim.add_entity(1, Vector2(600.0, 300.0), 0.0, 600) # 300 from centre -> spared
var cast := InputCommand.new()
cast.ability_slot = 0 # human Q = Bone-Hex
cast.target_point = Vector2(600.0, 0.0)
sim.step({id: cast})
assert_eq(sim.state.get_entity(inside).hp, 545, "an enemy in the wide zone takes Bone-Hex's 55")
assert_eq(sim.state.get_entity(outside).hp, 600, "an enemy beyond its radius is spared")
# --- Forms and economy ------------------------------------------------------
func test_each_solane_hero_transforms_to_its_beast() -> void:
for kit_id in SOLANE:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _solane(sim, kit_id, 0, Vector2.ZERO)
var beast := InputCommand.new()
beast.ability_slot = 3
sim.step({id: beast})
assert_eq(
sim.state.get_entity(id).form,
AbilitySpec.FORM_ANIMAL,
"%s flips to its animal form on the slot-3 transform" % kit_id,
)
func test_solane_resource_economies_are_tiered() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var lion := sim.state.get_entity(_solane(sim, "lion", 0, Vector2(0, 0)))
var cheetah := sim.state.get_entity(_solane(sim, "cheetah", 0, Vector2(50, 0)))
var hyena := sim.state.get_entity(_solane(sim, "hyena", 0, Vector2(100, 0)))
assert_eq(cheetah.resource_max, 80, "the cheetah runs the leanest pool")
assert_eq(hyena.resource_max, 100, "the hyena sits at the baseline")
assert_eq(lion.resource_max, 120, "the lion carries the deepest pool")
assert_true(
cheetah.resource_regen_ticks < hyena.resource_regen_ticks,
"and the cheetah's lean pool refills fastest",
)