Files
tactical-shooter/client/scripts/validate_map.gd
T
shawn ffa72a8f24 t_p5_validator: add 5 Godot 4 map validator scripts
- validate_map.gd — scene structure validator (CSG combiner root, spawn groups,
  bomb sites, buy zones, map bounds, LightmapGI config, lighting setup)
- validate_polycount.gd — CSG triangle budget checker
  (<50K faces, <5K per node, <200 CSG nodes)
- validate_textures.gd — texture size ≤1K, mipmaps, UV2 checker
- validate_lights.gd — dynamic lights ≤4, bake mode validator
- bake_lightmaps.gd — LightmapGI config checker
  (quality, bounces, texel_scale, interior, denoiser)

All scripts: @tool SceneTree, accept scene path via -- separator,
exit 0=pass / 1=fail / 2=scene-not-found. 443-610 lines each.
2026-07-01 18:35:52 -04:00

512 lines
17 KiB
GDScript

@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("")