GDScript 236 lines
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]
## Stop key — halts the hero where it stands, clearing the standing move/attack order (the
## MOBA-standard "S" hold-position). Tapped, it cancels the current path and plants the hero;
## held, it keeps a fresh right-click from carrying, so the hero stays put until released.
const STOP_KEY := KEY_S
## 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
## How many ticks a chase's routed direction is reused before recomputing — an A* every tick while
## attack-moving a target behind a wall is too costly, and a few-tick-old direction still tracks a
## moving enemy fine.
const CHASE_REFRESH_TICKS := 10
## 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
## The routed path behind the standing move order — the NavGrid waypoints from the hero to
## `move_target`, bending around the jungle walls and towers, walked one leg at a time via
## `_path_index`. Empty when there is no move order or the hero is closing on an attack target.
var _path: PackedVector2Array = PackedVector2Array()
var _path_index: int = 0
var _chase_dir_cache: Vector2 = Vector2.ZERO
var _chase_cooldown: 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, pointer_over_ui := false
) -> InputCommand:
var command := InputCommand.new()
if hero != null and hero.is_dead():
# Down and behind the death screen: ignore input and drop any standing order, so the
# hero respawns idle at base rather than marching off toward a pre-death click.
_halt()
return command
# Skip the world right-click order while the cursor is over the minimap (or other UI): that
# panel issues its own order from the click, and the camera ray under the card would otherwise
# fire a second, garbage move to wherever it pierces the ground.
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) and not pointer_over_ui:
_issue_order(state, hero, team, _mouse_world_point())
if Input.is_physical_key_pressed(STOP_KEY):
_halt()
command.move_dir = _move_dir(hero, state)
if cast_abilities:
_sample_ability(command, state, team)
return command
## Issues a move/attack order at a world point chosen off the minimap rather than the cursor — the
## same order a world right-click makes (auto-pathed, wire-reconciled), only the point comes from
## the plan. The driver projects the minimap click and hands the sim point straight in.
func order_at(state: SimState, hero: SimEntity, team: int, point: Vector2) -> void:
_issue_order(state, hero, team, point)
## 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, hero: SimEntity, team: int, point: Vector2) -> void:
var enemy := _enemy_under(state, team, point)
if enemy != 0:
attack_target_id = enemy
has_move_target = false
_path = PackedVector2Array()
else:
attack_target_id = 0
move_target = point
has_move_target = true
_set_path(hero, point)
## Routes the standing move order around the obstacles: asks the nav grid for a path to `point` and
## stores it to walk leg by leg. Falls back to a straight line to the click when the grid finds none
## (or the hero has not spawned yet), so a move order always produces motion.
func _set_path(hero: SimEntity, point: Vector2) -> void:
_path_index = 0
if hero == null:
_path = PackedVector2Array([point])
return
_path = NavGrid.shared().find_path(hero.position, point)
if _path.is_empty():
_path = PackedVector2Array([point])
## Cancels the standing order so the hero plants where it stands — the move target and the
## attack target both cleared, so `_move_dir` returns zero until the next click. Bound to
## STOP_KEY (the MOBA "S").
func _halt() -> void:
has_move_target = false
attack_target_id = 0
_path = PackedVector2Array()
_path_index = 0
## 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
return _follow_path(hero)
## This tick's direction along the routed path: head for the current waypoint, advancing to the next
## as each is reached, and on the final leg return a sub-unit vector that lands the hero exactly on
## the destination (apply_movement scales a move_dir under length 1 down) before clearing the order.
## Mirrors the creep waypoint-follow in SimCore — an empty or exhausted path stops the hero.
func _follow_path(hero: SimEntity) -> Vector2:
while _path_index < _path.size():
var to_target := _path[_path_index] - hero.position
if _path_index < _path.size() - 1:
if to_target.length() <= SimCore.WAYPOINT_ARRIVE_RADIUS:
_path_index += 1
continue
return to_target.normalized()
# the final waypoint — stop exactly on it
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()
has_move_target = false
return Vector2.ZERO
## 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
# Straight line clear: close directly. Blocked: route around it, but refresh the routed direction
# only every CHASE_REFRESH_TICKS so the A* runs a few times a second, not every frame.
var nav := NavGrid.shared()
if nav.segment_clear(hero.position, target.position):
return to_target.normalized()
_chase_cooldown -= 1
if _chase_cooldown <= 0:
var path := nav.find_path(hero.position, target.position)
_chase_dir_cache = (
(path[0] - hero.position).normalized() if path.size() > 0 else to_target.normalized()
)
_chase_cooldown = CHASE_REFRESH_TICKS
return _chase_dir_cache
## 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)