implemented unit movement

This commit is contained in:
hz
2026-05-19 05:29:51 -05:00
parent b0b270f4d8
commit 14e2c26e74
6 changed files with 247 additions and 6 deletions
@@ -15,6 +15,7 @@ var visible_request: HTTPRequest
var resources_request: HTTPRequest var resources_request: HTTPRequest
var units_request: HTTPRequest var units_request: HTTPRequest
var train_unit_request: HTTPRequest var train_unit_request: HTTPRequest
var move_units_request: HTTPRequest
var claim_request: HTTPRequest var claim_request: HTTPRequest
var build_request: HTTPRequest var build_request: HTTPRequest
var refresh_request: HTTPRequest var refresh_request: HTTPRequest
@@ -63,6 +64,9 @@ var selected_can_train_archer := false
var selected_is_home := false var selected_is_home := false
var completed_scout_count := 0 var completed_scout_count := 0
var completed_archer_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_prompt_active := false
var refresh_request_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) train_unit_request.request_completed.connect(_on_train_unit_request_completed)
add_child(train_unit_request) 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 = HTTPRequest.new()
claim_request.request_completed.connect(_on_claim_request_completed) claim_request.request_completed.connect(_on_claim_request_completed)
add_child(claim_request) 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: func _on_tile_selected(q: int, r: int, terrain: String) -> void:
if _handle_unit_selection_or_move(q, r):
return
selected_q = q selected_q = q
selected_r = r selected_r = r
selected_can_claim = terrain.contains("Unclaimed") 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] 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: func _can_build_log_camp_on_selected_tile() -> bool:
return _can_build_on_selected_tile("Forest") return _can_build_on_selected_tile("Forest")
@@ -481,6 +513,14 @@ func _update_action_buttons() -> void:
build_barracks_button.disabled = false 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: func _selected_tile_is_home() -> bool:
if not grid_view: if not grid_view:
return false return false
@@ -915,6 +955,60 @@ func _on_train_unit_request_completed(result: int, response_code: int, _headers:
_refresh_player_state() _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: func _claim_selected_tile() -> void:
if not selected_can_claim: if not selected_can_claim:
coordinate_label.text = "Select a visible unclaimed tile to send a scout." coordinate_label.text = "Select a visible unclaimed tile to send a scout."
@@ -17,6 +17,7 @@ var drag_distance: float = 0.0
var last_mouse_position: Vector2 = Vector2.ZERO var last_mouse_position: Vector2 = Vector2.ZERO
var hovered_tile: Vector2i = Vector2i(9999, 9999) var hovered_tile: Vector2i = Vector2i(9999, 9999)
var selected_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 home_tile: Vector2i = Vector2i(9999, 9999)
var tiles: Array[Dictionary] = [] var tiles: Array[Dictionary] = []
var visible_tiles: Dictionary = {} var visible_tiles: Dictionary = {}
@@ -157,6 +158,8 @@ func _draw_tile(tile: Dictionary) -> void:
if not tile_visible: if not tile_visible:
draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0) 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: elif is_selected:
draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0) draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0)
elif coord == home_tile: 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, -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) 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 count_text := str(count)
var badge_center: Vector2 = marker_center + Vector2(10.0, 9.0) * marker_scale var font := ThemeDB.fallback_font
draw_circle(badge_center, 6.0 * marker_scale, badge_color) var badge_font_size := int(round(9.0 * marker_scale))
draw_arc(badge_center, 6.0 * marker_scale, 0.0, TAU, 18, badge_border, 1.4 * marker_scale) var text_size := font.get_string_size(count_text, HORIZONTAL_ALIGNMENT_LEFT, -1, badge_font_size)
var font := ThemeDB.fallback_font var badge_size := Vector2(max(14.0 * marker_scale, text_size.x + 7.0 * marker_scale), 12.0 * marker_scale)
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 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: func _tile_data(q: int, r: int) -> Dictionary:
@@ -531,6 +542,25 @@ func set_unit_instances(units: Array) -> void:
queue_redraw() 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: func _tile_key(q: int, r: int) -> String:
return "%d,%d" % [q, r] return "%d,%d" % [q, r]
@@ -59,4 +59,34 @@ public class UnitsController : ControllerBase
return BadRequest(new { error = ex.Message }); return BadRequest(new { error = ex.Message });
} }
} }
[Authorize(Roles = "USER,SUPER")]
[HttpPost("move")]
public async Task<ActionResult<List<UnitInstanceResponse>>> 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 });
}
}
} }
@@ -7,6 +7,15 @@ public class TrainUnitRequest
public int? R { get; set; } 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 class UnitTrainingResponse
{ {
public string Id { get; set; } = ""; public string Id { get; set; } = "";
+5
View File
@@ -17,6 +17,7 @@ Tracks hex locations for the territory map.
- `GET /api/resources/me` Get the authenticated player's resources. - `GET /api/resources/me` Get the authenticated player's resources.
- `GET /api/units/me` Get completed units and active training for the authenticated player. - `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/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. `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 visible location consumes one completed Scout by deleting that Scout's training
record. 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
Buildings are stored on location records. `LogCamp` can be built on controlled Buildings are stored on location records. `LogCamp` can be built on controlled
@@ -18,6 +18,7 @@ public class UnitTrainingService
private readonly int _archerWoodCost; private readonly int _archerWoodCost;
private readonly int _defaultScoutTrainingSeconds; private readonly int _defaultScoutTrainingSeconds;
private readonly int _defaultArcherTrainingSeconds; private readonly int _defaultArcherTrainingSeconds;
private readonly int _visibilityDepth;
public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings)
{ {
@@ -39,6 +40,7 @@ public class UnitTrainingService
_defaultArcherTrainingSeconds = int.TryParse(cfg["Units:ArcherTrainingSeconds"], out var archerTrainingSeconds) _defaultArcherTrainingSeconds = int.TryParse(cfg["Units:ArcherTrainingSeconds"], out var archerTrainingSeconds)
? Math.Max(1, archerTrainingSeconds) ? Math.Max(1, archerTrainingSeconds)
: 60; : 60;
_visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
_training.Indexes.CreateOne(new CreateIndexModel<UnitTraining>( _training.Indexes.CreateOne(new CreateIndexModel<UnitTraining>(
Builders<UnitTraining>.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status))); Builders<UnitTraining>.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status)));
@@ -138,6 +140,56 @@ public class UnitTrainingService
return training; return training;
} }
public async Task<List<UnitInstance>> 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<UnitInstance>.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<Location?> GetBuiltBarracksAsync(string username, int q, int r) private async Task<Location?> GetBuiltBarracksAsync(string username, int q, int r)
{ {
await CompleteFinishedBuildingsAsync(username); await CompleteFinishedBuildingsAsync(username);
@@ -166,6 +218,27 @@ public class UnitTrainingService
update); update);
} }
private async Task<bool> 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<bool> TryConsumeCompletedScoutAsync(string username) public async Task<bool> TryConsumeCompletedScoutAsync(string username)
{ {
await CompleteFinishedTrainingAsync(username); await CompleteFinishedTrainingAsync(username);