ffa72a8f24
- 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.
512 lines
17 KiB
GDScript
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("")
|