ajhahn.de
← Theria commits

Commit

Theria

feat: right-click an enemy to attack it; extract player input to its own module

ajhahnde · Jun 2026 · 47e52c16632a9dbcaac49de2b4602af496fbee0b · parent: 4816f17 · view on GitHub →

modified CHANGELOG.md
@@ -64,6 +64,10 @@ protocol version.
### Added
- Right-clicking an enemy now attacks it: the hero closes to its attack range and the combat
step strikes it (LoL-style attack-on-click), while right-clicking open ground still just
walks there — one button both moves and engages. Client-side input only; the simulation and
the netcode protocol are unchanged.
- Combat now reads on screen. Every hit — an auto-attack, an ability, or a venom tick — pops
a floating damage number over the struck unit, so damage is legible instead of only a bar
ticking down. Auto-attacks themselves now show: a ranged attacker (a tower, a skirmisher
modified icon.svg
@@ -1,9 +1,20 @@
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<!-- Placeholder brand glyph: an ember 'A' on an ash field. Swap for the
rendered wordmark glyph once the brand art is finalised. -->
<rect width="128" height="128" rx="28" fill="#1d2025"/>
<g fill="none" stroke="#fb923c" stroke-width="11" stroke-linejoin="round" stroke-linecap="round">
<path d="M40 98 L64 34 L88 98"/>
<path d="M50 74 L78 74"/>
<!-- Placeholder brand glyph: a geometric-sans 'T' (Theria) in jade over faint
jungle fronds on a cool dark field. Swap for the rendered wordmark glyph
once the brand art is finalised. -->
<defs>
<clipPath id="field"><rect width="128" height="128" rx="28"/></clipPath>
</defs>
<rect width="128" height="128" rx="28" fill="#11181a"/>
<!-- faint fronds, clipped to the rounded field -->
<g clip-path="url(#field)" fill="#34d399">
<path fill-opacity="0.10" d="M-5 130 C 30 110, 55 78, 62 28 C 42 74, 14 100, -5 130 Z"/>
<path fill-opacity="0.07" d="M-6 132 C 40 122, 82 112, 112 100 C 80 124, 40 132, -6 132 Z"/>
<path fill-opacity="0.08" d="M134 -5 C 112 26, 92 38, 70 48 C 96 30, 120 16, 134 -5 Z"/>
</g>
<!-- geometric-sans T: filled, even weight, flat ends -->
<g fill="#34d399">
<rect x="34" y="34" width="60" height="14"/>
<rect x="57" y="34" width="14" height="62"/>
</g>
</svg>
modified src/client/main.gd
@@ -103,11 +103,6 @@ const DEFAULT_TRIBE := "solane"
## 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) — QWER, the MOBA-standard bind. Movement is
## click-to-move (right mouse), so the letter row is free for the kit. A held key recasts
## the slot as soon as its cooldown and resource allow (quick-cast).
const ABILITY_KEYS: Array[Key] = [KEY_Q, KEY_W, KEY_E, KEY_R]
## Form ring laid flat on the ground under a hero, reading its active shapeshifter
## form — white while human, amber while shifted to the animal form.
const FORM_RING_RADIUS := 70.0
@@ -141,11 +136,9 @@ var _bot := BotController.new()
var _hero_id: int = 0
var _bot_id: int = 0
## Click-to-move (LoL-style): the world point the player last right-clicked, the destination
## the hero walks to; `_has_move_target` gates it (false = stand still). Resolved to a per-tick
## `move_dir` on the client before it reaches the sim or the wire, so neither is changed.
var _move_target: Vector2 = Vector2.ZERO
var _has_move_target: bool = false
## Samples the local player's mouse/keys into an InputCommand and owns the move/attack order
## state (right-click to move or attack, QWER to cast). Built once the camera exists.
var _player_input: PlayerInput = null
## The on-ground marker drawn at the active move target while the hero walks to it.
var _move_marker: MoveMarker = null
@@ -624,6 +617,7 @@ func _build_world() -> void:
_camera.current = true
add_child(_camera)
_point_camera(MapData.BOUNDS.get_center())
_player_input = PlayerInput.new(_camera)
_move_marker = MoveMarker.new()
add_child(_move_marker)
@@ -650,8 +644,8 @@ func _sync_world() -> void:
CombatFx.strike(self, attack)
for hit in state.hit_events:
CombatFx.number(self, hit)
if _has_move_target:
_move_marker.point_at(_move_target)
if _player_input.has_move_target:
_move_marker.point_at(_player_input.move_target)
else:
_move_marker.clear()
_follow_camera(state)
@@ -886,87 +880,30 @@ func _hero_color(entity: SimEntity) -> Color:
return base.lightened(shade) if shade >= 0.0 else base.darkened(-shade)
## Samples this tick's intent: a held right mouse button (re)sets the move destination to
## the cursor point (click-to-move, hold-drag to steer); `_move_command_dir` turns it into
## the tick's `move_dir` and the cast is layered on. The sim and wire still see an ordinary
## per-tick direction — sourced from a click, not WASD.
## This tick's player command — delegated to PlayerInput, handed the world the player acts on,
## their hero, their team, and whether to sample casts (only with a local authoritative sim).
func _sample_player_input() -> InputCommand:
var command := InputCommand.new()
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
_move_target = _mouse_world_point()
_has_move_target = true
command.move_dir = _move_command_dir()
_sample_ability(command)
return command
## Turns the standing move target into this tick's `move_dir` (click-to-move): a unit vector
## toward it while far; once within a single tick's reach, a sub-unit vector that lands the
## hero exactly on it (apply_movement scales a move_dir under length 1 down, so it stops on
## the point instead of overshooting), clearing the target. No target or no hero holds still.
func _move_command_dir() -> Vector2:
if not _has_move_target:
return Vector2.ZERO
var hero := _player_hero_entity()
if hero == null:
return Vector2.ZERO
var to_target := _move_target - hero.position
var step := hero.current_move_speed() * SimCore.TICK_DELTA
if step <= 0.0 or to_target.length() <= step:
_has_move_target = false
return to_target / step if step > 0.0 else Vector2.ZERO
return to_target.normalized()
## The player's own hero — what the move target is measured from: the live sim entity where
## this client owns authority (LOCAL/HOST), or our team's hero in the latest snapshot on a
## pure CLIENT (the server still moves authoritatively from the direction we send). Null
## before one exists.
func _player_hero_entity() -> SimEntity:
return _player_input.sample(
_visible_state(), _player_hero_entity(), _player_team(), _sim != null
)
## The state the player acts on: the live sim where this client owns authority (LOCAL/HOST),
## or the latest snapshot on a pure CLIENT. Null before one exists.
func _visible_state() -> SimState:
if _mode == Mode.CLIENT:
var state := _net.latest_state() if _net != null else null
return _local_hero(state) if state != null else null
if _sim != null:
return _sim.state.get_entity(_hero_id)
return null
return _net.latest_state() if _net != null else null
return _sim.state if _sim != null else null
## Layers ability-cast intent onto a movement command. Only with a local authoritative sim
## (LOCAL/HOST): a pure CLIENT samples no abilities, as 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, and the enemy nearest it is the unit-target lock — the sim 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 := _mouse_world_point()
command.ability_slot = slot
command.target_point = aim
command.target_id = AbilityExecutor.pick_unit_target(_sim.state, HERO_TEAM, aim)
## The point on the 2D field under the mouse: a ray from the camera through the cursor,
## intersected with the ground plane (y = 0), returned in sim space. The aim a ground or
## skillshot cast lands on, the cursor a unit-target cast locks nearest, and the click-to-move
## destination.
func _mouse_world_point() -> Vector2:
if _camera == null:
return Vector2.ZERO
var mouse := get_viewport().get_mouse_position()
var origin := _camera.project_ray_origin(mouse)
var dir := _camera.project_ray_normal(mouse)
if absf(dir.y) < 0.0001:
return Vector2(origin.x, origin.z)
var hit := origin + dir * (-origin.y / dir.y)
return Vector2(hit.x, hit.z)
## 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
## The player's team — HERO_TEAM with local authority, the server-assigned team on a CLIENT.
func _player_team() -> int:
return _my_team if _mode == Mode.CLIENT else HERO_TEAM
## The player's own hero, what movement is measured from: our team's hero in the visible state.
func _player_hero_entity() -> SimEntity:
var state := _visible_state()
if state == null:
return null
return _local_hero(state) if _mode == Mode.CLIENT else state.get_entity(_hero_id)
added src/client/player_input.gd
@@ -0,0 +1,146 @@
class_name PlayerInput
extends RefCounted
## Samples the local player's intent into an `InputCommand` each tick and owns the order state
## behind it: right-click to move, right-click an enemy to attack it, and Q·W·E·R to cast aimed
## at the cursor. Engine input and the camera ground-ray live here; given the world the player
## acts on it returns the same per-tick `move_dir` + ability intent the simulation consumes, so
## the presenter just hands over context and takes back a command. Pure presentation-side —
## authority stays in the sim. Lifted out of `main.gd` to keep that file under the line cap.
## Ability bar keys, one per slot (0..3) — QWER, the MOBA-standard bind. Movement is
## click-to-move (right mouse), so the letter row is free. A held key recasts as soon as the
## slot's cooldown and resource allow (quick-cast).
const ABILITY_KEYS: Array[Key] = [KEY_Q, KEY_W, KEY_E, KEY_R]
## How close (world units) a right-click must land to an enemy's body to read as "attack this
## one" rather than "walk here" — its footprint plus a little slop.
const ENEMY_PICK_RADIUS := 90.0
## The standing click-to-move destination (a sim point); `has_move_target` gates it. Read by
## the presenter to draw the destination marker.
var move_target: Vector2 = Vector2.ZERO
var has_move_target: bool = false
## Right-clicking an enemy sets this to its id: the hero closes on it and the combat step
## strikes it (LoL-style attack-on-click). 0 means the last order was a plain ground move.
var attack_target_id: int = 0
var _camera: Camera3D = null
func _init(camera: Camera3D) -> void:
_camera = camera
## This tick's command. `state` is the world the player acts on and `hero` their own hero (null
## before one spawns); `team` is their team. `cast_abilities` is true only with a local
## authoritative sim (LOCAL/HOST) — a pure CLIENT casts nothing yet, as the wire carries
## movement alone.
func sample(state: SimState, hero: SimEntity, team: int, cast_abilities: bool) -> InputCommand:
var command := InputCommand.new()
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
_issue_order(state, team, _mouse_world_point())
command.move_dir = _move_dir(hero, state)
if cast_abilities:
_sample_ability(command, state, team)
return command
## Resolves a right-click into an order: clicking on an enemy attacks it (the hero closes to
## attack range, then the combat step strikes it), clicking open ground walks there. One button
## both moves and engages — lighter than LoL's separate attack-move key.
func _issue_order(state: SimState, team: int, point: Vector2) -> void:
var enemy := _enemy_under(state, team, point)
if enemy != 0:
attack_target_id = enemy
has_move_target = false
else:
attack_target_id = 0
move_target = point
has_move_target = true
## This tick's movement direction: closing on the attack target when one is set, else the
## click-to-move toward the standing ground target, else still.
func _move_dir(hero: SimEntity, state: SimState) -> Vector2:
if attack_target_id != 0:
return _chase_dir(hero, state)
if not has_move_target or hero == null:
return Vector2.ZERO
# Unit vector toward the target while far; within a single tick's reach, a sub-unit vector
# that lands the hero exactly on it (apply_movement scales a move_dir under length 1 down),
# so it stops on the point rather than overshooting. Then the target is cleared.
var to_target := move_target - hero.position
var step := hero.current_move_speed() * SimCore.TICK_DELTA
if step <= 0.0 or to_target.length() <= step:
has_move_target = false
return to_target / step if step > 0.0 else Vector2.ZERO
return to_target.normalized()
## Movement toward the attack target: close until the hero is inside its own attack range —
## then hold, and the combat step auto-strikes it as the nearest enemy — and drop the order
## once the target dies or leaves the world.
func _chase_dir(hero: SimEntity, state: SimState) -> Vector2:
var target := _target_enemy(state)
if hero == null or target == null:
attack_target_id = 0
return Vector2.ZERO
var to_target := target.position - hero.position
var reach := hero.attack_range if hero.attack_range > 0.0 else SimCore.HERO_RANGE
if to_target.length() <= reach:
return Vector2.ZERO
return to_target.normalized()
## The id of an enemy under `point` (within a click's slop of its body), or 0 for open ground —
## what tells an attack order from a move order. Uses the same nearest-enemy pick the sim does.
func _enemy_under(state: SimState, team: int, point: Vector2) -> int:
if state == null:
return 0
var id := AbilityExecutor.pick_unit_target(state, team, point)
var enemy := state.get_entity(id)
if enemy != null and enemy.position.distance_to(point) <= ENEMY_PICK_RADIUS:
return id
return 0
## The live attack-target enemy, or null once it is dead or gone.
func _target_enemy(state: SimState) -> SimEntity:
var enemy := state.get_entity(attack_target_id) if state != null else null
return enemy if enemy != null and enemy.hp > 0 else null
## Layers ability-cast intent onto the command. The pressed slot keys the cast; the cursor is
## the aim point a skillshot or ground ability uses, and the enemy nearest it is the lock a
## unit-targeted one uses — the sim reads whichever the cast ability needs.
func _sample_ability(command: InputCommand, state: SimState, team: int) -> void:
var slot := _pressed_ability_slot()
if slot < 0 or state == null:
return
var aim := _mouse_world_point()
command.ability_slot = slot
command.target_point = aim
command.target_id = AbilityExecutor.pick_unit_target(state, 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
## The point on the 2D field under the mouse: a ray from the camera through the cursor,
## intersected with the ground plane (y = 0), returned in sim space — the move/attack click
## point and the cast aim.
func _mouse_world_point() -> Vector2:
if _camera == null:
return Vector2.ZERO
var mouse := _camera.get_viewport().get_mouse_position()
var origin := _camera.project_ray_origin(mouse)
var dir := _camera.project_ray_normal(mouse)
if absf(dir.y) < 0.0001:
return Vector2(origin.x, origin.z)
var hit := origin + dir * (-origin.y / dir.y)
return Vector2(hit.x, hit.z)
added src/client/player_input.gd.uid
@@ -0,0 +1 @@
uid://dppf3mb7sb701