This commit is contained in:
hz
2026-05-21 02:21:54 -05:00
parent de42f85835
commit 5530a185a0
6 changed files with 216 additions and 52 deletions
@@ -46,7 +46,10 @@ var building_box: VBoxContainer
var building_label: Label
var building_bar: ProgressBar
var progress_panel: PanelContainer
var progress_stack: VBoxContainer
var action_buttons: HBoxContainer
var training_items: Array[Dictionary] = []
var training_item_boxes: Array[VBoxContainer] = []
var training_started_unix := 0.0
var training_completes_unix := 0.0
var training_completion_refresh_requested := false
@@ -433,12 +436,13 @@ func _build_map_overlays(map_layer: Control) -> void:
progress_margin.add_theme_constant_override("margin_bottom", 10)
progress_panel.add_child(progress_margin)
var progress_stack := VBoxContainer.new()
progress_stack = VBoxContainer.new()
progress_stack.add_theme_constant_override("separation", 8)
progress_margin.add_child(progress_stack)
training_box = _progress_overlay_item("Training")
progress_stack.add_child(training_box)
training_item_boxes.append(training_box)
training_label = training_box.get_child(0) as Label
training_bar = training_box.get_child(1) as ProgressBar
@@ -683,7 +687,7 @@ func _confirm_scout_claim() -> void:
selected_q = pending_move_q
selected_r = pending_move_r
selected_can_claim = true
_claim_selected_tile()
_move_selected_scouts(pending_move_q, pending_move_r, true)
func _confirm_worker_assign() -> void:
@@ -790,7 +794,7 @@ func _can_build_on_selected_tile(required_terrain: String) -> bool:
func _can_claim_selected_tile() -> bool:
return selected_can_claim and completed_scout_count > 0 and grid_view and grid_view.has_owned_neighbor(selected_q, selected_r)
return selected_can_claim and selected_scout_count > 0 and completed_scout_count > 0 and grid_view and grid_view.has_owned_neighbor(selected_q, selected_r)
func _can_claim_tile(q: int, r: int) -> bool:
@@ -1253,11 +1257,13 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack
_clear_removed_selected_unit()
unit_label.text = "Scouts: %d | Peasants: %d | Archers: %d | Queue: %d" % [completed_scout_count, completed_peasant_count, completed_archer_count, training.size()]
_update_action_buttons()
_load_visible_locations()
if units_reload_queued:
units_reload_queued = false
call_deferred("_load_units")
if training.is_empty():
training_items.clear()
training_started_unix = 0.0
training_completes_unix = 0.0
active_training_label = "Training"
@@ -1265,13 +1271,25 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack
_update_training_bar()
return
if typeof(training[0]) != TYPE_DICTIONARY:
return
training_items.clear()
for item in training:
if typeof(item) != TYPE_DICTIONARY:
continue
var training_item: Dictionary = item
training_items.append({
"label": _unit_display_name(str(training_item.get("unitType", "Training"))),
"started": _parse_utc_timestamp(str(training_item.get("startedAtUtc", ""))),
"completes": _parse_utc_timestamp(str(training_item.get("completesAtUtc", "")))
})
var active_training: Dictionary = training[0]
active_training_label = _unit_display_name(str(active_training.get("unitType", "Training")))
training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", "")))
training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", "")))
if training_items.is_empty():
training_started_unix = 0.0
training_completes_unix = 0.0
else:
var active_training: Dictionary = training_items[0]
active_training_label = str(active_training.get("label", "Training"))
training_started_unix = float(active_training.get("started", 0.0))
training_completes_unix = float(active_training.get("completes", 0.0))
training_completion_refresh_requested = false
_update_training_bar()
@@ -1403,7 +1421,7 @@ func _move_selected_archers(to_q: int, to_r: int) -> void:
coordinate_label.text = "Could not contact the unit move service."
func _move_selected_scouts(to_q: int, to_r: int) -> void:
func _move_selected_scouts(to_q: int, to_r: int, claim_on_arrival: bool = false) -> void:
if selected_scout_count <= 0:
return
if to_q == selected_scout_q and to_r == selected_scout_r:
@@ -1421,7 +1439,7 @@ func _move_selected_scouts(to_q: int, to_r: int) -> void:
if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
coordinate_label.text = "Ordering %d Scouts to move to q:%d r:%d..." % [selected_scout_count, to_q, to_r]
coordinate_label.text = "Ordering Scout to claim q:%d r:%d..." % [to_q, to_r] if claim_on_arrival else "Ordering %d Scouts to move to q:%d r:%d..." % [selected_scout_count, to_q, to_r]
var error: int = move_units_request.request(
LOCATIONS_BASE_URL + "/api/units/move",
PackedStringArray([
@@ -1434,7 +1452,8 @@ func _move_selected_scouts(to_q: int, to_r: int) -> void:
"fromQ": selected_scout_q,
"fromR": selected_scout_r,
"toQ": to_q,
"toR": to_r
"toR": to_r,
"claimOnArrival": claim_on_arrival
})
)
if error != OK:
@@ -1582,6 +1601,9 @@ func _claim_selected_tile() -> void:
if not selected_can_claim:
coordinate_label.text = "Select a visible unclaimed tile to send a scout."
return
if selected_scout_count <= 0:
coordinate_label.text = "Select a Scout, then choose Claim on the target tile."
return
if completed_scout_count <= 0:
coordinate_label.text = "Train a scout before claiming territory."
return
@@ -1594,23 +1616,8 @@ func _claim_selected_tile() -> void:
coordinate_label.text = "No auth token available. Territory was not claimed."
return
if claim_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
claim_button.disabled = true
coordinate_label.text = "Sending scout to claim q:%d r:%d..." % [selected_q, selected_r]
var error: int = claim_request.request(
LOCATIONS_BASE_URL + "/api/locations/claim",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_POST,
JSON.stringify({ "q": selected_q, "r": selected_r })
)
if error != OK:
_update_action_buttons()
coordinate_label.text = "Could not contact the locations service."
_move_selected_scouts(selected_q, selected_r, true)
func _on_claim_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
@@ -1816,30 +1823,55 @@ func _update_tick_bar() -> void:
func _update_training_bar() -> void:
if not training_bar or not training_label:
if not training_bar or not training_label or not progress_stack:
return
if training_started_unix <= 0.0 or training_completes_unix <= 0.0:
training_box.visible = false
if training_items.is_empty():
for box in training_item_boxes:
box.visible = false
training_bar.value = 0.0
training_label.text = "Training: idle"
_update_progress_panel_visibility()
return
training_box.visible = true
var now: float = Time.get_unix_time_from_system()
var duration: float = max(1.0, training_completes_unix - training_started_unix)
var elapsed: float = clamp(now - training_started_unix, 0.0, duration)
var remaining: int = int(max(0, int(ceil(training_completes_unix - now))))
training_bar.value = elapsed / duration
while training_item_boxes.size() < training_items.size():
var box := _progress_overlay_item("Training")
progress_stack.add_child(box)
progress_stack.move_child(building_box, progress_stack.get_child_count() - 1)
training_item_boxes.append(box)
if remaining <= 0:
training_label.text = "%s: ready" % active_training_label
if not training_completion_refresh_requested:
training_completion_refresh_requested = true
_load_units()
else:
training_label.text = "%s: %ds" % [active_training_label, remaining]
var any_ready := false
for index in range(training_item_boxes.size()):
var box: VBoxContainer = training_item_boxes[index]
if index >= training_items.size():
box.visible = false
continue
var item: Dictionary = training_items[index]
var label := box.get_child(0) as Label
var bar := box.get_child(1) as ProgressBar
var started := float(item.get("started", 0.0))
var completes := float(item.get("completes", 0.0))
var item_label := str(item.get("label", "Training"))
if started <= 0.0 or completes <= 0.0:
box.visible = false
continue
box.visible = true
var duration: float = max(1.0, completes - started)
var elapsed: float = clamp(now - started, 0.0, duration)
var remaining: int = int(max(0, int(ceil(completes - now))))
bar.value = elapsed / duration
if remaining <= 0:
label.text = "%s: ready" % item_label
any_ready = true
else:
label.text = "%s: %ds" % [item_label, remaining]
if any_ready and not training_completion_refresh_requested:
training_completion_refresh_requested = true
_load_units()
_update_progress_panel_visibility()
@@ -1875,7 +1907,12 @@ func _update_building_timer() -> void:
func _update_progress_panel_visibility() -> void:
if progress_panel:
progress_panel.visible = (training_box and training_box.visible) or (building_box and building_box.visible)
var has_training := false
for box in training_item_boxes:
if box.visible:
has_training = true
break
progress_panel.visible = has_training or (building_box and building_box.visible)
func _sync_active_building_timer(locations: Array, username: String) -> void:
@@ -181,6 +181,7 @@ func _draw_tile(tile: Dictionary) -> void:
var visible_location: Dictionary = visible_tiles.get(key, {})
var building_type: String = _nullable_string(visible_location.get("buildingType", ""))
var building_status: String = _nullable_string(visible_location.get("buildingStatus", ""))
var worker_unit_id: String = _nullable_string(visible_location.get("workerUnitId", ""))
if coord == home_tile:
_draw_town_hall_marker(center, height)
elif building_type == "LogCamp" and (building_status == "Built" or building_status.is_empty()):
@@ -193,6 +194,8 @@ func _draw_tile(tile: Dictionary) -> void:
_draw_barracks_marker(center, height)
elif not building_type.is_empty():
_draw_building_marker(center, height)
if _building_needs_worker(building_type, building_status, worker_unit_id):
_draw_missing_worker_marker(center, height)
var archer_count := _archer_count_at(q, r)
if archer_count > 0:
@@ -585,6 +588,30 @@ func _draw_building_marker(center: Vector2, height: float) -> void:
draw_line(left, right.lerp(top_right, 0.2), post_color.darkened(0.12), 1.5 * zoom)
func _building_needs_worker(building_type: String, building_status: String, worker_unit_id: String) -> bool:
if not worker_unit_id.is_empty():
return false
if building_type != "LogCamp" and building_type != "Mine" and building_type != "Farm":
return false
return building_status == "Built" or building_status.is_empty()
func _draw_missing_worker_marker(center: Vector2, height: float) -> void:
var marker_center: Vector2 = center + Vector2(20.0 * zoom, -height * zoom - 27.0 * zoom)
var marker_scale: float = zoom
var badge_radius: float = 7.0 * marker_scale
var fill_color := Color(0.93, 0.66, 0.18, 0.96)
var edge_color := Color(0.18, 0.10, 0.02, 0.92)
var icon_color := Color(0.10, 0.07, 0.03, 0.96)
draw_circle(marker_center + Vector2(1, 1) * marker_scale, badge_radius + 1.0 * marker_scale, Color(0.0, 0.0, 0.0, 0.35))
draw_circle(marker_center, badge_radius, fill_color)
draw_arc(marker_center, badge_radius, 0.0, TAU, 24, edge_color, 1.4 * marker_scale)
draw_circle(marker_center + Vector2(0, -2.3) * marker_scale, 2.0 * marker_scale, icon_color)
draw_rect(Rect2(marker_center + Vector2(-2.2, 0.8) * marker_scale, Vector2(4.4, 4.8) * marker_scale), icon_color, true)
draw_line(marker_center + Vector2(-5.0, 5.8) * marker_scale, marker_center + Vector2(5.0, -5.8) * marker_scale, edge_color, 1.7 * marker_scale)
func _draw_archer_unit_marker(center: Vector2, height: float, count: int) -> void:
var marker_center: Vector2 = _archer_marker_center(center, height)
var marker_scale: float = zoom
@@ -87,6 +87,10 @@ public class LocationsController : ControllerBase
{
return NotFound();
}
if (!User.IsInRole("SUPER"))
{
return BadRequest("Use a Scout move order to claim territory on arrival.");
}
if (!User.IsInRole("SUPER") && !await _locations.IsAdjacentToOwnedAsync(username, request.Q, request.R))
{
@@ -15,6 +15,7 @@ public class MoveUnitSquadRequest
public int FromR { get; set; }
public int ToQ { get; set; }
public int ToR { get; set; }
public bool ClaimOnArrival { get; set; }
}
public class DismissUnitSquadRequest
@@ -75,6 +76,7 @@ public class UnitInstanceResponse
public DateTime LastMovementRefillAtUtc { get; set; }
public int? DestinationQ { get; set; }
public int? DestinationR { get; set; }
public bool ClaimOnArrival { get; set; }
public DateTime? MovementStartedAtUtc { get; set; }
public DateTime? MovementCompletesAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; }
@@ -93,6 +95,7 @@ public class UnitInstanceResponse
LastMovementRefillAtUtc = unit.LastMovementRefillAtUtc,
DestinationQ = unit.DestinationQ,
DestinationR = unit.DestinationR,
ClaimOnArrival = unit.ClaimOnArrival,
MovementStartedAtUtc = unit.MovementStartedAtUtc,
MovementCompletesAtUtc = unit.MovementCompletesAtUtc,
CreatedAtUtc = unit.CreatedAtUtc,
@@ -19,6 +19,7 @@ public class UnitInstance
public DateTime LastMovementRefillAtUtc { get; set; } = DateTime.UtcNow;
public int? DestinationQ { get; set; }
public int? DestinationR { get; set; }
public bool ClaimOnArrival { get; set; }
public DateTime? MovementStartedAtUtc { get; set; }
public DateTime? MovementCompletesAtUtc { get; set; }
public string? SourceTrainingId { get; set; }
@@ -190,6 +190,10 @@ public class UnitTrainingService
{
throw new ArgumentException("Only Scouts, Archers, and Peasants can be moved right now.", nameof(request.UnitType));
}
if (request.ClaimOnArrival && normalizedUnitType != ScoutUnitType)
{
throw new ArgumentException("Only Scouts can claim territory.", nameof(request.ClaimOnArrival));
}
if (request.FromQ == request.ToQ && request.FromR == request.ToR)
{
@@ -206,6 +210,10 @@ public class UnitTrainingService
{
throw new InvalidOperationException("Peasants can only move within your controlled territory.");
}
if (request.ClaimOnArrival && !await IsAdjacentToOwnedAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException("Scout claims must target territory adjacent to controlled territory.");
}
var now = DateTime.UtcNow;
var unitFilter = Builders<UnitInstance>.Filter.And(
@@ -234,26 +242,24 @@ public class UnitTrainingService
throw new InvalidOperationException("That squad has no movement remaining this tick.");
}
var movingUnitIds = request.ClaimOnArrival
? units.OrderBy(x => x.Id).Take(1).Select(x => x.Id).ToList()
: units.Select(x => x.Id).ToList();
var movementCompletesAt = now.AddMinutes(1);
await _units.UpdateManyAsync(
unitFilter,
Builders<UnitInstance>.Filter.In(x => x.Id, movingUnitIds),
Builders<UnitInstance>.Update
.Set(x => x.Status, "Moving")
.Inc(x => x.MovementRemaining, -1)
.Set(x => x.DestinationQ, request.ToQ)
.Set(x => x.DestinationR, request.ToR)
.Set(x => x.ClaimOnArrival, request.ClaimOnArrival)
.Set(x => x.MovementStartedAtUtc, now)
.Set(x => x.MovementCompletesAtUtc, movementCompletesAt)
.Set(x => x.UpdatedAtUtc, now));
return await _units.Find(x =>
x.Username == username &&
x.UnitType == normalizedUnitType &&
x.Status == "Moving" &&
x.Q == request.FromQ &&
x.R == request.FromR &&
x.DestinationQ == request.ToQ &&
x.DestinationR == request.ToR)
movingUnitIds.Contains(x.Id))
.ToListAsync();
}
@@ -310,6 +316,7 @@ public class UnitTrainingService
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
@@ -331,6 +338,7 @@ public class UnitTrainingService
.Set(x => x.MovementRemaining, movementPerTick)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
@@ -371,10 +379,16 @@ public class UnitTrainingService
.Set(x => x.LastMovementRefillAtUtc, now)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
if (unit.UnitType == ScoutUnitType && unit.ClaimOnArrival)
{
await TryClaimWithArrivedScoutAsync(unit, destinationQ, destinationR, now);
}
}
}
@@ -409,6 +423,84 @@ public class UnitTrainingService
}
}
private async Task<bool> IsAdjacentToOwnedAsync(string username, int q, int r)
{
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
return owned.Any(location => HexDistance(location.Q, location.R, q, r) == 1);
}
private async Task TryClaimWithArrivedScoutAsync(UnitInstance scout, int q, int r, DateTime now)
{
var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync();
if (target is not null)
{
if (string.Equals(target.OwnerUsername, scout.Username, StringComparison.OrdinalIgnoreCase) ||
string.Equals(target.Kind, "Home", StringComparison.OrdinalIgnoreCase) ||
!string.IsNullOrWhiteSpace(target.BuildingType))
{
return;
}
}
var hasBlockingUnit = await _units.Find(x =>
x.Id != scout.Id &&
x.Q == q &&
x.R == r)
.AnyAsync();
if (hasBlockingUnit)
{
return;
}
if (!await IsAdjacentToOwnedAsync(scout.Username, q, r))
{
return;
}
var claimed = false;
if (target is null)
{
var location = new Location
{
Q = q,
R = r,
Terrain = "Field",
Kind = "Claimed",
OwnerUsername = scout.Username,
Level = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
try
{
await _locations.InsertOneAsync(location);
claimed = true;
}
catch (MongoWriteException)
{
}
}
else
{
var result = await _locations.UpdateOneAsync(
x => x.Id == target.Id &&
x.Kind != "Home" &&
(x.BuildingType == null || x.BuildingType == "") &&
x.OwnerUsername != scout.Username,
Builders<Location>.Update
.Set(x => x.OwnerUsername, scout.Username)
.Set(x => x.Kind, "Claimed")
.Set(x => x.UpdatedAtUtc, now));
claimed = result.ModifiedCount > 0;
}
if (claimed)
{
await _units.DeleteOneAsync(x => x.Id == scout.Id);
}
}
private async Task CompleteFinishedBuildingsAsync(string username)
{
var now = DateTime.UtcNow;