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
+88
View File
@@ -0,0 +1,88 @@
# Map Validator Scripts
Validator scripts for Tactical Shooter map scenes. Run against any `.tscn` file to
check structural integrity, performance budget, and lighting correctness before
packaging.
## Usage
```bash
# From the project root or anywhere with --path pointing at the Godot project
godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
```
The `--` separates Godot engine args from user args. The scene path must be a
`res://`-prefixed Godot resource path.
## Exit Codes
| Code | Meaning | Description |
|------|----------------|-------------------------------------------|
| 0 | PASS | All checks passed, no warnings |
| 1 | WARNINGS | Passed with non-blocking suggestions |
| 2 | ERRORS | Failed — must-fix items found |
Use exit codes in CI/CD pipelines to gate map merges:
```bash
godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
if [ $? -eq 2 ]; then
echo "Map validation FAILED — fix errors before submitting"
exit 1
fi
```
## Validator Modules
### 1. Scene Structure (`validate_scene.gd`)
- Scene root is a `Node3D`
- Required node types present: `WorldEnvironment`, `LightmapGI`
- Required Godot groups assigned to one node each: `ct_spawn`, `t_spawn`, `buy_zone`, `bomb_site`, `cubemap_origin`
- Node naming follows PascalCase convention
### 2. Polygon Count (`validate_polycount.gd`)
- Total mesh triangle count across all `MeshInstance3D` nodes ≤ **50,000**
- Per-mesh breakdown printed for debugging
- Warns on individual meshes exceeding 5K triangles
- Warns if no `MeshInstance3D` nodes found (empty map)
### 3. Texture Sizes (`validate_textures.gd`)
- All material textures (albedo, normal, roughness, metallic, ORM, emission, AO) ≤ **1024×1024**
- Checks `StandardMaterial3D`, `ORMMaterial3D`, and `ShaderMaterial` (Texture2D params)
- Warns on textures > 512×512 (suggested for secondary surfaces)
- LightmapGI baked textures checked separately
### 4. Lights & Lightmap (`validate_lights.gd`)
- Dynamic (non-baked) `OmniLight3D` + `SpotLight3D`**4**
- `DirectionalLight3D` counts toward dynamic budget if light_bake_mode ≠ Static
- `LightmapGI` node present and baked (light_data ≠ null)
- Lightmap quality, bounces, texel scale, and max texture size checked
- `WorldEnvironment` configured with Environment resource
- `ReflectionProbe` presence and settings
- Warns on dynamic lights with shadows enabled (performance cost)
## Architecture
```
client/tools/
├── validate_map.gd # Main entry point: parses args, loads scene, runs modules
└── validate_map/
├── validate_scene.gd # Scene structure validator
├── validate_polycount.gd # Polygon count validator
├── validate_textures.gd # Texture size validator
└── validate_lights.gd # Light/lightmap validator
```
All modules are `@tool` `RefCounted` scripts exporting a single `validate(scene_root: Node, scene_path: String) -> Dictionary` function.
## Adding a New Validator
1. Create `tools/validate_map/validate_<name>.gd` extending `RefCounted`
2. Implement `func validate(scene_root: Node, scene_path: String) -> Dictionary`
3. Return `{"pass": bool, "errors": [String], "warnings": [String]}`
4. Add a `_run_module("<name>", instance, scene_path)` call in `validate_map.gd:_ready()`
## Requirements
- Godot 4.x
- The map scene must be self-contained (all resources accessible via `res://` paths)
- Run from a terminal (not embedded in a running game)
+7
View File
@@ -0,0 +1,7 @@
@tool
extends Node
func _ready() -> void:
print("HELLO FROM VALIDATOR")
print("Args: ", OS.get_cmdline_user_args())
get_tree().quit(0)
@@ -0,0 +1 @@
uid://c6gr7qvmxxb2q
@@ -0,0 +1,10 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/validate_map.gd" id="1"]
[node name="TestRoot" type="Node3D"]
script = ExtResource("1")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
[node name="LightmapGI" type="LightmapGI" parent="."]
@@ -0,0 +1,151 @@
@tool
extends RefCounted
# Validator: Light Count & Lightmap Status
#
# Checks:
# - Dynamic (non-baked) OmniLight3D + SpotLight3D count ≤ 4
# - DirectionalLight3D is allowed (counts as 1 toward dynamic budget if not baked)
# - WorldEnvironment has tonemap/ambient configured
# - LightmapGI present and baked (light_data != null)
# - ReflectionProbe count and settings
# - Light bake modes should be 2 (Static) for all static geometry lights
const MAX_DYNAMIC_LIGHTS := 4 # Omni + Spot + unbaked Directional
func validate(scene_root: Node, scene_path: String) -> Dictionary:
var errors: Array[String] = []
var warnings: Array[String] = []
var all_lights: Array[Node] = []
_collect_light_nodes(scene_root, all_lights)
var dynamic_count := 0
var baked_count := 0
var directional_count := 0
var omni_count := 0
var spot_count := 0
for light in all_lights:
var bake_mode := -1
if light.has_method("get_light_bake_mode"):
bake_mode = light.get("light_bake_mode") as int
var is_dynamic := bake_mode == 0 # BAKE_DISABLED
if light is DirectionalLight3D:
directional_count += 1
if is_dynamic:
dynamic_count += 1
else:
baked_count += 1
elif light is OmniLight3D:
omni_count += 1
if is_dynamic:
dynamic_count += 1
else:
baked_count += 1
elif light is SpotLight3D:
spot_count += 1
if is_dynamic:
dynamic_count += 1
else:
baked_count += 1
print(" Light breakdown:")
print(" DirectionalLight3D: ", directional_count)
print(" OmniLight3D: ", omni_count)
print(" SpotLight3D: ", spot_count)
print(" Dynamic (non-baked): ", dynamic_count)
print(" Baked: ", baked_count)
print("")
# Check dynamic light budget
if dynamic_count > MAX_DYNAMIC_LIGHTS:
errors.append("Too many dynamic lights: %d (max %d) — set light_bake_mode = 2 (Static) on static lights to reduce" % [dynamic_count, MAX_DYNAMIC_LIGHTS])
else:
print(" ✓ Dynamic light count: ", dynamic_count, " (limit: ", MAX_DYNAMIC_LIGHTS, ")")
# Check LightmapGI
var lightmaps: Array[LightmapGI] = []
_find_nodes_by_type(scene_root, "LightmapGI", lightmaps)
if lightmaps.is_empty():
errors.append("No LightmapGI node found — maps should include a LightmapGI for baked global illumination")
else:
var lm := lightmaps[0]
if lightmaps.size() > 1:
warnings.append("Multiple LightmapGI nodes found (%d) — only one recommended" % lightmaps.size())
if lm.light_data != null:
print(" ✓ LightmapGI baked (light_data present)")
else:
warnings.append("LightmapGI node found but NOT BAKED — light_data is null. Select LightmapGI → 'Bake Lightmap' button in editor")
# Quality settings check
print(" Quality: ", lm.quality, " (0=Low, 1=Med, 2=High, 3=Ultra)")
print(" Bounces: ", lm.bounces)
print(" Texel Scale: ", lm.texel_scale)
print(" Max Texture Size: ", lm.max_texture_size)
if lm.bounces < 2:
warnings.append("LightmapGI bounces set to %d — recommend a minimum of 2 for accurate indirect lighting" % lm.bounces)
if lm.max_texture_size < 512:
warnings.append("LightmapGI max_texture_size is %d — may cause visible seams, recommend ≥ 512" % lm.max_texture_size)
# Check WorldEnvironment
var envs: Array[WorldEnvironment] = []
_find_nodes_by_type(scene_root, "WorldEnvironment", envs)
if envs.is_empty():
errors.append("No WorldEnvironment node found — maps need a WorldEnvironment for ambient lighting and sky")
else:
var env := envs[0].environment
if env == null:
warnings.append("WorldEnvironment node has no Environment resource assigned")
else:
print(" ✓ WorldEnvironment configured")
# ReflectionProbe check
var probes: Array[ReflectionProbe] = []
_find_nodes_by_type(scene_root, "ReflectionProbe", probes)
if probes.is_empty():
warnings.append("No ReflectionProbe found — interior maps benefit from at least one ReflectionProbe for specular reflections")
else:
print(" ReflectionProbes: ", probes.size())
for p in probes:
if p.max_distance < 5.0:
warnings.append("ReflectionProbe \"%s\" max_distance is %.1f — may be too short for interior spaces" % [p.name, p.max_distance])
# Warn on lights with shadows enabled that aren't baked
for light in all_lights:
if light.has_method("is_shadow_enabled") and light.get("shadow_enabled") == true:
var bake_mode := light.get("light_bake_mode") as int
if bake_mode == 0:
var light_name := light.name
var light_type := light.get_class()
warnings.append("Dynamic light has shadows enabled: \"%s\" (%s) — shadows on dynamic lights cost performance" % [light_name, light_type])
return {
"pass": errors.is_empty(),
"errors": errors,
"warnings": warnings,
}
func _collect_light_nodes(node: Node, result: Array) -> void:
if node is DirectionalLight3D or node is OmniLight3D or node is SpotLight3D:
result.append(node)
for child in node.get_children():
_collect_light_nodes(child, result)
func _find_nodes_by_type(root: Node, expected_type: String, output) -> void:
if root.get_class() == expected_type:
output.append(root)
for child in root.get_children():
_find_nodes_by_type(child, expected_type, output)
@@ -0,0 +1 @@
uid://b6v02wqbuawg8
@@ -0,0 +1,114 @@
@tool
extends RefCounted
# Validator: Polygon Count
#
# Checks:
# - Total mesh triangle count across all MeshInstance3D nodes ≤ 50K
# - Per-mesh breakdown printed for debugging
# - Warns on individual meshes > 5K triangles
const MAX_TOTAL_TRIANGLES := 50000
const PER_MESH_WARN_THRESHOLD := 5000
func validate(scene_root: Node, scene_path: String) -> Dictionary:
var errors: Array[String] = []
var warnings: Array[String] = []
var mesh_instances: Array[MeshInstance3D] = []
_collect_mesh_instances(scene_root, mesh_instances)
var total_triangles := 0
var mesh_breakdown: Array[Dictionary] = []
for mi in mesh_instances:
var mesh: Mesh = mi.mesh
if mesh == null:
continue
var tri_count := _count_triangles(mesh)
total_triangles += tri_count
var entry := {
"node": mi.name,
"path": _node_path_to_string(scene_root, mi),
"triangles": tri_count,
"mesh_type": mesh.get_class(),
}
mesh_breakdown.append(entry)
if tri_count > PER_MESH_WARN_THRESHOLD:
warnings.append("High-poly mesh: \"%s\" (%s) — %d triangles (limit: %d per mesh recommend)" % [
mi.name, entry["path"], tri_count, PER_MESH_WARN_THRESHOLD])
# Print breakdown
print(" Meshes scanned: ", mesh_instances.size())
print(" Total triangles: ", total_triangles)
print(" Budget: ", MAX_TOTAL_TRIANGLES)
print("")
# Sort and show top contributors
mesh_breakdown.sort_custom(func(a, b): return a["triangles"] > b["triangles"])
var top := mesh_breakdown.slice(0, min(10, mesh_breakdown.size()))
if not top.is_empty():
print(" Top meshes by triangle count:")
for m in top:
print(" %6d %s (%s)" % [m["triangles"], m["node"], m["mesh_type"]])
if mesh_breakdown.size() > 10:
print(" ... and ", mesh_breakdown.size() - 10, " more")
if total_triangles > MAX_TOTAL_TRIANGLES:
errors.append("Map exceeds %d triangle budget: %d total (%.1f%% over budget)" % [
MAX_TOTAL_TRIANGLES, total_triangles,
float(total_triangles - MAX_TOTAL_TRIANGLES) / MAX_TOTAL_TRIANGLES * 100.0])
else:
var pct := float(total_triangles) / MAX_TOTAL_TRIANGLES * 100.0
print(" ✓ Within budget (%.1f%% of %d)" % [pct, MAX_TOTAL_TRIANGLES])
# Warn if mesh_instances is empty (suspicious map with no geometry)
if mesh_instances.is_empty():
warnings.append("No MeshInstance3D nodes found in scene — map may be empty or using unsupported geometry type")
return {
"pass": errors.is_empty(),
"errors": errors,
"warnings": warnings,
}
func _collect_mesh_instances(node: Node, result: Array) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)
func _count_triangles(mesh: Mesh) -> int:
var total := 0
for i in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(i)
if arrays.is_empty():
continue
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if indices and indices.size() > 0:
total += indices.size() / 3
else:
# Non-indexed mesh — count vertices / 3 as approximation
var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
if verts:
total += verts.size() / 3
return total
func _node_path_to_string(root: Node, node: Node) -> String:
var full_path: String = node.get_path_to(root)
# full_path is a NodePath string like "../NodeA/NodeB" — split and drop the root
var parts := full_path.split("/")
# Remove the first segment (the "../" back-reference) and last if empty
var effective: Array[String] = []
for p in parts:
if p.is_empty() or p == "." or p.begins_with(".."):
continue
effective.append(p)
return "/".join(effective)
@@ -0,0 +1 @@
uid://hefbh8r7ujn
+122
View File
@@ -0,0 +1,122 @@
@tool
extends RefCounted
# Validator: Scene Structure
#
# Checks:
# - Scene root is a Node3D (or subtype)
# - Required nodes present: WorldEnvironment, LightmapGI
# - Required groups present: ct_spawn, t_spawn, buy_zone, bomb_site, cubemap_origin
# - No duplicate groups on different nodes
# - Node naming follows convention (PascalCase, no spaces)
const REQUIRED_GROUPS := [
"ct_spawn",
"t_spawn",
"buy_zone",
"bomb_site",
"cubemap_origin",
]
const DYNAMIC_BAKE_MODE := 0 # light_bake_mode = 0 means dynamic
func validate(scene_root: Node, scene_path: String) -> Dictionary:
var errors: Array[String] = []
var warnings: Array[String] = []
# 1. Root node type
if not scene_root is Node3D:
errors.append("Scene root must inherit Node3D, got: " + scene_root.get_class())
else:
print(" ✓ Root node: ", scene_root.name, " (", scene_root.get_class(), ")")
# 2. Required nodes
_check_required_node(scene_root, "WorldEnvironment", errors, "WorldEnvironment")
_check_required_node(scene_root, "LightmapGI", errors, "LightmapGI")
# 3. Node naming convention
_check_naming(scene_root, warnings, [])
# 4. Required groups
var found_groups: Dictionary = {} # group → [node names]
_collect_groups(scene_root, found_groups)
for group in REQUIRED_GROUPS:
if found_groups.has(group):
var nodes := found_groups[group] as Array
print(" ✓ Group \"", group, "\"", nodes[0], " (", nodes.size(), " node(s))")
if nodes.size() > 1:
warnings.append("Group \"%s\" assigned to %d nodes — only one expected" % [group, nodes.size()])
else:
errors.append("Missing required group: \"" + group + "\" — no node in the map has this group")
# 5. Check for orphan / stray groups (as hint)
var all_groups := _get_all_group_names(found_groups)
for g in all_groups:
if g not in REQUIRED_GROUPS and g.begins_with("res://"):
warnings.append("Unexpected group with resource path pattern: \"" + g + "\"")
return {
"pass": errors.is_empty(),
"errors": errors,
"warnings": warnings,
}
func _check_required_node(root: Node, node_name: String, errors: Array, type_hint: String) -> void:
var found: Array[Node] = []
_find_nodes_by_type(root, type_hint, found)
if found.is_empty():
errors.append("Missing required %s — map needs a %s node named \"%s\"" % [type_hint, type_hint, node_name])
return
# Check naming
if found[0].name != node_name:
errors.append("%s node found but not named \"%s\" — got \"%s\", rename to match convention" % [type_hint, node_name, found[0].name])
else:
print("", node_name, " (", type_hint, ")")
func _find_nodes_by_type(root: Node, expected_type: String, output) -> void:
if root.get_class() == expected_type:
output.append(root)
for child in root.get_children():
_find_nodes_by_type(child, expected_type, output)
func _collect_groups(root: Node, groups: Dictionary) -> void:
for g in root.get_groups():
# Godot auto-adds "__builtins" and similar — skip those
if g.begins_with("__"):
continue
if not groups.has(g):
groups[g] = []
(groups[g] as Array).append(root.name)
for child in root.get_children():
_collect_groups(child, groups)
func _get_all_group_names(groups: Dictionary) -> Array:
var names: Array = []
for key in groups:
names.append(key)
return names
func _check_naming(node: Node, warnings: Array, path: Array) -> void:
var path_str: String = "/".join(path + [node.name]) if not path.is_empty() else node.name
# Check for spaces in name
if " " in node.name:
warnings.append("Node name contains spaces: \"" + path_str + "\" — use PascalCase instead")
# Check for lowercase start on non-leaf nodes (leaf instances may have instance names)
if node.get_child_count() > 0 and node.name.length() > 0:
var first: String = String(node.name)[0]
if first == first.to_lower() and first != first.to_upper():
warnings.append("Node name should start uppercase (PascalCase): \"" + path_str + "\"")
for child in node.get_children():
_check_naming(child, warnings, path + [node.name])
@@ -0,0 +1 @@
uid://dt8vumbcs0bs
@@ -0,0 +1,157 @@
@tool
extends RefCounted
# Validator: Texture Size
#
# Checks:
# - All textures referenced by materials in the scene are ≤ 1024×1024
# - Checks StandardMaterial3D, ORMMaterial3D, and ShaderMaterial textures
# - Warns on textures > 512×512 (suggested for secondary surfaces)
# - Skips LightmapGI textures (baked lightmaps are handled separately)
const MAX_TEXTURE_SIZE := 1024
const WARN_TEXTURE_SIZE := 512
func validate(scene_root: Node, scene_path: String) -> Dictionary:
var errors: Array[String] = []
var warnings: Array[String] = []
var inspected := 0
var oversized: Array[String] = []
# Collect all MeshInstance3D nodes and their materials
var mesh_instances: Array[MeshInstance3D] = []
_collect_mesh_instances(scene_root, mesh_instances)
# Track visited textures to avoid duplicate reports
var visited_textures: Dictionary = {}
for mi in mesh_instances:
var mesh: Mesh = mi.mesh
if mesh == null:
continue
for surf_idx in mesh.get_surface_count():
var mat := mesh.surface_get_material(surf_idx)
if mat == null:
# Check override material on MeshInstance3D
mat = mi.get_surface_override_material(surf_idx)
if mat == null:
# Check material_override on MeshInstance3D
mat = mi.material_override
if mat == null:
continue
_check_material_textures(mat, visited_textures, errors, warnings, inspected)
# Check material_override if no per-surface materials
if mesh_instances.is_empty():
_find_materials_on_node(scene_root, visited_textures, errors, warnings, inspected)
# Check LightmapGI light_data texture sizes separately
var lightmaps: Array[LightmapGI] = []
_find_nodes_by_type(scene_root, "LightmapGI", lightmaps)
for lm in lightmaps:
if lm.light_data != null:
var lightmap_tex = lm.light_data.get_texture()
if lightmap_tex != null and not visited_textures.has(lightmap_tex.resource_path):
visited_textures[lightmap_tex.resource_path] = true
var size := _get_texture_size(lightmap_tex)
if size > 0:
print(" Lightmap texture: ", lightmap_tex.resource_path, "", size, "×")
if size > MAX_TEXTURE_SIZE:
warnings.append("Lightmap texture exceeds %d: %s (%d×)" % [MAX_TEXTURE_SIZE, lightmap_tex.resource_path, size])
print("")
print(" Textures inspected: ", inspected)
if oversized.is_empty():
print(" ✓ All textures within ", MAX_TEXTURE_SIZE, "×", MAX_TEXTURE_SIZE, " limit")
else:
for o in oversized:
print("", o)
return {
"pass": errors.is_empty(),
"errors": errors,
"warnings": warnings,
}
func _check_material_textures(mat: Material, visited: Dictionary, errors: Array, warnings: Array, inspected: int) -> void:
if mat is StandardMaterial3D:
var smat := mat as StandardMaterial3D
_check_single_texture(smat.albedo_texture, "albedo", smat.resource_path, visited, errors, warnings, inspected)
_check_single_texture(smat.normal_texture, "normal", smat.resource_path, visited, errors, warnings, inspected)
_check_single_texture(smat.roughness_texture, "roughness", smat.resource_path, visited, errors, warnings, inspected)
_check_single_texture(smat.metallic_texture, "metallic", smat.resource_path, visited, errors, warnings, inspected)
_check_single_texture(smat.emission_texture, "emission", smat.resource_path, visited, errors, warnings, inspected)
_check_single_texture(smat.ao_texture, "ambient_occlusion", smat.resource_path, visited, errors, warnings, inspected)
elif mat is ORMMaterial3D:
var orm := mat as ORMMaterial3D
_check_single_texture(orm.albedo_texture, "albedo", orm.resource_path, visited, errors, warnings, inspected)
_check_single_texture(orm.normal_texture, "normal", orm.resource_path, visited, errors, warnings, inspected)
_check_single_texture(orm.orm_texture, "ORM", orm.resource_path, visited, errors, warnings, inspected)
_check_single_texture(orm.emission_texture, "emission", orm.resource_path, visited, errors, warnings, inspected)
elif mat is ShaderMaterial:
var sm := mat as ShaderMaterial
# ShaderMaterial textures are set via shader params — check all Texture2D params
var param_list = sm.get_shader_parameter_list() if sm.shader else []
for param in param_list:
var val = sm.get_shader_parameter(param)
if val is Texture2D:
_check_single_texture(val as Texture2D, param, sm.resource_path, visited, errors, warnings, inspected)
func _check_single_texture(tex: Texture2D, slot_name: String, material_path: String, visited: Dictionary, errors: Array, warnings: Array, inspected: int) -> void:
if tex == null:
return
var path := tex.resource_path
if path.is_empty():
path = tex.name # built-in/placeholder texture
if visited.has(path):
return
visited[path] = true
inspected += 1
var size := _get_texture_size(tex)
if size <= 0:
return
var label := "%s/%s" % [material_path.get_file(), slot_name]
if size > MAX_TEXTURE_SIZE:
errors.append("Texture exceeds %d×%d: %s (%s) — %d× (path: %s)" % [MAX_TEXTURE_SIZE, MAX_TEXTURE_SIZE, label, path, size, path])
elif size > WARN_TEXTURE_SIZE:
warnings.append("Texture > %d×%d: %s (%s) — %d× (consider downscaling)" % [WARN_TEXTURE_SIZE, WARN_TEXTURE_SIZE, label, path, size])
func _get_texture_size(tex: Texture2D) -> int:
# Return the larger dimension
return max(tex.get_width(), tex.get_height())
func _collect_mesh_instances(node: Node, result: Array) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)
func _find_nodes_by_type(root: Node, expected_type: String, output) -> void:
if root.get_class() == expected_type:
output.append(root)
for child in root.get_children():
_find_nodes_by_type(child, expected_type, output)
func _find_materials_on_node(node: Node, visited: Dictionary, errors: Array, warnings: Array, inspected: int) -> void:
# Fallback: walk all nodes and check their material_override
for child in node.get_children():
if child.has_method("get_material_override"):
var mat = child.get("material_override")
if mat != null and mat is Material:
_check_material_textures(mat, visited, errors, warnings, inspected)
_find_materials_on_node(child, visited, errors, warnings, inspected)
@@ -0,0 +1 @@
uid://bgpcwrlic4dww
@@ -0,0 +1,31 @@
@tool
extends Node
# Bootstrap: loads and runs validate_map.gd from the main scene.
# Usage:
# 1. Set project.godot run/main_scene to this scene
# 2. godot --path client [-- <scene_path>]
# 3. Then restore the original main_scene
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.is_empty():
print("Usage: godot --path client [-- <scene_path>]")
print("")
print("If no scene_path given, validates the default scene.")
get_tree().quit(1)
return
var scene_path := args[0]
# Load and run the validate_map.gd script
var validator_script := load("res://tools/validate_map.gd")
if validator_script == null:
push_error("Failed to load validator script")
get_tree().quit(1)
return
var validator := Node.new()
validator.set_script(validator_script)
add_child(validator)
@@ -0,0 +1 @@
uid://cyql0huyj6r5y
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/validate_map.gd" id="1"]
[node name="Validator" type="Node"]
script = ExtResource("1")