Commit
Theria
feat: tint heroes per kit and pick the practice hero in-menu
modified CHANGELOG.md
@@ -35,6 +35,12 @@ protocol version.
### Added
- A practice match can now be set up entirely from the connect screen: a hero picker lists
every hero of both tribes, and the choice drives the same tribe-versus-tribe seating as the
command line's `--hero`, so picking a side no longer needs a flag. On the field, each hero
now wears a distinct shade of its team colour, so three squadmates read apart at a glance
instead of sharing one flat colour. Presentation and menu only; the simulation and the
netcode protocol are unchanged.
- 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
modified README.md
@@ -89,10 +89,11 @@ 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 is a tribe-vs-tribe
match: `--hero` names the hero you drive — any hero of either tribe — and that hero's
tribe fields your team while the opposing tribe 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
match: pick the hero you drive from the menu's roster list — any hero of either tribe — and
that hero's tribe fields your team while the opposing tribe fills the bots, so picking the
snake puts you on the Verdani against the Solane, and the default lion keeps the Solane
against the Verdani. The command line's `--hero` makes the same choice for a launch that
skips the menu. 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 fight to their kit's
stance — brawlers close on the nearest enemy and shift into the form that keeps a hit
@@ -102,7 +103,8 @@ poke range and back off rather than melee — and all cast their own kits, heali
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
abilities drawing on its own resource (the bar under the health bar). Each hero wears a
distinct shade of its team colour, so your three squadmates read apart at a glance. Abilities are
cast in a single-machine or hosted match; a joined client moves but does not yet
cast.
modified src/client/connect_menu.gd
@@ -16,14 +16,25 @@ signal host_requested
## The player chose to join a server at `address` (already resolved to the default
## when the field was left blank).
signal join_requested(address: String)
## The player chose a single-machine practice match.
signal practice_requested
## The player chose a single-machine practice match driving `hero` (a kit id). That
## hero's tribe fields the player's team and the opposing tribe the bots, so the pick
## also chooses the match-up — the same role `--hero` fills on the command line.
signal practice_requested(hero: String)
## The address used when the player leaves the field blank. The driver injects its
## own default so the menu and the `--join` flag resolve to one value.
var default_address := "127.0.0.1"
## The hero the picker starts on (a kit id). The driver injects its own default — any
## `--hero` already parsed, else the default tribe's lead — so the menu reflects the
## command line. Empty selects the first hero in the list.
var default_hero := ""
var _address_field: LineEdit
## Picks the hero the player drives in a practice match. Populated from
## `AbilityData.TRIBE` so the roster cannot drift from the simulation's; each item
## carries its kit id as metadata.
var _hero_picker: OptionButton
func _ready() -> void:
@@ -42,6 +53,10 @@ func _ready() -> void:
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(title)
_hero_picker = OptionButton.new()
_populate_heroes()
box.add_child(_hero_picker)
var practice_button := Button.new()
practice_button.text = "Practice (single machine)"
practice_button.pressed.connect(_on_practice_pressed)
@@ -66,8 +81,33 @@ func _ready() -> void:
join_row.add_child(join_button)
## Fills the hero picker from the tribe rosters — one item per hero, labelled
## "Tribe — Hero", carrying its kit id as metadata — and selects `default_hero` (or the
## first hero when none was injected). Reading `AbilityData.TRIBE` keeps the menu's roster
## in lockstep with the simulation's: a new hero appears here the moment it joins a tribe.
func _populate_heroes() -> void:
for tribe in AbilityData.TRIBE:
for hero in AbilityData.TRIBE[tribe]:
_hero_picker.add_item("%s — %s" % [tribe.capitalize(), (hero as String).capitalize()])
_hero_picker.set_item_metadata(_hero_picker.item_count - 1, hero)
if default_hero.is_empty():
return
for i in _hero_picker.item_count:
if _hero_picker.get_item_metadata(i) == default_hero:
_hero_picker.select(i)
return
## The kit id of the selected hero, falling back to `default_hero` if nothing is
## selected (an empty roster — never the case while a tribe is defined).
func _selected_hero() -> String:
if _hero_picker.selected < 0:
return default_hero
return _hero_picker.get_item_metadata(_hero_picker.selected)
func _on_practice_pressed() -> void:
practice_requested.emit()
practice_requested.emit(_selected_hero())
func _on_host_pressed() -> void:
modified src/client/main.gd
@@ -46,6 +46,12 @@ const HERO_COLOR := Color(0.36, 0.66, 1.0)
const BOT_COLOR := Color(1.0, 0.42, 0.38)
const ENTITY_RADIUS := 44.0
## Per-hero tint: a team's heroes share its base colour but each is shaded by its roster
## seat (0..2), so three squadmates read apart while the team hue stays obvious. Indexed
## by `AbilityData.roster_index`; a positive value lightens, a negative one darkens. A hero
## with no roster seat (an unknown kit) falls back to the flat team colour.
const HERO_SHADES: Array[float] = [0.0, 0.28, -0.22]
## Creeps render as small, darkened team-coloured circles so a wave reads as a
## cluster distinct from the larger heroes.
const CREEP_RADIUS := 22.0
@@ -257,6 +263,7 @@ func _is_headless() -> bool:
func _open_connect_menu() -> void:
var menu := ConnectMenu.new()
menu.default_address = DEFAULT_JOIN_ADDRESS
menu.default_hero = _player_hero
menu.practice_requested.connect(_on_practice_requested)
menu.host_requested.connect(_on_host_requested)
menu.join_requested.connect(_on_join_requested)
@@ -265,8 +272,12 @@ func _open_connect_menu() -> void:
add_child(_menu_layer)
func _on_practice_requested() -> void:
## The menu's Practice choice carries the hero the player picked; it overrides any
## `--hero` parsed from the command line, and (like `--hero`) its tribe fields the player's
## team and the opposing tribe the bots, so the pick also chooses the match-up.
func _on_practice_requested(hero: String) -> void:
_mode = Mode.LOCAL
_player_hero = hero
_close_menu_and_enter()
@@ -566,7 +577,7 @@ func _draw_entities() -> void:
draw_circle(entity.position, CREEP_RADIUS, _team_color(entity.team).darkened(CREEP_DARKEN))
_draw_hp_bar(entity, CREEP_HP_BAR_SIZE, CREEP_HP_BAR_OFFSET)
else:
draw_circle(entity.position, ENTITY_RADIUS, _team_color(entity.team))
draw_circle(entity.position, ENTITY_RADIUS, _hero_color(entity))
_draw_hp_bar(entity, HP_BAR_SIZE, HP_BAR_OFFSET)
if entity.is_hero:
_draw_form_ring(entity)
@@ -606,6 +617,19 @@ func _team_color(team: int) -> Color:
return HERO_COLOR if team == HERO_TEAM else BOT_COLOR
## A hero's draw colour: its team colour shaded by its roster seat, so squadmates on one
## team read apart while still wearing the team hue. A non-hero or a hero whose kit sits in
## no tribe (an unequipped or unknown one) keeps the flat team colour. Structures and creeps
## use `_team_color` directly — only heroes share a team three at a time.
func _hero_color(entity: SimEntity) -> Color:
var base := _team_color(entity.team)
var slot := AbilityData.roster_index(entity.kit_id)
if slot < 0 or slot >= HERO_SHADES.size():
return base
var shade := HERO_SHADES[slot]
return base.lightened(shade) if shade >= 0.0 else base.darkened(-shade)
func _sample_player_input() -> InputCommand:
var command := InputCommand.new()
var dir := Vector2.ZERO
modified src/sim/ability_data.gd
@@ -766,6 +766,16 @@ static func tribe_of(kit_id: String) -> String:
return ""
## A hero kit's seat index within its tribe (0..n-1, the order it sits in `TRIBE`), or -1
## if the kit is in no tribe (the wildkin reference kit, or an unknown name). The renderer
## shades a hero's team colour by this index so squadmates read apart; a pure roster lookup.
static func roster_index(kit_id: String) -> int:
var tribe := tribe_of(kit_id)
if tribe == "":
return -1
return (TRIBE[tribe] as Array).find(kit_id)
## The tribe a given tribe is matched against — the next other tribe in declaration order.
## v0.1 fields exactly two, so this is simply "the other one"; returns `tribe` itself if
## it is the only tribe defined.
modified src/sim/sim_core.gd
@@ -128,6 +128,7 @@ func equip_kit(hero_id: int, kit_id: String) -> void:
hero.is_hero = true
hero.form = AbilitySpec.FORM_HUMAN
hero.stance = kit_def.get("stance", AbilityData.STANCE_BRAWL)
hero.kit_id = kit_id
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
@@ -71,6 +71,13 @@ var form_resource_regen: PackedInt32Array = PackedInt32Array([0, 0])
## transforms back to it.
var ability_cooldowns: Dictionary = {}
## The kit this hero was equipped with (an AbilityData kit id), or "" for a non-hero
## or an unequipped hero. The hero's identity: the renderer tints each kit distinctly,
## so squadmates sharing a team read apart while keeping the team hue. Set at
## SimCore.equip_kit; not carried on the wire — the networked duel is one hero per
## team, already distinguished by team colour.
var kit_id: String = ""
## The hero's bar, by form: `kit[form][slot]` is the ability id in that slot, or
## absent for an empty slot. Set once when the kit is equipped; the catalog holds
## the immutable specs the ids resolve to.
@@ -117,6 +124,7 @@ func clone() -> SimEntity:
copy.is_hero = is_hero
copy.form = form
copy.stance = stance
copy.kit_id = kit_id
copy.resource = resource
copy.resource_max = resource_max
copy.resource_regen_ticks = resource_regen_ticks
modified test/unit/test_bot_controller.gd
@@ -195,6 +195,9 @@ func test_equip_stamps_the_kit_stance() -> void:
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")
# Equip also stamps the kit id — the hero's identity the renderer tints by.
assert_eq(sim.state.get_entity(cheetah).kit_id, "cheetah", "the cheetah carries its kit id")
assert_eq(sim.state.get_entity(lion).kit_id, "lion", "the lion carries its kit id")
func test_a_kiter_backs_off_a_point_blank_enemy_instead_of_meleeing() -> void:
modified test/unit/test_connect_menu.gd
@@ -14,11 +14,48 @@ func _menu() -> ConnectMenu:
return menu
# Finds the picker item carrying `hero` as metadata and selects it; fails the test if
# no item matches, so a typo'd hero name surfaces rather than silently selecting nothing.
func _select_hero(menu: ConnectMenu, hero: String) -> void:
for i in menu._hero_picker.item_count:
if menu._hero_picker.get_item_metadata(i) == hero:
menu._hero_picker.select(i)
return
fail_test("hero %s is not in the picker" % hero)
func test_practice_choice_requests_a_local_match() -> void:
var menu := _menu()
watch_signals(menu)
menu._on_practice_pressed()
assert_signal_emitted(menu, "practice_requested")
# With no injected default the picker rests on the first roster hero, the Solane lead.
assert_signal_emitted_with_parameters(menu, "practice_requested", ["lion"])
func test_practice_carries_the_picked_hero() -> void:
var menu := _menu()
_select_hero(menu, "chameleon")
watch_signals(menu)
menu._on_practice_pressed()
assert_signal_emitted_with_parameters(menu, "practice_requested", ["chameleon"])
func test_picker_offers_every_tribe_hero() -> void:
var menu := _menu()
var total := 0
for tribe in AbilityData.TRIBE:
total += (AbilityData.TRIBE[tribe] as Array).size()
assert_eq(menu._hero_picker.item_count, total, "every hero in every tribe is offered")
assert_eq(menu._hero_picker.get_item_metadata(0), "lion", "the first item is the Solane lead")
func test_injected_default_preselects_its_hero() -> void:
# default_hero must be set before `_ready` populates, so build it outside `_menu()`.
var menu := ConnectMenu.new()
menu.default_hero = "spider"
add_child_autoqfree(menu)
var selected: String = menu._hero_picker.get_item_metadata(menu._hero_picker.selected)
assert_eq(selected, "spider", "the driver's default hero is pre-selected")
func test_host_choice_requests_a_host() -> void:
modified test/unit/test_local_seating.gd
@@ -27,3 +27,13 @@ func test_tribe_rosters_seat_three_heroes_each_in_order() -> void:
# The seat a hero lands in is its index here — the slot _start_local hands the player.
var spider_seat := (AbilityData.TRIBE["verdani"] as Array).find("spider")
assert_eq(spider_seat, 1, "the spider is the second Verdani seat")
func test_roster_index_reports_a_heros_seat_within_its_tribe() -> void:
# The renderer shades a hero's team colour by this seat, so squadmates read apart.
assert_eq(AbilityData.roster_index("lion"), 0, "the lion leads the Solane")
assert_eq(AbilityData.roster_index("hyena"), 2, "the hyena is the third Solane seat")
assert_eq(AbilityData.roster_index("snake"), 0, "the snake leads the Verdani")
assert_eq(AbilityData.roster_index("chameleon"), 2, "the chameleon is the third Verdani seat")
assert_eq(AbilityData.roster_index("wildkin"), -1, "the reference kit has no roster seat")
assert_eq(AbilityData.roster_index("griffin"), -1, "an unknown kit has no roster seat")