extends Node3D const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" @export var tile_size := 16.0 @export var block_height := 1.0 @export_range(1, 8, 1) var tile_radius := 3 @export var tracked_node_path: NodePath @export var player_spawn_height := 2.0 @export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0) @export var border_height_bias := 0.005 @export var show_tile_labels := true @export var tile_label_height := 0.01 @export var tile_label_color: Color = Color(1, 1, 1, 1) @onready var _block: MeshInstance3D = $TerrainBlock @onready var _player: RigidBody3D = $Player @onready var _camera: Camera3D = $Player/Camera3D var _center_coord := Vector2i.ZERO var _tiles_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 var _biome_materials: Dictionary = {} var _known_locations: Dictionary = {} var _locations_loaded := false var _character_id := "" var _persisted_coord := Vector2i.ZERO var _coord_sync_in_flight := false var _queued_coord_sync: Variant = null var _locations_refresh_in_flight := false var _queued_locations_refresh := false var _gather_in_flight := false func _ready() -> void: _tiles_root = Node3D.new() _tiles_root.name = "GeneratedTiles" add_child(_tiles_root) if _camera: _camera_start_offset = _camera.position _tracked_node = get_node_or_null(tracked_node_path) as Node3D if _tracked_node == null: _tracked_node = _player var start_coord := SelectedCharacter.get_coord() _center_coord = Vector2i(roundi(start_coord.x), roundi(start_coord.y)) _persisted_coord = _center_coord _character_id = String(SelectedCharacter.character.get("id", SelectedCharacter.character.get("Id", ""))).strip_edges() _block.visible = false await _load_existing_locations() _ensure_selected_location_exists(_center_coord) _rebuild_tiles(_center_coord) _move_player_to_coord(_center_coord) func _process(_delta: float) -> void: if not _locations_loaded: return var target_world_pos := _get_stream_position() var target_coord := _world_to_coord(target_world_pos) if target_coord == _center_coord: return _center_coord = target_coord _queue_coord_sync(_center_coord) _queue_locations_refresh() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("interact"): _try_gather_current_tile() func _get_stream_position() -> Vector3: if _tracked_node: return _tracked_node.global_position return _coord_to_world(_center_coord) func _world_to_coord(world_pos: Vector3) -> Vector2i: return Vector2i( roundi(world_pos.x / tile_size), roundi(world_pos.z / tile_size) ) func _coord_to_world(coord: Vector2i) -> Vector3: return Vector3(coord.x * tile_size, block_height * 0.5, coord.y * tile_size) func _move_player_to_coord(coord: Vector2i) -> void: if _player == null: return _player.global_position = Vector3(coord.x * tile_size, player_spawn_height, coord.y * tile_size) _player.linear_velocity = Vector3.ZERO _player.angular_velocity = Vector3.ZERO func _rebuild_tiles(center: Vector2i) -> void: var wanted_keys: Dictionary = {} for x in range(center.x - tile_radius, center.x + tile_radius + 1): for y in range(center.y - tile_radius, center.y + tile_radius + 1): var coord := Vector2i(x, y) if not _known_locations.has(coord): continue wanted_keys[coord] = true if _tile_nodes.has(coord): continue _spawn_tile(coord, _get_location_name(coord), _get_location_biome_key(coord)) for key in _tile_nodes.keys(): if wanted_keys.has(key): continue var tile_node := _tile_nodes[key] as Node3D if tile_node: tile_node.queue_free() _tile_nodes.erase(key) func _spawn_tile(coord: Vector2i, location_name: String, biome_key: String) -> void: var tile_root := Node3D.new() tile_root.name = "Tile_%d_%d" % [coord.x, coord.y] tile_root.position = _coord_to_world(coord) _tiles_root.add_child(tile_root) var tile_body := StaticBody3D.new() tile_body.name = "TileBody" tile_body.scale = Vector3(tile_size, block_height, tile_size) tile_root.add_child(tile_body) var collision_shape := CollisionShape3D.new() collision_shape.name = "CollisionShape3D" collision_shape.shape = BoxShape3D.new() tile_body.add_child(collision_shape) var tile := _block.duplicate() as MeshInstance3D tile.name = "TileMesh" tile.visible = true var biome_material := _get_biome_material(tile, biome_key) if biome_material: tile.material_override = biome_material tile_body.add_child(tile) tile.add_child(_create_tile_border()) if show_tile_labels: tile_root.add_child(_create_tile_label(location_name)) _tile_nodes[coord] = tile_root func _create_tile_border() -> MeshInstance3D: var top_y := 0.5 + border_height_bias var corners := [ Vector3(-0.5, top_y, -0.5), Vector3(0.5, top_y, -0.5), Vector3(0.5, top_y, 0.5), Vector3(-0.5, top_y, 0.5), ] var border_mesh := ImmediateMesh.new() border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material()) for idx in range(corners.size()): var current: Vector3 = corners[idx] var next: Vector3 = corners[(idx + 1) % corners.size()] border_mesh.surface_add_vertex(current) border_mesh.surface_add_vertex(next) border_mesh.surface_end() var border := MeshInstance3D.new() border.name = "TileBorder" border.mesh = border_mesh return border func _get_border_material() -> StandardMaterial3D: if _border_material: return _border_material _border_material = StandardMaterial3D.new() _border_material.albedo_color = border_color _border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _border_material.disable_receive_shadows = true _border_material.no_depth_test = true return _border_material func _create_tile_label(location_name: String) -> Label3D: var label := Label3D.new() label.name = "LocationNameLabel" label.text = location_name label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0) label.rotation_degrees = Vector3(-90.0, 0.0, 0.0) label.billboard = BaseMaterial3D.BILLBOARD_DISABLED label.modulate = tile_label_color label.pixel_size = 0.01 label.outline_size = 12 label.no_depth_test = false return label func _ensure_selected_location_exists(coord: Vector2i) -> void: if _known_locations.has(coord): return _known_locations[coord] = { "id": "", "name": _selected_location_name(coord), "biomeKey": "plains", "resources": [] } func _selected_location_name(coord: Vector2i) -> String: var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges() if not selected_name.is_empty(): return selected_name var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges() if not character_name.is_empty(): return "%s's Location" % character_name return "Location %d,%d" % [coord.x, coord.y] func _load_existing_locations() -> void: _locations_refresh_in_flight = true _locations_loaded = false _known_locations.clear() if _character_id.is_empty(): push_warning("Selected character is missing an id; cannot load visible locations.") _locations_loaded = true return 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-locations" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET) if err != OK: push_warning("Failed to request visible locations: %s" % err) request.queue_free() _locations_loaded = true return var result: Array = await request.request_completed request.queue_free() 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 locations (%s/%s): %s" % [result_code, response_code, response_body]) _locations_loaded = true return var parsed: Variant = JSON.parse_string(response_body) if typeof(parsed) != TYPE_ARRAY: push_warning("Visible locations response was not an array.") _locations_loaded = true return var loaded_count := 0 for item in parsed: if typeof(item) != TYPE_DICTIONARY: continue var location := item as Dictionary var coord_variant: Variant = location.get("coord", {}) if typeof(coord_variant) != TYPE_DICTIONARY: continue var coord_dict := coord_variant as Dictionary var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0))) var location_name := String(location.get("name", "")).strip_edges() if location_name.is_empty(): location_name = "Location %d,%d" % [coord.x, coord.y] _known_locations[coord] = { "id": String(location.get("id", "")).strip_edges(), "name": location_name, "biomeKey": String(location.get("biomeKey", "plains")).strip_edges(), "resources": _parse_location_resources(location.get("resources", [])) } loaded_count += 1 print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id]) if loaded_count == 0: push_warning("Visible locations request succeeded but returned 0 locations for character %s." % _character_id) _locations_loaded = true _locations_refresh_in_flight = false _rebuild_tiles(_center_coord) if _queued_locations_refresh: _queued_locations_refresh = false _queue_locations_refresh() func _queue_locations_refresh() -> void: if _locations_refresh_in_flight: _queued_locations_refresh = true return _refresh_visible_locations() func _refresh_visible_locations() -> void: if _character_id.is_empty(): return _refresh_visible_locations_async() func _refresh_visible_locations_async() -> void: await _load_existing_locations() func _queue_coord_sync(coord: Vector2i) -> void: if coord == _persisted_coord: return if _coord_sync_in_flight: _queued_coord_sync = coord return _sync_character_coord(coord) func _sync_character_coord(coord: Vector2i) -> void: if _character_id.is_empty(): return _coord_sync_in_flight = true _queued_coord_sync = null _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: push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [ coord.x, coord.y, response.get("status", "n/a"), response.get("error", ""), response.get("body", "") ]) _coord_sync_in_flight = false if _queued_coord_sync != null and _queued_coord_sync is Vector2i and _queued_coord_sync != _persisted_coord: var queued_coord: Vector2i = _queued_coord_sync _sync_character_coord(queued_coord) func _try_gather_current_tile() -> void: if _gather_in_flight: return var location_data := _get_location_data(_center_coord) if location_data.is_empty(): push_warning("No location data available for current tile.") return var location_id := String(location_data.get("id", "")).strip_edges() if location_id.is_empty(): push_warning("Current tile has no location id; cannot gather.") return var resources: Array = location_data.get("resources", []) if resources.is_empty(): push_warning("No gatherable resources remain at this location.") return var resource: Dictionary = resources[0] var resource_key := String(resource.get("itemKey", "")).strip_edges() if resource_key.is_empty(): push_warning("Current location resource is missing an itemKey.") return _gather_current_tile(location_id, resource_key) func _gather_current_tile(location_id: String, resource_key: String) -> void: _gather_in_flight = true _gather_current_tile_async(location_id, resource_key) func _gather_current_tile_async(location_id: String, resource_key: String) -> void: 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) headers.append("Content-Type: application/json") var body := JSON.stringify({ "characterId": _character_id, "resourceKey": resource_key }) var err := request.request("%s/%s/gather" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body) if err != OK: push_warning("Failed to request gather action: %s" % err) request.queue_free() _gather_in_flight = false return var result: Array = await request.request_completed request.queue_free() 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 gather resource (%s/%s): %s" % [result_code, response_code, response_body]) _gather_in_flight = false return var parsed: Variant = JSON.parse_string(response_body) if typeof(parsed) != TYPE_DICTIONARY: push_warning("Gather response was not a dictionary.") _gather_in_flight = false return var gather_response := parsed as Dictionary var remaining_quantity := int(gather_response.get("remainingQuantity", 0)) var quantity_granted := int(gather_response.get("quantityGranted", 0)) _update_location_resource(_center_coord, resource_key, remaining_quantity) print("Gathered %s x%s at %s." % [resource_key, quantity_granted, _center_coord]) _gather_in_flight = false func _get_location_data(coord: Vector2i) -> Dictionary: var value: Variant = _known_locations.get(coord, {}) if typeof(value) == TYPE_DICTIONARY: return value as Dictionary return {} func _get_location_name(coord: Vector2i) -> String: var location_data := _get_location_data(coord) return String(location_data.get("name", "Location %d,%d" % [coord.x, coord.y])) func _get_location_biome_key(coord: Vector2i) -> String: var location_data := _get_location_data(coord) return String(location_data.get("biomeKey", "plains")).strip_edges() func _parse_location_resources(resources_value: Variant) -> Array: var results: Array = [] if typeof(resources_value) != TYPE_ARRAY: return results for entry in resources_value: if typeof(entry) != TYPE_DICTIONARY: continue var resource := entry as Dictionary results.append({ "itemKey": String(resource.get("itemKey", "")).strip_edges(), "remainingQuantity": int(resource.get("remainingQuantity", 0)), "gatherQuantity": int(resource.get("gatherQuantity", 1)) }) return results func _update_location_resource(coord: Vector2i, resource_key: String, remaining_quantity: int) -> void: var location_data := _get_location_data(coord) if location_data.is_empty(): return var resources: Array = location_data.get("resources", []) var updated_resources: Array = [] for entry in resources: if typeof(entry) != TYPE_DICTIONARY: continue var resource := (entry as Dictionary).duplicate() if String(resource.get("itemKey", "")).strip_edges() == resource_key: resource["remainingQuantity"] = remaining_quantity if int(resource.get("remainingQuantity", 0)) > 0: updated_resources.append(resource) location_data["resources"] = updated_resources _known_locations[coord] = location_data func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material: var normalized_biome := biome_key if not biome_key.is_empty() else "plains" if _biome_materials.has(normalized_biome): return _biome_materials[normalized_biome] var source_material := tile.get_active_material(0) if source_material is StandardMaterial3D: var material := (source_material as StandardMaterial3D).duplicate() as StandardMaterial3D material.albedo_color = _get_biome_color(normalized_biome) _biome_materials[normalized_biome] = material return material return source_material func _get_biome_color(biome_key: String) -> Color: match biome_key: "forest": return Color(0.36, 0.62, 0.34, 1.0) "wetlands": return Color(0.28, 0.52, 0.44, 1.0) "rocky": return Color(0.52, 0.50, 0.44, 1.0) "desert": return Color(0.76, 0.67, 0.38, 1.0) _: return Color(0.56, 0.72, 0.38, 1.0)