diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index bcc7298..a9c20fa 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -6,6 +6,8 @@ const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory" const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn" const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn" const CHARACTER_SLOT_COUNT := 6 +const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0 +const HEARTBEAT_INTERVAL := 10.0 @export var tile_size := 8.0 @export var block_height := 1.0 @@ -34,8 +36,9 @@ const CHARACTER_SLOT_COUNT := 6 @onready var _inventory_status_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/StatusLabel var _center_coord := Vector2i.ZERO -var _tiles_root: Node3D -var _tracked_node: Node3D +var _tiles_root: Node3D +var _remote_players_root: Node3D +var _tracked_node: Node3D var _tile_nodes: Dictionary = {} var _camera_start_offset := Vector3(0.0, 6.0, 10.0) var _border_material: StandardMaterial3D @@ -53,12 +56,20 @@ var _inventory_request_in_flight := false var _character_inventory_items: Array = [] var _selected_character_item_id := "" var _selected_ground_item_id := "" +var _visible_characters_in_flight := false +var _heartbeat_in_flight := false +var _visible_character_refresh_elapsed := 0.0 +var _heartbeat_elapsed := 0.0 +var _remote_character_nodes: Dictionary = {} func _ready() -> void: _tiles_root = Node3D.new() _tiles_root.name = "GeneratedTiles" add_child(_tiles_root) + _remote_players_root = Node3D.new() + _remote_players_root.name = "RemotePlayers" + add_child(_remote_players_root) if _camera: _camera_start_offset = _camera.position @@ -75,6 +86,7 @@ func _ready() -> void: _block.visible = false _deactivate_player_for_load() await _load_existing_locations() + _refresh_visible_characters() _ensure_selected_location_exists(_center_coord) _rebuild_tiles(_center_coord) _move_player_to_coord(_center_coord) @@ -84,6 +96,14 @@ func _ready() -> void: func _process(_delta: float) -> void: if not _locations_loaded: return + _visible_character_refresh_elapsed += _delta + _heartbeat_elapsed += _delta + if _visible_character_refresh_elapsed >= VISIBLE_CHARACTERS_REFRESH_INTERVAL: + _visible_character_refresh_elapsed = 0.0 + _refresh_visible_characters() + if _heartbeat_elapsed >= HEARTBEAT_INTERVAL: + _heartbeat_elapsed = 0.0 + _send_presence_heartbeat() if _inventory_menu.visible: return var target_world_pos := _get_stream_position() @@ -899,7 +919,7 @@ func _selected_location_name(coord: Vector2i) -> String: return "Location %d,%d" % [coord.x, coord.y] -func _load_existing_locations() -> void: +func _load_existing_locations() -> void: _locations_refresh_in_flight = true _locations_loaded = false _known_locations.clear() @@ -974,9 +994,10 @@ func _load_existing_locations() -> void: _locations_loaded = true _locations_refresh_in_flight = false _rebuild_tiles(_center_coord) - if _queued_locations_refresh: - _queued_locations_refresh = false - _queue_locations_refresh() + _refresh_visible_characters() + if _queued_locations_refresh: + _queued_locations_refresh = false + _queue_locations_refresh() func _queue_locations_refresh() -> void: @@ -1110,12 +1131,13 @@ func _sync_character_coord(coord: Vector2i) -> void: _sync_character_coord_async(coord) -func _sync_character_coord_async(coord: Vector2i) -> void: - var response := await CharacterService.update_character_coord(_character_id, coord) - if response.get("ok", false): - _persisted_coord = coord - SelectedCharacter.set_coord(coord) - else: +func _sync_character_coord_async(coord: Vector2i) -> void: + var response := await CharacterService.update_character_coord(_character_id, coord) + if response.get("ok", false): + _persisted_coord = coord + SelectedCharacter.set_coord(coord) + _refresh_visible_characters() + else: push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [ coord.x, coord.y, @@ -1224,6 +1246,120 @@ func _fetch_location_inventory(location_id: String) -> Array: var payload := parsed as Dictionary return _parse_floor_inventory_items(payload.get("items", [])) + + +func _refresh_visible_characters() -> void: + if _visible_characters_in_flight or _character_id.is_empty(): + return + _refresh_visible_characters_async() + + +func _refresh_visible_characters_async() -> void: + _visible_characters_in_flight = true + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + + var err := request.request("%s/%s/visible-characters" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET) + if err != OK: + request.queue_free() + push_warning("Failed to request visible characters: %s" % err) + _visible_characters_in_flight = false + return + + var result: Array = await request.request_completed + request.queue_free() + _visible_characters_in_flight = false + + var result_code: int = result[0] + var response_code: int = result[1] + var response_body: String = result[3].get_string_from_utf8() + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Failed to load visible characters (%s/%s): %s" % [result_code, response_code, response_body]) + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_ARRAY: + return + + _apply_visible_characters(parsed as Array) + + +func _apply_visible_characters(characters: Array) -> void: + var wanted_ids := {} + for character_variant in characters: + if typeof(character_variant) != TYPE_DICTIONARY: + continue + var character := character_variant as Dictionary + var character_id := String(character.get("id", "")).strip_edges() + if character_id.is_empty() or character_id == _character_id: + continue + wanted_ids[character_id] = true + _upsert_remote_character(character) + + for character_id in _remote_character_nodes.keys(): + if wanted_ids.has(character_id): + continue + var remote_node := _remote_character_nodes[character_id] as Node3D + if remote_node: + remote_node.queue_free() + _remote_character_nodes.erase(character_id) + + +func _upsert_remote_character(character: Dictionary) -> void: + var character_id := String(character.get("id", "")).strip_edges() + var coord_value: Variant = character.get("coord", {}) + if typeof(coord_value) != TYPE_DICTIONARY: + return + var coord_dict := coord_value as Dictionary + var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0))) + + var remote_root := _remote_character_nodes.get(character_id) as Node3D + if remote_root == null: + remote_root = Node3D.new() + remote_root.name = "RemoteCharacter_%s" % character_id + _remote_players_root.add_child(remote_root) + + var visual := _player_visual.duplicate() as Node3D + visual.name = "Visual" + visual.visible = true + remote_root.add_child(visual) + + var label := Label3D.new() + label.name = "NameLabel" + label.position = Vector3(0.0, 1.8, 0.0) + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + label.pixel_size = 0.01 + label.outline_size = 10 + remote_root.add_child(label) + + _remote_character_nodes[character_id] = remote_root + + remote_root.position = Vector3(coord.x * tile_size, _get_tile_surface_y(coord), coord.y * tile_size) + var name_label := remote_root.get_node_or_null("NameLabel") as Label3D + if name_label: + name_label.text = String(character.get("name", "Player")).strip_edges() + + +func _send_presence_heartbeat() -> void: + if _heartbeat_in_flight or _character_id.is_empty(): + return + _send_presence_heartbeat_async() + + +func _send_presence_heartbeat_async() -> void: + _heartbeat_in_flight = true + var response := await CharacterService.heartbeat_character(_character_id) + if not response.get("ok", false): + push_warning("Failed to send presence heartbeat: status=%s error=%s body=%s" % [ + response.get("status", "n/a"), + response.get("error", ""), + response.get("body", "") + ]) + _heartbeat_in_flight = false func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material: diff --git a/game/scenes/UI/character_service.gd b/game/scenes/UI/character_service.gd index ea57882..26c08c2 100644 --- a/game/scenes/UI/character_service.gd +++ b/game/scenes/UI/character_service.gd @@ -24,6 +24,10 @@ func update_character_coord(character_id: String, coord: Vector2i) -> Dictionary } }) return await _request(HTTPClient.METHOD_PUT, url, payload) + +func heartbeat_character(character_id: String) -> Dictionary: + var url := "%s/%s/heartbeat" % [CHARACTER_API_URL, character_id] + return await _request(HTTPClient.METHOD_POST, url, "") func _request(method: int, url: String, body: String = "") -> Dictionary: var request := HTTPRequest.new() diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs index 8835ee0..56bee80 100644 --- a/microservices/CharacterApi/Controllers/CharactersController.cs +++ b/microservices/CharacterApi/Controllers/CharactersController.cs @@ -13,6 +13,7 @@ namespace CharacterApi.Controllers; [Route("api/[controller]")] public class CharactersController : ControllerBase { + private static readonly TimeSpan PresenceTimeout = TimeSpan.FromSeconds(45); private readonly CharacterStore _characters; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; @@ -43,6 +44,7 @@ public class CharactersController : ControllerBase Name = req.Name.Trim(), Coord = new Coord { X = 0, Y = 0 }, VisionRadius = 3, + LastSeenUtc = DateTime.UtcNow, CreatedUtc = DateTime.UtcNow }; @@ -89,6 +91,8 @@ public class CharactersController : ControllerBase return Forbid(); } + await _characters.TouchAsync(id); + _logger.LogInformation( "Visible locations requested for character {CharacterId} at ({X},{Y}) radius {VisionRadius} by user {UserId}", character.Id, @@ -164,6 +168,53 @@ public class CharactersController : ControllerBase } } + [HttpGet("{id}/visible-characters")] + [Authorize(Roles = "USER,SUPER")] + public async Task VisibleCharacters(string id) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var allowAnyOwner = User.IsInRole("SUPER"); + var character = await _characters.GetByIdAsync(id); + if (character is null) + return NotFound(); + if (!allowAnyOwner && character.OwnerUserId != userId) + return Forbid(); + + await _characters.TouchAsync(id); + + var onlineSinceUtc = DateTime.UtcNow.Subtract(PresenceTimeout); + var visibleCharacters = await _characters.GetVisibleOthersAsync( + id, + character.Coord.X, + character.Coord.Y, + character.VisionRadius > 0 ? character.VisionRadius : 3, + onlineSinceUtc); + + return Ok(visibleCharacters); + } + + [HttpPost("{id}/heartbeat")] + [Authorize(Roles = "USER,SUPER")] + public async Task Heartbeat(string id) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var allowAnyOwner = User.IsInRole("SUPER"); + var character = await _characters.GetByIdAsync(id); + if (character is null) + return NotFound(); + if (!allowAnyOwner && character.OwnerUserId != userId) + return Forbid(); + + await _characters.TouchAsync(id); + return Ok(new { lastSeenUtc = DateTime.UtcNow }); + } + [HttpPut("{id}/coord")] [Authorize(Roles = "USER,SUPER")] public async Task UpdateCoord(string id, [FromBody] UpdateCharacterCoordRequest req) diff --git a/microservices/CharacterApi/Models/Character.cs b/microservices/CharacterApi/Models/Character.cs index 2055e4a..e4e613d 100644 --- a/microservices/CharacterApi/Models/Character.cs +++ b/microservices/CharacterApi/Models/Character.cs @@ -17,5 +17,7 @@ public class Character public int VisionRadius { get; set; } = 3; + public DateTime LastSeenUtc { get; set; } = DateTime.UtcNow; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/microservices/CharacterApi/Models/VisibleCharacter.cs b/microservices/CharacterApi/Models/VisibleCharacter.cs new file mode 100644 index 0000000..0adf9cd --- /dev/null +++ b/microservices/CharacterApi/Models/VisibleCharacter.cs @@ -0,0 +1,12 @@ +namespace CharacterApi.Models; + +public class VisibleCharacter +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public Coord Coord { get; set; } = new(); + + public DateTime LastSeenUtc { get; set; } +} diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 7ff1d88..07a00a9 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -17,6 +17,8 @@ public class CharacterStore var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId); _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex)); + var coordIndex = Builders.IndexKeys.Ascending("Coord.X").Ascending("Coord.Y"); + _col.Indexes.CreateOne(new CreateIndexModel(coordIndex)); } public Task CreateAsync(Character character) => _col.InsertOneAsync(character); @@ -30,11 +32,38 @@ public class CharacterStore public async Task UpdateCoordAsync(string id, Coord coord) { var filter = Builders.Filter.Eq(c => c.Id, id); - var update = Builders.Update.Set(c => c.Coord, coord); + var update = Builders.Update + .Set(c => c.Coord, coord) + .Set(c => c.LastSeenUtc, DateTime.UtcNow); var result = await _col.UpdateOneAsync(filter, update); return result.ModifiedCount > 0 || result.MatchedCount > 0; } + public async Task TouchAsync(string id) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + var update = Builders.Update.Set(c => c.LastSeenUtc, DateTime.UtcNow); + var result = await _col.UpdateOneAsync(filter, update); + return result.ModifiedCount > 0 || result.MatchedCount > 0; + } + + public Task> GetVisibleOthersAsync(string characterId, int x, int y, int radius, DateTime onlineSinceUtc) => + _col.Find(c => + c.Id != characterId && + c.Coord.X >= x - radius && + c.Coord.X <= x + radius && + c.Coord.Y >= y - radius && + c.Coord.Y <= y + radius && + c.LastSeenUtc >= onlineSinceUtc) + .Project(c => new VisibleCharacter + { + Id = c.Id ?? string.Empty, + Name = c.Name, + Coord = c.Coord, + LastSeenUtc = c.LastSeenUtc + }) + .ToListAsync(); + public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner) { var filter = Builders.Filter.Eq(c => c.Id, id);