Commit
Theria
feat: let the player drive either Volk in a practice match
modified CHANGELOG.md
@@ -35,6 +35,11 @@ protocol version.
### Added
- A practice match is now a Volk-versus-Volk choice: `--hero` accepts any hero of either
Volk, and the chosen hero's Volk fields the player's team while the opposing Volk fills
the bots — so the Verdani are now playable, not just an opponent. The default still
seats the Solane against the Verdani. Which heroes form which Volk is recorded once in
the ability catalog and read by the client, so the rosters cannot drift apart.
- A second hero roster, the **Verdani** — jungle venom-and-shadow shifters (snake,
spider, chameleon) — joins the Solane as the opposing Volk, authored on the same
ability primitives as a deliberate foil: the snake is a venom striker with the
modified README.md
@@ -86,11 +86,12 @@ 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. Practice fields the full
Solane squad against the opposing Verdani squad — you drive one Solane hero (the lion
by default, or pass `--hero cheetah`/`--hero hyena` on the command line), bots drive
the rest. A hosted or joined match is still a one-hero-per-team duel on the lion until
multi-hero play
start a listen-server, or type an address and **Join** one. Practice is a Volk-vs-Volk
match: `--hero` names the hero you drive — any hero of either Volk — and that hero's
Volk fields your team while the opposing Volk fills the bots, so `--hero snake` puts you
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
@@ -112,7 +113,7 @@ a role, since a menu cannot be driven without a display:
godot --path . -- --host # host the match (you are team 0)
godot --path . -- --join 127.0.0.1 # join a host at an address (you are team 1)
godot --path . -- --local # a single-machine practice match, no menu
godot --path . -- --local --hero cheetah # practice driving a different Solane hero
godot --path . -- --local --hero snake # drive a Verdani hero (your team fields the Verdani)
```
The host is authoritative and fills any empty player slot with a bot. The joining
modified src/client/main.gd
@@ -72,15 +72,14 @@ 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 two Volk a LOCAL practice match fields, one hero per kit (see AbilityData): the
## player's squad draws the Solane, the bot squad the opposing Verdani, so a single
## match exercises both rosters and all four targeting modes. The player drives one
## Solane hero — picked with `--hero`, default the first — and every other seat on both
## teams is bot-driven. HOST/CLIENT still seat the one-per-team duel (DUEL_KIT below):
## the wire identifies a hero by its team, so a networked squad waits on the protocol
## step that gives each client a controlled-entity id.
const SOLANE_ROSTER: Array[String] = ["lion", "cheetah", "hyena"]
const VERDANI_ROSTER: Array[String] = ["snake", "spider", "chameleon"]
## The Volk the player's team falls back to in a LOCAL practice match when `--hero`
## names no known hero. The rosters themselves live in `AbilityData.VOLK` — the single
## source of which heroes form which Volk — and `_start_local` seats the player's chosen
## Volk against the opposing one, so the match exercises both rosters and all four
## targeting modes. HOST/CLIENT still seat the one-per-team duel (DUEL_KIT below): the
## wire identifies a hero by its team, so a networked squad waits on the protocol step
## that gives each client a controlled-entity id.
const DEFAULT_VOLK := "solane"
## The kit both heroes mirror in a HOST/CLIENT duel — the one-per-team walking
## skeleton the netcode is built around until the multi-hero wire step lands.
@@ -128,10 +127,11 @@ var _bot := BotController.new()
var _hero_id: int = 0
var _bot_id: int = 0
## LOCAL: the kit the player drives, from `--hero` (a Solane roster name); falls
## back to the roster's first if unset or unrecognised. Ignored by HOST/CLIENT,
## which seat the duel.
var _player_hero := SOLANE_ROSTER[0]
## LOCAL: the hero the player drives, from `--hero` — any hero of either Volk. Its
## Volk fills the player's team and the opposing Volk the bot team, so the choice also
## picks the match-up. Falls back to the first hero of the default Volk if unset or
## unrecognised. Ignored by HOST/CLIENT, which seat the duel.
var _player_hero: String = AbilityData.VOLK[DEFAULT_VOLK][0]
## LOCAL: every bot-driven hero this match — the player's two squadmates and the
## three opponents — each stepped from its own BotController decision.
var _bot_ids: Array[int] = []
@@ -290,13 +290,25 @@ func _close_menu_and_enter() -> void:
_enter_match()
## Practice: the player's team fields the full Solane squad, the bot team the opposing
## Verdani squad, one hero per roster kit. The player drives the Solane seat picked by
## `--hero`; the other five are bot-driven, so both rosters are on the field at once.
## Practice: a Volk-vs-Volk match. `--hero` names the hero the player drives; that
## hero's Volk (per `AbilityData.VOLK`) fills the player's team and the opposing Volk the
## bot team, one hero per roster kit. The player drives the matching seat; the other five
## are bot-driven, so both rosters are on the field at once. An unknown name falls back
## to the default Volk's first hero, so a typo starts a valid match instead of crashing.
func _start_local() -> void:
_sim = _new_world()
_seat_squad(HERO_TEAM, HERO_SPEED, SOLANE_ROSTER, _player_slot())
_seat_squad(BOT_TEAM, BOT_SPEED, VERDANI_ROSTER, -1)
var player_volk := AbilityData.volk_of(_player_hero)
if player_volk == "":
var fallback: String = AbilityData.VOLK[DEFAULT_VOLK][0]
push_warning("unknown --hero %s; defaulting to %s" % [_player_hero, fallback])
_player_hero = fallback
player_volk = DEFAULT_VOLK
var player_roster: Array[String] = []
player_roster.assign(AbilityData.VOLK[player_volk])
var bot_roster: Array[String] = []
bot_roster.assign(AbilityData.VOLK[AbilityData.opposing_volk(player_volk)])
_seat_squad(HERO_TEAM, HERO_SPEED, player_roster, player_roster.find(_player_hero))
_seat_squad(BOT_TEAM, BOT_SPEED, bot_roster, -1)
## Seats one hero per kit in `roster` for `team`, each fanned across the base fountain
@@ -313,17 +325,6 @@ func _seat_squad(team: int, speed: float, roster: Array[String], player_slot: in
_bot_ids.append(id)
## The roster index the player drives, resolved from `--hero`. An unset or
## unrecognised name falls back to the first kit (with a warning), so a typo starts
## a valid match instead of crashing.
func _player_slot() -> int:
var slot := SOLANE_ROSTER.find(_player_hero)
if slot < 0:
push_warning("unknown --hero %s; defaulting to %s" % [_player_hero, SOLANE_ROSTER[0]])
return 0
return slot
## The HOST/CLIENT walking skeleton: exactly one hero per team, both mirroring the
## duel kit. The wire identifies a hero by its team, so this one-per-team seating is
## what the netcode — prediction, interpolation, snapshot identity — is built
modified src/sim/ability_data.gd
@@ -693,6 +693,17 @@ const KITS := {
}
## The Völker: each Volk's hero roster, in seating order. The single source of which
## heroes form which Volk — the client reads it to seat a Volk-vs-Volk match, and the
## roster order fixes each hero's squad slot. The wildkin reference kit is deliberately
## in no Volk. v0.1 ships two Völker; a match pairs one against another (see
## `opposing_volk`).
const VOLK := {
"solane": ["lion", "cheetah", "hyena"],
"verdani": ["snake", "spider", "chameleon"],
}
## The typed spec for a catalog id. Parses the row on demand — the catalog is small
## and the executor caches nothing, so a spec is always read fresh by value.
static func spec(id: int) -> AbilitySpec:
@@ -707,3 +718,22 @@ static func has_ability(id: int) -> bool:
## A kit definition by id, or an empty dictionary if unknown.
static func kit(kit_id: String) -> Dictionary:
return KITS.get(kit_id, {})
## The Volk a hero kit belongs to, or "" if the kit is in no Volk (the wildkin reference
## kit, or an unknown name). A pure lookup over the roster data.
static func volk_of(kit_id: String) -> String:
for volk in VOLK:
if (VOLK[volk] as Array).has(kit_id):
return volk
return ""
## The Volk a given Volk is matched against — the next other Volk in declaration order.
## v0.1 fields exactly two, so this is simply "the other one"; returns `volk` itself if
## it is the only Volk defined.
static func opposing_volk(volk: String) -> String:
for other in VOLK:
if other != volk:
return other
return volk
added test/unit/test_local_seating.gd
@@ -0,0 +1,29 @@
extends GutTest
## The Völker roster data that drives a LOCAL Volk-vs-Volk match: `AbilityData.VOLK`
## records which heroes form which Volk, `volk_of` maps a hero back to its Volk, and
## `opposing_volk` names the side it is matched against. `main._start_local` composes
## these to seat the player's chosen Volk against the opposing one — so `--hero snake`
## now fields the Verdani for the player. Pure data, checked without a live client.
const SOLANE: Array[String] = ["lion", "cheetah", "hyena"]
const VERDANI: Array[String] = ["snake", "spider", "chameleon"]
func test_each_hero_maps_back_to_its_volk() -> void:
assert_eq(AbilityData.volk_of("hyena"), "solane", "a Solane hero reports the Solane")
assert_eq(AbilityData.volk_of("snake"), "verdani", "a Verdani hero reports the Verdani")
assert_eq(AbilityData.volk_of("wildkin"), "", "the reference kit belongs to no Volk")
assert_eq(AbilityData.volk_of("griffin"), "", "an unknown name belongs to no Volk")
func test_the_two_volk_oppose_each_other() -> void:
assert_eq(AbilityData.opposing_volk("solane"), "verdani", "the Solane face the Verdani")
assert_eq(AbilityData.opposing_volk("verdani"), "solane", "and the Verdani the Solane")
func test_volk_rosters_seat_three_heroes_each_in_order() -> void:
assert_eq(AbilityData.VOLK["solane"], SOLANE, "the Solane seating order")
assert_eq(AbilityData.VOLK["verdani"], VERDANI, "the Verdani seating order")
# The seat a hero lands in is its index here — the slot _start_local hands the player.
var spider_seat := (AbilityData.VOLK["verdani"] as Array).find("spider")
assert_eq(spider_seat, 1, "the spider is the second Verdani seat")