extends Node2D ## Pawn entity — grid-snapped, sim-tick-driven movement with smooth render lerp. ## ## Movement model (docs/architecture.md "Pawn movement"): ## At 1× speed, crossing one tile costs STEP_TICKS sim ticks (10 ticks = 0.5 s ## at 20 Hz). Each sim tick advances _step_progress by 1/STEP_TICKS. When ## progress reaches 1.0 the pawn snaps to the next waypoint. ## ## Speed scaling is free: Pause → no ticks → pawn frozen; Ultra → 12× ticks/s → ## pawn crosses the map in ~7 s real time. No per-pawn speed handling needed. ## ## Render: _process() lerps world-position between current and next tile every ## render frame at 60 Hz — motion is smooth even at low sim Hz. class_name Pawn const STEP_TICKS: int = 10 const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference. signal walk_started signal walk_completed signal arrived_at_destination(tile: Vector2i) @export var pawn_name: String = "" var tile: Vector2i = Vector2i.ZERO var _path: Array[Vector2i] = [] var _step_progress: float = 0.0 var _selected: bool = false @onready var _name_label: Label = $NameLabel @onready var _state_label: Label = $StateLabel func _ready() -> void: EventBus.sim_tick.connect(_on_sim_tick) _state_label.text = Strings.t(&"pawn.state.idle") func setup(p_name: String, start_tile: Vector2i) -> void: pawn_name = p_name tile = start_tile position = _tile_to_world(tile) _name_label.text = pawn_name _state_label.text = Strings.t(&"pawn.state.idle") Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile]) # ── public API ────────────────────────────────────────────────────────────── func walk_along_path(new_path: Array[Vector2i]) -> void: if new_path.is_empty(): return var was_walking := is_walking() _path = new_path.duplicate() # _step_progress carries over; when it hits 1.0 the pawn snaps to # the first tile of the new path and picks up the new direction. if not was_walking: walk_started.emit() _state_label.text = Strings.t(&"pawn.state.walking") Audit.log("pawn", "%s walk path len %d → %s" % [pawn_name, new_path.size(), new_path[-1]]) func is_walking() -> bool: return not _path.is_empty() func set_selected(value: bool) -> void: if _selected == value: return _selected = value queue_redraw() func is_selected() -> bool: return _selected # ── sim tick ──────────────────────────────────────────────────────────────── func _on_sim_tick(_tick_number: int) -> void: if not is_walking(): return _step_progress += 1.0 / float(STEP_TICKS) if _step_progress >= 1.0: tile = _path[0] _path.remove_at(0) _step_progress = 0.0 if _path.is_empty(): _state_label.text = Strings.t(&"pawn.state.idle") walk_completed.emit() arrived_at_destination.emit(tile) Audit.log("pawn", "%s arrived at %s" % [pawn_name, tile]) # ── render ────────────────────────────────────────────────────────────────── func _process(_delta: float) -> void: var from_world := _tile_to_world(tile) var next := _path[0] if is_walking() else tile var to_world := _tile_to_world(next) position = from_world.lerp(to_world, _step_progress) func _draw() -> void: # Body disc — colour derived deterministically from pawn name so each pawn # is visually distinct without any art dependency. var hue := float(pawn_name.hash() % 360) / 360.0 var body_colour := Color.from_hsv(hue, 0.7, 0.85) draw_circle(Vector2.ZERO, 6.0, body_colour) # Dark outline ring. draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.6), 1.0) # Selection ring. if _selected: draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0) # ── helpers ───────────────────────────────────────────────────────────────── func _tile_to_world(t: Vector2i) -> Vector2: return Vector2( t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0, t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0 )