Commit
Theria
feat: let the player cast hero abilities and shapeshift
modified CHANGELOG.md
@@ -35,6 +35,12 @@ protocol version.
### Added
- 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)
and its resource pool (a bar under the health bar) as it casts and transforms.
Abilities are cast in a single-machine or hosted match; a joined client moves but
does not yet cast (networked casting follows with the protocol that carries it).
- A data-driven hero ability layer for Theria's shapeshifters. Every hero carries
two kits — a human form and an animal form — and transforms between them, each
form metering its own resource pool with separate cooldowns that keep running
modified README.md
@@ -83,7 +83,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. Move the hero with
**WASD** or the **arrow keys**; the bot walks toward it.
**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
cast in a single-machine or hosted match; a joined client moves but does not yet
cast.
### Multiplayer
modified src/client/main.gd
@@ -71,6 +71,26 @@ 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"
## 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
## soon as its cooldown and resource allow (quick-cast).
const ABILITY_KEYS: Array[Key] = [KEY_1, KEY_2, KEY_3, KEY_4]
## Resource bar, drawn just under a hero's HP bar, and the form ring around a hero —
## white while human, amber while shifted to the animal form.
const RES_BAR_SIZE := Vector2(160.0, 14.0)
const RES_BAR_OFFSET := Vector2(-80.0, -118.0)
const RES_BAR_BG := Color(0.0, 0.0, 0.0, 0.6)
const RES_BAR_FG := Color(0.35, 0.6, 0.95)
const FORM_RING_WIDTH := 6.0
const FORM_RING_GAP := 6.0
const HUMAN_RING_COLOR := Color(0.95, 0.95, 0.95)
const ANIMAL_RING_COLOR := Color(1.0, 0.62, 0.2)
var _mode: int = Mode.LOCAL
var _join_address := DEFAULT_JOIN_ADDRESS
@@ -252,6 +272,10 @@ func _start_local() -> void:
_sim.spawn_structures()
_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)
func _start_host() -> void:
@@ -472,6 +496,9 @@ func _draw_entities() -> void:
else:
draw_circle(entity.position, ENTITY_RADIUS, _team_color(entity.team))
_draw_hp_bar(entity, HP_BAR_SIZE, HP_BAR_OFFSET)
if entity.is_hero:
_draw_form_ring(entity)
_draw_resource_bar(entity)
func _draw_hp_bar(entity: SimEntity, size: Vector2, offset: Vector2) -> void:
@@ -483,6 +510,26 @@ func _draw_hp_bar(entity: SimEntity, size: Vector2, offset: Vector2) -> void:
draw_rect(Rect2(top_left, Vector2(size.x * frac, size.y)), HP_BAR_FG, true)
## A ring around a hero whose colour reads its active shapeshifter form — white
## while human, amber once shifted to the animal form. Drawn just outside the hero
## circle so it never hides the team colour.
func _draw_form_ring(entity: SimEntity) -> void:
var color := ANIMAL_RING_COLOR if entity.form == AbilitySpec.FORM_ANIMAL else HUMAN_RING_COLOR
draw_arc(entity.position, ENTITY_RADIUS + FORM_RING_GAP, 0.0, TAU, 48, color, FORM_RING_WIDTH)
## A hero's resource pool as a bar under its HP bar. Nothing is drawn for an entity
## with no pool (an unequipped hero, or a snapshot-decoded one — the resource is not
## carried over the wire).
func _draw_resource_bar(entity: SimEntity) -> void:
if entity.resource_max <= 0:
return
var frac := clampf(float(entity.resource) / float(entity.resource_max), 0.0, 1.0)
var top_left := entity.position + RES_BAR_OFFSET
draw_rect(Rect2(top_left, RES_BAR_SIZE), RES_BAR_BG, true)
draw_rect(Rect2(top_left, Vector2(RES_BAR_SIZE.x * frac, RES_BAR_SIZE.y)), RES_BAR_FG, true)
func _team_color(team: int) -> Color:
return HERO_COLOR if team == HERO_TEAM else BOT_COLOR
@@ -499,4 +546,31 @@ func _sample_player_input() -> InputCommand:
if Input.is_physical_key_pressed(KEY_D) or Input.is_physical_key_pressed(KEY_RIGHT):
dir.x += 1.0
command.move_dir = dir
_sample_ability(command)
return command
## Layers ability-cast intent onto a movement command. Only with a local
## authoritative simulation (LOCAL/HOST): a pure CLIENT samples no abilities, since
## the wire carries movement alone and networked casting is a later, protocol-
## versioned step. The pressed slot keys the cast; the cursor is the aim point a
## skillshot or ground ability uses, and the enemy nearest the cursor is the lock a
## unit-targeted ability uses — the simulation reads whichever the cast ability needs.
func _sample_ability(command: InputCommand) -> void:
if _sim == null:
return
var slot := _pressed_ability_slot()
if slot < 0:
return
var aim := get_global_mouse_position()
command.ability_slot = slot
command.target_point = aim
command.target_id = AbilityExecutor.pick_unit_target(_sim.state, HERO_TEAM, aim)
## The bar slot of the first held ability key (0..3), or -1 if none is down.
func _pressed_ability_slot() -> int:
for slot in ABILITY_KEYS.size():
if Input.is_physical_key_pressed(ABILITY_KEYS[slot]):
return slot
return -1
modified src/sim/ability_executor.gd
@@ -94,6 +94,25 @@ static func _landing_point(caster: SimEntity, spec: AbilitySpec, command: InputC
return caster.position + dir * minf(spec.range, dist)
## The id of the living enemy nearest `point` — a unit-targeted ability's target
## acquisition for a cursor or click, picked by the caster's driver and validated by
## `execute` against the ability's range. 0 when the caster's enemies hold no living
## unit. Pure: a function of the world, the caster's team, and the point, so a bot
## and the client pick the same lock.
static func pick_unit_target(state: SimState, caster_team: int, point: Vector2) -> int:
var best_id := 0
var best_dist := INF
for id in state.entities:
var e: SimEntity = state.entities[id]
if e.team == caster_team or e.max_hp <= 0:
continue
var d := point.distance_to(e.position)
if d < best_dist:
best_dist = d
best_id = id
return best_id
## Every living enemy of `caster` within `radius` of `center`, in deterministic
## insertion order.
static func _enemies_in_area(
modified test/unit/test_ability.gd
@@ -107,6 +107,29 @@ func test_unit_ability_whiffs_on_an_out_of_range_target() -> void:
assert_eq(sim.state.get_entity(id).resource, 70, "but the whiffed cast still books its cost")
# --- Unit target acquisition (the driver's cursor pick) ---------------------
func test_pick_unit_target_returns_the_nearest_enemy() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var near := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
sim.add_entity(1, Vector2(500.0, 0.0), 0.0, 600)
var picked := AbilityExecutor.pick_unit_target(sim.state, 0, Vector2(120.0, 0.0))
assert_eq(picked, near, "the enemy nearest the point is acquired")
func test_pick_unit_target_ignores_allies_and_empties() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_entity(0, Vector2(100.0, 0.0), 0.0, 600) # an ally near the point
assert_eq(
AbilityExecutor.pick_unit_target(sim.state, 0, Vector2(100.0, 0.0)),
0,
"no enemy in the world acquires nothing, never an ally",
)
# --- Effects: heal, transform -----------------------------------------------