Commit
Theria
feat: field the full Solane squad in a practice match
modified CHANGELOG.md
@@ -35,14 +35,19 @@ protocol version.
### Added
- A practice match now fields the full **Solane** squad on each team — one hero per
kit (Lion, Cheetah, Hyena) — so all three are on the field at once. The player drives
one (the Lion by default, or `--hero cheetah`/`--hero hyena`) and bots drive the rest.
A hosted or joined match stays a one-hero-per-team duel until multi-hero play reaches
the wire.
- 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.
economy. A practice match fields all three (see the squad entry above); a hosted or
joined match equips the Lion for both sides until multi-hero play reaches the wire.
- 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
@@ -36,7 +36,9 @@ 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, 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.
it. A practice match fields the full Solane squad per team — the player drives
one hero, bots fill the rest — so all three kits are on the field at once; the
opposing Volk, multi-hero teams over the wire, and the art direction come next.
## Architecture
@@ -83,9 +85,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. 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
start a listen-server, or type an address and **Join** one. Practice fields the full
Solane squad on each team — you drive one 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
reaches the wire. Move the hero with **WASD** or the **arrow keys**; the bots
walk toward the nearest enemy. 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
@@ -101,7 +106,8 @@ a role, since a menu cannot be driven without a display:
```sh
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 match, no menu
godot --path . -- --local # a single-machine practice match, no menu
godot --path . -- --local --hero cheetah # practice driving a different Solane hero
```
The host is authoritative and fills any empty player slot with a bot. The joining
modified src/client/main.gd
@@ -5,8 +5,9 @@ extends Node2D
## a headless launch with no flag defaults to LOCAL, so the automated smokes need no
## menu and stay flag-driven:
##
## LOCAL — owns the authoritative SimCore and drives both heroes (player + bot),
## exactly the single-machine walking skeleton.
## LOCAL — owns the authoritative SimCore and fields a full Solane squad per
## team: the player drives one hero (picked with `--hero`), every other
## seat is bot-driven. The single-machine practice match.
## HOST — the listen-server: owns the authoritative SimCore, drives team 0 from
## local input, hands team 1 to a remote client when one connects (a bot
## until then), and broadcasts a snapshot every tick.
@@ -71,11 +72,17 @@ 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 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"
## The Solane roster each team fields in a LOCAL practice match, one hero per kit
## (see AbilityData). The player drives one of them — picked with `--hero`, default
## the first — and the rest are bot-driven allies and opponents, so all three
## authored kits are reachable in-game. 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"]
## 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.
const DUEL_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
@@ -119,6 +126,14 @@ 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: 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] = []
var _net: NetSession = null
## HOST: the peer id controlling team 1, or 0 while the slot is bot-filled.
var _team1_peer: int = 0
@@ -182,6 +197,10 @@ func _configure_from_cmdline() -> void:
elif arg == "--local":
_mode = Mode.LOCAL
_explicit_mode = true
elif arg == "--hero":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_player_hero = args[i + 1]
i += 1
elif arg == "--netsim":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_netsim_params = _parse_netsim(args[i + 1])
@@ -269,19 +288,64 @@ func _close_menu_and_enter() -> void:
_enter_match()
## Practice: both teams field the full Solane squad, one hero per roster kit. The
## player drives the seat picked by `--hero`; the other five are bot-driven, so all
## three authored kits are on the field at once.
func _start_local() -> void:
_sim = SimCore.new()
_sim.spawn_structures()
_sim = _new_world()
_seat_squad(HERO_TEAM, HERO_SPEED, _player_slot())
_seat_squad(BOT_TEAM, BOT_SPEED, -1)
## Seats one Solane hero per roster kit for `team`, each fanned across the base
## fountain and equipped with its kit. The seat at `player_slot` becomes the
## player's hero (`_hero_id`); every other seat is bot-driven (appended to
## `_bot_ids`). A `player_slot` of -1 leaves the whole team bot-driven.
func _seat_squad(team: int, speed: float, player_slot: int) -> void:
for i in SOLANE_ROSTER.size():
var id := _sim.add_hero(team, MapData.squad_spawn(team, i, SOLANE_ROSTER.size()), speed)
_sim.equip_kit(id, SOLANE_ROSTER[i])
if i == player_slot:
_hero_id = id
else:
_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
## around; the LOCAL squad stays off the wire until that protocol step lands.
func _seat_duel() -> void:
_sim = _new_world()
_hero_id = _sim.add_hero(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED)
_bot_id = _sim.add_hero(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED)
# Both heroes carry the kit so the match starts mirror-fair; the bot does not yet
# cast (its controller drives movement only), but it shows its form and resource.
_sim.equip_kit(_hero_id, HERO_KIT)
_sim.equip_kit(_bot_id, HERO_KIT)
# Both heroes carry the kit so the match starts mirror-fair; the bot drives
# movement only (no casts yet) but shows its form and resource.
_sim.equip_kit(_hero_id, DUEL_KIT)
_sim.equip_kit(_bot_id, DUEL_KIT)
## A fresh authoritative world with its structures spawned, shared by both the
## LOCAL squad and the HOST/CLIENT duel seating.
func _new_world() -> SimCore:
var sim := SimCore.new()
sim.spawn_structures()
return sim
func _start_host() -> void:
_start_local() # the authoritative world; team 1 is bot-filled until a client takes it
_seat_duel() # the authoritative world; team 1 is bot-filled until a client takes it
_net = _make_session()
var err := _net.start_host()
if err != OK:
@@ -321,7 +385,10 @@ func _make_session() -> NetSession:
func _tick_local() -> void:
_sim.step({_hero_id: _sample_player_input(), _bot_id: _bot.decide(_sim.state, _bot_id)})
var inputs := {_hero_id: _sample_player_input()}
for id in _bot_ids:
inputs[id] = _bot.decide(_sim.state, id)
_sim.step(inputs)
func _tick_host() -> void:
modified src/sim/map_data.gd
@@ -24,6 +24,10 @@ const NEXUS_POSITIONS: Array[Vector2] = [
## the map centre so a hero starts at its base without sitting on the nexus.
const FOUNTAIN_PULLBACK := 300.0
## Lateral gap between squadmates fanned across a base fountain, so a full team
## spawns side by side instead of stacked on one point.
const SQUAD_SPACING := 150.0
## Top corridor: out of team 0's base, up the left edge, across the top.
const LANE_TOP: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
@@ -81,6 +85,22 @@ static func nexus_for_team(team: int) -> Vector2:
return NEXUS_POSITIONS[team % NEXUS_POSITIONS.size()]
## A squadmate's spawn within its team's roster of `count`, fanned laterally
## across the base fountain so the team starts side by side rather than stacked.
## `index` runs 0..count-1; the fan is centred on the fountain and laid out along
## the axis perpendicular to the base→centre direction. Mirror-fair like the rest
## of the map: team 1's squad spawns are team 0's negated, because the fountain,
## the inward direction, and the lateral axis all negate between teams.
static func squad_spawn(team: int, index: int, count: int) -> Vector2:
var fountain := spawn_for_team(team)
if count <= 1:
return fountain
var inward := -nexus_for_team(team).normalized() # base toward the map centre
var lateral := Vector2(-inward.y, inward.x) # perpendicular to the inward axis
var offset := float(index) - float(count - 1) * 0.5
return clamp_to_bounds(fountain + lateral * (offset * SQUAD_SPACING))
## The tower slots for `team`: team 0's stored slots, negated for team 1 so the
## two teams' defences are point reflections of each other. Returns a fresh copy
## so callers cannot mutate the stored geometry.
modified test/unit/test_map_data.gd
@@ -102,6 +102,48 @@ func test_every_tower_sits_on_a_lane_and_inside_the_bounds() -> void:
assert_true(on_a_lane, "every tower must sit on a lane corridor")
func test_squad_spawn_of_one_falls_back_to_the_fountain() -> void:
for team in MapData.NEXUS_POSITIONS.size():
assert_eq(
MapData.squad_spawn(team, 0, 1),
MapData.spawn_for_team(team),
"a squad of one spawns on the bare fountain",
)
func test_squad_spawn_fans_a_team_into_distinct_in_bounds_seats() -> void:
var count := 3
for team in MapData.NEXUS_POSITIONS.size():
var seen: Array[Vector2] = []
for i in count:
var seat := MapData.squad_spawn(team, i, count)
assert_eq(MapData.clamp_to_bounds(seat), seat, "every squad seat sits inside the bounds")
assert_false(seen.has(seat), "squadmates spawn on distinct points, not stacked")
seen.append(seat)
func test_squad_spawn_is_point_symmetric_between_teams() -> void:
var count := 3
for i in count:
assert_eq(
MapData.squad_spawn(1, i, count),
-MapData.squad_spawn(0, i, count),
"team 1's squad seat must be team 0's negated, so neither side has an edge",
)
func test_squad_spawn_fan_is_centred_on_the_fountain() -> void:
# The fan is symmetric about the fountain, so the seats average back to it.
var count := 3
var sum := Vector2.ZERO
for i in count:
sum += MapData.squad_spawn(0, i, count)
var centre := sum / float(count)
assert_almost_eq(
centre, MapData.spawn_for_team(0), Vector2(0.01, 0.01), "the squad fan centres on the fountain"
)
## True when `point` lies on one of the polyline's segments (within a small
## tolerance): the segment endpoints span it and it is collinear with them.
func _point_on_polyline(point: Vector2, path: PackedVector2Array) -> bool: