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_q := 9999
var selected_peasant_r := 9999
var selected_peasant_count := 0
var selected_archer_q := 9999
var selected_archer_r := 9999
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 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:
unit_action_popup.hide()
selected_peasant_id = peasant_id
selected_peasant_q = q
selected_peasant_r = r
selected_peasant_count = peasant_count
selected_archer_count = 0
grid_view.set_selected_unit_tile(q, r)
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
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_count = archer_count
selected_peasant_id = ""
selected_peasant_count = 0
grid_view.set_selected_unit_tile(q, r)
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]
@@ -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 has_building := _location_has_completed_building(location)
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:
unit_action_popup.hide()
if coordinate_label:
@@ -561,6 +566,10 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position
if coordinate_label:
coordinate_label.text = "Archers can only move to an adjacent tile."
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 coordinate_label:
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:
coordinate_label.text = "Orders for q:%d r:%d." % [q, r]
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_deassign_button.visible = has_worker
var popup_size := Vector2i(116, 132)
@@ -587,7 +596,10 @@ func _confirm_unit_move() -> void:
unit_action_popup.hide()
if pending_move_q == 9999:
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:
@@ -689,6 +701,7 @@ func _clear_selected_archers() -> void:
selected_peasant_id = ""
selected_peasant_q = 9999
selected_peasant_r = 9999
selected_peasant_count = 0
pending_move_q = 9999
pending_move_r = 9999
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."
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:
var text: String = body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
coordinate_label.text = "Archer move request failed."
coordinate_label.text = "Unit move request failed."
return
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()
return
_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_units()
@@ -122,6 +122,9 @@ func _draw() -> void:
for tile in sorted_tiles:
_draw_tile(tile)
_draw_movement_indicators()
func _draw_grid_lines() -> void:
var line_color: Color = Color(0.12, 0.17, 0.17, 0.35)
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:
var closest := _closest_tile_at(mouse_position)
@@ -695,6 +722,14 @@ func get_peasant_id_at(q: int, r: int) -> String:
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:
var key := _tile_key(q, r)
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
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:
var owner_value: Variant = location.get("ownerUsername", "")
if owner_value == null:
@@ -9,6 +9,7 @@ public class TrainUnitRequest
public class MoveUnitSquadRequest
{
public string? UnitId { get; set; }
public string UnitType { get; set; } = "Archer";
public int FromQ { get; set; }
public int FromR { get; set; }
@@ -177,9 +177,9 @@ public class UnitTrainingService
await RefillMovementAsync(username);
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)
@@ -187,24 +187,38 @@ public class UnitTrainingService
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.");
}
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.");
}
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 units = await _units.Find(x =>
x.Username == username &&
x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ &&
x.R == request.FromR)
.ToListAsync();
var unitFilter = Builders<UnitInstance>.Filter.And(
Builders<UnitInstance>.Filter.Eq(x => x.Username, username),
Builders<UnitInstance>.Filter.Eq(x => x.UnitType, normalizedUnitType),
Builders<UnitInstance>.Filter.Eq(x => x.Status, "Idle"),
Builders<UnitInstance>.Filter.Eq(x => x.Q, request.FromQ),
Builders<UnitInstance>.Filter.Eq(x => x.R, request.FromR));
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)
{
@@ -216,13 +230,9 @@ public class UnitTrainingService
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(
x => x.Username == username &&
x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ &&
x.R == request.FromR,
unitFilter,
Builders<UnitInstance>.Update
.Set(x => x.Status, "Moving")
.Inc(x => x.MovementRemaining, -1)
@@ -234,7 +244,7 @@ public class UnitTrainingService
return await _units.Find(x =>
x.Username == username &&
x.UnitType == ArcherUnitType &&
x.UnitType == normalizedUnitType &&
x.Status == "Moving" &&
x.Q == request.FromQ &&
x.R == request.FromR &&
@@ -282,11 +292,34 @@ public class UnitTrainingService
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(
x => x.Id == unit.Id,
Builders<UnitInstance>.Update
.Set(x => x.Q, unit.DestinationQ.Value)
.Set(x => x.R, unit.DestinationR.Value)
.Set(x => x.Q, destinationQ)
.Set(x => x.R, destinationR)
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
@@ -299,7 +332,7 @@ public class UnitTrainingService
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);
}
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)
{
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));
}
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)
{
await CompleteFinishedTrainingAsync(username);