@tool extends SceneTree # ============================================================================= # validate_map.gd — Map Scene Structure Validator # ============================================================================= # Validates that a competitive FPS map scene follows the required structure: # - CSG Combiner as root node # - Spawn groups (T Spawn / CT Spawn) # - Bomb sites (A / B) # - Buy zones (T / CT) # - Map bounds (invisible walls / kill plane) # - LightmapGI configuration # - Lighting setup (WorldEnvironment, directional/ambient lights) # # Usage: # godot --headless --script client/scripts/validate_map.gd -- res://path/to/map.tscn # # Exit codes: # 0 — All checks passed # 1 — One or more checks failed # 2 — Scene file not found at the given path # ============================================================================= const EXPECTED_MIN_NODES := 15 const EXPECTED_MAX_NODES := 500 # Separator used to mark user arguments after -- const ARG_SEPARATOR := "--" var _pass_count := 0 var _fail_count := 0 var _warning_count := 0 var _scene_path := "" var _scene_instance: Node = null var _root_node: Node = null func _init() -> void: _parse_arguments() if _scene_path.is_empty(): printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_map.gd -- res://path/to/map.tscn") quit(2) return print("") print("==========================================") print(" MAP SCENE STRUCTURE VALIDATOR") print(" Scene: ", _scene_path) print("==========================================") print("") # Step 1 — Load and instantiate the scene var scene: PackedScene = _load_scene(_scene_path) if scene == null: printerr("ERROR: Scene not found or failed to load: ", _scene_path) quit(2) return _scene_instance = _instantiate_scene(scene) if _scene_instance == null: printerr("ERROR: Failed to instantiate scene: ", _scene_path) quit(2) return root.add_child(_scene_instance) _root_node = _scene_instance # Step 2 — Run all validation checks _run_all_checks() # Step 3 — Print summary _print_summary() # Step 4 — Exit with appropriate code if _fail_count > 0: quit(1) else: quit(0) # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- func _parse_arguments() -> void: """ Parse command-line arguments to extract the scene path. Args after '--' are user arguments: godot ... --script validate_map.gd -- res://map.tscn """ var args: PackedStringArray = OS.get_cmdline_user_args() if args.is_empty(): # Fallback: look for '--' in full cmdline var full_args: PackedStringArray = OS.get_cmdline_args() var found_sep := false for i in range(full_args.size()): if full_args[i] == ARG_SEPARATOR: found_sep = true continue if found_sep: _scene_path = full_args[i].strip_edges() break else: # First user arg is the scene path _scene_path = args[0].strip_edges() # Basic validation if _scene_path.is_empty(): return # Ensure it's a res:// path or convert relative to res:// if not _scene_path.begins_with("res://"): # Could be a relative filesystem path — keep as-is, ResourceLoader handles it pass # --------------------------------------------------------------------------- # Scene loading helpers # --------------------------------------------------------------------------- func _load_scene(path: String) -> PackedScene: """ Load a .tscn or .scn file and return the PackedScene, or null on failure. """ if not ResourceLoader.exists(path): # Try adding extension if not path.ends_with(".tscn") and not path.ends_with(".scn"): path = path + ".tscn" if not ResourceLoader.exists(path): return null var scene: PackedScene = ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) return scene func _instantiate_scene(scene: PackedScene) -> Node: """ Safely instantiate a packed scene, returning the root node or null. """ var instance: Node = null var ok := false instance = scene.instantiate() if instance != null: ok = true return instance if ok else null # --------------------------------------------------------------------------- # Check registration helpers # --------------------------------------------------------------------------- func _pass(msg: String) -> void: """Record a passed check.""" _pass_count += 1 print(" ✓ ", msg) func _fail(msg: String) -> void: """Record a failed check.""" _fail_count += 1 print(" ✗ ", msg) func _warn(msg: String) -> void: """Record a non-blocking warning.""" _warning_count += 1 print(" ⚠ ", msg) func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool: """Convenience: pass or fail based on condition.""" if condition: _pass(msg_pass) else: _fail(msg_fail) return condition # --------------------------------------------------------------------------- # Node search helpers # --------------------------------------------------------------------------- func _find_node(parent: Node, name: String) -> Node: """Recursively find a node by exact name.""" for child in parent.get_children(): if child.name == name: return child var found := _find_node(child, name) if found != null: return found return null func _find_node_by_type(parent: Node, type_name: String) -> Node: """Recursively find a node by its script or class name.""" for child in parent.get_children(): var class_str := str(child.get_class()) if class_str == type_name: return child var found := _find_node_by_type(child, type_name) if found != null: return found return null func _find_nodes_by_type(parent: Node, type_name: String) -> Array[Node]: """Recursively find all nodes of a given type.""" var results: Array[Node] = [] if str(parent.get_class()) == type_name: results.append(parent) for child in parent.get_children(): results.append_array(_find_nodes_by_type(child, type_name)) return results func _find_children_by_name_prefix(parent: Node, prefix: String) -> Array[Node]: """Find all immediate children whose name starts with prefix.""" var results: Array[Node] = [] for child in parent.get_children(): if child.name.begins_with(prefix): results.append(child) return results func _count_nodes(node: Node) -> int: """Count total nodes in the subtree, including node itself.""" var count := 1 for child in node.get_children(): count += _count_nodes(child) return count # --------------------------------------------------------------------------- # Main validation runner # --------------------------------------------------------------------------- func _run_all_checks() -> void: """Execute all validation categories.""" print("--- Root Node ---") _check_root_node() print("") print("--- Spawn Groups ---") _check_spawn_groups() print("") print("--- Bomb Sites ---") _check_bomb_sites() print("") print("--- Buy Zones ---") _check_buy_zones() print("") print("--- Map Bounds ---") _check_map_bounds() print("") print("--- LightmapGI Configuration ---") _check_lightmapgi() print("") print("--- Lighting Setup ---") _check_lighting() print("") print("--- Node Count Sanity ---") _check_node_sanity() print("") print("--- CSG Combiner Hierarchy ---") _check_csg_hierarchy() # --------------------------------------------------------------------------- # Individual check groups # --------------------------------------------------------------------------- func _check_root_node() -> void: """Verify root node is a CSG Combiner or suitable root.""" if _root_node == null: _fail("Root node is null") return var class_name := _root_node.get_class() # Accept CSGCombiner3D as root, or any reasonable Spatial/Node3D var is_csg := class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D"] var is_node3d := class_name in ["Node3D", "Spatial"] if is_csg: _pass("Root node is CSGCombiner3D ('" + _root_node.name + "')") elif is_node3d: _warn("Root node is Node3D ('" + _root_node.name + "') — expected CSGCombiner3D for map geometry") else: _warn("Root node is '" + class_name + "' ('" + _root_node.name + "') — expected CSGCombiner3D") func _check_spawn_groups() -> void: """Verify spawn point groups exist (T Spawn, CT Spawn).""" var found_t_spawn := false var found_ct_spawn := false for child in _root_node.get_children(): var name_lower := child.name.to_lower() if "t_spawn" in name_lower or "tspawn" in name_lower or "terrorist" in name_lower: found_t_spawn = true _pass("T Spawn group found: '" + child.name + "'") if "ct_spawn" in name_lower or "ctspawn" in name_lower or "counter" in name_lower: found_ct_spawn = true _pass("CT Spawn group found: '" + child.name + "'") if not found_t_spawn: _fail("T Spawn group not found (expected node containing 'T_Spawn' or 'Terrorist')") if not found_ct_spawn: _fail("CT Spawn group not found (expected node containing 'CT_Spawn' or 'Counter')") func _check_bomb_sites() -> void: """Verify bomb site marker nodes exist (A, B).""" var found_a := false var found_b := false for child in _root_node.get_children(): var name_lower := child.name.to_lower() if "bombsite_a" in name_lower or "bomb_site_a" in name_lower or "site_a" in name_lower or "a_site" in name_lower: found_a = true _pass("Bomb Site A found: '" + child.name + "'") if "bombsite_b" in name_lower or "bomb_site_b" in name_lower or "site_b" in name_lower or "b_site" in name_lower: found_b = true _pass("Bomb Site B found: '" + child.name + "'") if not found_a: _fail("Bomb Site A not found (expected node containing 'Bombsite_A' or 'Site_A')") if not found_b: _fail("Bomb Site B not found (expected node containing 'Bombsite_B' or 'Site_B')") func _check_buy_zones() -> void: """Verify buy zone areas exist for T and CT spawns.""" var found_t_buy := false var found_ct_buy := false for child in _root_node.get_children(): var name_lower := child.name.to_lower() if ("t_buy" in name_lower or "buy_t" in name_lower or "buyzone_t" in name_lower) and "zone" in name_lower: found_t_buy = true _pass("T Buy Zone found: '" + child.name + "'") if ("ct_buy" in name_lower or "buy_ct" in name_lower or "buyzone_ct" in name_lower) and "zone" in name_lower: found_ct_buy = true _pass("CT Buy Zone found: '" + child.name + "'") if not found_t_buy or not found_ct_buy: _warn("Buy zones not found — expected Area3D nodes named 'T_BuyZone' and 'CT_BuyZone'") func _check_map_bounds() -> void: """Verify map bounds exist (invisible walls, kill plane).""" var found_bounds := false var found_killplane := false for child in _root_node.get_children(): var name_lower := child.name.to_lower() if "bounds" in name_lower or "boundary" in name_lower or "wall" in name_lower: if "invisible" in name_lower or "clip" in name_lower or "world" in name_lower: found_bounds = true _pass("Map bounds found: '" + child.name + "'") if "kill" in name_lower or "death" in name_lower or "fall" in name_lower: found_killplane = true _pass("Kill plane / death zone found: '" + child.name + "'") if not found_bounds: _warn("No map boundary/invisible wall nodes detected (optional but recommended)") if not found_killplane: _warn("No kill plane / death zone found (optional but recommended)") func _check_lightmapgi() -> void: """Validate LightmapGI node configuration.""" var lightmap := _find_node_by_type(_root_node, "LightmapGI") if lightmap == null: _fail("LightmapGI node not found in scene") return _pass("LightmapGI node found: '" + lightmap.name + "'") # Check quality if lightmap.has_method("get_quality"): var q := lightmap.get_quality() _check(q >= 1, "LightmapGI quality set (≥ Medium)", "LightmapGI quality is Low (0) — set to at least Medium") # Check bounces var bounces: int = lightmap.get("bounces") if "bounces" in lightmap else -1 if bounces >= 0: _check(bounces >= 2, "LightmapGI.bounces >= 2", "LightmapGI.bounces is " + str(bounces) + " — expected ≥ 2") # Check interior var interior: bool = lightmap.get("interior") if "interior" in lightmap else false _check(interior == true, "LightmapGI.interior = true (indoor map)", "LightmapGI.interior is false — set to true for indoor scenes") # Check texel scale var texel_scale: float = lightmap.get("texel_scale") if "texel_scale" in lightmap else 0.0 _check(abs(texel_scale - 1.0) < 0.01, "LightmapGI.texel_scale ≈ 1.0", "LightmapGI.texel_scale is " + str(texel_scale) + " — expected 1.0") # Check denoiser var denoiser: bool = lightmap.get("use_denoiser") if "use_denoiser" in lightmap else false _check(denoiser == true, "LightmapGI.use_denoiser = true", "LightmapGI.use_denoiser is false — recommended true") # Check max texture size var max_tex: int = lightmap.get("max_texture_size") if "max_texture_size" in lightmap else 0 if max_tex > 0: _check(max_tex <= 1024, "LightmapGI.max_texture_size ≤ 1024", "LightmapGI.max_texture_size is " + str(max_tex) + " — consider ≤ 1024") # Check energy var energy: float = lightmap.get("energy") if "energy" in lightmap else 1.0 _warn("LightmapGI energy = " + str(energy) + " (verify visually)") func _check_lighting() -> void: """Verify lighting setup: WorldEnvironment, directional light, etc.""" var world_env := _find_node_by_type(_root_node, "WorldEnvironment") if world_env != null: _pass("WorldEnvironment node found") # Check environment resource if world_env.has_method("get_environment"): var env := world_env.get_environment() if env != null: _pass("WorldEnvironment has Environment resource") else: _fail("WorldEnvironment has no Environment resource assigned") else: _warn("WorldEnvironment — check environment assignment manually") else: _fail("WorldEnvironment node not found — required for map lighting") # Directional light (sun) var sun := _find_node_by_type(_root_node, "DirectionalLight3D") if sun != null: _pass("DirectionalLight3D (sun) found: '" + sun.name + "'") else: _fail("DirectionalLight3D not found — map needs a sun light") # Check for too many dynamic lights var omni_lights := _find_nodes_by_type(_root_node, "OmniLight3D") var spot_lights := _find_nodes_by_type(_root_node, "SpotLight3D") if omni_lights.size() + spot_lights.size() > 4: _warn("Scene has " + str(omni_lights.size() + spot_lights.size()) + " dynamic lights — consider baking (recommended ≤ 4)") else: _pass("Dynamic light count (" + str(omni_lights.size() + spot_lights.size()) + ") within budget (≤ 4)") # Reflection probes var ref_probes := _find_nodes_by_type(_root_node, "ReflectionProbe") if ref_probes.size() > 0: _pass("ReflectionProbe(s) found: " + str(ref_probes.size())) else: _warn("No ReflectionProbe found — consider adding for visual quality") func _check_node_sanity() -> void: """Verify total node count is within expected range.""" var total := _count_nodes(_root_node) if total < EXPECTED_MIN_NODES: _warn("Scene has only " + str(total) + " nodes — very minimal (< " + str(EXPECTED_MIN_NODES) + ")") elif total > EXPECTED_MAX_NODES: _warn("Scene has " + str(total) + " nodes — consider optimization (> " + str(EXPECTED_MAX_NODES) + ")") else: _pass("Node count " + str(total) + " within expected range [" + str(EXPECTED_MIN_NODES) + ", " + str(EXPECTED_MAX_NODES) + "]") func _check_csg_hierarchy() -> void: """Check that CSG nodes are properly parented under a CSGCombiner3D.""" var csg_nodes := _find_nodes_by_type(_root_node, "CSGCombiner3D") if csg_nodes.size() > 1: _warn("Multiple CSGCombiner3D nodes found (" + str(csg_nodes.size()) + ") — expected single root combiner") elif csg_nodes.size() == 0: _warn("No CSGCombiner3D found at root (map may use mesh instances instead)") var csg_shape_nodes := 0 csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGBox3D").size() csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGSphere3D").size() csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGCylinder3D").size() csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGTorus3D").size() csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGPolygon3D").size() if csg_shape_nodes > 0: _pass("CSG primitive nodes found: " + str(csg_shape_nodes)) else: _warn("No CSG primitive nodes found — map may use imported meshes instead") # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- func _print_summary() -> void: """Print a final summary of all checks.""" print("") print("==========================================") print(" VALIDATION SUMMARY") print("==========================================") print(" Scene: ", _scene_path) print(" Passed: ", _pass_count) print(" Failed: ", _fail_count) print(" Warnings: ", _warning_count) print("------------------------------------------") if _fail_count > 0: print(" RESULT: FAILED ❌") printerr("Validation FAILED — ", _fail_count, " check(s) failed.") else: print(" RESULT: PASSED ✅") print("==========================================") print("")