Movement enhancements

This commit is contained in:
hz
2026-05-20 14:33:12 -05:00
parent dfbe1d63c0
commit 84136ad534
4 changed files with 187 additions and 29 deletions
@@ -77,6 +77,7 @@ var completed_peasant_count := 0
var selected_peasant_id := "" var selected_peasant_id := ""
var selected_peasant_q := 9999 var selected_peasant_q := 9999
var selected_peasant_r := 9999 var selected_peasant_r := 9999
var selected_peasant_count := 0
var selected_archer_q := 9999 var selected_archer_q := 9999
var selected_archer_r := 9999 var selected_archer_r := 9999
var selected_archer_count := 0 var selected_archer_count := 0
@@ -514,16 +515,18 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool:
var archer_count: int = grid_view.get_archer_count_at(q, r) var archer_count: int = grid_view.get_archer_count_at(q, r)
var peasant_id: String = grid_view.get_peasant_id_at(q, r) var peasant_id: String = grid_view.get_peasant_id_at(q, r)
if not peasant_id.is_empty(): var peasant_count: int = grid_view.get_peasant_count_at(q, r)
if peasant_count > 0:
if unit_action_popup: if unit_action_popup:
unit_action_popup.hide() unit_action_popup.hide()
selected_peasant_id = peasant_id selected_peasant_id = peasant_id
selected_peasant_q = q selected_peasant_q = q
selected_peasant_r = r selected_peasant_r = r
selected_peasant_count = peasant_count
selected_archer_count = 0 selected_archer_count = 0
grid_view.set_selected_unit_tile(q, r) grid_view.set_selected_unit_tile(q, r)
if coordinate_label: if coordinate_label:
coordinate_label.text = "Selected Peasant at q:%d r:%d. Right-click a building to assign work." % [q, r] coordinate_label.text = "Selected Peasant squad: %d at q:%d r:%d. Right-click controlled territory to move or a building to assign work." % [peasant_count, q, r]
return true return true
if archer_count > 0: if archer_count > 0:
@@ -533,6 +536,7 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool:
selected_archer_r = r selected_archer_r = r
selected_archer_count = archer_count selected_archer_count = archer_count
selected_peasant_id = "" selected_peasant_id = ""
selected_peasant_count = 0
grid_view.set_selected_unit_tile(q, r) grid_view.set_selected_unit_tile(q, r)
if coordinate_label: if coordinate_label:
coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Right-click an adjacent visible tile for orders." % [archer_count, q, r] coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Right-click an adjacent visible tile for orders." % [archer_count, q, r]
@@ -547,7 +551,8 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position
var location: Dictionary = grid_view.get_visible_location(q, r) var location: Dictionary = grid_view.get_visible_location(q, r)
var has_building := _location_has_completed_building(location) var has_building := _location_has_completed_building(location)
var has_worker := not _nullable_string(location.get("workerUnitId", "")).is_empty() var has_worker := not _nullable_string(location.get("workerUnitId", "")).is_empty()
if selected_archer_count <= 0 and selected_peasant_id.is_empty() and not has_worker: var selected_peasants := selected_peasant_count > 0
if selected_archer_count <= 0 and not selected_peasants and not has_worker:
if unit_action_popup: if unit_action_popup:
unit_action_popup.hide() unit_action_popup.hide()
if coordinate_label: if coordinate_label:
@@ -561,6 +566,10 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position
if coordinate_label: if coordinate_label:
coordinate_label.text = "Archers can only move to an adjacent tile." coordinate_label.text = "Archers can only move to an adjacent tile."
return return
if selected_peasants and not grid_view.is_owned_tile(q, r):
if coordinate_label:
coordinate_label.text = "Peasants can only move within controlled territory."
return
if not grid_view.is_visible_tile(q, r): if not grid_view.is_visible_tile(q, r):
if coordinate_label: if coordinate_label:
coordinate_label.text = "Archers can only move to visible tiles." coordinate_label.text = "Archers can only move to visible tiles."
@@ -571,7 +580,7 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position
if coordinate_label: if coordinate_label:
coordinate_label.text = "Orders for q:%d r:%d." % [q, r] coordinate_label.text = "Orders for q:%d r:%d." % [q, r]
if unit_action_popup: if unit_action_popup:
unit_action_move_button.visible = selected_archer_count > 0 unit_action_move_button.visible = selected_archer_count > 0 or selected_peasants
unit_action_assign_button.visible = not selected_peasant_id.is_empty() and has_building and not has_worker unit_action_assign_button.visible = not selected_peasant_id.is_empty() and has_building and not has_worker
unit_action_deassign_button.visible = has_worker unit_action_deassign_button.visible = has_worker
var popup_size := Vector2i(116, 132) var popup_size := Vector2i(116, 132)
@@ -587,7 +596,10 @@ func _confirm_unit_move() -> void:
unit_action_popup.hide() unit_action_popup.hide()
if pending_move_q == 9999: if pending_move_q == 9999:
return return
_move_selected_archers(pending_move_q, pending_move_r) if selected_archer_count > 0:
_move_selected_archers(pending_move_q, pending_move_r)
elif selected_peasant_count > 0:
_move_selected_peasants(pending_move_q, pending_move_r)
func _confirm_worker_assign() -> void: func _confirm_worker_assign() -> void:
@@ -689,6 +701,7 @@ func _clear_selected_archers() -> void:
selected_peasant_id = "" selected_peasant_id = ""
selected_peasant_q = 9999 selected_peasant_q = 9999
selected_peasant_r = 9999 selected_peasant_r = 9999
selected_peasant_count = 0
pending_move_q = 9999 pending_move_q = 9999
pending_move_r = 9999 pending_move_r = 9999
if unit_action_popup: if unit_action_popup:
@@ -1205,19 +1218,57 @@ func _move_selected_archers(to_q: int, to_r: int) -> void:
coordinate_label.text = "Could not contact the unit move service." coordinate_label.text = "Could not contact the unit move service."
func _move_selected_peasants(to_q: int, to_r: int) -> void:
if selected_peasant_count <= 0:
return
if to_q == selected_peasant_q and to_r == selected_peasant_r:
coordinate_label.text = "Peasant squad is already on that tile."
return
if not grid_view.is_owned_tile(to_q, to_r):
coordinate_label.text = "Peasants can only move within controlled territory."
return
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
coordinate_label.text = "No auth token available. Peasant squad was not moved."
return
if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
coordinate_label.text = "Ordering %d Peasants to move to q:%d r:%d..." % [selected_peasant_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": "Peasant",
"fromQ": selected_peasant_q,
"fromR": selected_peasant_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: func _on_move_units_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var text: String = body.get_string_from_utf8() var text: String = body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS: if result != HTTPRequest.RESULT_SUCCESS:
coordinate_label.text = "Archer move request failed." coordinate_label.text = "Unit move request failed."
return return
if response_code < 200 or response_code >= 300: if response_code < 200 or response_code >= 300:
coordinate_label.text = "Archer move failed: %s" % text coordinate_label.text = "Unit move failed: %s" % text
_refresh_player_state() _refresh_player_state()
return return
_clear_selected_archers() _clear_selected_archers()
coordinate_label.text = "Archer squad movement ordered. It will arrive on the next tick." coordinate_label.text = "Unit movement ordered."
_load_visible_locations() _load_visible_locations()
_load_units() _load_units()
@@ -122,6 +122,9 @@ func _draw() -> void:
for tile in sorted_tiles: for tile in sorted_tiles:
_draw_tile(tile) _draw_tile(tile)
_draw_movement_indicators()
func _draw_grid_lines() -> void: func _draw_grid_lines() -> void:
var line_color: Color = Color(0.12, 0.17, 0.17, 0.35) var line_color: Color = Color(0.12, 0.17, 0.17, 0.35)
for x in range(-8, 9): for x in range(-8, 9):
@@ -243,6 +246,30 @@ func _draw_control_layer(control_tiles: Array, control: String, layer_scale: flo
) )
func _draw_movement_indicators() -> void:
for unit in unit_instances:
var status := str(unit.get("status", ""))
if status != "Moving" and status != "MovingToWork":
continue
if unit.get("destinationQ", null) == null or unit.get("destinationR", null) == null:
continue
var from_q := int(unit.get("q", 0))
var from_r := int(unit.get("r", 0))
var to_q := int(unit.get("destinationQ", 0))
var to_r := int(unit.get("destinationR", 0))
var from_center := _tile_center(from_q, from_r)
var to_center := _tile_center(to_q, to_r)
var target_tile: Dictionary = _tile_data(to_q, to_r)
var target_height := float(target_tile.get("height", _tile_height(to_q, to_r)))
var marker_color := Color(0.98, 0.82, 0.28, 0.92) if str(unit.get("unitType", "")) == "Peasant" else Color(0.38, 0.86, 1.0, 0.92)
draw_line(from_center, to_center, Color(marker_color.r, marker_color.g, marker_color.b, 0.72), 3.0)
draw_circle(from_center, 4.0, Color(marker_color.r, marker_color.g, marker_color.b, 0.88))
draw_polyline(_closed_points(_hex_points(to_center, target_height + 3.0)), marker_color, 4.0)
draw_circle(to_center, 7.0, Color(marker_color.r, marker_color.g, marker_color.b, 0.76))
func _update_hover(mouse_position: Vector2) -> void: func _update_hover(mouse_position: Vector2) -> void:
var closest := _closest_tile_at(mouse_position) var closest := _closest_tile_at(mouse_position)
@@ -695,6 +722,14 @@ func get_peasant_id_at(q: int, r: int) -> String:
return "" return ""
func get_peasant_count_at(q: int, r: int) -> int:
return _idle_peasant_count_at(q, r)
func is_owned_tile(q: int, r: int) -> bool:
return owned_tiles.has(_tile_key(q, r))
func is_visible_non_rival_tile(q: int, r: int) -> bool: func is_visible_non_rival_tile(q: int, r: int) -> bool:
var key := _tile_key(q, r) var key := _tile_key(q, r)
return visible_tiles.has(key) and not rival_tiles.has(key) return visible_tiles.has(key) and not rival_tiles.has(key)
@@ -829,6 +864,16 @@ func _peasant_count_at(q: int, r: int) -> int:
return count return count
func _idle_peasant_count_at(q: int, r: int) -> int:
var count := 0
for unit in unit_instances:
if str(unit.get("unitType", "")) != "Peasant" or str(unit.get("status", "")) != "Idle":
continue
if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r:
count += 1
return count
func _owner_username(location: Dictionary) -> String: func _owner_username(location: Dictionary) -> String:
var owner_value: Variant = location.get("ownerUsername", "") var owner_value: Variant = location.get("ownerUsername", "")
if owner_value == null: if owner_value == null:
@@ -9,6 +9,7 @@ public class TrainUnitRequest
public class MoveUnitSquadRequest public class MoveUnitSquadRequest
{ {
public string? UnitId { get; set; }
public string UnitType { get; set; } = "Archer"; public string UnitType { get; set; } = "Archer";
public int FromQ { get; set; } public int FromQ { get; set; }
public int FromR { get; set; } public int FromR { get; set; }
@@ -177,9 +177,9 @@ public class UnitTrainingService
await RefillMovementAsync(username); await RefillMovementAsync(username);
var normalizedUnitType = NormalizeUnitType(request.UnitType); var normalizedUnitType = NormalizeUnitType(request.UnitType);
if (normalizedUnitType != ArcherUnitType) if (normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType)
{ {
throw new ArgumentException("Only Archer squads can be moved right now.", nameof(request.UnitType)); throw new ArgumentException("Only Archers and Peasants can be moved right now.", nameof(request.UnitType));
} }
if (request.FromQ == request.ToQ && request.FromR == request.ToR) if (request.FromQ == request.ToQ && request.FromR == request.ToR)
@@ -187,24 +187,38 @@ public class UnitTrainingService
throw new InvalidOperationException("Select a different destination tile."); throw new InvalidOperationException("Select a different destination tile.");
} }
if (HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR) != 1) var movementDistance = HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR);
if (normalizedUnitType == ArcherUnitType && movementDistance != 1)
{ {
throw new InvalidOperationException("Archers can only move to adjacent tiles."); throw new InvalidOperationException("Archers can only move to adjacent tiles.");
} }
if (!await CanMoveToAsync(username, request.ToQ, request.ToR)) if (normalizedUnitType == ArcherUnitType && !await CanMoveToAsync(username, request.ToQ, request.ToR))
{ {
throw new InvalidOperationException("Archers can only move to visible tiles."); throw new InvalidOperationException("Archers can only move to visible tiles.");
} }
if (normalizedUnitType == PeasantUnitType && !await CanPeasantMoveToAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException("Peasants can only move within your controlled territory.");
}
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var units = await _units.Find(x => var unitFilter = Builders<UnitInstance>.Filter.And(
x.Username == username && Builders<UnitInstance>.Filter.Eq(x => x.Username, username),
x.UnitType == ArcherUnitType && Builders<UnitInstance>.Filter.Eq(x => x.UnitType, normalizedUnitType),
x.Status == "Idle" && Builders<UnitInstance>.Filter.Eq(x => x.Status, "Idle"),
x.Q == request.FromQ && Builders<UnitInstance>.Filter.Eq(x => x.Q, request.FromQ),
x.R == request.FromR) Builders<UnitInstance>.Filter.Eq(x => x.R, request.FromR));
.ToListAsync(); if (normalizedUnitType == PeasantUnitType)
{
if (!string.IsNullOrWhiteSpace(request.UnitId))
{
unitFilter &= Builders<UnitInstance>.Filter.Eq(x => x.Id, request.UnitId);
}
}
var units = await _units.Find(unitFilter).ToListAsync();
if (units.Count == 0) if (units.Count == 0)
{ {
@@ -216,13 +230,9 @@ public class UnitTrainingService
throw new InvalidOperationException("That squad has no movement remaining this tick."); throw new InvalidOperationException("That squad has no movement remaining this tick.");
} }
var movementCompletesAt = now.AddMinutes(1); var movementCompletesAt = now.AddMinutes(Math.Max(1, movementDistance));
await _units.UpdateManyAsync( await _units.UpdateManyAsync(
x => x.Username == username && unitFilter,
x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ &&
x.R == request.FromR,
Builders<UnitInstance>.Update Builders<UnitInstance>.Update
.Set(x => x.Status, "Moving") .Set(x => x.Status, "Moving")
.Inc(x => x.MovementRemaining, -1) .Inc(x => x.MovementRemaining, -1)
@@ -234,7 +244,7 @@ public class UnitTrainingService
return await _units.Find(x => return await _units.Find(x =>
x.Username == username && x.Username == username &&
x.UnitType == ArcherUnitType && x.UnitType == normalizedUnitType &&
x.Status == "Moving" && x.Status == "Moving" &&
x.Q == request.FromQ && x.Q == request.FromQ &&
x.R == request.FromR && x.R == request.FromR &&
@@ -282,11 +292,34 @@ public class UnitTrainingService
continue; continue;
} }
var destinationQ = unit.DestinationQ.Value;
var destinationR = unit.DestinationR.Value;
var currentQ = unit.Q;
var currentR = unit.R;
var nextTick = unit.MovementCompletesAtUtc!.Value;
while (nextTick <= now && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
(currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR);
nextTick = nextTick.AddMinutes(1);
}
if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
await _units.UpdateOneAsync(
x => x.Id == unit.Id,
Builders<UnitInstance>.Update
.Set(x => x.Q, currentQ)
.Set(x => x.R, currentR)
.Set(x => x.MovementCompletesAtUtc, nextTick)
.Set(x => x.UpdatedAtUtc, now));
continue;
}
await _units.UpdateOneAsync( await _units.UpdateOneAsync(
x => x.Id == unit.Id, x => x.Id == unit.Id,
Builders<UnitInstance>.Update Builders<UnitInstance>.Update
.Set(x => x.Q, unit.DestinationQ.Value) .Set(x => x.Q, destinationQ)
.Set(x => x.R, unit.DestinationR.Value) .Set(x => x.R, destinationR)
.Set(x => x.Status, "Idle") .Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick) .Set(x => x.MovementRemaining, movementPerTick)
@@ -299,7 +332,7 @@ public class UnitTrainingService
if (unit.UnitType == ArcherUnitType) if (unit.UnitType == ArcherUnitType)
{ {
await ClaimTargetIfEmptyAsync(username, unit.DestinationQ.Value, unit.DestinationR.Value, now); await ClaimTargetIfEmptyAsync(username, destinationQ, destinationR, now);
} }
} }
} }
@@ -363,6 +396,15 @@ public class UnitTrainingService
return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1); return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1);
} }
private async Task<bool> CanPeasantMoveToAsync(string username, int q, int r)
{
return await _locations.Find(x =>
x.OwnerUsername == username &&
x.Q == q &&
x.R == r)
.AnyAsync();
}
private async Task ClaimTargetIfEmptyAsync(string username, int q, int r, DateTime now) private async Task ClaimTargetIfEmptyAsync(string username, int q, int r, DateTime now)
{ {
var hasOtherUnits = await _units.Find(x => x.Username != username && x.Q == q && x.R == r).AnyAsync(); var hasOtherUnits = await _units.Find(x => x.Username != username && x.Q == q && x.R == r).AnyAsync();
@@ -428,6 +470,25 @@ public class UnitTrainingService
return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2)); return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2));
} }
private static (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR)
{
return NeighborCoordinates(fromQ, fromR)
.OrderBy(x => HexDistance(x.q, x.r, toQ, toR))
.ThenBy(x => x.q)
.ThenBy(x => x.r)
.First();
}
private static IEnumerable<(int q, int r)> NeighborCoordinates(int q, int r)
{
yield return (q + 1, r);
yield return (q + 1, r - 1);
yield return (q, r - 1);
yield return (q - 1, r);
yield return (q - 1, r + 1);
yield return (q, r + 1);
}
public async Task<bool> TryConsumeCompletedScoutAsync(string username) public async Task<bool> TryConsumeCompletedScoutAsync(string username)
{ {
await CompleteFinishedTrainingAsync(username); await CompleteFinishedTrainingAsync(username);