extends Node3D const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" 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 @export var tile_size := 8.0 @export var block_height := 1.0 @export var elevation_step_height := 0.5 @export var tile_wall_thickness := 0.08 @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 @onready var _player_visual: Node3D = $Player/TestCharAnimated @onready var _pause_menu: CanvasLayer = $PauseMenu @onready var _inventory_menu: CanvasLayer = $InventoryMenu @onready var _inventory_location_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/CurrentLocationLabel @onready var _character_items_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/CharacterPanel/VBoxContainer/CharacterItems @onready var _ground_items_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/GroundPanel/VBoxContainer/GroundItems @onready var _target_slot_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/TargetSlotSpinBox @onready var _quantity_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/QuantitySpinBox @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 _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 _interact_in_flight := false var _inventory_request_in_flight := false var _character_inventory_items: Array = [] var _selected_character_item_id := "" var _selected_ground_item_id := "" 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 _deactivate_player_for_load() await _load_existing_locations() _ensure_selected_location_exists(_center_coord) _rebuild_tiles(_center_coord) _move_player_to_coord(_center_coord) _activate_player_after_load() func _process(_delta: float) -> void: if not _locations_loaded: return if _inventory_menu.visible: 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 _input(event: InputEvent) -> void: if event.is_action_pressed("player_phone"): if get_tree().paused: return _toggle_inventory_menu() get_viewport().set_input_as_handled() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel") and _inventory_menu.visible: _close_inventory_menu() get_viewport().set_input_as_handled() return if event.is_action_pressed("ui_cancel"): _toggle_pause_menu() get_viewport().set_input_as_handled() return if event.is_action_pressed("interact"): if get_tree().paused or _inventory_menu.visible: return _try_interact_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, _get_tile_center_y(coord), coord.y * tile_size) func _move_player_to_coord(coord: Vector2i) -> void: if _player == null: return _player.global_position = Vector3(coord.x * tile_size, _get_tile_surface_y(coord) + player_spawn_height, coord.y * tile_size) _player.linear_velocity = Vector3.ZERO _player.angular_velocity = Vector3.ZERO func _deactivate_player_for_load() -> void: if _player == null: return _player.freeze = true _player.sleeping = true _player.linear_velocity = Vector3.ZERO _player.angular_velocity = Vector3.ZERO if _player_visual: _player_visual.visible = false func _activate_player_after_load() -> void: if _player == null: return _player.linear_velocity = Vector3.ZERO _player.angular_velocity = Vector3.ZERO _player.sleeping = false _player.freeze = false if _player_visual: _player_visual.visible = true func _toggle_pause_menu() -> void: if _pause_menu == null: return if get_tree().paused: _resume_game() else: _pause_game() func _pause_game() -> void: if _inventory_menu.visible: _close_inventory_menu() get_tree().paused = true _pause_menu.visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) func _resume_game() -> void: get_tree().paused = false _pause_menu.visible = false Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED if not _inventory_menu.visible else Input.MOUSE_MODE_VISIBLE) func _toggle_inventory_menu() -> void: if _inventory_menu.visible: _close_inventory_menu() return _open_inventory_menu() func _open_inventory_menu() -> void: if _character_id.is_empty(): return _inventory_menu.visible = true _inventory_status_label.text = "Loading inventory..." _set_player_menu_lock(true) _update_inventory_location_label() _refresh_inventory_menu_data() func _close_inventory_menu() -> void: _inventory_menu.visible = false _inventory_status_label.text = "" _selected_character_item_id = "" _selected_ground_item_id = "" _character_items_list.deselect_all() _ground_items_list.deselect_all() _set_player_menu_lock(false) func _set_player_menu_lock(locked: bool) -> void: if _player == null: return if locked: _player.freeze = true _player.sleeping = true _player.linear_velocity = Vector3.ZERO _player.angular_velocity = Vector3.ZERO Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) return _player.sleeping = false _player.freeze = false if not get_tree().paused: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _on_pause_continue_pressed() -> void: _resume_game() func _on_pause_settings_pressed() -> void: _resume_game() Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) get_tree().change_scene_to_file(SETTINGS_SCENE) func _on_pause_main_menu_pressed() -> void: _resume_game() Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) get_tree().change_scene_to_file(START_SCREEN_SCENE) 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 var location_data := _known_locations[coord] as Dictionary if _tile_nodes.has(coord): _update_tile(coord, location_data) continue _spawn_tile(coord, location_data) 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_data: Dictionary) -> 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_key := String(location_data.get("biomeKey", "plains")).strip_edges() var biome_material := _get_biome_material(tile, biome_key) if biome_material: tile.material_override = biome_material tile_body.add_child(tile) _update_tile_edge_walls(tile_root, coord, biome_material) tile.add_child(_create_tile_border()) if show_tile_labels: tile_root.add_child(_create_tile_label(String(location_data.get("name", "")))) _update_tile_object(tile_root, location_data) _update_tile_inventory(tile_root, location_data) _tile_nodes[coord] = tile_root func _update_tile(coord: Vector2i, location_data: Dictionary) -> void: var tile_root := _tile_nodes.get(coord) as Node3D if tile_root == null: return tile_root.position = _coord_to_world(coord) var tile_mesh := tile_root.get_node_or_null("TileBody/TileMesh") as MeshInstance3D var biome_material: Material = null if tile_mesh: biome_material = tile_mesh.material_override _update_tile_edge_walls(tile_root, coord, biome_material) if show_tile_labels: var label := tile_root.get_node_or_null("LocationNameLabel") as Label3D if label: label.text = String(location_data.get("name", "")) _update_tile_object(tile_root, location_data) _update_tile_inventory(tile_root, location_data) func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void: var object_data_variant: Variant = location_data.get("locationObject", {}) if typeof(object_data_variant) != TYPE_DICTIONARY: var existing_missing := tile_root.get_node_or_null("LocationObject") if existing_missing: existing_missing.queue_free() return var object_data := object_data_variant as Dictionary if object_data.is_empty(): var existing_empty := tile_root.get_node_or_null("LocationObject") if existing_empty: existing_empty.queue_free() return var object_root := tile_root.get_node_or_null("LocationObject") as Node3D if object_root == null: object_root = Node3D.new() object_root.name = "LocationObject" object_root.position = Vector3(0.0, (block_height * 0.5) + 0.6, 0.0) tile_root.add_child(object_root) var object_mesh := object_root.get_node_or_null("ObjectMesh") as MeshInstance3D if object_mesh == null: object_mesh = MeshInstance3D.new() object_mesh.name = "ObjectMesh" object_mesh.mesh = SphereMesh.new() object_mesh.scale = Vector3(0.6, 0.4, 0.6) object_root.add_child(object_mesh) object_mesh.material_override = _create_object_material(String(object_data.get("objectKey", ""))) var object_label := object_root.get_node_or_null("ObjectLabel") as Label3D if object_label == null: object_label = Label3D.new() object_label.name = "ObjectLabel" object_label.position = Vector3(0.0, 0.6, 0.0) object_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED object_label.pixel_size = 0.01 object_label.outline_size = 10 object_root.add_child(object_label) object_label.text = _build_object_label(object_data) func _update_tile_inventory(tile_root: Node3D, location_data: Dictionary) -> void: var floor_items: Array = location_data.get("floorItems", []) var existing_label := tile_root.get_node_or_null("FloorInventoryLabel") as Label3D if floor_items.is_empty(): if existing_label: existing_label.queue_free() return if existing_label == null: existing_label = Label3D.new() existing_label.name = "FloorInventoryLabel" existing_label.position = Vector3(0.0, (block_height * 0.5) + 1.7, -0.9) existing_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED existing_label.pixel_size = 0.01 existing_label.outline_size = 10 existing_label.modulate = Color(1.0, 0.95, 0.75, 1.0) tile_root.add_child(existing_label) existing_label.text = _build_floor_inventory_label(floor_items) 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 _update_tile_edge_walls(tile_root: Node3D, coord: Vector2i, biome_material: Material) -> void: var walls_root := tile_root.get_node_or_null("TileEdgeWalls") as Node3D if walls_root == null: walls_root = Node3D.new() walls_root.name = "TileEdgeWalls" tile_root.add_child(walls_root) for child in walls_root.get_children(): child.queue_free() var current_bottom := _get_tile_bottom_y(coord) var directions := [ {"name": "NorthWall", "offset": Vector2i(0, -1), "position": Vector3(0.0, 0.0, -((tile_size * 0.5) - (tile_wall_thickness * 0.5))), "scale": Vector3(tile_size, 1.0, tile_wall_thickness)}, {"name": "SouthWall", "offset": Vector2i(0, 1), "position": Vector3(0.0, 0.0, (tile_size * 0.5) - (tile_wall_thickness * 0.5)), "scale": Vector3(tile_size, 1.0, tile_wall_thickness)}, {"name": "WestWall", "offset": Vector2i(-1, 0), "position": Vector3(-((tile_size * 0.5) - (tile_wall_thickness * 0.5)), 0.0, 0.0), "scale": Vector3(tile_wall_thickness, 1.0, tile_size)}, {"name": "EastWall", "offset": Vector2i(1, 0), "position": Vector3((tile_size * 0.5) - (tile_wall_thickness * 0.5), 0.0, 0.0), "scale": Vector3(tile_wall_thickness, 1.0, tile_size)} ] for entry_variant in directions: var entry := entry_variant as Dictionary var neighbor_coord := coord + (entry["offset"] as Vector2i) var neighbor_top := _get_neighbor_top_y(neighbor_coord) if neighbor_top >= current_bottom: continue var wall_height := current_bottom - neighbor_top var wall := MeshInstance3D.new() wall.name = String(entry["name"]) var wall_mesh := BoxMesh.new() wall_mesh.size = Vector3(1, 1, 1) wall.mesh = wall_mesh wall.scale = (entry["scale"] as Vector3) * Vector3(1.0, wall_height, 1.0) var wall_position := entry["position"] as Vector3 wall.position = Vector3(wall_position.x, -(block_height * 0.5) - (wall_height * 0.5), wall_position.z) if biome_material: wall.material_override = biome_material walls_root.add_child(wall) 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 _create_object_material(object_key: String) -> StandardMaterial3D: var material := StandardMaterial3D.new() material.roughness = 1.0 material.metallic = 0.0 if object_key.contains("grass"): material.albedo_color = Color(0.28, 0.68, 0.25, 1.0) elif object_key.contains("wood"): material.albedo_color = Color(0.54, 0.36, 0.18, 1.0) elif object_key.contains("stone"): material.albedo_color = Color(0.55, 0.57, 0.6, 1.0) else: material.albedo_color = Color(0.85, 0.75, 0.3, 1.0) return material func _build_object_label(object_data: Dictionary) -> String: var object_name := String(object_data.get("name", "")).strip_edges() var state_variant: Variant = object_data.get("state", {}) var remaining_quantity := 0 if typeof(state_variant) == TYPE_DICTIONARY: remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0)) if object_name.is_empty(): object_name = "Object" return "%s x%d" % [object_name, remaining_quantity] func _build_floor_inventory_label(floor_items: Array) -> String: var parts: Array[String] = [] for entry in floor_items: if typeof(entry) != TYPE_DICTIONARY: continue var item := entry as Dictionary parts.append("%s x%d" % [ String(item.get("itemKey", "")).strip_edges(), int(item.get("quantity", 0)) ]) if parts.size() >= 3: break if parts.is_empty(): return "" var label := "Floor: %s" % ", ".join(parts) if floor_items.size() > parts.size(): label += " ..." return label func _get_tile_elevation(coord: Vector2i) -> int: var location_data := _get_location_data(coord) return int(location_data.get("elevation", 0)) func _get_tile_center_y(coord: Vector2i) -> float: return (_get_tile_elevation(coord) * elevation_step_height) + (block_height * 0.5) func _get_tile_bottom_y(coord: Vector2i) -> float: return _get_tile_elevation(coord) * elevation_step_height func _get_tile_surface_y(coord: Vector2i) -> float: return (_get_tile_elevation(coord) * elevation_step_height) + block_height func _get_neighbor_top_y(coord: Vector2i) -> float: if _known_locations.has(coord): return _get_tile_surface_y(coord) return _get_tile_bottom_y(coord) func _update_inventory_location_label() -> void: if _inventory_location_label == null: return var location_data := _get_location_data(_center_coord) var location_name := String(location_data.get("name", "Unknown Location")).strip_edges() _inventory_location_label.text = "%s (%d,%d)" % [location_name, _center_coord.x, _center_coord.y] func _refresh_inventory_menu_data() -> void: if _inventory_request_in_flight: return _refresh_inventory_menu_data_async() func _refresh_inventory_menu_data_async() -> void: _inventory_request_in_flight = true _update_inventory_location_label() _character_inventory_items = await _fetch_character_inventory() var location_id := _get_current_location_id() if not location_id.is_empty(): await _refresh_location_inventory(location_id) _render_inventory_menu() _inventory_request_in_flight = false func _render_inventory_menu() -> void: var current_character_selection := _selected_character_item_id var current_ground_selection := _selected_ground_item_id _character_items_list.clear() var slot_map := {} for item_variant in _character_inventory_items: if typeof(item_variant) != TYPE_DICTIONARY: continue var item := item_variant as Dictionary var slot_value: Variant = item.get("slot", null) if typeof(slot_value) == TYPE_NIL: continue slot_map[int(slot_value)] = item for slot_index in range(CHARACTER_SLOT_COUNT): var text := "Slot %d: (empty)" % slot_index var metadata: Dictionary = {} if slot_map.has(slot_index): var slot_item := slot_map[slot_index] as Dictionary text = "Slot %d: %s x%d" % [ slot_index, String(slot_item.get("itemKey", "")).strip_edges(), int(slot_item.get("quantity", 0)) ] metadata = slot_item _character_items_list.add_item(text) _character_items_list.set_item_metadata(slot_index, metadata) if not current_character_selection.is_empty() and String(metadata.get("itemId", metadata.get("id", ""))).strip_edges() == current_character_selection: _character_items_list.select(slot_index) _ground_items_list.clear() var floor_items := _get_current_floor_items() for index in range(floor_items.size()): var floor_item := floor_items[index] as Dictionary var floor_text := "%s x%d" % [ String(floor_item.get("itemKey", "")).strip_edges(), int(floor_item.get("quantity", 0)) ] _ground_items_list.add_item(floor_text) _ground_items_list.set_item_metadata(index, floor_item) if not current_ground_selection.is_empty() and String(floor_item.get("itemId", floor_item.get("id", ""))).strip_edges() == current_ground_selection: _ground_items_list.select(index) _update_inventory_controls() func _update_inventory_controls() -> void: var selected_item := _get_selected_inventory_item() var max_quantity := 1 if not selected_item.is_empty(): max_quantity = max(1, int(selected_item.get("quantity", 1))) if _selected_ground_item_id == String(selected_item.get("itemId", selected_item.get("id", ""))).strip_edges(): _target_slot_spin_box.value = float(_default_slot_for_item(selected_item)) _quantity_spin_box.max_value = float(max_quantity) if int(_quantity_spin_box.value) > max_quantity: _quantity_spin_box.value = float(max_quantity) if int(_quantity_spin_box.value) < 1: _quantity_spin_box.value = 1.0 func _get_selected_inventory_item() -> Dictionary: if not _selected_character_item_id.is_empty(): return _find_item_by_id(_character_inventory_items, _selected_character_item_id) if not _selected_ground_item_id.is_empty(): return _find_item_by_id(_get_current_floor_items(), _selected_ground_item_id) return {} func _find_item_by_id(items: Array, item_id: String) -> Dictionary: for item_variant in items: if typeof(item_variant) != TYPE_DICTIONARY: continue var item := item_variant as Dictionary if String(item.get("itemId", item.get("id", ""))).strip_edges() == item_id: return item return {} func _get_current_location_id() -> String: var location_data := _get_location_data(_center_coord) return String(location_data.get("id", "")).strip_edges() func _get_current_floor_items() -> Array: var location_data := _get_location_data(_center_coord) return location_data.get("floorItems", []) func _default_slot_for_item(item: Dictionary) -> int: var item_key := String(item.get("itemKey", "")).strip_edges() for existing_variant in _character_inventory_items: if typeof(existing_variant) != TYPE_DICTIONARY: continue var existing := existing_variant as Dictionary if String(existing.get("itemKey", "")).strip_edges() != item_key: continue return int(existing.get("slot", 0)) return _first_open_character_slot() func _first_open_character_slot() -> int: var used_slots := {} for item_variant in _character_inventory_items: if typeof(item_variant) != TYPE_DICTIONARY: continue var item := item_variant as Dictionary var slot_value: Variant = item.get("slot", null) if typeof(slot_value) == TYPE_NIL: continue used_slots[int(slot_value)] = true for slot_index in range(CHARACTER_SLOT_COUNT): if not used_slots.has(slot_index): return slot_index return 0 func _on_character_items_selected(index: int) -> void: var metadata: Variant = _character_items_list.get_item_metadata(index) _selected_ground_item_id = "" _ground_items_list.deselect_all() if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty(): _selected_character_item_id = "" else: var item := metadata as Dictionary _selected_character_item_id = String(item.get("itemId", item.get("id", ""))).strip_edges() _target_slot_spin_box.value = float(int(item.get("slot", 0))) _update_inventory_controls() func _on_ground_items_selected(index: int) -> void: var metadata: Variant = _ground_items_list.get_item_metadata(index) _selected_character_item_id = "" _character_items_list.deselect_all() if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty(): _selected_ground_item_id = "" else: var item := metadata as Dictionary _selected_ground_item_id = String(item.get("itemId", item.get("id", ""))).strip_edges() _target_slot_spin_box.value = float(_default_slot_for_item(item)) _update_inventory_controls() func _on_inventory_move_pressed() -> void: if _selected_character_item_id.is_empty(): _inventory_status_label.text = "Select a character item first." return var to_slot := int(_target_slot_spin_box.value) var quantity := int(_quantity_spin_box.value) _move_character_item_async(_selected_character_item_id, to_slot, quantity) func _on_inventory_drop_pressed() -> void: if _selected_character_item_id.is_empty(): _inventory_status_label.text = "Select a character item to drop." return var location_id := _get_current_location_id() if location_id.is_empty(): _inventory_status_label.text = "Current location is missing an id." return var quantity := int(_quantity_spin_box.value) _transfer_item_async(_selected_character_item_id, "character", _character_id, "location", location_id, null, quantity, "Dropped item.") func _on_inventory_pickup_pressed() -> void: if _selected_ground_item_id.is_empty(): _inventory_status_label.text = "Select a ground item to pick up." return var location_id := _get_current_location_id() if location_id.is_empty(): _inventory_status_label.text = "Current location is missing an id." return var quantity := int(_quantity_spin_box.value) _transfer_item_async(_selected_ground_item_id, "location", location_id, "character", _character_id, null, quantity, "Picked up item.") func _on_inventory_refresh_pressed() -> void: _inventory_status_label.text = "Refreshing..." _refresh_inventory_menu_data() func _on_inventory_close_pressed() -> void: _close_inventory_menu() func _move_character_item_async(item_id: String, to_slot: int, quantity: int) -> void: if _inventory_request_in_flight: return _inventory_request_in_flight = true _inventory_status_label.text = "Moving item..." 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({ "itemId": item_id, "toSlot": to_slot, "quantity": quantity }) var err := request.request("%s/by-owner/character/%s/move" % [INVENTORY_API_URL, _character_id], headers, HTTPClient.METHOD_POST, body) if err != OK: request.queue_free() _inventory_status_label.text = "Move request failed." _inventory_request_in_flight = false return var result: Array = await request.request_completed request.queue_free() _inventory_request_in_flight = false _handle_inventory_mutation_response(result, "Item moved.") func _transfer_item_async(item_id: String, from_owner_type: String, from_owner_id: String, to_owner_type: String, to_owner_id: String, to_slot: Variant, quantity: int, success_message: String) -> void: if _inventory_request_in_flight: return _inventory_request_in_flight = true _inventory_status_label.text = "Transferring item..." 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 payload := { "itemId": item_id, "fromOwnerType": from_owner_type, "fromOwnerId": from_owner_id, "toOwnerType": to_owner_type, "toOwnerId": to_owner_id, "quantity": quantity } if typeof(to_slot) != TYPE_NIL: payload["toSlot"] = int(to_slot) var err := request.request("%s/transfer" % INVENTORY_API_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: request.queue_free() _inventory_status_label.text = "Transfer request failed." _inventory_request_in_flight = false return var result: Array = await request.request_completed request.queue_free() _inventory_request_in_flight = false _handle_inventory_mutation_response(result, success_message) func _handle_inventory_mutation_response(result: Array, success_message: String) -> void: 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: _inventory_status_label.text = "Inventory action failed." push_warning("Inventory action failed (%s/%s): %s" % [result_code, response_code, response_body]) return _inventory_status_label.text = success_message _refresh_inventory_menu_data() func _fetch_character_inventory() -> Array: 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/by-owner/character/%s" % [INVENTORY_API_URL, _character_id], headers, HTTPClient.METHOD_GET) if err != OK: request.queue_free() push_warning("Failed to request character inventory: %s" % err) 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 character inventory (%s/%s): %s" % [result_code, response_code, response_body]) return [] var parsed: Variant = JSON.parse_string(response_body) if typeof(parsed) != TYPE_DICTIONARY: return [] var payload := parsed as Dictionary return _parse_floor_inventory_items(payload.get("items", [])) 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", "elevation": 0, "locationObject": {}, "floorItems": [] } 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 _locations_refresh_in_flight = false 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 _locations_refresh_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 load visible locations (%s/%s): %s" % [result_code, response_code, response_body]) _locations_loaded = true _locations_refresh_in_flight = false 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 _locations_refresh_in_flight = false 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(), "elevation": int(location.get("elevation", 0)), "locationObject": _parse_location_object(location.get("locationObject", {})), "floorItems": _parse_floor_inventory_items(location.get("floorItems", [])) } 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 _try_interact_current_tile() -> void: if _interact_in_flight: return if _character_id.is_empty(): return var location_data: Dictionary = _known_locations.get(_center_coord, {}) if location_data.is_empty(): push_warning("No known location data for %s." % _center_coord) return var location_id := String(location_data.get("id", "")).strip_edges() if location_id.is_empty(): push_warning("Current location is missing an id.") return var object_data: Dictionary = location_data.get("locationObject", {}) if object_data.is_empty(): push_warning("Current location has no interactable object.") return var object_id := String(object_data.get("id", "")).strip_edges() if object_id.is_empty(): push_warning("Current location object is missing an id.") return _interact_in_flight = true _interact_with_location_async(location_id, object_id) func _interact_with_location_async(location_id: String, object_id: 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, "objectId": object_id }) var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body) if err != OK: push_warning("Failed to send location interaction request: %s" % err) request.queue_free() _interact_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("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body]) _interact_in_flight = false return var parsed: Variant = JSON.parse_string(response_body) if typeof(parsed) != TYPE_DICTIONARY: push_warning("Location interaction response was not an object.") _interact_in_flight = false return var interaction := parsed as Dictionary _apply_interaction_result(location_id, interaction) await _refresh_location_inventory(location_id) _interact_in_flight = false func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void: var consumed := bool(interaction.get("consumed", false)) var remaining_quantity := int(interaction.get("remainingQuantity", 0)) for coord_variant in _known_locations.keys(): var coord: Vector2i = coord_variant var location_data: Dictionary = _known_locations[coord] if String(location_data.get("id", "")) != location_id: continue var updated_location := location_data.duplicate(true) if consumed: updated_location["locationObject"] = {} else: var object_data: Dictionary = updated_location.get("locationObject", {}) var state: Dictionary = object_data.get("state", {}) state["remainingQuantity"] = remaining_quantity object_data["state"] = state updated_location["locationObject"] = object_data _known_locations[coord] = updated_location _update_tile(coord, updated_location) return 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 _get_location_data(coord: Vector2i) -> Dictionary: var value: Variant = _known_locations.get(coord, {}) if typeof(value) == TYPE_DICTIONARY: return value as Dictionary return {} func _parse_location_object(value: Variant) -> Dictionary: if typeof(value) != TYPE_DICTIONARY: return {} var object_data := value as Dictionary var state_value: Variant = object_data.get("state", {}) var state: Dictionary = {} if typeof(state_value) == TYPE_DICTIONARY: var raw_state := state_value as Dictionary state = { "itemKey": String(raw_state.get("itemKey", "")).strip_edges(), "remainingQuantity": int(raw_state.get("remainingQuantity", 0)), "gatherQuantity": int(raw_state.get("gatherQuantity", 1)) } return { "id": String(object_data.get("id", "")).strip_edges(), "objectType": String(object_data.get("objectType", "")).strip_edges(), "objectKey": String(object_data.get("objectKey", "")).strip_edges(), "name": String(object_data.get("name", "")).strip_edges(), "state": state } func _parse_floor_inventory_items(value: Variant) -> Array: var items: Array = [] if typeof(value) != TYPE_ARRAY: return items for entry in value: if typeof(entry) != TYPE_DICTIONARY: continue var item := entry as Dictionary items.append({ "itemId": String(item.get("itemId", item.get("id", ""))).strip_edges(), "itemKey": String(item.get("itemKey", "")).strip_edges(), "quantity": int(item.get("quantity", 0)), "slot": item.get("slot", null) }) return items func _refresh_location_inventory(location_id: String) -> void: if location_id.is_empty(): return var floor_items := await _fetch_location_inventory(location_id) for coord_variant in _known_locations.keys(): var coord: Vector2i = coord_variant var location_data: Dictionary = _known_locations[coord] if String(location_data.get("id", "")).strip_edges() != location_id: continue location_data["floorItems"] = floor_items _known_locations[coord] = location_data _update_tile(coord, location_data) return func _fetch_location_inventory(location_id: String) -> Array: 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/by-owner/location/%s" % [INVENTORY_API_URL, location_id], headers, HTTPClient.METHOD_GET) if err != OK: request.queue_free() push_warning("Failed to request floor inventory for location %s: %s" % [location_id, err]) 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 floor inventory for location %s (%s/%s): %s" % [location_id, result_code, response_code, response_body]) return [] var parsed: Variant = JSON.parse_string(response_body) if typeof(parsed) != TYPE_DICTIONARY: return [] var payload := parsed as Dictionary return _parse_floor_inventory_items(payload.get("items", [])) 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)