Phase 7: netfox + godot-jolt stack upgrade

Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+153
View File
@@ -0,0 +1,153 @@
@tool
extends EditorScript
# Apply distance-based visibility ranges to all modular CSG pieces.
#
# This is the equivalent of LOD for CSG-based geometry — it hides
# entire pieces when they're beyond a useful visual distance.
#
# Ranges (defined per asset type):
# Walls: hide past 50 m
# Floors: hide past 60 m
# Pillars: hide past 30 m
# Beams: hide past 30 m
# Accent panels: hide past 20 m
# Door/Window: hide past 40 m
#
# Usage:
# Select a scene in the Godot editor, then:
# Scene > Run Script (select this file)
#
# Or from CLI on a specific scene:
# godot --script scripts/apply_visibility_ranges.gd \
# --scene res://assets/scenes/modular/kit_demo.tscn
const DEBUG := false # Enable for verbose logging
# Default ranges per type (begin = 0.0 means always visible up to end)
const RANGES := {
"wall": {"begin": 0.0, "end": 50.0, "margin": 5.0}, # fade out over 5m
"floor": {"begin": 0.0, "end": 60.0, "margin": 10.0},
"pillar": {"begin": 0.0, "end": 30.0, "margin": 3.0},
"beam": {"begin": 0.0, "end": 30.0, "margin": 3.0},
"accent": {"begin": 0.0, "end": 20.0, "margin": 2.0},
"doorway": {"begin": 0.0, "end": 40.0, "margin": 5.0},
"window": {"begin": 0.0, "end": 40.0, "margin": 5.0},
"endcap": {"begin": 0.0, "end": 50.0, "margin": 5.0},
"default": {"begin": 0.0, "end": 40.0, "margin": 5.0},
}
func _run():
var scene_root: Node
if Engine.is_editor_hint():
scene_root = get_scene() as Node
if not scene_root:
printerr("No open scene in editor. Open a scene first.")
return
else:
var args = OS.get_cmdline_args()
var scene_path := ""
for i in range(args.size()):
if args[i] == "--scene" and i + 1 < args.size():
scene_path = args[i + 1]
if scene_path.is_empty():
printerr("Usage: godot --script apply_visibility_ranges.gd --scene res://path/to/scene.tscn" +
"\nOr run from the Godot editor with a scene open.")
return
var packed = ResourceLoader.load(scene_path)
if not packed:
printerr("Failed to load scene: ", scene_path)
return
scene_root = packed.instantiate()
print("=== Visibility Range Applicator ===")
print("Scene: ", scene_root.name)
var counts := {} # type → number of nodes modified
var total := 0
_apply_to_node(scene_root, counts, total)
print("")
print("Modified nodes: ", total)
for type_name in counts:
print(" %s: %d" % [type_name, counts[type_name]])
print("")
print("=== Ranges applied ===")
func _apply_to_node(node: Node, counts: Dictionary, total: int) -> void:
# Only process CSG nodes (the modular pieces)
if node is CSGShape3D:
var range_config := _get_range_for_node(node)
if range_config != null:
node.visibility_range_begin = range_config.begin
node.visibility_range_end = range_config.end
node.visibility_range_begin_margin = range_config.margin
node.visibility_range_end_margin = range_config.margin
var type_name := _classify_node(node)
counts[type_name] = counts.get(type_name, 0) + 1
total += 1
if DEBUG:
print(" %s%.0f%.0f m (type: %s)" % [node.name, range_config.begin, range_config.end, type_name])
# Also apply to CSGCombiner3D parents (hold doorway/window subtractions)
if node is CSGCombiner3D:
var range_config := _get_range_for_combiner(node)
if range_config != null:
node.visibility_range_begin = range_config.begin
node.visibility_range_end = range_config.end
node.visibility_range_begin_margin = range_config.margin
node.visibility_range_end_margin = range_config.margin
var type_name := _classify_node(node)
counts[type_name] = counts.get(type_name, 0) + 1
total += 1
if DEBUG:
print(" %s%.0f%.0f m (type: %s)" % [node.name, range_config.begin, range_config.end, type_name])
# Recurse
for child in node.get_children():
_apply_to_node(child, counts, total)
func _classify_node(node: Node) -> String:
var name_lower := node.name.to_lower()
if name_lower.contains("wall"):
if name_lower.contains("door"): return "doorway"
if name_lower.contains("window"): return "window"
if name_lower.contains("corner"): return "wall"
if name_lower.contains("endcap"): return "endcap"
return "wall"
if name_lower.contains("floor"): return "floor"
if name_lower.contains("pillar"): return "pillar"
if name_lower.contains("beam"): return "beam"
if name_lower.contains("accent") or name_lower.contains("blue") or name_lower.contains("red"):
return "accent"
if name_lower.contains("panel"):
return "accent"
return "default"
func _get_range_for_node(node: CSGShape3D) -> Dictionary:
# Detect CSGBox3D size to infer type
if node is CSGBox3D:
var box := node as CSGBox3D
var size := box.size
# Floor slabs: thin, wide (Z > X on rotated ones, check min dimension)
if size.y <= 0.1 and (size.x >= 2.0 or size.z >= 2.0):
return RANGES.floor.duplicate()
return _get_range_by_name(node)
func _get_range_for_combiner(node: CSGCombiner3D) -> Dictionary:
return _get_range_by_name(node)
func _get_range_by_name(node: Node) -> Dictionary:
var type_name := _classify_node(node)
return RANGES.get(type_name, RANGES.default).duplicate()
+1 -1
View File
@@ -1 +1 @@
uid://q8nutj781fyf
uid://tvxatyq01ubm
+360
View File
@@ -0,0 +1,360 @@
## MapDownloader — Client-side map download and cache management
##
## Autoload singleton that downloads .pck map packs from the map registry
## server and loads them into the running game. Provides a cache manifest
## at user://maps/manifest.json for persistence across restarts.
##
## ## Architecture
##
## Registry Server MapDownloader (Godot autoload)
## ┌──────────────────────┐ ┌─────────────────────────┐
## │ GET /maps │ ◄──── │ fetch_map_list() │
## │ GET /maps/:name.pck │ ◄──── │ download_map(name) │
## │ GET /maps/:name.json│ ◄──── │ get_map_info(name) │
## └──────────────────────┘ │ │
## │ Cache: │
## user://maps/<name>.pck ══╡ .pck files │
## user://maps/manifest.json ═╡ manifest │
## └─────────────────────────┘
##
## ## Signals
## map_list_loaded(maps: Array[Dictionary]) — registry map list
## map_download_progress(map_name, received, total)
## map_download_complete(map_name, success)
## map_loaded(map_name)
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal map_list_loaded(maps: Array) # maps from registry server
signal map_download_progress(map_name: String, bytes_received: int, bytes_total: int)
signal map_download_complete(map_name: String, success: bool)
signal map_loaded(map_name: String)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const MAP_CACHE_DIR: String = "user://maps/"
const MANIFEST_FILE: String = "user://maps/manifest.json"
const DEFAULT_REGISTRY_URL: String = "http://127.0.0.1:8090"
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
## URL of the map registry server (no trailing slash).
var registry_url: String = DEFAULT_REGISTRY_URL:
set(val):
registry_url = val.trim_suffix("/")
## Timeout for HTTP requests in seconds.
var http_timeout: float = 30.0
## Max concurrent downloads.
var max_concurrent: int = 2
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _http_busy: bool = false
var _active_downloads: Dictionary = {} # map_name → Dictionary
var _download_queue: Array[Dictionary] = []
var _manifest: Dictionary = {} # {map_name: {version, size, downloaded_at}}
var _loaded_pcks: Array[String] = []
var _http_nodes: Array[HTTPRequest] = []
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Create cache directory
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(MAP_CACHE_DIR))
# Load local manifest
_load_manifest()
# Override registry URL from environment
if OS.has_environment("MAP_REGISTRY_URL"):
registry_url = OS.get_environment("MAP_REGISTRY_URL")
print("[MapDownloader] Registry URL: %s" % registry_url)
print("[MapDownloader] Cache dir: %s" % MAP_CACHE_DIR)
print("[MapDownloader] Cached maps: %d" % _manifest.size())
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Fetch the list of available maps from the registry server.
## Emits map_list_loaded when done.
func fetch_map_list() -> void:
if _http_busy:
return
_http_busy = true
var url: String = "%s/maps" % [registry_url]
var http := HTTPRequest.new()
http.name = "MapListHTTP"
http.timeout = http_timeout
add_child(http)
_http_nodes.append(http)
http.request_completed.connect(_on_map_list_received.bind(http))
http.request(url)
## Check if a map is in the local cache.
func is_map_cached(map_name: String) -> bool:
if not _manifest.has(map_name):
return false
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
var global_path: String = ProjectSettings.globalize_path(pck_path)
return FileAccess.file_exists(global_path)
## Get the version of a cached map. Returns 0 if not cached.
func get_cached_version(map_name: String) -> int:
var info = _manifest.get(map_name, {})
return info.get("version", 1)
## Download a single map .pck from the registry server.
## The map is saved to user://maps/<name>.pck and loaded automatically.
func download_map(map_name: String) -> void:
if is_map_cached(map_name):
print("[MapDownloader] %s already cached — loading" % map_name)
_load_map_pck(map_name)
return
if map_name in _active_downloads:
print("[MapDownloader] %s is already downloading" % map_name)
return
var entry := {
map_name = map_name,
url = "%s/maps/%s.pck" % [registry_url, map_name],
}
if _active_downloads.size() < max_concurrent:
_start_download(entry)
else:
_download_queue.append(entry)
print("[MapDownloader] %s queued (%d waiting)" % [map_name, _download_queue.size()])
## Download multiple maps.
func download_maps(map_names: Array[String]) -> void:
for name in map_names:
download_map(name)
## Load a cached .pck into the resource system.
## Returns true if the pack was loaded successfully.
func load_map(map_name: String) -> bool:
return _load_map_pck(map_name)
## Remove a cached map from disk and manifest.
func remove_map(map_name: String) -> void:
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
var global_path: String = ProjectSettings.globalize_path(pck_path)
if FileAccess.file_exists(global_path):
DirAccess.remove_absolute(global_path)
_manifest.erase(map_name)
_save_manifest()
var idx = _loaded_pcks.find(map_name)
if idx >= 0:
_loaded_pcks.remove_at(idx)
print("[MapDownloader] Removed cached map: %s" % map_name)
## Get list of locally cached map names.
func get_cached_maps() -> Array[String]:
return _manifest.keys()
## Get info about a cached map from the manifest.
func get_cached_map_info(map_name: String) -> Dictionary:
return _manifest.get(map_name, {})
## Clear all cached maps.
func clear_cache() -> void:
for name in _manifest.keys():
remove_map(name)
print("[MapDownloader] Cache cleared")
## Get total cache size in bytes.
func get_cache_size_bytes() -> int:
var total := 0
for name in _manifest.keys():
total += _manifest[name].get("size", 0)
return total
# ---------------------------------------------------------------------------
# Internal: Download handling
# ---------------------------------------------------------------------------
func _start_download(entry: Dictionary) -> void:
var map_name: String = entry.map_name
var url: String = entry.url
var save_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, map_name])
_active_downloads[map_name] = entry
print("[MapDownloader] Downloading: %s" % map_name)
var http := HTTPRequest.new()
http.name = "MapDl_%s" % map_name
http.timeout = http_timeout
http.download_file = save_path
add_child(http)
_http_nodes.append(http)
http.request_completed.connect(_on_map_downloaded.bind(map_name, http))
http.request(url)
func _process_download_queue() -> void:
if _download_queue.is_empty():
return
var next: Dictionary = _download_queue.pop_front()
_start_download(next)
# ---------------------------------------------------------------------------
# Internal: Callbacks
# ---------------------------------------------------------------------------
func _on_map_list_received(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray, http_node: HTTPRequest) -> void:
_http_busy = false
# Cleanup the HTTP node
http_node.request_completed.disconnect(_on_map_list_received)
_http_nodes.erase(http_node)
http_node.queue_free()
if result != HTTPRequest.RESULT_SUCCESS:
push_error("[MapDownloader] Network error fetching map list: %d" % result)
map_list_loaded.emit([])
return
if response_code != 200:
push_error("[MapDownloader] Failed to fetch map list: HTTP %d" % response_code)
map_list_loaded.emit([])
return
var json := JSON.new()
var parse_err: Error = json.parse(body.get_string_from_utf8())
if parse_err != OK:
push_error("[MapDownloader] Failed to parse map list JSON")
map_list_loaded.emit([])
return
var data: Dictionary = json.data
var maps: Array = data.get("maps", [])
print("[MapDownloader] Registry offers %d maps" % maps.size())
map_list_loaded.emit(maps)
func _on_map_downloaded(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray, map_name: String, http_node: HTTPRequest) -> void:
_active_downloads.erase(map_name)
# Cleanup the HTTP node
http_node.request_completed.disconnect(_on_map_downloaded)
_http_nodes.erase(http_node)
http_node.queue_free()
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
push_error("[MapDownloader] Download failed for %s: result=%d HTTP=%d" % [map_name, result, response_code])
map_download_complete.emit(map_name, false)
_process_download_queue()
return
print("[MapDownloader] Downloaded %s successfully" % map_name)
map_download_complete.emit(map_name, true)
# Update manifest
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
var global_path: String = ProjectSettings.globalize_path(pck_path)
var file_size: int = 0
if FileAccess.file_exists(global_path):
file_size = FileAccess.get_size(global_path)
_manifest[map_name] = {
version = _manifest.get(map_name, {}).get("version", 1),
size = file_size,
downloaded_at = Time.get_datetime_string_from_system(),
}
_save_manifest()
# Load the map into the resource system
_load_map_pck(map_name)
_process_download_queue()
# ---------------------------------------------------------------------------
# Internal: PCK loading
# ---------------------------------------------------------------------------
func _load_map_pck(map_name: String) -> bool:
if map_name in _loaded_pcks:
return true
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
var global_path: String = ProjectSettings.globalize_path(pck_path)
if not FileAccess.file_exists(global_path):
push_error("[MapDownloader] Cannot load %s: .pck not found" % map_name)
return false
var err: Error = ProjectSettings.load_resource_pack(global_path)
if err != OK:
push_error("[MapDownloader] Failed to load %s: %s" % [map_name, error_string(err)])
return false
_loaded_pcks.append(map_name)
print("[MapDownloader] Loaded map: %s" % map_name)
map_loaded.emit(map_name)
return true
# ---------------------------------------------------------------------------
# Internal: Manifest persistence
# ---------------------------------------------------------------------------
func _load_manifest() -> void:
var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE)
if not FileAccess.file_exists(global_path):
_manifest = {}
return
var f := FileAccess.open(global_path, FileAccess.READ)
if f == null:
_manifest = {}
return
var json := JSON.new()
var parse_err: Error = json.parse(f.get_as_text())
f.close()
if parse_err != OK:
push_warning("[MapDownloader] Failed to parse manifest — starting fresh")
_manifest = {}
else:
_manifest = json.data
_validate_manifest()
func _save_manifest() -> void:
var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE)
var f := FileAccess.open(global_path, FileAccess.WRITE)
if f == null:
push_error("[MapDownloader] Cannot save manifest to %s" % global_path)
return
f.store_string(JSON.stringify(_manifest, "\t", false))
f.close()
func _validate_manifest() -> void:
var stale: Array[String] = []
for name in _manifest.keys():
var pck_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, name])
if not FileAccess.file_exists(pck_path):
stale.append(name)
for name in stale:
print("[MapDownloader] Manifest cleanup: %s (file missing)" % name)
_manifest.erase(name)
if not stale.is_empty():
_save_manifest()
+1
View File
@@ -0,0 +1 @@
uid://5fcqul7vigrv
+513
View File
@@ -0,0 +1,513 @@
## MapWorkshop — In-game Workshop-style Map Browser
##
## Full-screen UI for browsing, downloading, and managing community maps.
## Two tabs: "Installed" (local cache) and "Online" (registry server).
## Follows the visual style of the existing ServerBrowserUI.
##
## Signals:
## map_selected(map_name: String) — Player clicked "Play" on a map
##
## Usage:
## var workshop = preload("res://client/scripts/map_workshop.gd").new()
## add_child(workshop)
## workshop.show_tab("online") # or "installed"
extends Control
signal map_selected(map_name: String)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const COL_WIDTHS := {
"name": 0.32,
"version": 0.08,
"size": 0.10,
"status": 0.20,
"action": 0.30,
}
const SIZE_SUFFIXES := ["B", "KB", "MB", "GB"]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _current_tab: String = "installed" # "installed" or "online"
var _registry_maps: Array[Dictionary] = []
var _cached_maps: Array[String] = []
var _downloading: Dictionary = {} # map_name → float (progress 0.0-1.0)
# UI nodes
var _panel: Panel
var _title_label: Label
var _tab_installed: Button
var _tab_online: Button
var _filter_edit: LineEdit
var _map_list: ItemList
var _status_label: Label
var _action_button: Button
var _close_button: Button
var _refresh_button: Button
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_build_ui()
# Hook into MapDownloader signals
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
md.map_list_loaded.connect(_on_map_list_loaded)
md.map_download_complete.connect(_on_download_complete)
md.map_loaded.connect(_on_map_loaded)
# Load initial data
_refresh_installed()
_refresh_online()
func _build_ui() -> void:
# Main panel
_panel = Panel.new()
_panel.name = "WorkshopPanel"
_panel.anchor_right = 1.0
_panel.anchor_bottom = 1.0
add_child(_panel)
var margin := 8
var style = StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.12, 0.14, 0.95)
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
style.content_margin_left = margin
style.content_margin_top = margin
style.content_margin_right = margin
style.content_margin_bottom = margin
_panel.add_theme_stylebox_override("panel", style)
# Title
_title_label = Label.new()
_title_label.name = "TitleLabel"
_title_label.text = "MAP WORKSHOP"
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title_label.add_theme_font_size_override("font_size", 18)
_title_label.add_theme_color_override("font_color", Color(1.0, 0.84, 0.0)) # gold
_title_label.position = Vector2(margin, margin)
_panel.add_child(_title_label)
# Tab buttons
_tab_installed = Button.new()
_tab_installed.name = "TabInstalled"
_tab_installed.text = "Installed"
_tab_installed.toggle_mode = true
_tab_installed.button_pressed = true
_tab_installed.pressed.connect(_on_tab_changed.bind("installed"))
_panel.add_child(_tab_installed)
_tab_online = Button.new()
_tab_online.name = "TabOnline"
_tab_online.text = "Online"
_tab_online.toggle_mode = true
_tab_online.pressed.connect(_on_tab_changed.bind("online"))
_panel.add_child(_tab_online)
# Filter
_filter_edit = LineEdit.new()
_filter_edit.name = "FilterEdit"
_filter_edit.placeholder_text = "Filter maps by name..."
_filter_edit.text_changed.connect(_on_filter_changed)
_panel.add_child(_filter_edit)
# Map list
_map_list = ItemList.new()
_map_list.name = "MapList"
_map_list.allow_reselect = false
_map_list.select_mode = ItemList.SELECT_SINGLE
_map_list.item_selected.connect(_on_map_selected)
_map_list.nothing_selected.connect(_clear_selection)
_panel.add_child(_map_list)
# Action button (changes context: "Play" / "Download" / "Remove")
_action_button = Button.new()
_action_button.name = "ActionButton"
_action_button.text = ""
_action_button.disabled = true
_action_button.pressed.connect(_on_action_pressed)
_panel.add_child(_action_button)
# Refresh button
_refresh_button = Button.new()
_refresh_button.name = "RefreshButton"
_refresh_button.text = "↻ Refresh"
_refresh_button.pressed.connect(_on_refresh_pressed)
_panel.add_child(_refresh_button)
# Status bar
_status_label = Label.new()
_status_label.name = "StatusLabel"
_status_label.text = "Ready"
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
_panel.add_child(_status_label)
# Close button
_close_button = Button.new()
_close_button.name = "CloseButton"
_close_button.text = ""
_close_button.pressed.connect(_on_close_pressed)
_panel.add_child(_close_button)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED or what == NOTIFICATION_SORT_CHILDREN:
_resize_ui()
func _resize_ui() -> void:
var w = _panel.size.x
var h = _panel.size.y
var m := 8
var y := m
# Title
_title_label.position = Vector2(m, y)
_title_label.size = Vector2(w - m * 2, 24)
y += 30
# Tabs row
var tab_w = (w - m * 3) / 2
_tab_installed.position = Vector2(m, y)
_tab_installed.size = Vector2(tab_w, 26)
_tab_online.position = Vector2(m + tab_w + m, y)
_tab_online.size = Vector2(tab_w, 26)
y += 34
# Filter
_filter_edit.position = Vector2(m, y)
_filter_edit.size = Vector2(w - m * 2, 24)
y += 32
# Map list
var list_height = h - y - 80
_map_list.position = Vector2(m, y)
_map_list.size = Vector2(w - m * 2, max(list_height, 60))
y += _map_list.size.y + 8
# Buttons row
var btn_w = (w - m * 3) / 3
_action_button.position = Vector2(m, y)
_action_button.size = Vector2(btn_w, 28)
_refresh_button.position = Vector2(m + btn_w + m / 2, y)
_refresh_button.size = Vector2(btn_w, 28)
y += 36
# Status
_status_label.position = Vector2(m, y)
_status_label.size = Vector2(w - m * 2, 20)
# Close button (top-right corner)
_close_button.position = Vector2(w - 28, 4)
_close_button.size = Vector2(24, 24)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Open to a specific tab.
func show_tab(tab: String) -> void:
match tab:
"installed":
_tab_installed.button_pressed = true
_tab_online.button_pressed = false
_current_tab = "installed"
_populate_list()
"online":
_tab_installed.button_pressed = false
_tab_online.button_pressed = true
_current_tab = "online"
_refresh_online()
_populate_list()
# ---------------------------------------------------------------------------
# Data
# ---------------------------------------------------------------------------
func _refresh_installed() -> void:
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
_cached_maps = md.get_cached_maps()
else:
_cached_maps = []
func _refresh_online() -> void:
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
md.fetch_map_list()
_status_label.text = "Fetching map list..."
else:
_status_label.text = "MapDownloader not available"
# ---------------------------------------------------------------------------
# List population
# ---------------------------------------------------------------------------
func _populate_list() -> void:
_map_list.clear()
_action_button.disabled = true
_action_button.text = ""
var filter_text = _filter_edit.text.strip_edges().to_lower()
match _current_tab:
"installed":
_populate_installed(filter_text)
"online":
_populate_online(filter_text)
func _populate_installed(filter_text: String) -> void:
if _cached_maps.is_empty():
_map_list.add_item(" No maps installed")
_status_label.text = "No maps cached. Switch to Online tab to download maps."
return
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
var count := 0
for name in _cached_maps:
if not filter_text.is_empty() and not name.to_lower().contains(filter_text):
continue
var info = md.get_cached_map_info(name)
var size_bytes = info.get("size", 0)
var version = info.get("version", 1)
var size_str = _format_size(size_bytes)
var status = "Ready"
var icon = ""
var label = "%s %s v%d %s %s" % [icon, name, version, size_str, status]
_map_list.add_item(label)
count += 1
_status_label.text = "%d installed map(s)" % count
else:
_map_list.add_item(" MapDownloader not loaded")
_status_label.text = "MapDownloader singleton not found"
func _populate_online(filter_text: String) -> void:
if _registry_maps.is_empty():
_map_list.add_item(" Fetching map list... (press Refresh)")
_status_label.text = "No maps from registry yet"
return
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
var count := 0
for m in _registry_maps:
if not (m is Dictionary):
continue
var name: String = m.get("name", "")
if name.is_empty():
continue
if not filter_text.is_empty() and not name.to_lower().contains(filter_text):
continue
var size_bytes = m.get("size", 0)
var description = m.get("description", "")
var reg_version = m.get("version", 1)
var is_cached = md.is_map_cached(name)
var cached_version = md.get_cached_version(name) if is_cached else 0
var needs_update = is_cached and reg_version > cached_version
var status: String
var icon: String
if _downloading.has(name):
status = "Downloading..."
icon = ""
elif needs_update:
status = "Update available"
icon = ""
elif is_cached:
status = "Installed"
icon = ""
else:
status = description if not description.is_empty() else "Not installed"
icon = "+"
var size_str = _format_size(size_bytes)
var label = "%s %s v%d %s %s" % [icon, name, reg_version, size_str, status]
_map_list.add_item(label)
count += 1
_status_label.text = "%d map(s) on registry" % count
else:
_status_label.text = "MapDownloader not available"
# ---------------------------------------------------------------------------
# UI callbacks
# ---------------------------------------------------------------------------
func _on_tab_changed(tab: String) -> void:
_current_tab = tab
# Update toggle states
_tab_installed.button_pressed = (tab == "installed")
_tab_online.button_pressed = (tab == "online")
if tab == "installed":
_refresh_installed()
elif tab == "online":
_refresh_online()
_populate_list()
func _on_filter_changed(_new_text: String) -> void:
_populate_list()
func _on_map_selected(index: int) -> void:
if index < 0:
_clear_selection()
return
var label: String = _map_list.get_item_text(index)
if label.begins_with(" "):
_clear_selection()
return
# Parse the map name from the label (skipping icon prefix)
var map_name = _extract_map_name(label)
if map_name.is_empty():
_clear_selection()
return
match _current_tab:
"installed":
_action_button.disabled = false
_action_button.text = "► Play"
"online":
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
if md.is_map_cached(map_name):
_action_button.disabled = false
_action_button.text = "► Play"
else:
_action_button.disabled = false
_action_button.text = "↓ Download"
else:
_action_button.disabled = true
_action_button.text = ""
func _clear_selection() -> void:
_action_button.disabled = true
_action_button.text = ""
func _on_action_pressed() -> void:
var selected = _map_list.get_selected_items()
if selected.is_empty():
return
var index = selected[0]
var label = _map_list.get_item_text(index)
var map_name = _extract_map_name(label)
if map_name.is_empty():
return
var action = _action_button.text
if action.contains("Play"):
map_selected.emit(map_name)
elif action.contains("Download"):
_start_download(map_name)
func _on_refresh_pressed() -> void:
match _current_tab:
"installed":
_refresh_installed()
"online":
_refresh_online()
_populate_list()
_status_label.text = "Refreshed"
func _on_close_pressed() -> void:
queue_free()
# ---------------------------------------------------------------------------
# Download handling
# ---------------------------------------------------------------------------
func _start_download(map_name: String) -> void:
_downloading[map_name] = 0.0
if Engine.has_singleton("MapDownloader"):
var md = Engine.get_singleton("MapDownloader")
md.download_map(map_name)
_status_label.text = "Downloading %s..." % map_name
_populate_list()
func _on_map_list_loaded(maps: Array) -> void:
_registry_maps = maps
if _current_tab == "online":
_populate_list()
func _on_download_complete(map_name: String, success: bool) -> void:
_downloading.erase(map_name)
if success:
_status_label.text = "%s downloaded and loaded" % map_name
_refresh_installed()
else:
_status_label.text = "Download failed: %s" % map_name
if _current_tab == "online":
_populate_list()
func _on_map_loaded(map_name: String) -> void:
print("[MapWorkshop] Map loaded: %s" % map_name)
# The caller can now load res://scenes/maps/<name>.tscn
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
## Extract the map name from a formatted list item label.
## Labels are formatted: "✓ map_name v1 12.3 MB Ready"
func _extract_map_name(label: String) -> String:
# Skip the first character (icon) and space
if label.length() < 3:
return ""
var rest = label.substr(2).strip_edges()
# The map name runs until we hit two consecutive spaces or " v"
var v_idx = rest.find(" v")
if v_idx < 0:
v_idx = rest.find(" v")
if v_idx < 0:
v_idx = rest.find(" v")
if v_idx > 0:
return rest.substr(0, v_idx).strip_edges()
return rest.strip_edges()
## Format bytes to human-readable size.
static func _format_size(bytes: int) -> String:
if bytes <= 0:
return "0 B"
var unit_idx := 0
var size := float(bytes)
while size >= 1024.0 and unit_idx < SIZE_SUFFIXES.size() - 1:
size /= 1024.0
unit_idx += 1
if unit_idx == 0:
return "%d %s" % [bytes, SIZE_SUFFIXES[unit_idx]]
return "%.1f %s" % [size, SIZE_SUFFIXES[unit_idx]]
+1
View File
@@ -0,0 +1 @@
uid://cokemo2x02v8k
+155
View File
@@ -0,0 +1,155 @@
## RoundReplicator — Client-side round state synced from RoundManager Self-RPCs.
##
## The server's round_manager signals arrive on clients via netfox Self-RPC
## (@rpc with call_local). This replicator listens to those signals and
## maintains a local round state snapshot for rollback-accessible use.
##
## This script is ONLY loaded from client scenes.
extends Node
# ---------------------------------------------------------------------------
# Signals (mirrors server RoundManager for client-side consumers)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Round state (mirrors server RoundManager)
# ---------------------------------------------------------------------------
enum RoundState {
INACTIVE = 0,
WAITING_FOR_PLAYERS = 1,
WARMUP = 2,
LIVE = 3,
POST_ROUND = 4,
MATCH_END = 5,
}
# Current round state (populated from server broadcasts)
var round_state: int = RoundState.INACTIVE
var round_number: int = 0
var team_a_score: int = 0
var team_b_score: int = 0
var time_remaining: float = 0.0
var round_time_seconds: float = 600.0
var win_limit: int = 3
# ---------------------------------------------------------------------------
# Rollback-friendly state (used by rollback_tick in player scripts)
# ---------------------------------------------------------------------------
## Whether the round is in freeze time (before round start).
var is_frozen: bool = false
## The network tick when freeze ends (for rollback checks).
var freeze_end_tick: int = -1
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Connect to RoundManager signals (which now arrive via Self-RPC)
if Engine.has_singleton(&"RoundManager"):
var rm = Engine.get_singleton(&"RoundManager")
if rm.round_started.is_connected(_on_round_start):
return # Already connected
rm.round_started.connect(_on_round_start)
rm.round_ended.connect(_on_round_end)
rm.match_ended.connect(_on_match_end)
rm.score_changed.connect(_on_score_change)
else:
push_warning("[RoundReplicator] RoundManager not available")
func _on_connected() -> void:
print("[RoundReplicator] Connected to server — awaiting round state")
# ---------------------------------------------------------------------------
# Signal handlers
# ---------------------------------------------------------------------------
func _on_round_start(round_num: int) -> void:
round_number = round_num
# Read round_time_seconds from RoundManager (replicated via _rpc_round_started)
if Engine.has_singleton(&"RoundManager"):
var rm = Engine.get_singleton(&"RoundManager")
round_time_seconds = rm.round_time_seconds if rm.round_time_seconds > 0 else 600
time_remaining = round_time_seconds
round_state = RoundState.LIVE
# Freeze time handling (10 seconds for first round, 5 for subsequent)
var freeze_time: float = 10.0 if round_num <= 1 else 5.0
is_frozen = true
var nt = Engine.get_singleton(&"NetworkTime")
if nt:
freeze_end_tick = nt.tick + int(freeze_time * 64.0) # 64 tick-rate assumption
else:
freeze_end_tick = -1
print("[RoundReplicator] Round %d started (%.0fs, frozen for %.0fs)" % [round_num, round_time_seconds, freeze_time])
func _on_round_end(winner_team: int, reason: String) -> void:
# Read scores from RoundManager (replicated by Self-RPC)
if Engine.has_singleton(&"RoundManager"):
var rm = Engine.get_singleton(&"RoundManager")
team_a_score = rm.team_a_score
team_b_score = rm.team_b_score
is_frozen = false
round_state = RoundState.POST_ROUND
print("[RoundReplicator] Round ended: %s wins (%s). Score: %d-%d" % [_team_name(winner_team), reason, team_a_score, team_b_score])
func _on_match_end(winner_team: int) -> void:
# Read scores from RoundManager
if Engine.has_singleton(&"RoundManager"):
var rm = Engine.get_singleton(&"RoundManager")
team_a_score = rm.team_a_score
team_b_score = rm.team_b_score
round_state = RoundState.MATCH_END
print("[RoundReplicator] MATCH OVER: %s wins %d-%d" % [_team_name(winner_team), team_a_score, team_b_score])
func _on_score_change(a_score: int, b_score: int) -> void:
team_a_score = a_score
team_b_score = b_score
print("[RoundReplicator] Score updated: %d-%d" % [a_score, b_score])
func _process(delta: float) -> void:
# Update time remaining (for HUD display)
if round_state == RoundState.LIVE:
time_remaining -= delta
if time_remaining < 0.0:
time_remaining = 0.0
# Update freeze state
if is_frozen and freeze_end_tick >= 0:
var nt = Engine.get_singleton(&"NetworkTime")
if nt:
if nt.tick >= freeze_end_tick:
is_frozen = false
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
## Get a snapshot of the current round state (for HUD/UI).
func get_snapshot() -> Dictionary:
return {
"state": round_state,
"round_number": round_number,
"team_a_score": team_a_score,
"team_b_score": team_b_score,
"time_remaining": time_remaining,
"is_frozen": is_frozen,
}
## Check if the player can move (not frozen, not dead, round is LIVE).
func can_player_move(player_is_alive: bool) -> bool:
return player_is_alive and round_state == RoundState.LIVE and not is_frozen
## Check if the round is currently active (LIVE or WARMUP).
func is_round_active() -> bool:
return round_state == RoundState.LIVE or round_state == RoundState.WARMUP
## Get the human-readable team name.
static func _team_name(team: int) -> String:
match team:
0: return "Draw"
1: return "Team A"
2: return "Team B"
_: return "Unknown"
+411
View File
@@ -0,0 +1,411 @@
extends Control
# =============================================================================
# Server Browser UI
# =============================================================================
# In-game server list that fetches from the master server API and lets the
# player pick a server to connect to.
#
# Usage:
# var browser = preload("res://client/scripts/server_browser_ui.gd").new()
# add_child(browser)
# browser.master_url = "http://master.example.com:28961"
# browser.refresh()
#
# Signals:
# server_selected(server_info: Dictionary)
# Emitted when the player clicks "Connect" on a server.
# The server_info dict has ip, port, name, password, etc.
# =============================================================================
signal server_selected(server_info: Dictionary)
const DEFAULT_COLUMN_WIDTHS := {
"name": 0.30,
"map": 0.15,
"players": 0.10,
"game_mode": 0.12,
"ping": 0.08,
"version": 0.10,
"tags": 0.15,
}
# Configuration — set these before calling refresh()
var master_url: String = "http://127.0.0.1:28961"
var refresh_interval: float = 30.0
var auto_refresh: bool = false
# Internal state
var _http: HTTPRequest
var _refresh_timer: Timer
var _servers: Array = []
var _selected_index: int = -1
var _loading: bool = false
# UI nodes (assigned in _ready or injected)
var _panel: Panel
var _title_label: Label
var _server_list: ItemList
var _refresh_button: Button
var _connect_button: Button
var _status_label: Label
var _auto_refresh_check: CheckBox
var _filter_edit: LineEdit
func _ready() -> void:
_build_ui()
# HTTP request node
_http = HTTPRequest.new()
_http.name = "BrowserHTTP"
_http.timeout = 10
_http.use_threads = true
add_child(_http)
_http.request_completed.connect(_on_servers_received)
# Auto-refresh timer
_refresh_timer = Timer.new()
_refresh_timer.name = "RefreshTimer"
_refresh_timer.one_shot = false
_refresh_timer.wait_time = refresh_interval
add_child(_refresh_timer)
_refresh_timer.timeout.connect(_do_refresh)
func _build_ui() -> void:
# Main panel
_panel = Panel.new()
_panel.name = "BrowserPanel"
_panel.anchor_right = 1.0
_panel.anchor_bottom = 1.0
add_child(_panel)
var margin := 8
var style = StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.12, 0.14, 0.95)
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
style.content_margin_left = margin
style.content_margin_top = margin
style.content_margin_right = margin
style.content_margin_bottom = margin
_panel.add_theme_stylebox_override("panel", style)
# Title
_title_label = Label.new()
_title_label.name = "TitleLabel"
_title_label.text = "SERVER BROWSER"
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title_label.add_theme_font_size_override("font_size", 18)
_title_label.add_theme_color_override("font_color", Color(1.0, 0.84, 0.0)) # gold
_title_label.position = Vector2(margin, margin)
_panel.add_child(_title_label)
# Filter
_filter_edit = LineEdit.new()
_filter_edit.name = "FilterEdit"
_filter_edit.placeholder_text = "Filter by name, map, or tag..."
_filter_edit.text_changed.connect(_on_filter_changed)
_panel.add_child(_filter_edit)
# Server list
_server_list = ItemList.new()
_server_list.name = "ServerList"
_server_list.allow_reselect = false
_server_list.select_mode = ItemList.SELECT_SINGLE
_server_list.item_selected.connect(_on_server_selected)
_server_list.nothing_selected.connect(_clear_selection)
_panel.add_child(_server_list)
# Buttons row
_refresh_button = Button.new()
_refresh_button.name = "RefreshButton"
_refresh_button.text = "↻ Refresh"
_refresh_button.pressed.connect(_do_refresh)
_panel.add_child(_refresh_button)
_connect_button = Button.new()
_connect_button.name = "ConnectButton"
_connect_button.text = "Connect"
_connect_button.disabled = true
_connect_button.pressed.connect(_on_connect_pressed)
_panel.add_child(_connect_button)
_auto_refresh_check = CheckBox.new()
_auto_refresh_check.name = "AutoRefreshCheck"
_auto_refresh_check.text = "Auto-refresh"
_auto_refresh_check.button_pressed = auto_refresh
_auto_refresh_check.toggled.connect(_on_auto_refresh_toggled)
_panel.add_child(_auto_refresh_check)
# Status bar
_status_label = Label.new()
_status_label.name = "StatusLabel"
_status_label.text = "Ready"
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
_panel.add_child(_status_label)
# Close/resize buttons
var close_btn = Button.new()
close_btn.name = "CloseButton"
close_btn.text = ""
close_btn.pressed.connect(_on_close_pressed)
_panel.add_child(close_btn)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED or what == NOTIFICATION_SORT_CHILDREN:
_resize_ui()
func _resize_ui() -> void:
var w = _panel.size.x
var h = _panel.size.y
var m := 8
var y := m
# Title
_title_label.position = Vector2(m, y)
_title_label.size = Vector2(w - m * 2, 24)
y += 30
# Filter
_filter_edit.position = Vector2(m, y)
_filter_edit.size = Vector2(w - m * 2, 24)
y += 32
# Server list
var list_height = h - y - 80
_server_list.position = Vector2(m, y)
_server_list.size = Vector2(w - m * 2, max(list_height, 60))
y += _server_list.size.y + 8
# Buttons row
var btn_y = y
var btn_w = (w - m * 3) / 3
_refresh_button.position = Vector2(m, btn_y)
_refresh_button.size = Vector2(btn_w, 28)
_connect_button.position = Vector2(m + btn_w + m / 2, btn_y)
_connect_button.size = Vector2(btn_w, 28)
_auto_refresh_check.position = Vector2(m + btn_w * 2 + m, btn_y)
_auto_refresh_check.size = Vector2(btn_w, 28)
y += 36
# Status
_status_label.position = Vector2(m, y)
_status_label.size = Vector2(w - m * 2, 20)
# Close button
close_btn = _panel.get_node("CloseButton")
close_btn.position = Vector2(w - 28, 4)
close_btn.size = Vector2(24, 24)
func _get_close_button() -> Button:
var node = _panel.get_node("CloseButton")
return node as Button
# -------------------------------------------------------------------------
# Public API
# -------------------------------------------------------------------------
func refresh() -> void:
"""Manually trigger a server list refresh."""
_do_refresh()
func set_master_url(url: String) -> void:
master_url = url.trim_suffix("/")
if not master_url.begins_with("http"):
master_url = "http://" + master_url
# -------------------------------------------------------------------------
# Internal
# -------------------------------------------------------------------------
func _do_refresh() -> void:
if _loading:
return
_loading = true
_status_label.text = "Fetching servers..."
_refresh_button.disabled = true
var endpoint = master_url + "/api/v1/servers"
var err = _http.request(endpoint, [], HTTPClient.METHOD_GET)
if err != OK:
_status_label.text = "Failed to send request (error " + str(err) + ")"
_loading = false
_refresh_button.disabled = false
func _on_servers_received(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_loading = false
_refresh_button.disabled = false
if result != HTTPRequest.RESULT_SUCCESS:
_status_label.text = "Network error " + str(result)
return
if response_code != 200:
_status_label.text = "Server error (HTTP " + str(response_code) + ")"
return
var body_text = body.get_string_from_utf8()
var json = JSON.new()
var parse_err = json.parse(body_text)
if parse_err != OK or not (json.data is Dictionary):
_status_label.text = "Invalid response from master server"
return
var data = json.data as Dictionary
_servers = data.get("servers", []) as Array
_populate_list()
_status_label.text = str(_servers.size()) + " server(s) found"
# Start auto-refresh timer if supposed to
if auto_refresh and not _refresh_timer.is_stopped():
_refresh_timer.start()
func _populate_list() -> void:
_server_list.clear()
var filter_text = _filter_edit.text.strip_edges().to_lower()
for srv in _servers:
if not (srv is Dictionary):
continue
# Apply filter
if not filter_text.is_empty():
var match_found = false
for field in ["name", "map", "tags", "game_mode"]:
var val = _get_str(srv, field, "")
if val.to_lower().contains(filter_text):
match_found = true
break
if not match_found:
continue
var name = _get_str(srv, "name", "Unnamed")
var map_name = _get_str(srv, "map", "?")
var players = _get_int(srv, "players", 0)
var max_players = _get_int(srv, "max_players", 16)
var mode = _get_str(srv, "game_mode", "?")
var version = _get_str(srv, "version", "?")
var tags = _get_str(srv, "tags", "")
var has_pass = _get_bool(srv, "password", false)
# Format: "Server Name dust2 8/16 DM v1.0 eu,ranked"
var pass_mark = "🔒" if has_pass else " "
var tag_str = ""
if tags is Array:
tag_str = ",".join(tags)
elif tags is String:
tag_str = tags
var label = pass_mark + " " + name
# Pad fields with spaces
label += " " + _pad_right(map_name, 14)
label += str(players) + "/" + str(max_players) + " "
label += _pad_right(mode, 10)
label += version + " "
label += tag_str
_server_list.add_item(label)
_connect_button.disabled = true
_selected_index = -1
func _on_server_selected(index: int) -> void:
_selected_index = index
_connect_button.disabled = index < 0
func _clear_selection() -> void:
_selected_index = -1
_connect_button.disabled = true
func _on_connect_pressed() -> void:
if _selected_index < 0 or _selected_index >= _servers.size():
return
var srv = _servers[_selected_index] as Dictionary
var host = _get_str(srv, "host", "")
var port = _get_int(srv, "port", 0)
if host.is_empty() or port == 0:
_status_label.text = "ERROR: Server has no address info"
return
emit_signal("server_selected", srv)
func _on_filter_changed(_new_text: String) -> void:
_populate_list()
func _on_auto_refresh_toggled(enabled: bool) -> void:
auto_refresh = enabled
if enabled:
_refresh_timer.wait_time = refresh_interval
_refresh_timer.start()
else:
_refresh_timer.stop()
func _on_close_pressed() -> void:
_refresh_timer.stop()
queue_free()
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
static func _get_str(d: Dictionary, key: String, fallback: String) -> String:
var val = d.get(key, fallback)
if val is String:
return val
return str(val)
static func _get_int(d: Dictionary, key: String, fallback: int) -> int:
var val = d.get(key, fallback)
if val is int:
return val
if val is float:
return int(val)
if val is String:
return val.to_int()
return fallback
static func _get_bool(d: Dictionary, key: String, fallback: bool) -> bool:
var val = d.get(key, fallback)
if val is bool:
return val
if val is int:
return val != 0
return fallback
static func _pad_right(text: String, min_width: int) -> String:
var result = text
while result.length() < min_width:
result += " "
return result
+1
View File
@@ -0,0 +1 @@
uid://piem67enrl6r
+42
View File
@@ -0,0 +1,42 @@
extends SceneTree
func _init():
print("Current working dir: ", OS.get_executable_path())
# List resources in assets/scenes/modular/
var dir = DirAccess.open("res://assets/scenes/modular/")
if dir:
print("Files in res://assets/scenes/modular/:")
dir.list_dir_begin()
var f = dir.get_next()
while f != "":
print(" ", f)
f = dir.get_next()
else:
print("Cannot open res://assets/scenes/modular/")
print("Error code: ", DirAccess.get_open_error())
# Try loading kit_demo
var path = "res://assets/scenes/modular/kit_demo.tscn"
print("Loading: ", path)
var scene = ResourceLoader.load(path)
if scene:
print("Loaded! Class: ", scene.get_class())
var instance = scene.instantiate()
print("Instantiate result: ", instance != null)
if instance:
print("Instance type: ", instance.get_class())
print("Instance name: ", instance.name)
else:
print("Failed to load: ", path)
# Try wall_straight_01
path = "res://assets/scenes/modular/wall_straight_01.tscn"
print("Loading: ", path)
scene = ResourceLoader.load(path)
if scene:
print("Loaded! Class: ", scene.get_class())
else:
print("Failed to load: ", path)
quit(0)
+1 -1
View File
@@ -1 +1 @@
uid://d0go24t1lsx2j
uid://d34jlb2dhy72t