From 14e2c26e7420bc2409e4bab4b8e692c9b5396589 Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 19 May 2026 05:29:51 -0500 Subject: [PATCH] implemented unit movement --- .../scenes/game/hex_grid_screen.gd | 94 +++++++++++++++++++ .../scenes/game/hex_grid_view.gd | 42 +++++++-- .../Controllers/UnitsController.cs | 30 ++++++ microservices/LocationsApi/Models/UnitDto.cs | 9 ++ microservices/LocationsApi/README.md | 5 + .../Services/UnitTrainingService.cs | 73 ++++++++++++++ 6 files changed, 247 insertions(+), 6 deletions(-) diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 85b4401..313c86f 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -15,6 +15,7 @@ var visible_request: HTTPRequest var resources_request: HTTPRequest var units_request: HTTPRequest var train_unit_request: HTTPRequest +var move_units_request: HTTPRequest var claim_request: HTTPRequest var build_request: HTTPRequest var refresh_request: HTTPRequest @@ -63,6 +64,9 @@ var selected_can_train_archer := false var selected_is_home := false var completed_scout_count := 0 var completed_archer_count := 0 +var selected_archer_q := 9999 +var selected_archer_r := 9999 +var selected_archer_count := 0 var refresh_prompt_active := false var refresh_request_active := false @@ -258,6 +262,10 @@ func _build_ui() -> void: train_unit_request.request_completed.connect(_on_train_unit_request_completed) add_child(train_unit_request) + move_units_request = HTTPRequest.new() + move_units_request.request_completed.connect(_on_move_units_request_completed) + add_child(move_units_request) + claim_request = HTTPRequest.new() claim_request.request_completed.connect(_on_claim_request_completed) add_child(claim_request) @@ -410,6 +418,9 @@ func _on_tile_hovered(q: int, r: int, terrain: String) -> void: func _on_tile_selected(q: int, r: int, terrain: String) -> void: + if _handle_unit_selection_or_move(q, r): + return + selected_q = q selected_r = r selected_can_claim = terrain.contains("Unclaimed") @@ -423,6 +434,27 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void: coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain] +func _handle_unit_selection_or_move(q: int, r: int) -> bool: + if not grid_view: + return false + + var archer_count: int = grid_view.get_archer_count_at(q, r) + if archer_count > 0: + selected_archer_q = q + selected_archer_r = r + selected_archer_count = archer_count + grid_view.set_selected_unit_tile(q, r) + if coordinate_label: + coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Select a visible non-rival tile to move." % [archer_count, q, r] + return true + + if selected_archer_count > 0: + _move_selected_archers(q, r) + return true + + return false + + func _can_build_log_camp_on_selected_tile() -> bool: return _can_build_on_selected_tile("Forest") @@ -481,6 +513,14 @@ func _update_action_buttons() -> void: build_barracks_button.disabled = false +func _clear_selected_archers() -> void: + selected_archer_q = 9999 + selected_archer_r = 9999 + selected_archer_count = 0 + if grid_view: + grid_view.clear_selected_unit_tile() + + func _selected_tile_is_home() -> bool: if not grid_view: return false @@ -915,6 +955,60 @@ func _on_train_unit_request_completed(result: int, response_code: int, _headers: _refresh_player_state() +func _move_selected_archers(to_q: int, to_r: int) -> void: + if selected_archer_count <= 0: + return + if to_q == selected_archer_q and to_r == selected_archer_r: + coordinate_label.text = "Archer squad is already on that tile." + return + if not grid_view.is_visible_non_rival_tile(to_q, to_r): + coordinate_label.text = "Archers can only move to visible non-rival tiles." + return + + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Archer squad was not moved." + return + + if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + coordinate_label.text = "Moving %d Archers to q:%d r:%d..." % [selected_archer_count, to_q, to_r] + var error: int = move_units_request.request( + LOCATIONS_BASE_URL + "/api/units/move", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ + "unitType": "Archer", + "fromQ": selected_archer_q, + "fromR": selected_archer_r, + "toQ": to_q, + "toR": to_r + }) + ) + if error != OK: + coordinate_label.text = "Could not contact the unit move service." + + +func _on_move_units_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var text: String = body.get_string_from_utf8() + if result != HTTPRequest.RESULT_SUCCESS: + coordinate_label.text = "Archer move request failed." + return + + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Archer move failed: %s" % text + _refresh_player_state() + return + + _clear_selected_archers() + coordinate_label.text = "Archer squad moved." + _refresh_player_state() + + func _claim_selected_tile() -> void: if not selected_can_claim: coordinate_label.text = "Select a visible unclaimed tile to send a scout." diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index f5158a3..f3d5923 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -17,6 +17,7 @@ var drag_distance: float = 0.0 var last_mouse_position: Vector2 = Vector2.ZERO var hovered_tile: Vector2i = Vector2i(9999, 9999) var selected_tile: Vector2i = Vector2i(9999, 9999) +var selected_unit_tile: Vector2i = Vector2i(9999, 9999) var home_tile: Vector2i = Vector2i(9999, 9999) var tiles: Array[Dictionary] = [] var visible_tiles: Dictionary = {} @@ -157,6 +158,8 @@ func _draw_tile(tile: Dictionary) -> void: if not tile_visible: draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0) + elif coord == selected_unit_tile: + draw_polyline(_closed_points(points), Color(0.90, 0.68, 0.28), 5.0) elif is_selected: draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0) elif coord == home_tile: @@ -453,12 +456,20 @@ func _draw_archer_unit_marker(center: Vector2, height: float, count: int) -> voi draw_line(marker_center + Vector2(18.0, -1.0) * marker_scale, marker_center + Vector2(14.0, -4.0) * marker_scale, arrow_color, 1.2 * marker_scale) draw_line(marker_center + Vector2(18.0, -1.0) * marker_scale, marker_center + Vector2(14.0, 2.0) * marker_scale, arrow_color, 1.2 * marker_scale) - if count > 1: - var badge_center: Vector2 = marker_center + Vector2(10.0, 9.0) * marker_scale - draw_circle(badge_center, 6.0 * marker_scale, badge_color) - draw_arc(badge_center, 6.0 * marker_scale, 0.0, TAU, 18, badge_border, 1.4 * marker_scale) - var font := ThemeDB.fallback_font - draw_string(font, badge_center + Vector2(-3.5, 3.5) * marker_scale, str(min(count, 9)), HORIZONTAL_ALIGNMENT_LEFT, -1, 9 * marker_scale, Color(0.93, 0.86, 0.58)) + var count_text := str(count) + var font := ThemeDB.fallback_font + var badge_font_size := int(round(9.0 * marker_scale)) + var text_size := font.get_string_size(count_text, HORIZONTAL_ALIGNMENT_LEFT, -1, badge_font_size) + var badge_size := Vector2(max(14.0 * marker_scale, text_size.x + 7.0 * marker_scale), 12.0 * marker_scale) + var badge_rect := Rect2(marker_center + Vector2(5.0, 3.0) * marker_scale, badge_size) + draw_rect(badge_rect, badge_color, true) + draw_polyline(_closed_points(PackedVector2Array([ + badge_rect.position, + badge_rect.position + Vector2(badge_rect.size.x, 0), + badge_rect.position + badge_rect.size, + badge_rect.position + Vector2(0, badge_rect.size.y) + ])), badge_border, 1.2 * marker_scale) + draw_string(font, badge_rect.position + Vector2(3.5, 9.0) * marker_scale, count_text, HORIZONTAL_ALIGNMENT_LEFT, -1, badge_font_size, Color(0.93, 0.86, 0.58)) func _tile_data(q: int, r: int) -> Dictionary: @@ -531,6 +542,25 @@ func set_unit_instances(units: Array) -> void: queue_redraw() +func set_selected_unit_tile(q: int, r: int) -> void: + selected_unit_tile = Vector2i(q, r) + queue_redraw() + + +func clear_selected_unit_tile() -> void: + selected_unit_tile = Vector2i(9999, 9999) + queue_redraw() + + +func get_archer_count_at(q: int, r: int) -> int: + return _archer_count_at(q, r) + + +func is_visible_non_rival_tile(q: int, r: int) -> bool: + var key := _tile_key(q, r) + return visible_tiles.has(key) and not rival_tiles.has(key) + + func _tile_key(q: int, r: int) -> String: return "%d,%d" % [q, r] diff --git a/microservices/LocationsApi/Controllers/UnitsController.cs b/microservices/LocationsApi/Controllers/UnitsController.cs index 98d3ead..b4c9e49 100644 --- a/microservices/LocationsApi/Controllers/UnitsController.cs +++ b/microservices/LocationsApi/Controllers/UnitsController.cs @@ -59,4 +59,34 @@ public class UnitsController : ControllerBase return BadRequest(new { error = ex.Message }); } } + + [Authorize(Roles = "USER,SUPER")] + [HttpPost("move")] + public async Task>> Move([FromBody] MoveUnitSquadRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + try + { + var units = await _units.MoveSquadAsync(username, request); + if (units.Count == 0) + { + return BadRequest(new { error = "No matching units are available to move." }); + } + + return Ok(units.Select(UnitInstanceResponse.FromUnit).ToList()); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } } diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index 7ce6ff8..00f3936 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -7,6 +7,15 @@ public class TrainUnitRequest public int? R { get; set; } } +public class MoveUnitSquadRequest +{ + public string UnitType { get; set; } = "Archer"; + public int FromQ { get; set; } + public int FromR { get; set; } + public int ToQ { get; set; } + public int ToR { get; set; } +} + public class UnitTrainingResponse { public string Id { get; set; } = ""; diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index 06dcb9f..7c03150 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -17,6 +17,7 @@ Tracks hex locations for the territory map. - `GET /api/resources/me` Get the authenticated player's resources. - `GET /api/units/me` Get completed units and active training for the authenticated player. - `POST /api/units/train` Spend gold and start training a unit. +- `POST /api/units/move` Move a persisted unit squad to a visible non-rival tile. `claim` and `put` require the same JWT issued by AuthApi. @@ -41,6 +42,10 @@ training is completed lazily when unit state is read. Claiming an unclaimed visible location consumes one completed Scout by deleting that Scout's training record. +Completed Archers are stored as persisted unit instances in the `Units` +collection with their current `q`/`r` location. Archer squads can move to +visible tiles that are unclaimed or controlled by the owning player. + ## Buildings Buildings are stored on location records. `LogCamp` can be built on controlled diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 4825451..20c4beb 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -18,6 +18,7 @@ public class UnitTrainingService private readonly int _archerWoodCost; private readonly int _defaultScoutTrainingSeconds; private readonly int _defaultArcherTrainingSeconds; + private readonly int _visibilityDepth; public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) { @@ -39,6 +40,7 @@ public class UnitTrainingService _defaultArcherTrainingSeconds = int.TryParse(cfg["Units:ArcherTrainingSeconds"], out var archerTrainingSeconds) ? Math.Max(1, archerTrainingSeconds) : 60; + _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3; _training.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status))); @@ -138,6 +140,56 @@ public class UnitTrainingService return training; } + public async Task> MoveSquadAsync(string username, MoveUnitSquadRequest request) + { + var normalizedUnitType = NormalizeUnitType(request.UnitType); + if (normalizedUnitType != ArcherUnitType) + { + throw new ArgumentException("Only Archer squads can be moved right now.", nameof(request.UnitType)); + } + + if (request.FromQ == request.ToQ && request.FromR == request.ToR) + { + throw new InvalidOperationException("Select a different destination tile."); + } + + if (!await CanMoveToAsync(username, request.ToQ, request.ToR)) + { + throw new InvalidOperationException("Archers can only move to visible non-rival tiles."); + } + + var now = DateTime.UtcNow; + var units = await _units.Find(x => + x.Username == username && + x.UnitType == ArcherUnitType && + x.Q == request.FromQ && + x.R == request.FromR) + .ToListAsync(); + + if (units.Count == 0) + { + return []; + } + + await _units.UpdateManyAsync( + x => x.Username == username && + x.UnitType == ArcherUnitType && + x.Q == request.FromQ && + x.R == request.FromR, + Builders.Update + .Set(x => x.Q, request.ToQ) + .Set(x => x.R, request.ToR) + .Set(x => x.Status, "Idle") + .Set(x => x.UpdatedAtUtc, now)); + + return await _units.Find(x => + x.Username == username && + x.UnitType == ArcherUnitType && + x.Q == request.ToQ && + x.R == request.ToR) + .ToListAsync(); + } + private async Task GetBuiltBarracksAsync(string username, int q, int r) { await CompleteFinishedBuildingsAsync(username); @@ -166,6 +218,27 @@ public class UnitTrainingService update); } + private async Task CanMoveToAsync(string username, int q, int r) + { + var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + if (!ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth)) + { + return false; + } + + var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync(); + return target is null || + string.IsNullOrWhiteSpace(target.OwnerUsername) || + string.Equals(target.OwnerUsername, username, StringComparison.OrdinalIgnoreCase); + } + + private static int HexDistance(int q1, int r1, int q2, int r2) + { + var s1 = -q1 - r1; + var s2 = -q2 - r2; + return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2)); + } + public async Task TryConsumeCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username);