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:
@@ -1 +1 @@
|
||||
uid://c6dxl1qhan4yl
|
||||
uid://dwuuyukbsetmm
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
extends SceneTree
|
||||
|
||||
# Generalised Lightmap Baking Config Checker
|
||||
# ==========================================
|
||||
# Validates that a map scene's LightmapGI is configured correctly for baking.
|
||||
# Generalised replacement for scripts/bake_lighting.gd — works on ANY map scene.
|
||||
#
|
||||
# Note: Godot 4.7 LightmapGI baking is editor-only. This script validates
|
||||
# configuration only; actual baking must be done from the Godot editor.
|
||||
#
|
||||
# Usage:
|
||||
# godot --headless --script bake_lightmaps.gd -- res://maps/my_map.tscn
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — All checks passed (or warnings only)
|
||||
# 1 — One or more checks failed (fatal)
|
||||
# 2 — Scene file could not be loaded
|
||||
|
||||
var _scene_path: String = ""
|
||||
var _checks: Array = [] # [label, passed, is_warning]
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_parse_args()
|
||||
if _scene_path.is_empty():
|
||||
printerr("ERROR: No scene path provided.")
|
||||
print("Usage: godot --headless --script bake_lightmaps.gd -- res://maps/my_map.tscn")
|
||||
quit(2)
|
||||
return
|
||||
|
||||
print("")
|
||||
print("=== Lightmap Baking Config Checker: %s ===" % _scene_path)
|
||||
print("")
|
||||
|
||||
var scene = ResourceLoader.load(_scene_path)
|
||||
if not scene:
|
||||
printerr("ERROR: Could not load scene: %s" % _scene_path)
|
||||
quit(2)
|
||||
return
|
||||
|
||||
var instance = scene.instantiate()
|
||||
if not instance:
|
||||
printerr("ERROR: Could not instantiate scene!")
|
||||
quit(2)
|
||||
return
|
||||
|
||||
root.add_child(instance)
|
||||
print("Scene loaded and instantiated successfully.")
|
||||
print("")
|
||||
print("Note: Godot 4.x LightmapGI baking is editor-only.")
|
||||
print("This script validates configuration only.")
|
||||
print("To bake: Open scene in editor > Select LightmapGI > 'Bake Lightmap'")
|
||||
print("")
|
||||
|
||||
var lightmap: LightmapGI = _find_node_of_type(instance, LightmapGI)
|
||||
if not lightmap:
|
||||
printerr("ERROR: No LightmapGI node found in scene!")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
_print_config(lightmap)
|
||||
_run_checks(lightmap, instance)
|
||||
_print_results()
|
||||
|
||||
|
||||
func _parse_args() -> void:
|
||||
var args := OS.get_cmdline_args()
|
||||
var found_sep := false
|
||||
for a in args:
|
||||
if a == "--":
|
||||
found_sep = true
|
||||
continue
|
||||
if found_sep and not a.begins_with("-"):
|
||||
_scene_path = a
|
||||
return
|
||||
for a in args:
|
||||
if a.contains("res://") or a.ends_with(".tscn"):
|
||||
_scene_path = a
|
||||
return
|
||||
|
||||
|
||||
func _check(label: String, passed: bool, is_warning: bool = false) -> void:
|
||||
_checks.append([label, passed, is_warning])
|
||||
|
||||
|
||||
func _print_config(lightmap: LightmapGI) -> void:
|
||||
print(" LightmapGI configuration:")
|
||||
print(" Quality: ", _quality_name(lightmap.quality), " (", lightmap.quality, ")")
|
||||
print(" Bounces: ", lightmap.bounces)
|
||||
print(" Texel Scale: ", lightmap.texel_scale)
|
||||
print(" Max Tex Size: ", lightmap.max_texture_size)
|
||||
print(" Interior: ", lightmap.interior)
|
||||
print(" Denoiser: ", lightmap.is_using_denoiser())
|
||||
print(" Energy: ", lightmap.lightmap_energy)
|
||||
print(" Bias: ", lightmap.lightmap_probe_bias)
|
||||
|
||||
if lightmap.light_data != null:
|
||||
print(" Baked data: PRESENT ✓")
|
||||
else:
|
||||
print(" Baked data: NOT YET BAKED")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
func _run_checks(lightmap: LightmapGI, instance: Node) -> void:
|
||||
# Configuration checks
|
||||
_check("Quality ≥ Medium (1)", lightmap.quality >= 1)
|
||||
_check("Bounces ≥ 2", lightmap.bounces >= 2)
|
||||
_check("Texel Scale ≤ 2.0", lightmap.texel_scale <= 2.0)
|
||||
_check("Interior = true", lightmap.interior == true)
|
||||
|
||||
# Denoiser recommended
|
||||
_check("Use denoiser enabled", lightmap.is_using_denoiser(), true)
|
||||
|
||||
# Max texture size ≤ 4096 (sanity)
|
||||
_check("Max texture size ≤ 4096", lightmap.max_texture_size <= 4096)
|
||||
|
||||
# Energy shouldn't be zero
|
||||
_check("Lightmap energy > 0", lightmap.lightmap_energy > 0)
|
||||
|
||||
# Check for lightmap-enabled meshes
|
||||
var meshes: Array = _find_nodes_of_type(instance, MeshInstance3D)
|
||||
var static_meshes := 0
|
||||
var dynamic_meshes := 0
|
||||
for mi in meshes:
|
||||
if mi.gi_mode == GeometryInstance3D.GI_MODE_STATIC:
|
||||
static_meshes += 1
|
||||
elif mi.gi_mode == GeometryInstance3D.GI_MODE_DYNAMIC:
|
||||
dynamic_meshes += 1
|
||||
|
||||
_check("Static geometry meshes found for lightmapping", static_meshes > 0)
|
||||
|
||||
# Baked data status (informational)
|
||||
if lightmap.light_data != null:
|
||||
print(" ✓ Lightmap has baked data.")
|
||||
# Check bake timestamp if available
|
||||
var ld = lightmap.light_data
|
||||
if ld is LightmapGIData and ld.has_method("get_user_data"):
|
||||
print(" Bake data size: %d bytes" % ld.get_user_data().size())
|
||||
else:
|
||||
print(" ⚠ Lightmap NOT YET BAKED. Open in Godot editor and bake.")
|
||||
|
||||
# Summary
|
||||
print("")
|
||||
print(" Geometry summary:")
|
||||
print(" MeshInstance3D nodes: %d" % meshes.size())
|
||||
print(" GI_MODE_STATIC: %d" % static_meshes)
|
||||
print(" GI_MODE_DYNAMIC: %d" % dynamic_meshes)
|
||||
|
||||
|
||||
func _print_results() -> void:
|
||||
print("")
|
||||
print(" ─── Validation Results ───")
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var warnings := 0
|
||||
for c in _checks:
|
||||
if c[1]:
|
||||
passed += 1
|
||||
print(" ✓ %s" % c[0])
|
||||
else:
|
||||
if c[2]:
|
||||
warnings += 1
|
||||
print(" ⚠ %s" % c[0])
|
||||
else:
|
||||
failed += 1
|
||||
print(" ✗ %s" % c[0])
|
||||
|
||||
print("")
|
||||
print(" Passed: %d Warnings: %d Failed: %d" % [passed, warnings, failed])
|
||||
print("")
|
||||
|
||||
if failed > 0:
|
||||
printerr("Validation FAILED — %d check(s) failed." % failed)
|
||||
quit(1)
|
||||
else:
|
||||
print("=== Config check complete ===")
|
||||
quit(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _quality_name(q: int) -> String:
|
||||
match q:
|
||||
0: return "Low"
|
||||
1: return "Medium"
|
||||
2: return "High"
|
||||
3: return "Ultra"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
func _find_node_of_type(parent: Node, type: Script) -> Node:
|
||||
for child in parent.get_children():
|
||||
if is_instance_of(child, type):
|
||||
return child
|
||||
var found = _find_node_of_type(child, type)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _find_nodes_of_type(parent: Node, type: Script) -> Array:
|
||||
var results: Array = []
|
||||
for child in parent.get_children():
|
||||
if is_instance_of(child, type):
|
||||
results.append(child)
|
||||
results.append_array(_find_nodes_of_type(child, type))
|
||||
return results
|
||||
@@ -0,0 +1 @@
|
||||
uid://b12200dkbovx2
|
||||
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://bake_lightmaps.gd" id="1"]
|
||||
|
||||
[node name="BakeRunner" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -51,8 +51,8 @@ extends Camera3D
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Reference to parent controller.
|
||||
var _controller: FPSCharacterController = null
|
||||
## Reference to parent controller (duck-typed — uses has_method instead of class_name).
|
||||
var _controller: Node = null
|
||||
|
||||
## Bob time accumulator.
|
||||
var _bob_time: float = 0.0
|
||||
@@ -82,9 +82,9 @@ var _base_eye_y: float = 0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_controller = get_parent() as FPSCharacterController
|
||||
if _controller == null:
|
||||
push_warning("FpsCamera: Parent is not an FPSCharacterController. View features disabled.")
|
||||
_controller = get_parent()
|
||||
if _controller == null or not _controller.has_method(&"is_sprinting"):
|
||||
push_warning("FpsCamera: Parent has no is_sprinting() — view features disabled.")
|
||||
|
||||
_base_fov = fov
|
||||
|
||||
@@ -172,9 +172,9 @@ func _update_bob(delta: float) -> void:
|
||||
func _update_sway(delta: float) -> void:
|
||||
# Sway follows mouse movement indirectly via controller yaw/pitch changes
|
||||
# Use velocity for physics-based sway: recentre over time
|
||||
var vel: Vector3 = _controller.velocity
|
||||
var sway_h := clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||
var sway_v := clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||
var vel := _controller.velocity as Vector3
|
||||
var sway_h: float = clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||
var sway_v: float = clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||
# Add a tiny amount from pitch/yaw delta (not mouse, from movement direction change)
|
||||
_sway_target = Vector2(
|
||||
move_toward(_sway_target.x, sway_h, sway_response * delta),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
## PlayerNetInput — netfox BaseNetInput subclass for the tactical shooter.
|
||||
##
|
||||
## Collects player input each network tick for the rollback system.
|
||||
## Referenced by RollbackSynchronizer's `input_properties`.
|
||||
##
|
||||
## This script is ONLY loaded from client scenes where netfox is active.
|
||||
|
||||
extends "res://addons/netfox.extras/base-net-input.gd"
|
||||
class_name PlayerNetInput
|
||||
|
||||
# Movement
|
||||
var move_direction: Vector3
|
||||
var look_yaw: float
|
||||
var look_pitch: float
|
||||
|
||||
# Actions
|
||||
var jump: bool
|
||||
var sprint: bool
|
||||
var crouch: bool
|
||||
var shoot: bool
|
||||
var aim: bool
|
||||
|
||||
# Weapon
|
||||
var weapon_slot: int = -1 # -1 = no change, 0-4 for slot index
|
||||
var reload: bool
|
||||
var use: bool # interact / plant / defuse
|
||||
|
||||
# Input sequence for reconciliation
|
||||
var input_sequence: int
|
||||
@@ -0,0 +1,210 @@
|
||||
## PlayerNetfox — netfox rollback-aware player for the tactical-shooter client.
|
||||
##
|
||||
## Extends the base Player with netfox RollbackSynchronizer, TickInterpolator,
|
||||
## BaseNetInput, and WeaponManager for deterministic first-person combat.
|
||||
##
|
||||
## This script is ONLY loaded from client scenes where netfox is active.
|
||||
## The headless server uses the base player.gd which has no netfox deps.
|
||||
|
||||
extends "res://scripts/network/player.gd"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Netfox node references
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var _tick_interpolator := $TickInterpolator as TickInterpolator
|
||||
@onready var _input := $PlayerNetInput as PlayerNetInput
|
||||
@onready var _weapon_manager := $WeaponManager as WeaponManager
|
||||
@onready var _weapon_anchor := $WeaponAnchor as Node3D
|
||||
|
||||
var _rs: RollbackSynchronizer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var _gravity: float = ProjectSettings.get_setting(&"physics/3d/default_gravity", 9.8)
|
||||
var _is_local: bool = false
|
||||
var _camera: Camera3D
|
||||
var _mouse_captured: bool = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
super._ready()
|
||||
|
||||
if Engine.has_singleton(&"NetworkTime") and Engine.has_singleton(&"NetworkRollback"):
|
||||
_setup_rollback()
|
||||
_connect_netfox_signals()
|
||||
print("[PlayerNetfox] Rollback initialised for %s" % name)
|
||||
else:
|
||||
push_warning("[PlayerNetfox] netfox not available — running without rollback")
|
||||
set_process(false)
|
||||
set_physics_process(false)
|
||||
|
||||
_camera = get_viewport().get_camera_3d() if get_viewport() else null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _setup_rollback() -> void:
|
||||
_rs = RollbackSynchronizer.new()
|
||||
_rs.name = "RollbackSynchronizer"
|
||||
_rs.state_properties = PackedStringArray([
|
||||
"global_position", "is_alive", "team_id",
|
||||
"is_spectating", "spectate_target_id",
|
||||
])
|
||||
_rs.input_properties = PackedStringArray([
|
||||
"PlayerNetInput/move_direction", "PlayerNetInput/look_yaw",
|
||||
"PlayerNetInput/look_pitch", "PlayerNetInput/jump",
|
||||
"PlayerNetInput/sprint", "PlayerNetInput/crouch",
|
||||
"PlayerNetInput/shoot", "PlayerNetInput/aim",
|
||||
"PlayerNetInput/input_sequence", "PlayerNetInput/weapon_slot",
|
||||
"PlayerNetInput/reload", "PlayerNetInput/use",
|
||||
])
|
||||
add_child(_rs, true)
|
||||
_rs.rollback_ticks.connect(_rollback_tick)
|
||||
|
||||
func _connect_netfox_signals() -> void:
|
||||
var nt = Engine.get_singleton(&"NetworkTime")
|
||||
if nt:
|
||||
nt.before_tick_loop.connect(_before_tick_loop)
|
||||
nt.after_tick_loop.connect(_after_tick_loop)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _before_tick_loop() -> void:
|
||||
if _tick_interpolator and _tick_interpolator.enabled:
|
||||
_tick_interpolator.save_transform()
|
||||
|
||||
func _after_tick_loop() -> void:
|
||||
if _tick_interpolator and _tick_interpolator.enabled:
|
||||
_tick_interpolator.restore_transform()
|
||||
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
if not is_alive:
|
||||
return
|
||||
|
||||
var input := _input
|
||||
|
||||
# Look rotation
|
||||
rotate_object_local(Vector3(0, 1, 0), input.look_yaw)
|
||||
|
||||
# Gravity + jump
|
||||
_force_update_is_on_floor()
|
||||
if is_on_floor():
|
||||
if input.jump:
|
||||
velocity.y = ServerConfig.movement_jump_velocity if ServerConfig else 6.0
|
||||
else:
|
||||
velocity.y -= _gravity * delta
|
||||
|
||||
# Speed
|
||||
var speed := 5.0
|
||||
if ServerConfig:
|
||||
speed = ServerConfig.movement_walk_speed
|
||||
if input.sprint:
|
||||
speed = ServerConfig.movement_sprint_speed if ServerConfig else 7.0
|
||||
if input.crouch:
|
||||
speed = ServerConfig.movement_crouch_speed if ServerConfig else 2.5
|
||||
|
||||
# Horizontal movement
|
||||
var dir := (transform.basis * input.move_direction).normalized()
|
||||
if dir.length() > 0.0:
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
else:
|
||||
velocity.x = move_toward(velocity.x, 0.0, speed * 2.0)
|
||||
velocity.z = move_toward(velocity.z, 0.0, speed * 2.0)
|
||||
|
||||
move_and_slide()
|
||||
|
||||
func _force_update_is_on_floor() -> void:
|
||||
var old := velocity
|
||||
velocity = Vector3.ZERO
|
||||
move_and_slide()
|
||||
velocity = old
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weapon system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Give starting weapons (called by spawner).
|
||||
func give_default_weapons(team_id: int) -> void:
|
||||
if _weapon_manager == null:
|
||||
return
|
||||
|
||||
# Always give knife
|
||||
_weapon_manager.add_weapon(WeaponRegistry.get_by_id(WeaponRegistry.ID_KNIFE))
|
||||
|
||||
# Give team-default sidearm
|
||||
var default_wep := WeaponRegistry.get_default_weapon(team_id)
|
||||
_weapon_manager.add_weapon(default_wep)
|
||||
|
||||
## Handle weapon slot switching from input.
|
||||
func _handle_weapon_input(input: PlayerNetInput) -> void:
|
||||
if _weapon_manager == null:
|
||||
return
|
||||
|
||||
if input.reload:
|
||||
_weapon_manager.reload()
|
||||
|
||||
if input.weapon_slot >= 0:
|
||||
_weapon_manager.switch_to_slot(input.weapon_slot)
|
||||
# Reset so it doesn't keep switching
|
||||
input.weapon_slot = -1
|
||||
|
||||
if input.shoot:
|
||||
_weapon_manager.fire()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input handling (frame-rate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _is_local or not _mouse_captured:
|
||||
return
|
||||
if _input == null:
|
||||
return
|
||||
|
||||
if event is InputEventMouseMotion:
|
||||
_input.look_yaw -= event.relative.x * 0.005
|
||||
_input.look_pitch = clamp(_input.look_pitch - event.relative.y * 0.005 * 0.8, -1.57, 1.57)
|
||||
|
||||
if event is InputEventMouseButton and event.pressed:
|
||||
if event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_input.shoot = true
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not _is_local:
|
||||
return
|
||||
if event.is_action_pressed(&"reload"):
|
||||
_input.reload = true
|
||||
elif event.is_action_pressed(&"shoot"):
|
||||
_input.shoot = true
|
||||
|
||||
# Weapon slots 1-4
|
||||
for i in range(4):
|
||||
if event.is_action_pressed(&"weapon_slot_%d" % (i + 1)):
|
||||
_input.weapon_slot = i
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func mark_local() -> void:
|
||||
_is_local = true
|
||||
if _tick_interpolator:
|
||||
_tick_interpolator.enabled = false
|
||||
if _input:
|
||||
_input.set_process(true)
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
|
||||
health -= amount
|
||||
if health <= 0:
|
||||
health = 0
|
||||
is_alive = false
|
||||
print("[PlayerNetfox] %s killed by peer %d with weapon %d" % [name, attacker_id, weapon_id])
|
||||
@@ -0,0 +1,75 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
func _ready():
|
||||
print("=== LightmapGI Method Check (Godot 4.2) ===")
|
||||
|
||||
# Check bake method via ClassDB
|
||||
var has_bake = ClassDB.class_has_method("LightmapGI", "bake")
|
||||
print("ClassDB: LightmapGI has bake()? ", has_bake)
|
||||
|
||||
# Actually instantiate and check
|
||||
var scene = load("res://assets/scenes/modular/kit_demo.tscn")
|
||||
if not scene:
|
||||
printerr("Cannot load scene")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var inst = scene.instantiate()
|
||||
get_tree().root.add_child(inst)
|
||||
|
||||
var lgi = null
|
||||
for c in inst.get_children():
|
||||
if c is LightmapGI:
|
||||
lgi = c
|
||||
break
|
||||
|
||||
if lgi:
|
||||
var has_bake_runtime = lgi.has_method("bake")
|
||||
print("Runtime: LightmapGI has bake()? ", has_bake_runtime)
|
||||
if has_bake_runtime:
|
||||
print("OK: bake() is available!")
|
||||
else:
|
||||
print("NOT AVAILABLE: bake() is not in the runtime API")
|
||||
|
||||
# List custom methods (not from Node3D)
|
||||
var custom = []
|
||||
for m in lgi.get_method_list():
|
||||
var name = m.get("name") if m is Dictionary else ""
|
||||
# Skip Node3D/Node/Object methods
|
||||
if name.begins_with("set_") or name.begins_with("get_") or name.begins_with("is_"):
|
||||
if name not in [
|
||||
"set_name", "get_name", "set_visible", "is_visible", "is_visible_in_tree",
|
||||
"set_process", "is_processing", "set_physics_process", "is_physics_processing",
|
||||
"set_owner", "get_owner", "set_transform", "get_transform",
|
||||
"set_position", "get_position", "set_rotation", "get_rotation",
|
||||
"set_scale", "get_scale", "set_global_transform", "get_global_transform",
|
||||
"set_global_position", "get_global_position", "set_global_basis", "get_global_basis",
|
||||
"set_meta", "get_meta", "has_meta", "set_script", "get_script",
|
||||
"set_block_signals", "is_blocking_signals", "set_message_translation",
|
||||
"set_notify_transform", "is_transform_notification_enabled",
|
||||
"set_notify_local_transform", "is_local_transform_notification_enabled",
|
||||
"set_as_top_level", "is_set_as_top_level", "set_disable_scale", "is_scale_disabled",
|
||||
"set_layer_mask", "get_layer_mask", "set_layer_mask_value", "get_layer_mask_value",
|
||||
"set_sorting_offset", "get_sorting_offset", "set_sorting_use_aabb_center", "is_sorting_use_aabb_center",
|
||||
"set_editable_instance", "is_editable_instance", "set_scene_file_path", "get_scene_file_path",
|
||||
"set_unique_name_in_owner", "is_unique_name_in_owner",
|
||||
"set_editor_description", "get_editor_description",
|
||||
"set_rotation_degrees", "get_rotation_degrees", "set_rotation_order", "get_rotation_order",
|
||||
"set_rotation_edit_mode", "get_rotation_edit_mode",
|
||||
"set_physics_process_priority", "get_physics_process_priority",
|
||||
"set_process_priority", "get_process_priority", "set_process_thread_group", "get_process_thread_group",
|
||||
"set_process_thread_messages", "get_process_thread_messages",
|
||||
"set_process_thread_group_order", "get_process_thread_group_order",
|
||||
"set_physics_interpolation_mode", "get_physics_interpolation_mode",
|
||||
"set_auto_translate_mode", "get_auto_translate_mode",
|
||||
"set_display_folded", "is_displayed_folded",
|
||||
"set_process_mode", "get_process_mode",
|
||||
"set_process_internal", "is_processing_internal",
|
||||
"set_physics_process_internal", "is_physics_processing_internal",
|
||||
"set_visibility_parent", "get_visibility_parent",
|
||||
]:
|
||||
custom.append(name)
|
||||
print("LightmapGI-specific methods: ", custom)
|
||||
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://boy5c8dckndev
|
||||
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://check_bake.gd" id="1"]
|
||||
|
||||
[node name="Root" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,181 @@
|
||||
# Tactical Shooter — Map Template
|
||||
|
||||
A standalone Godot 4 project for building custom competitive maps.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Copy this directory** — `cp -r map_template/ maps/my_custom_map/`
|
||||
2. **Rename `template_map.tscn`** — e.g. `de_dust2.tscn`
|
||||
3. **Open the new directory in Godot 4** — add the map template project
|
||||
4. **Build geometry** — use CSG brushes or instance modular kit pieces
|
||||
5. **Place gameplay prefabs** — spawn points, bomb sites, buy zones
|
||||
6. **Bake lighting** — select LightmapGI → "Bake Lightmap"
|
||||
7. **Package & ship** — use the PCK pipeline to publish
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
map_template/
|
||||
├── project.godot # Standalone Godot project config
|
||||
├── template_map.tscn # Demo map scene (copy & rename)
|
||||
├── template_map.gd # Editor tool script (auto-validates map)
|
||||
└── assets/
|
||||
└── prefabs/
|
||||
├── ct_spawn.tscn # Counter-Terrorist spawn point
|
||||
├── t_spawn.tscn # Terrorist spawn point
|
||||
├── buy_zone.tscn # Purchase zone area
|
||||
├── bomb_site.tscn # Bomb plant site area
|
||||
├── cubemap_origin.tscn # Reflection probe origin
|
||||
└── map_bounds.tscn # Out-of-bounds kill wall
|
||||
```
|
||||
|
||||
## Prefab Reference
|
||||
|
||||
### Node Type Conventions
|
||||
|
||||
All gameplay nodes are discovered by **Godot groups**, not by node name.
|
||||
Add nodes to these groups for game logic to find them:
|
||||
|
||||
| Group | Node Type | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `ct_spawn` | Marker3D | CT team spawn position |
|
||||
| `t_spawn` | Marker3D | Terrorist team spawn position |
|
||||
| `buy_zone` | Area3D | Purchase-eligible zone |
|
||||
| `bomb_site` | Area3D | Bomb plant zone |
|
||||
| `cubemap_origin` | Node3D | Reflection cubemap capture point |
|
||||
| `map_bounds` | Area3D | Out-of-bounds kill wall |
|
||||
|
||||
**Additional groups (informational, set by map author):**
|
||||
- `bombsite_a` / `bombsite_b` — sub-identify which bomb site
|
||||
- `ct_buy` / `t_buy` — team-specific buy zones (if separated)
|
||||
|
||||
### ct_spawn.tscn
|
||||
|
||||
- **Type:** Marker3D (green pad with +Z direction arrow)
|
||||
- **Group:** `ct_spawn`
|
||||
- **Usage:** Place on floor, one per player slot (usually 5). The +Z arrow
|
||||
indicates the direction players face on spawn. Add child Node3Ds for
|
||||
additional spawn positions.
|
||||
- **Customisation:** Duplicate instances rather than editing the prefab.
|
||||
Adjust position and rotation only — the pad is cosmetic.
|
||||
|
||||
### t_spawn.tscn
|
||||
|
||||
- **Type:** Marker3D (red pad with +Z direction arrow)
|
||||
- **Group:** `t_spawn`
|
||||
- **Same usage as ct_spawn** but for the Terrorist team.
|
||||
|
||||
### buy_zone.tscn
|
||||
|
||||
- **Type:** Area3D with CollisionShape3D
|
||||
- **Group:** `buy_zone`
|
||||
- **Usage:** Place to define an area where players can purchase items.
|
||||
Resize the child CollisionShape3D to match the room. The Area3D
|
||||
monitors body_entered/body_exited to activate/deactivate the buy menu.
|
||||
- **Visual:** Semi-transparent yellow box as editor reference.
|
||||
|
||||
### bomb_site.tscn
|
||||
|
||||
- **Type:** Area3D with CollisionShape3D
|
||||
- **Group:** `bomb_site`
|
||||
- **Usage:** Place to define where the bomb can be planted.
|
||||
Resize the CollisionShape3D to cover the site. Rename the instance
|
||||
"BombsiteA" or "BombsiteB" and add the sub-group (bombsite_a / bombsite_b).
|
||||
- **Visual:** Semi-transparent orange floor panel + CSG subtraction hint
|
||||
for plantable ground area.
|
||||
|
||||
### cubemap_origin.tscn
|
||||
|
||||
- **Type:** Node3D with small blue marker
|
||||
- **Group:** `cubemap_origin`
|
||||
- **Usage:** Mark the ideal position for the ReflectionProbe cubemap
|
||||
capture. Place at eye height (1.6 units above floor) in the most
|
||||
visually prominent part of the map.
|
||||
- **Note:** The ReflectionProbe itself is a separate node — place it at
|
||||
this marker's position after geometry is finalised.
|
||||
|
||||
### map_bounds.tscn
|
||||
|
||||
- **Type:** Area3D with CollisionShape3D
|
||||
- **Group:** `map_bounds`
|
||||
- **Usage:** Invisible kill wall. Place one per perimeter side, resize
|
||||
to form a closed box around the playable area. Detects body_exited
|
||||
to teleport/kill out-of-bounds players.
|
||||
- **Visual:** Semi-transparent red wall as editor reference.
|
||||
|
||||
## Building a Map — Checklist
|
||||
|
||||
### Essential
|
||||
- [ ] Floor geometry (CSGBox3D or modular kit tiles)
|
||||
- [ ] Wall geometry enclosing the playable area
|
||||
- [ ] Ceiling or skybox boundary
|
||||
- [ ] Collision on all CSG brushes (`use_collision = true`)
|
||||
|
||||
### Gameplay
|
||||
- [ ] CT spawn points for each player (group `ct_spawn`)
|
||||
- [ ] T spawn points for each player (group `t_spawn`)
|
||||
- [ ] Two bomb sites, each with group `bomb_site`
|
||||
- [ ] Buy zones at each spawn area (group `buy_zone`)
|
||||
- [ ] Map bounds perimeter walls (group `map_bounds`)
|
||||
|
||||
### Lighting
|
||||
- [ ] WorldEnvironment with sky/ambient
|
||||
- [ ] DirectionalLight3D (sun) with shadows enabled
|
||||
- [ ] Fill/ambient OmniLight3D(s)
|
||||
- [ ] ReflectionProbe at cubemap origin
|
||||
- [ ] LightmapGI configured with quality=2, bounces=3
|
||||
- [ ] Lightmap baked (select LightmapGI → "Bake Lightmap")
|
||||
|
||||
### Validation
|
||||
- [ ] Open template_map.tscn in Godot editor — the tool script auto-runs
|
||||
- [ ] Check Output panel (bottom dock) for validation results
|
||||
- [ ] All checks pass (green ✓)
|
||||
- [ ] For headless validation, use the main project's validator from `client/`:
|
||||
`godot --script scripts/validate_scene.gd -- --scene=res://maps/my_map.tscn`
|
||||
|
||||
## CSG Building Guide
|
||||
|
||||
Godot's CSG (Constructive Solid Geometry) nodes let you build map geometry
|
||||
directly in the editor. Key shortcuts:
|
||||
|
||||
- **W** — Move tool
|
||||
- **E** — Rotate tool
|
||||
- **R** — Scale tool
|
||||
- **Ctrl+D** — Duplicate selected node
|
||||
- **Ctrl+Shift+C** — Bake LightmapGI
|
||||
|
||||
CSG node types used in this template:
|
||||
|
||||
- `CSGBox3D` — Basic wall/floor/ceiling blocks
|
||||
- `CSGCombiner3D` — Grouped geometry (container, non-collidable parent)
|
||||
- `CSGSphere3D` / `CSGCylinder3D` — Curved shapes
|
||||
|
||||
CSG operations:
|
||||
| Operation | Effect |
|
||||
|-----------|--------|
|
||||
| Union (0) | Adds to existing CSG (default) |
|
||||
| Subtraction (1) | Cuts hole through parent CSG |
|
||||
| Intersection (2) | Keeps only overlapping volume |
|
||||
|
||||
**Performance rule:** Use as few CSG nodes as possible. For detailed maps,
|
||||
bake CSG to a MeshInstance3D via Scene → "Convert CSG to Mesh" when
|
||||
geometry is finalised.
|
||||
|
||||
## Integrating with the Main Project
|
||||
|
||||
When your map is complete:
|
||||
|
||||
1. **Copy your .tscn + assets** into the main project at `client/maps/<map_name>/`
|
||||
2. **Pack as .pck:** `cd client && godot --headless --pack --export-pack maps/<map_name>.pck res://maps/<map_name>.tscn`
|
||||
3. **Register with master server** via POST `/api/v1/maps/register`
|
||||
4. **Test in-game:** connect to server and `changelevel <map_name>`
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Standalone preview:** Direct CSG rendering in this template uses basic
|
||||
placeholder materials. Final maps should use the main project's modular
|
||||
kit assets and PBR materials.
|
||||
- **Lightmap baking:** Godot 4.7 LightmapGI baking is editor-only. Open
|
||||
this project in the editor to bake.
|
||||
- **Non-Euclidean geometry:** Not supported by CSG. Use MeshInstance3D
|
||||
for complex shapes.
|
||||
@@ -0,0 +1,36 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="SiteMat"]
|
||||
albedo_color = Color(1.0, 0.65, 0.0, 0.3)
|
||||
metallic = 0.0
|
||||
roughness = 0.5
|
||||
transparency = 0.7
|
||||
flags_transparent = true
|
||||
|
||||
[sub_resource type="BoxShape3D" id="SiteShape"]
|
||||
size = Vector3(6.0, 3.0, 6.0)
|
||||
|
||||
[node name="BombSite" type="Area3D"]
|
||||
groups = ["bomb_site"]
|
||||
; Bomb site — the bomb can be planted within this area.
|
||||
; Resize the CollisionShape3D to cover the site's playable area.
|
||||
; Name the node "BombsiteA" or "BombsiteB" for game logic identification.
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("SiteShape")
|
||||
|
||||
[node name="Visualizer" type="CSGBox3D" parent="."]
|
||||
size = Vector3(6.0, 0.04, 6.0)
|
||||
material = SubResource("SiteMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.02, 0)
|
||||
; Semi-transparent orange floor panel to visualise the site in the editor.
|
||||
; Adjust to match the site's floor area.
|
||||
|
||||
[node name="PlaneZone" type="CSGBox3D" parent="BombSite"]
|
||||
operation = 1
|
||||
size = Vector3(5.5, 0.5, 5.5)
|
||||
material = SubResource("SiteMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
|
||||
; Invisible CSG subtraction hint — marks the plantable ground area.
|
||||
@@ -0,0 +1,28 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="ZoneMat"]
|
||||
albedo_color = Color(0.8, 0.8, 0.2, 0.3)
|
||||
metallic = 0.0
|
||||
roughness = 0.5
|
||||
transparency = 0.7
|
||||
flags_transparent = true
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="ZoneShape"]
|
||||
radius = 2.0
|
||||
height = 4.0
|
||||
|
||||
[node name="BuyZone" type="Area3D"]
|
||||
groups = ["buy_zone"]
|
||||
; Buy zone — players inside this Area3D can purchase weapons/equipment.
|
||||
; Resize the CollisionShape3D child to match the room.
|
||||
; Monitor player bodies with area_entered / area_exited signals.
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("ZoneShape")
|
||||
|
||||
[node name="Visualizer" type="CSGBox3D" parent="."]
|
||||
size = Vector3(4.0, 3.0, 4.0)
|
||||
material = SubResource("ZoneMat")
|
||||
use_collision = false
|
||||
; Semi-transparent yellow box to visualise the zone in the editor.
|
||||
; Disabled in-game — the game uses the Area3D collision, not this visualiser.
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="SpawnMat"]
|
||||
albedo_color = Color(0.2, 0.4, 1.0, 1.0)
|
||||
metallic = 0.0
|
||||
roughness = 0.8
|
||||
|
||||
[node name="CTSpawn" type="Marker3D"]
|
||||
groups = ["ct_spawn"]
|
||||
; CT spawn point — players on Counter-Terrorist team spawn here.
|
||||
; Place on the floor surface. The +Z arrow indicates spawn forward direction.
|
||||
; Child Node3Ds can be added for additional spawn positions.
|
||||
|
||||
[node name="SpawnPad" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.5, 0.03, 0.5)
|
||||
material = SubResource("SpawnMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.015, 0)
|
||||
@@ -0,0 +1,21 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="OriginMat"]
|
||||
albedo_color = Color(0.5, 0.8, 1.0, 0.5)
|
||||
metallic = 0.3
|
||||
roughness = 0.4
|
||||
transparency = 0.5
|
||||
flags_transparent = true
|
||||
|
||||
[node name="CubemapOrigin" type="Node3D"]
|
||||
groups = ["cubemap_origin"]
|
||||
; Cubemap reflection origin — position for ReflectionProbe cubemap capture.
|
||||
; The ReflectionProbe should be placed here after map lighting is finalised.
|
||||
; Place at eye height (1.6 units above floor) in the most visually prominent
|
||||
; area of the map for best reflection results.
|
||||
|
||||
[node name="Marker" type="CSGBox3D" parent="."]
|
||||
material = SubResource("OriginMat")
|
||||
size = Vector3(0.2, 0.2, 0.2)
|
||||
use_collision = false
|
||||
; Small translucent blue cube as visual reference.
|
||||
@@ -0,0 +1,27 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="BoundMat"]
|
||||
albedo_color = Color(1.0, 0.0, 0.0, 0.2)
|
||||
metallic = 0.0
|
||||
roughness = 1.0
|
||||
transparency = 0.8
|
||||
flags_transparent = true
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoundShape"]
|
||||
size = Vector3(2.0, 4.0, 200.0)
|
||||
|
||||
[node name="MapBound" type="Area3D"]
|
||||
groups = ["map_bounds"]
|
||||
; Map boundary wall — prevents players from exiting the playable area.
|
||||
; Copy this prefab for each side of the map, rotating and resizing
|
||||
; the CollisionShape3D to form a closed perimeter.
|
||||
; MapBounds detect body_exited and teleport/kill out-of-bounds players.
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("BoundShape")
|
||||
|
||||
[node name="Visualizer" type="CSGBox3D" parent="."]
|
||||
size = Vector3(2.0, 3.8, 200.0)
|
||||
material = SubResource("BoundMat")
|
||||
use_collision = false
|
||||
; Semi-transparent red wall visualiser. Resize to match perimeter.
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="SpawnMat"]
|
||||
albedo_color = Color(1.0, 0.3, 0.15, 1.0)
|
||||
metallic = 0.0
|
||||
roughness = 0.8
|
||||
|
||||
[node name="TSpawn" type="Marker3D"]
|
||||
groups = ["t_spawn"]
|
||||
; Terrorist spawn point — players on Terrorist team spawn here.
|
||||
; Place on the floor surface. The +Z arrow indicates spawn forward direction.
|
||||
; Child Node3Ds can be added for additional spawn positions.
|
||||
|
||||
[node name="SpawnPad" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.5, 0.03, 0.5)
|
||||
material = SubResource("SpawnMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.015, 0)
|
||||
@@ -0,0 +1,202 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
# Validates template map structure in the editor output panel.
|
||||
#
|
||||
# Run this in the Godot editor:
|
||||
# 1. Open template_map.tscn
|
||||
# 2. Look at the Output panel (bottom dock) for validation results
|
||||
#
|
||||
# Checks:
|
||||
# - Required gameplay node groups are present
|
||||
# - Spawn points exist for both teams
|
||||
# - Bomb sites are defined
|
||||
# - Buy zones are present
|
||||
# - LightmapGI is configured
|
||||
#
|
||||
# Groups used by map nodes:
|
||||
# ct_spawn — Counter-Terrorist spawn positions
|
||||
# t_spawn — Terrorist spawn positions
|
||||
# buy_zone — Purchase zone areas
|
||||
# bomb_site — Bomb plant site areas (+ bombsite_a / bombsite_b for ID)
|
||||
# cubemap_origin — Reflection cubemap capture origin
|
||||
# map_bounds — Out-of-bounds kill walls
|
||||
#
|
||||
# Game logic discovers these at runtime by scanning for nodes with
|
||||
# the corresponding group, not by node name.
|
||||
|
||||
|
||||
func _ready():
|
||||
if not Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
print("")
|
||||
print("=== Map Template: Validate Scene ===")
|
||||
print("")
|
||||
|
||||
var checks = []
|
||||
|
||||
# --- Required node types by group ---
|
||||
var groups_to_check = {
|
||||
"ct_spawn": "CT Spawn points",
|
||||
"t_spawn": "T Spawn points",
|
||||
"buy_zone": "Buy zones",
|
||||
"bomb_site": "Bomb sites",
|
||||
"cubemap_origin": "Cubemap capture origins",
|
||||
"map_bounds": "Map boundary walls",
|
||||
}
|
||||
|
||||
for group in groups_to_check:
|
||||
var nodes = get_tree().get_nodes_in_group(group)
|
||||
var label = groups_to_check[group]
|
||||
if nodes.size() > 0:
|
||||
checks.append([str(" + ", label, " (", nodes.size(), " found)"), true])
|
||||
else:
|
||||
checks.append([str(" - ", label, " — NONE FOUND"), false])
|
||||
|
||||
# --- CSG floor check ---
|
||||
var floor_nodes = _find_csg_floor()
|
||||
checks.append([str(" + CSG Floor geometry (", floor_nodes.size(), " nodes)"), floor_nodes.size() > 0])
|
||||
|
||||
# --- Wall check ---
|
||||
var wall_count = _find_csg_walls()
|
||||
checks.append([str(" + CSG Wall geometry (", wall_count, " walls)"), wall_count >= 3])
|
||||
|
||||
# --- LightmapGI check ---
|
||||
var lightmap = _find_lightmap_gi()
|
||||
if lightmap:
|
||||
checks.append([" + LightmapGI configured ✓", true])
|
||||
if lightmap.light_data != null:
|
||||
checks.append([" + LightmapGI data: BAKED ✓", true])
|
||||
else:
|
||||
checks.append([" - LightmapGI: NOT YET BAKED", false])
|
||||
checks.append([str(" + LightmapGI.bounces = ", lightmap.bounces), lightmap.bounces >= 2])
|
||||
checks.append([str(" + LightmapGI.texel_scale = ", lightmap.texel_scale), lightmap.texel_scale <= 2.0])
|
||||
else:
|
||||
checks.append([" - LightmapGI — NOT FOUND", false])
|
||||
|
||||
# --- ReflectionProbe check ---
|
||||
var probe = _find_reflection_probe()
|
||||
checks.append([" + ReflectionProbe present", probe != null])
|
||||
|
||||
# --- WorldEnvironment check ---
|
||||
var env = _find_world_env()
|
||||
checks.append([" + WorldEnvironment present", env != null])
|
||||
|
||||
# --- SunLight check ---
|
||||
var sun = _find_dir_light()
|
||||
checks.append([" + DirectionalLight3D (sun) present", sun != null])
|
||||
|
||||
# --- Map scale / playable area estimate ---
|
||||
var extents = _estimate_floor_extents(floor_nodes)
|
||||
if extents:
|
||||
checks.append([str(" + Playable area: ~", extents[0], "x", extents[1], " units"), true])
|
||||
|
||||
# Print results
|
||||
print(" ─── Validation Results ───")
|
||||
var passed = 0
|
||||
var failed = 0
|
||||
for check in checks:
|
||||
if check[1]:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(check[0])
|
||||
|
||||
print("")
|
||||
print(" Passed: ", passed, " Failed: ", failed)
|
||||
print("")
|
||||
|
||||
if failed > 0:
|
||||
print(" NOTE: ", failed, " check(s) did not pass.")
|
||||
print(" See warnings above for details — these are advisory,")
|
||||
print(" the map will still function but may be missing critical nodes.")
|
||||
else:
|
||||
print(" All checks passed. Map template is ready!")
|
||||
|
||||
print("")
|
||||
print(" === Validation complete ===")
|
||||
print("")
|
||||
|
||||
|
||||
func _find_nodes_by_group(group_name: String) -> Array:
|
||||
return get_tree().get_nodes_in_group(group_name)
|
||||
|
||||
|
||||
func _find_csg_floor() -> Array:
|
||||
"""Return all CSG nodes with floor-like Y position and flat orientation."""
|
||||
var results = []
|
||||
for child in get_children():
|
||||
if child is CSGBox3D or child is CSGCombiner3D:
|
||||
if abs(child.transform.origin.y) < 0.5:
|
||||
var s = child
|
||||
if s is CSGBox3D and s.size.y < 0.3:
|
||||
results.append(child)
|
||||
elif s is CSGCombiner3D:
|
||||
results.append(child)
|
||||
return results
|
||||
|
||||
|
||||
func _find_csg_walls() -> int:
|
||||
"""Count CSG box nodes with vertical wall-like dimensions."""
|
||||
var count = 0
|
||||
for child in get_children():
|
||||
if child is CSGBox3D:
|
||||
var s: CSGBox3D = child
|
||||
# Wall-like: one thin dimension, tall Y
|
||||
var dims = [s.size.x, s.size.y, s.size.z]
|
||||
dims.sort()
|
||||
if dims[0] < 0.5 and dims[1] > 1.5 and dims[2] > 0.5:
|
||||
if child.name.begins_with("Wall") or child.name.begins_with("Divider") or child.name.begins_with("Mid"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _find_lightmap_gi() -> LightmapGI:
|
||||
for child in get_children():
|
||||
if child is LightmapGI:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _find_reflection_probe() -> ReflectionProbe:
|
||||
for child in get_children():
|
||||
if child is ReflectionProbe:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _find_world_env() -> WorldEnvironment:
|
||||
for child in get_children():
|
||||
if child is WorldEnvironment:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _find_dir_light() -> DirectionalLight3D:
|
||||
for child in get_children():
|
||||
if child is DirectionalLight3D:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _estimate_floor_extents(floor_nodes: Array) -> Array:
|
||||
"""Estimate playable area width and depth from flat CSGBox3D nodes."""
|
||||
var min_x = INF
|
||||
var max_x = -INF
|
||||
var min_z = INF
|
||||
var max_z = -INF
|
||||
|
||||
for node in floor_nodes:
|
||||
if node is CSGBox3D:
|
||||
var p = node.transform.origin
|
||||
var s = node.size
|
||||
# Approximate bounding box using position + half-extents
|
||||
min_x = min(min_x, p.x - s.x * 0.5)
|
||||
max_x = max(max_x, p.x + s.x * 0.5)
|
||||
min_z = min(min_z, p.z - s.z * 0.5)
|
||||
max_z = max(max_z, p.z + s.z * 0.5)
|
||||
|
||||
if min_x != INF and max_x != INF and min_z != INF and max_z != INF:
|
||||
return [int(max_x - min_x), int(max_z - min_z)]
|
||||
return null
|
||||
@@ -0,0 +1,356 @@
|
||||
[gd_scene load_steps=32 format=3]
|
||||
|
||||
; === Prefab references ===
|
||||
[ext_resource type="PackedScene" path="res://assets/prefabs/ct_spawn.tscn" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://assets/prefabs/t_spawn.tscn" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://assets/prefabs/buy_zone.tscn" id="3"]
|
||||
[ext_resource type="PackedScene" path="res://assets/prefabs/bomb_site.tscn" id="4"]
|
||||
[ext_resource type="PackedScene" path="res://assets/prefabs/cubemap_origin.tscn" id="5"]
|
||||
[ext_resource type="PackedScene" path="res://assets/prefabs/map_bounds.tscn" id="6"]
|
||||
|
||||
; === Script ===
|
||||
[ext_resource type="Script" path="res://template_map.gd" id="7"]
|
||||
|
||||
; === Subresources — Materials ===
|
||||
[sub_resource type="StandardMaterial3D" id="FloorMat"]
|
||||
albedo_color = Color(0.3, 0.3, 0.32, 1.0)
|
||||
metallic = 0.0
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="WallMat"]
|
||||
albedo_color = Color(0.55, 0.55, 0.58, 1.0)
|
||||
metallic = 0.0
|
||||
roughness = 0.85
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="TrimMat"]
|
||||
albedo_color = Color(0.25, 0.25, 0.28, 1.0)
|
||||
metallic = 0.3
|
||||
roughness = 0.6
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="FloorTrimMat"]
|
||||
albedo_color = Color(0.2, 0.2, 0.22, 1.0)
|
||||
metallic = 0.1
|
||||
roughness = 0.7
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="SiteAFloorMat"]
|
||||
albedo_color = Color(0.45, 0.35, 0.25, 1.0)
|
||||
metallic = 0.0
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="SiteBFloorMat"]
|
||||
albedo_color = Color(0.3, 0.35, 0.45, 1.0)
|
||||
metallic = 0.0
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="PillarMat"]
|
||||
albedo_color = Color(0.4, 0.4, 0.42, 1.0)
|
||||
metallic = 0.2
|
||||
roughness = 0.7
|
||||
|
||||
; === Subresources — Environment ===
|
||||
[sub_resource type="Environment" id="Env"]
|
||||
background_mode = 0
|
||||
tonemap_mode = 0
|
||||
glow_enabled = false
|
||||
ambient_light_color = Color(0.2, 0.22, 0.25, 1.0)
|
||||
ambient_light_energy = 0.35
|
||||
ambient_light_sky_contribution = 0.0
|
||||
|
||||
; ============================
|
||||
; === SHOWROOM DEMO MAP ===
|
||||
; ============================
|
||||
; This template demonstrates a 3-lane layout with:
|
||||
; - CSG floor, walls, pillars, and dividers
|
||||
; - CT spawn (left) and T spawn (right) with buy zones
|
||||
; - Bomb sites A (CT-side) and B (T-side)
|
||||
; - Cubemap origin for reflections
|
||||
; - Map bounds around the perimeter
|
||||
;
|
||||
; Copy this .tscn + assets/prefabs/ to start a new map.
|
||||
; Replace CSG geometry with the modular kit pieces from the main project.
|
||||
|
||||
[node name="TemplateMap" type="Node3D"]
|
||||
script = ExtResource("7")
|
||||
|
||||
; ============ FLOOR ============
|
||||
; Main floor — CSG box covering the entire playable area
|
||||
; Subdivided into functional zones with different floor materials
|
||||
|
||||
[node name="Floor" type="CSGBox3D" parent="."]
|
||||
size = Vector3(20.0, 0.08, 16.0)
|
||||
material = SubResource("FloorMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
|
||||
|
||||
; Bombsite A floor — warmer tones (CT side, middle-left)
|
||||
[node name="SiteAFloor" type="CSGCombiner3D" parent="."]
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.0, 0, -3.0)
|
||||
[node name="Base" type="CSGBox3D" parent="SiteAFloor"]
|
||||
operation = 0
|
||||
size = Vector3(5.0, 0.09, 5.0)
|
||||
material = SubResource("SiteAFloorMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.005, 0)
|
||||
|
||||
; Bombsite B floor — cooler tones (T side, middle-right)
|
||||
[node name="SiteBFloor" type="CSGCombiner3D" parent="."]
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.0, 0, -3.0)
|
||||
[node name="Base" type="CSGBox3D" parent="SiteBFloor"]
|
||||
operation = 0
|
||||
size = Vector3(5.0, 0.09, 5.0)
|
||||
material = SubResource("SiteBFloorMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.005, 0)
|
||||
|
||||
; Floor trim — baseboard around perimeter
|
||||
; North wall trim
|
||||
[node name="FloorTrim_N" type="CSGBox3D" parent="."]
|
||||
size = Vector3(20.0, 0.24, 0.08)
|
||||
material = SubResource("FloorTrimMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.04, -8.04)
|
||||
; South wall trim
|
||||
[node name="FloorTrim_S" type="CSGBox3D" parent="."]
|
||||
size = Vector3(20.0, 0.24, 0.08)
|
||||
material = SubResource("FloorTrimMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.04, 8.04)
|
||||
; West wall trim
|
||||
[node name="FloorTrim_W" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.08, 0.24, 16.0)
|
||||
material = SubResource("FloorTrimMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10.04, 0.04, 0)
|
||||
; East wall trim
|
||||
[node name="FloorTrim_E" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.08, 0.24, 16.0)
|
||||
material = SubResource("FloorTrimMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10.04, 0.04, 0)
|
||||
|
||||
; Center divider wall (floor-level trim)
|
||||
[node name="DividerTrim" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.08, 0.32, 8.0)
|
||||
material = SubResource("TrimMat")
|
||||
use_collision = false
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.04, 1.0)
|
||||
|
||||
; ============ WALLS ============
|
||||
|
||||
; --- North wall (Z = -8) ---
|
||||
[node name="Wall_N" type="CSGBox3D" parent="."]
|
||||
size = Vector3(20.0, 3.0, 0.16)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, -8.0)
|
||||
|
||||
; --- South wall (Z = 8) ---
|
||||
[node name="Wall_S" type="CSGBox3D" parent="."]
|
||||
size = Vector3(20.0, 3.0, 0.16)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 8.0)
|
||||
|
||||
; --- West wall (X = -10) ---
|
||||
[node name="Wall_W" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.16, 3.0, 16.0)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10.0, 1.5, 0)
|
||||
|
||||
; --- East wall (X = 10) ---
|
||||
[node name="Wall_E" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.16, 3.0, 16.0)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10.0, 1.5, 0)
|
||||
|
||||
; ============ INTERIOR DIVIDERS ============
|
||||
|
||||
; CT-side cover wall — partial wall to create a corridor
|
||||
[node name="Divider_CT" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.16, 2.4, 4.0)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6.0, 1.2, -4.0)
|
||||
|
||||
; T-side cover wall
|
||||
[node name="Divider_T" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.16, 2.4, 4.0)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 1.2, -4.0)
|
||||
|
||||
; Mid divider — lane separator running from CT area toward T area
|
||||
[node name="MidWall" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.16, 2.0, 6.0)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 1.0)
|
||||
|
||||
; Mid divider short — second offset wall for zigzag corridor
|
||||
[node name="MidWallShort" type="CSGBox3D" parent="."]
|
||||
size = Vector3(3.0, 2.0, 0.16)
|
||||
material = SubResource("WallMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.0, 1.0, -3.0)
|
||||
|
||||
; ============ PILLARS ============
|
||||
|
||||
[node name="Pillar_1" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.4, 2.8, 0.4)
|
||||
material = SubResource("PillarMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.0, 1.4, 2.0)
|
||||
|
||||
[node name="Pillar_2" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.4, 2.8, 0.4)
|
||||
material = SubResource("PillarMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.0, 1.4, 2.0)
|
||||
|
||||
[node name="Pillar_3" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.4, 2.8, 0.4)
|
||||
material = SubResource("PillarMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.0, 1.4, -5.0)
|
||||
|
||||
[node name="Pillar_4" type="CSGBox3D" parent="."]
|
||||
size = Vector3(0.4, 2.8, 0.4)
|
||||
material = SubResource("PillarMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.0, 1.4, -5.0)
|
||||
|
||||
; ============ CEILING / SKYBOX BOUNDARY ============
|
||||
|
||||
; Ceiling panel (roof) — encloses the room for lightmap baking
|
||||
[node name="Ceiling" type="CSGBox3D" parent="."]
|
||||
size = Vector3(20.0, 0.08, 16.0)
|
||||
material = SubResource("TrimMat")
|
||||
use_collision = true
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.0, 0)
|
||||
|
||||
; ============ GAMEPLAY PREFABS ============
|
||||
|
||||
; --- Spawn points ---
|
||||
; CT spawns (left side of map)
|
||||
[node name="CTSpawn1" parent="." instance=ExtResource("1")]
|
||||
; CT spawn 1 — main position
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8.0, 0.03, -5.0)
|
||||
|
||||
[node name="CTSpawn2" parent="." instance=ExtResource("1")]
|
||||
; CT spawn 2 — staggered offset for multi-player spawn
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8.0, 0.03, -3.0)
|
||||
|
||||
[node name="CTSpawn3" parent="." instance=ExtResource("1")]
|
||||
; CT spawn 3
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8.0, 0.03, -1.0)
|
||||
|
||||
; T spawns (right side of map)
|
||||
[node name="TSpawn1" parent="." instance=ExtResource("2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.0, 0.03, 5.0)
|
||||
|
||||
[node name="TSpawn2" parent="." instance=ExtResource("2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.0, 0.03, 3.0)
|
||||
|
||||
[node name="TSpawn3" parent="." instance=ExtResource("2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.0, 0.03, 1.0)
|
||||
|
||||
; --- Buy zones ---
|
||||
[node name="CTBuyZone" parent="." instance=ExtResource("3")]
|
||||
; Buy zone at CT spawn — players can buy in this area
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8.0, 1.5, -3.0)
|
||||
; Resize the CollisionShape3D child to cover the CT spawn area
|
||||
|
||||
[node name="TBuyZone" parent="." instance=ExtResource("3")]
|
||||
; Buy zone at T spawn
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.0, 1.5, 3.0)
|
||||
|
||||
; --- Bomb sites ---
|
||||
[node name="BombsiteA" parent="." instance=ExtResource("4")]
|
||||
; Bomb site A — CT-side area (warm floor zone)
|
||||
groups = ["bomb_site"]
|
||||
; Additional group "bombsite_a" for game logic identification
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.0, 0.5, -3.0)
|
||||
; Resize Child/CollisionShape3D to match the brown floor zone (5x5)
|
||||
|
||||
[node name="BombsiteB" parent="." instance=ExtResource("4")]
|
||||
; Bomb site B — T-side area (cool floor zone)
|
||||
groups = ["bomb_site", "bombsite_b"]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.0, 0.5, -3.0)
|
||||
; Resize Child/CollisionShape3D to match the blue floor zone (5x5)
|
||||
|
||||
; --- Cubemap origin ---
|
||||
[node name="CubemapOrigin" parent="." instance=ExtResource("5")]
|
||||
; Reflection probe capture position at eye height in the middle lane
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.6, 0)
|
||||
|
||||
; --- Map bounds ---
|
||||
; Perimeter boundary walls — extend slightly beyond visible walls
|
||||
[node name="Bound_N" parent="." instance=ExtResource("6")]
|
||||
; North bounds
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0, -10.0)
|
||||
|
||||
[node name="Bound_S" parent="." instance=ExtResource("6")]
|
||||
; South bounds
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0, 10.0)
|
||||
|
||||
[node name="Bound_W" parent="." instance=ExtResource("6")]
|
||||
; West bounds — rotate 90°
|
||||
transform = Transform3D(0, 0, -1, 0, 1, 0, 1, 0, 0, -12.0, 2.0, 0)
|
||||
|
||||
[node name="Bound_E" parent="." instance=ExtResource("6")]
|
||||
; East bounds — rotate 90°
|
||||
transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 12.0, 2.0, 0)
|
||||
|
||||
; ============ LIGHTING ============
|
||||
|
||||
; WorldEnvironment — sky and ambient light
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("Env")
|
||||
|
||||
; Directional light — key light from high angle (slightly warm)
|
||||
[node name="SunLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866, 0, -0.5, -0.354, 0.707, -0.612, 0.354, 0.707, 0.612, 0, 5, 0)
|
||||
light_energy = 1.0
|
||||
light_indirect_energy = 0.8
|
||||
light_color = Color(1.0, 0.95, 0.9, 1.0)
|
||||
shadow_enabled = true
|
||||
light_bake_mode = 2
|
||||
directional_shadow_max_distance = 30.0
|
||||
directional_shadow_split_1 = 0.1
|
||||
directional_shadow_split_2 = 0.3
|
||||
directional_shadow_split_3 = 0.6
|
||||
directional_shadow_blend_splits = true
|
||||
|
||||
; Fill light — warm interior ambient from the center
|
||||
[node name="FillLight" type="OmniLight3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.5, -2.0)
|
||||
light_energy = 0.3
|
||||
light_indirect_energy = 0.4
|
||||
light_color = Color(1.0, 0.85, 0.7, 1.0)
|
||||
light_bake_mode = 2
|
||||
omni_range = 8.0
|
||||
omni_attenuation = 0.8
|
||||
shadow_enabled = false
|
||||
|
||||
; Reflection probe — interior specular reflections, positioned at cubemap origin
|
||||
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.6, 0)
|
||||
box_projection = true
|
||||
interior = true
|
||||
extents = Vector3(10.0, 1.8, 8.0)
|
||||
intensity = 1.0
|
||||
max_distance = 15.0
|
||||
|
||||
; LightmapGI — baked global illumination
|
||||
[node name="LightmapGI" type="LightmapGI" parent="."]
|
||||
quality = 2
|
||||
bounces = 3
|
||||
bounce_indirect_energy = 1.0
|
||||
texel_scale = 1.0
|
||||
max_texture_size = 2048
|
||||
use_denoiser = true
|
||||
interior = true
|
||||
@@ -0,0 +1,33 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
func _ready():
|
||||
print("=== LightmapGI Method Discovery ===")
|
||||
|
||||
# Create a mock LightmapGI to check methods
|
||||
# Actually, let's check the class reference
|
||||
var methods = ClassDB.class_get_method_list("LightmapGI")
|
||||
print("LightmapGI methods:")
|
||||
for m in methods:
|
||||
print(" ", m)
|
||||
|
||||
print("")
|
||||
print("ClassDB method list end.")
|
||||
|
||||
# Also try to actually load and check
|
||||
var scene = load("res://assets/scenes/modular/kit_demo.tscn")
|
||||
if scene:
|
||||
var inst = scene.instantiate()
|
||||
get_tree().root.add_child(inst)
|
||||
var lgi = null
|
||||
for c in inst.get_children():
|
||||
if c is LightmapGI:
|
||||
lgi = c
|
||||
break
|
||||
if lgi:
|
||||
print("")
|
||||
print("Runtime LightmapGI methods:")
|
||||
for m in lgi.get_method_list():
|
||||
print(" ", m.get("name", m) if m is Dictionary else m)
|
||||
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8pmywckai7qq
|
||||
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://method_dump.gd" id="1"]
|
||||
|
||||
[node name="Root" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,49 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[animation]
|
||||
|
||||
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Tactical Shooter"
|
||||
config/description="A competitive round-based tactical FPS"
|
||||
run/main_scene="res://bake_lightmaps.tscn"
|
||||
config/features=PackedStringArray("4.3")
|
||||
|
||||
[autoload]
|
||||
|
||||
MapDownloader="*res://scripts/map_downloader.gd"
|
||||
|
||||
[input]
|
||||
|
||||
use_accumulated_input=false
|
||||
|
||||
[memory]
|
||||
|
||||
limits/message_queue_max_size_kb=2048
|
||||
|
||||
[physics]
|
||||
|
||||
common/physics_ticks_per_second=128
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method.mobile="glcompatibility_4"
|
||||
rendering/lightmapping/texel_scale=1.0
|
||||
rendering/reflections/reflection_atlas/reflection_size=512
|
||||
rendering/reflections/reflection_atlas/reflection_count=8
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_size=2048
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_quadrant_0_subdiv=1
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_quadrant_1_subdiv=2
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_quadrant_2_subdiv=3
|
||||
rendering/limits/buffers/vertex_buffer_max_size=256
|
||||
rendering/limits/buffers/index_buffer_max_size=128
|
||||
@@ -0,0 +1,49 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[animation]
|
||||
|
||||
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Tactical Shooter"
|
||||
config/description="A competitive round-based tactical FPS"
|
||||
run/main_scene="res://bake_lightmaps.tscn"
|
||||
config/features=PackedStringArray("4.3")
|
||||
|
||||
[autoload]
|
||||
|
||||
MapDownloader="*res://scripts/map_downloader.gd"
|
||||
|
||||
[input]
|
||||
|
||||
use_accumulated_input=false
|
||||
|
||||
[memory]
|
||||
|
||||
limits/message_queue_max_size_kb=2048
|
||||
|
||||
[physics]
|
||||
|
||||
common/physics_ticks_per_second=128
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method.mobile="glcompatibility_4"
|
||||
rendering/lightmapping/texel_scale=1.0
|
||||
rendering/reflections/reflection_atlas/reflection_size=512
|
||||
rendering/reflections/reflection_atlas/reflection_count=8
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_size=2048
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_quadrant_0_subdiv=1
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_quadrant_1_subdiv=2
|
||||
rendering/lights_and_shadows/positional_shadow/atlas_quadrant_2_subdiv=3
|
||||
rendering/limits/buffers/vertex_buffer_max_size=256
|
||||
rendering/limits/buffers/index_buffer_max_size=128
|
||||
@@ -0,0 +1,12 @@
|
||||
*** /dev/null
|
||||
--- /dev/null
|
||||
***************
|
||||
*** 2
|
||||
- config/features=PackedStringArray("4.0")
|
||||
--- 2 -----
|
||||
+ config/features=PackedStringArray("4.0")
|
||||
***************
|
||||
*** 6
|
||||
- run/main_scene="res://assets/scenes/modular/kit_demo.tscn"
|
||||
--- 6 -----
|
||||
+ run/main_scene="res://tools/validate_map/validator_scene.tscn"
|
||||
@@ -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 @@
|
||||
uid://q8nutj781fyf
|
||||
uid://tvxatyq01ubm
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://5fcqul7vigrv
|
||||
@@ -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]]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cokemo2x02v8k
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://piem67enrl6r
|
||||
@@ -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 @@
|
||||
uid://d0go24t1lsx2j
|
||||
uid://d34jlb2dhy72t
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
func _ready():
|
||||
print("Hello from Godot bake script!")
|
||||
print("Loading scene...")
|
||||
var scene = load("res://assets/scenes/modular/kit_demo.tscn")
|
||||
print("Scene loaded: ", scene)
|
||||
print("Quitting.")
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ca6jmkqw7bj8k
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[node name="TestScene" type="Node3D"]
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
|
||||
[node name="LightmapGI" type="LightmapGI" parent="."]
|
||||
light_data = null
|
||||
quality = 2
|
||||
bounces = 3
|
||||
|
||||
[node name="SunLight" type="DirectionalLight3D" parent="."]
|
||||
light_bake_mode = 2
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="FillLight" type="OmniLight3D" parent="."]
|
||||
light_energy = 0.5
|
||||
light_bake_mode = 2
|
||||
@@ -0,0 +1,6 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
func _ready() -> void:
|
||||
print("HELLO FROM MAIN SCENE TEST")
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bixco4epggvr1
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[node name="TestRunner" type="Node"]
|
||||
script = ExtResource("1_tools_test_runner_gd")
|
||||
|
||||
[ext_resource type="Script" path="res://tools/test_runner.gd" id="1_tools_test_runner_gd"]
|
||||
@@ -0,0 +1,7 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
func _ready() -> void:
|
||||
print("HELLO WORLD")
|
||||
print("User args: ", OS.get_cmdline_user_args())
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmd2i00ukdy36
|
||||
@@ -0,0 +1,183 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# Map Validator — Command-line entry point.
|
||||
#
|
||||
# Usage:
|
||||
# godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
|
||||
#
|
||||
# The scene path must come after `--` so Godot passes it as a user argument.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — All checks passed
|
||||
# 1 — Warnings only (light suggestions, no blocked items)
|
||||
# 2 — Errors found (must-fix items)
|
||||
#
|
||||
# Runs four validation modules in order:
|
||||
# 1. Scene structure — node hierarchy, required groups, LightmapGI, WorldEnvironment
|
||||
# 2. Polygon count — total mesh triangles ≤ 50K
|
||||
# 3. Texture size — all material textures ≤ 1024×1024
|
||||
# 4. Light count — dynamic (non-baked) lights ≤ 4, lightmap bake status
|
||||
#
|
||||
# Each module returns a dictionary {pass: bool, errors: [str], warnings: [str]}.
|
||||
|
||||
const VALIDATOR_DIR := "res://tools/validate_map/"
|
||||
|
||||
enum ExitCode {
|
||||
PASS = 0,
|
||||
WARNINGS = 1,
|
||||
ERRORS = 2,
|
||||
}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if args.is_empty():
|
||||
_print_usage()
|
||||
get_tree().quit(ExitCode.ERRORS)
|
||||
return
|
||||
|
||||
var scene_path := args[0]
|
||||
|
||||
# Validate path prefix
|
||||
if not scene_path.begins_with("res://"):
|
||||
push_error("Scene path must be a Godot resource path (res://) — got: ", scene_path)
|
||||
get_tree().quit(ExitCode.ERRORS)
|
||||
return
|
||||
|
||||
# Load the scene
|
||||
var scene := ResourceLoader.load(scene_path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
if scene == null:
|
||||
push_error("Failed to load scene: ", scene_path)
|
||||
get_tree().quit(ExitCode.ERRORS)
|
||||
return
|
||||
|
||||
print("═══════════════════════════════════════════")
|
||||
print(" MAP VALIDATOR")
|
||||
print(" Scene: ", scene_path)
|
||||
print("═══════════════════════════════════════════")
|
||||
print("")
|
||||
|
||||
# Instantiate the scene (in @tool mode this runs in editor)
|
||||
var instance_data: Array = _try_instantiate(scene, scene_path)
|
||||
var instance: Node = instance_data[0] as Node
|
||||
var instantiate_error: String = instance_data[1] as String
|
||||
if instance == null:
|
||||
push_error(instantiate_error)
|
||||
get_tree().quit(ExitCode.ERRORS)
|
||||
return
|
||||
|
||||
add_child(instance)
|
||||
|
||||
# Run all validators
|
||||
var results: Array[Dictionary] = []
|
||||
results.append(_run_module("validate_scene", instance, scene_path))
|
||||
results.append(_run_module("validate_polycount", instance, scene_path))
|
||||
results.append(_run_module("validate_textures", instance, scene_path))
|
||||
results.append(_run_module("validate_lights", instance, scene_path))
|
||||
|
||||
# Summary
|
||||
print("")
|
||||
print("═══════════════════════════════════════════")
|
||||
print(" SUMMARY")
|
||||
print("═══════════════════════════════════════════")
|
||||
|
||||
var total_errors := 0
|
||||
var total_warnings := 0
|
||||
|
||||
for result in results:
|
||||
var label: String = "PASS" if result["pass"] else result["status"]
|
||||
print(" [", label, "] ", result["name"])
|
||||
total_errors += result["errors"].size()
|
||||
total_warnings += result["warnings"].size()
|
||||
|
||||
print("")
|
||||
print(" Errors: ", total_errors)
|
||||
print(" Warnings: ", total_warnings)
|
||||
|
||||
if total_errors > 0:
|
||||
print(" Result: FAIL")
|
||||
print("")
|
||||
get_tree().quit(ExitCode.ERRORS)
|
||||
elif total_warnings > 0:
|
||||
print(" Result: PASS (with warnings)")
|
||||
print("")
|
||||
get_tree().quit(ExitCode.WARNINGS)
|
||||
else:
|
||||
print(" Result: PASS")
|
||||
print("")
|
||||
get_tree().quit(ExitCode.PASS)
|
||||
|
||||
|
||||
# Attempts to instantiate a PackedScene, catching errors gracefully.
|
||||
func _try_instantiate(scene: PackedScene, path: String) -> Array:
|
||||
var err_test := scene.can_instantiate()
|
||||
if not err_test:
|
||||
return [null, "Scene cannot be instantiated (may depend on other resources): " + path]
|
||||
|
||||
var inst := scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
|
||||
if inst == null:
|
||||
return [null, "Scene instantiate returned null: " + path]
|
||||
return [inst, ""]
|
||||
|
||||
|
||||
# Loads a validator script from VALIDATOR_DIR and runs its checks.
|
||||
func _run_module(name: String, scene_root: Node, scene_path: String) -> Dictionary:
|
||||
var script_path := VALIDATOR_DIR.path_join(name + ".gd")
|
||||
var script := ResourceLoader.load(script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
if script == null:
|
||||
push_error("Failed to load validator module: ", script_path)
|
||||
return {
|
||||
"name": name,
|
||||
"pass": false,
|
||||
"status": "ERROR",
|
||||
"errors": ["Validator script not found: " + script_path],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
# Instantiate the validator module directly (scripts extend RefCounted)
|
||||
if not script.has_method("validate"):
|
||||
push_error("Validator module missing validate() method: ", script_path)
|
||||
return {
|
||||
"name": name,
|
||||
"pass": false,
|
||||
"status": "ERROR",
|
||||
"errors": ["Missing validate() method in " + script_path],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
print("── [", name, "] ────────────────────────────")
|
||||
|
||||
var validator = script.new()
|
||||
var result: Dictionary = validator.validate(scene_root, scene_path)
|
||||
|
||||
# Print inline results
|
||||
for w in result.get("warnings", []):
|
||||
print(" ⚠ ", w)
|
||||
for e in result.get("errors", []):
|
||||
print(" ✖ ", e)
|
||||
|
||||
var ok: bool = result.get("pass", false)
|
||||
if ok:
|
||||
print(" ✓ pass")
|
||||
else:
|
||||
print(" ✖ FAIL")
|
||||
|
||||
print("")
|
||||
return result
|
||||
|
||||
|
||||
func _print_usage() -> void:
|
||||
print("Map Validator — usage:")
|
||||
print("")
|
||||
print(" godot --path <project> --script tools/validate_map.gd -- <scene_path>")
|
||||
print("")
|
||||
print("Examples:")
|
||||
print(" godot --path client --script tools/validate_map.gd -- res://maps/de_dust2.tscn")
|
||||
print(" godot --path client --script tools/validate_map.gd -- res://maps/my_map.tscn")
|
||||
print("")
|
||||
print("Exit codes:")
|
||||
print(" 0 — All checks passed")
|
||||
print(" 1 — Warnings (non-blocking suggestions)")
|
||||
print(" 2 — Errors (must-fix items)")
|
||||
print("")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ds3mr0f0s80xd
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,51 @@
|
||||
## WeaponData — resource containing all stats for a weapon.
|
||||
##
|
||||
## Used by the weapon system to configure damage, fire rate, recoil,
|
||||
## economy pricing, and team restrictions.
|
||||
##
|
||||
## This is a pure Resource (no Node dependency) so it loads clean
|
||||
## in both client and server contexts.
|
||||
|
||||
extends Resource
|
||||
class_name WeaponData
|
||||
|
||||
enum FireMode { SEMI, AUTO, MELEE }
|
||||
|
||||
# Identification
|
||||
@export var weapon_id: int = 0
|
||||
@export var weapon_name: String = ""
|
||||
@export var slot: int = 0 # 0=melee, 1=pistol, 2=SMG, 3=rifle, 4=sniper
|
||||
|
||||
# Damage
|
||||
@export var damage: int = 30
|
||||
@export var headshot_multiplier: float = 2.0
|
||||
@export var chest_multiplier: float = 1.0
|
||||
@export var waist_multiplier: float = 0.75
|
||||
@export var leg_multiplier: float = 0.5
|
||||
@export var armor_penetration: float = 0.5 # 0.0-1.0, % damage through armor
|
||||
|
||||
# Firing
|
||||
@export var fire_mode: FireMode = FireMode.SEMI
|
||||
@export var fire_rate: float = 10.0 # rounds per second
|
||||
@export var reload_time: float = 2.0 # seconds
|
||||
@export var max_distance: float = 1000.0
|
||||
|
||||
# Magazine
|
||||
@export var magazine_size: int = 30
|
||||
@export var reserve_size: int = 90
|
||||
|
||||
# Recoil & Spread
|
||||
@export var recoil_per_shot: float = 0.5 # degrees
|
||||
@export var recoil_recovery: float = 8.0 # degrees/sec
|
||||
@export var spread_idle: float = 0.0 # degrees while standing still
|
||||
@export var spread_walking: float = 2.0 # degrees while moving
|
||||
@export var spread_sprinting: float = 6.0 # degrees while sprinting
|
||||
|
||||
# Movement
|
||||
@export var move_speed_multiplier: float = 1.0 # multiplier on base move speed
|
||||
@export var aim_speed_multiplier: float = 0.5 # move speed while aiming
|
||||
|
||||
# Economy
|
||||
@export var price: int = 0
|
||||
@export var kill_reward: int = 300
|
||||
@export var team: int = -1 # -1=both, 0=T, 1=CT
|
||||
@@ -0,0 +1,175 @@
|
||||
## WeaponRegistry — central registry of all weapon definitions.
|
||||
##
|
||||
## All weapon data is defined here as static constants. Systems that need
|
||||
## weapon stats (WeaponManager, BuyMenu, RoundManager) reference this.
|
||||
##
|
||||
## To add a new weapon: create a WeaponData resource in client/weapons/data/
|
||||
## and register it in the ALL array below.
|
||||
|
||||
extends Node
|
||||
class_name WeaponRegistry
|
||||
|
||||
# Define all weapons as convenient static getters
|
||||
static var KNIFE: WeaponData:
|
||||
get: return _knife
|
||||
static var GLOCK: WeaponData:
|
||||
get: return _glock
|
||||
static var USP: WeaponData:
|
||||
get: return _usp
|
||||
static var AK47: WeaponData:
|
||||
get: return _ak47
|
||||
static var M4A1: WeaponData:
|
||||
get: return _m4a1
|
||||
static var AWP: WeaponData:
|
||||
get: return _awp
|
||||
|
||||
# Weapon ID constants
|
||||
const ID_KNIFE: int = 0
|
||||
const ID_GLOCK: int = 1
|
||||
const ID_USP: int = 2
|
||||
const ID_AK47: int = 3
|
||||
const ID_M4A1: int = 4
|
||||
const ID_AWP: int = 5
|
||||
|
||||
## Array of all weapons for iteration
|
||||
static var ALL: Array[WeaponData] = []
|
||||
|
||||
## Map of weapon_id → WeaponData for fast lookup
|
||||
static var BY_ID: Dictionary = {}
|
||||
|
||||
## Static initializer
|
||||
static func _static_init() -> void:
|
||||
ALL = [_knife, _glock, _usp, _ak47, _m4a1, _awp]
|
||||
for w in ALL:
|
||||
BY_ID[w.weapon_id] = w
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weapon definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static var _knife: WeaponData = _make_weapon_data({
|
||||
weapon_id = ID_KNIFE,
|
||||
weapon_name = "Knife",
|
||||
slot = 0,
|
||||
damage = 34,
|
||||
fire_mode = WeaponData.FireMode.MELEE,
|
||||
fire_rate = 0.0,
|
||||
magazine_size = 0,
|
||||
reserve_size = 0,
|
||||
move_speed_multiplier = 1.0,
|
||||
price = 0,
|
||||
kill_reward = 1500,
|
||||
armor_penetration = 0.0,
|
||||
max_distance = 3.0,
|
||||
})
|
||||
|
||||
static var _glock: WeaponData = _make_weapon_data({
|
||||
weapon_id = ID_GLOCK,
|
||||
weapon_name = "Glock-18",
|
||||
slot = 1,
|
||||
damage = 25,
|
||||
fire_rate = 6.0,
|
||||
magazine_size = 20,
|
||||
reserve_size = 120,
|
||||
recoil_per_shot = 0.4,
|
||||
move_speed_multiplier = 0.86,
|
||||
price = 200,
|
||||
kill_reward = 300,
|
||||
armor_penetration = 0.35,
|
||||
team = 0, # Terrorist default
|
||||
})
|
||||
|
||||
static var _usp: WeaponData = _make_weapon_data({
|
||||
weapon_id = ID_USP,
|
||||
weapon_name = "USP-S",
|
||||
slot = 1,
|
||||
damage = 28,
|
||||
fire_rate = 5.0,
|
||||
magazine_size = 12,
|
||||
reserve_size = 24,
|
||||
recoil_per_shot = 0.3,
|
||||
move_speed_multiplier = 0.86,
|
||||
price = 200,
|
||||
kill_reward = 300,
|
||||
armor_penetration = 0.50,
|
||||
team = 1, # CT default
|
||||
})
|
||||
|
||||
static var _ak47: WeaponData = _make_weapon_data({
|
||||
weapon_id = ID_AK47,
|
||||
weapon_name = "AK-47",
|
||||
slot = 3,
|
||||
damage = 36,
|
||||
fire_mode = WeaponData.FireMode.AUTO,
|
||||
fire_rate = 10.0,
|
||||
magazine_size = 30,
|
||||
reserve_size = 90,
|
||||
recoil_per_shot = 0.8,
|
||||
recoil_recovery = 6.0,
|
||||
spread_walking = 3.0,
|
||||
spread_sprinting = 8.0,
|
||||
move_speed_multiplier = 0.78,
|
||||
price = 2700,
|
||||
kill_reward = 300,
|
||||
armor_penetration = 0.77,
|
||||
team = 0,
|
||||
})
|
||||
|
||||
static var _m4a1: WeaponData = _make_weapon_data({
|
||||
weapon_id = ID_M4A1,
|
||||
weapon_name = "M4A1-S",
|
||||
slot = 3,
|
||||
damage = 33,
|
||||
fire_mode = WeaponData.FireMode.AUTO,
|
||||
fire_rate = 10.0,
|
||||
magazine_size = 25,
|
||||
reserve_size = 75,
|
||||
recoil_per_shot = 0.7,
|
||||
recoil_recovery = 7.0,
|
||||
spread_walking = 2.5,
|
||||
spread_sprinting = 7.0,
|
||||
move_speed_multiplier = 0.78,
|
||||
price = 2900,
|
||||
kill_reward = 300,
|
||||
armor_penetration = 0.77,
|
||||
team = 1,
|
||||
})
|
||||
|
||||
static var _awp: WeaponData = _make_weapon_data({
|
||||
weapon_id = ID_AWP,
|
||||
weapon_name = "AWP",
|
||||
slot = 4,
|
||||
damage = 115,
|
||||
fire_rate = 1.5,
|
||||
magazine_size = 10,
|
||||
reserve_size = 30,
|
||||
recoil_per_shot = 4.0,
|
||||
recoil_recovery = 1.0,
|
||||
spread_idle = 0.0,
|
||||
spread_walking = 10.0,
|
||||
spread_sprinting = 20.0,
|
||||
move_speed_multiplier = 0.57,
|
||||
price = 4750,
|
||||
kill_reward = 100,
|
||||
armor_penetration = 0.87,
|
||||
max_distance = 2000.0,
|
||||
headshot_multiplier = 5.0, # AWP one-shots through armor
|
||||
})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _make_weapon_data(dict: Dictionary) -> WeaponData:
|
||||
var w := WeaponData.new()
|
||||
for key in dict:
|
||||
w.set(key, dict[key])
|
||||
return w
|
||||
|
||||
## Look up a weapon by its ID.
|
||||
static func get_by_id(id: int) -> WeaponData:
|
||||
return BY_ID.get(id, _knife)
|
||||
|
||||
## Get the default weapon for a team (0=T, 1=CT).
|
||||
static func get_default_weapon(team_id: int) -> WeaponData:
|
||||
return _glock if team_id == 0 else _usp
|
||||
@@ -0,0 +1,89 @@
|
||||
## TacticalWeaponHitscan — rollback-aware hitscan weapon for the tactical shooter.
|
||||
##
|
||||
## Extends NetworkWeaponHitscan3D with per-limb damage, team checks,
|
||||
## and headshot detection.
|
||||
##
|
||||
## This script is ONLY loaded from client scenes where netfox is active.
|
||||
## The server handles damage authority directly.
|
||||
|
||||
extends "res://addons/netfox.extras/weapon/network-weapon-hitscan-3d.gd"
|
||||
class_name TacticalWeaponHitscan
|
||||
|
||||
## Weapon data resource (stats) — set on spawn
|
||||
var weapon_data: WeaponData = null
|
||||
|
||||
## Weapon owner's peer ID — set on spawn
|
||||
var owner_peer_id: int = -1
|
||||
|
||||
## Cooldown tick for rate-of-fire limiting
|
||||
var _last_fire_tick: int = -1
|
||||
|
||||
func _can_fire() -> bool:
|
||||
if weapon_data == null:
|
||||
return false
|
||||
if weapon_data.fire_mode == WeaponData.FireMode.MELEE:
|
||||
return true # Melee always "can fire"
|
||||
|
||||
# Rate-of-fire check using network tick
|
||||
var nt = Engine.get_singleton(&"NetworkTime")
|
||||
if nt == null:
|
||||
return true
|
||||
var current_tick := nt.tick if nt.has_method(&"get_tick") else 0
|
||||
if _last_fire_tick >= 0:
|
||||
var tick_interval := maxi(1, int(weapon_data.fire_rate / 64.0))
|
||||
if current_tick - _last_fire_tick < tick_interval:
|
||||
return false
|
||||
return true
|
||||
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return peer_id == owner_peer_id
|
||||
|
||||
func _after_fire():
|
||||
# Track fire tick for cooldown
|
||||
var nt = Engine.get_singleton(&"NetworkTime")
|
||||
if nt != null:
|
||||
_last_fire_tick = nt.tick if nt.has_method(&"get_tick") else 0
|
||||
|
||||
## Handle a raycast hit — called on ALL peers with rollback reconciliation.
|
||||
func _on_hit(result: Dictionary):
|
||||
if weapon_data == null:
|
||||
return
|
||||
if not multiplayer.is_server() and not is_multiplayer_authority():
|
||||
return # Only server/authority applies damage
|
||||
|
||||
var collider := result.get("collider")
|
||||
if collider == null:
|
||||
return
|
||||
|
||||
# Determine limb hit via collision groups
|
||||
var damage_multiplier: float = weapon_data.chest_multiplier
|
||||
var is_headshot := false
|
||||
|
||||
if collider is Node:
|
||||
if collider.is_in_group(&"head"):
|
||||
damage_multiplier = weapon_data.headshot_multiplier
|
||||
is_headshot = true
|
||||
elif collider.is_in_group(&"chest"):
|
||||
damage_multiplier = weapon_data.chest_multiplier
|
||||
elif collider.is_in_group(&"waist"):
|
||||
damage_multiplier = weapon_data.waist_multiplier
|
||||
elif collider.is_in_group(&"legs"):
|
||||
damage_multiplier = weapon_data.leg_multiplier
|
||||
|
||||
var final_damage := int(weapon_data.damage * damage_multiplier)
|
||||
|
||||
# Find the player node from the collider
|
||||
var player := _find_player(collider)
|
||||
if player != null and player.has_method(&"apply_damage"):
|
||||
if is_headshot:
|
||||
print("[TacticalWeapon] HEADSHOT on %s for %d" % [player.name, final_damage])
|
||||
player.apply_damage(final_damage, owner_peer_id, weapon_data.weapon_id)
|
||||
|
||||
## Find a player node walking up the scene tree.
|
||||
func _find_player(node: Node) -> Node:
|
||||
var current = node
|
||||
while current != null:
|
||||
if current.has_method(&"apply_damage") or current.is_in_group(&"players"):
|
||||
return current
|
||||
current = current.get_parent()
|
||||
return null
|
||||
@@ -0,0 +1,200 @@
|
||||
## WeaponManager — client-side weapon inventory with rollback-safe firing.
|
||||
##
|
||||
## Manages weapon switching, ammo tracking. Firing is handled by the
|
||||
## RollbackHitscanManager (in _rollback_tick) for lag compensation.
|
||||
##
|
||||
## This script is ONLY loaded from client scenes where netfox is active.
|
||||
|
||||
extends Node
|
||||
class_name WeaponManager
|
||||
|
||||
signal weapon_switched(old_slot: int, new_slot: int, weapon_data: WeaponData)
|
||||
signal ammo_changed(weapon_id: int, magazine: int, reserve: int)
|
||||
|
||||
# Preload the weapon scripts (only works when netfox is active)
|
||||
const _HitscanWeapon := preload("res://client/weapons/scripts/tactical_weapon_hitscan.gd")
|
||||
|
||||
## Reference to the player's weapon anchor node.
|
||||
@export var weapon_anchor: Node3D
|
||||
|
||||
## Reference to the RollbackHitscanManager (set by player).
|
||||
var rollback_hitscan_mgr: Node = null
|
||||
|
||||
## Current weapon data.
|
||||
var current_weapon: WeaponData = null
|
||||
var current_weapon_node: Node3D = null
|
||||
var current_slot: int = 1
|
||||
|
||||
## Inventory: slot → { data, node, magazine, reserve }
|
||||
var _inventory: Dictionary = {}
|
||||
|
||||
var _last_fire_time: float = 0.0
|
||||
|
||||
func add_weapon(data: WeaponData) -> void:
|
||||
var slot: int = data.slot
|
||||
if _inventory.has(slot):
|
||||
_remove_weapon(slot)
|
||||
|
||||
var weapon_node: Node3D = _create_weapon(data)
|
||||
if weapon_node == null:
|
||||
return
|
||||
weapon_node.visible = false
|
||||
add_child(weapon_node)
|
||||
|
||||
_inventory[slot] = {
|
||||
"data": data,
|
||||
"node": weapon_node,
|
||||
"magazine": data.magazine_size,
|
||||
"reserve": data.reserve_size,
|
||||
}
|
||||
|
||||
if current_weapon == null:
|
||||
switch_to_slot(slot)
|
||||
|
||||
func remove_weapon(slot: int) -> void:
|
||||
_remove_weapon(slot)
|
||||
if current_slot == slot:
|
||||
_switch_to_next_available()
|
||||
|
||||
func switch_to_slot(slot: int) -> bool:
|
||||
if not _inventory.has(slot):
|
||||
return false
|
||||
var entry = _inventory[slot]
|
||||
var old_slot := current_slot
|
||||
current_slot = slot
|
||||
current_weapon = entry["data"] as WeaponData
|
||||
current_weapon_node = entry["node"] as Node3D
|
||||
_update_visibility()
|
||||
weapon_switched.emit(old_slot, slot, current_weapon)
|
||||
|
||||
# Update the RollbackHitscanManager with the new weapon data
|
||||
_notify_hitscan_mgr_of_weapon_change(current_weapon)
|
||||
return true
|
||||
|
||||
func switch_next() -> void:
|
||||
var slots := _inventory.keys()
|
||||
slots.sort()
|
||||
var idx := slots.find(current_slot)
|
||||
if idx >= 0 and idx + 1 < slots.size():
|
||||
switch_to_slot(slots[idx + 1])
|
||||
|
||||
func switch_prev() -> void:
|
||||
var slots := _inventory.keys()
|
||||
slots.sort()
|
||||
var idx := slots.find(current_slot)
|
||||
if idx > 0:
|
||||
switch_to_slot(slots[idx - 1])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firing (rollback-safe — delegates to RollbackHitscanManager)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called by the client to attempt firing through the rollback system.
|
||||
## Returns true if ammo was consumed.
|
||||
func fire() -> bool:
|
||||
if current_weapon_node == null or current_weapon == null:
|
||||
return false
|
||||
if current_weapon.magazine_size > 0 and _get_magazine() <= 0:
|
||||
return false
|
||||
if not _check_fire_rate():
|
||||
return false
|
||||
|
||||
# Consume ammo immediately for client prediction
|
||||
var entry = _inventory[current_slot]
|
||||
entry["magazine"] -= 1
|
||||
ammo_changed.emit(current_weapon.weapon_id, entry["magazine"], entry["reserve"])
|
||||
_last_fire_time = Time.get_ticks_msec() / 1000.0
|
||||
|
||||
# The actual raycast and damage happen in the RollbackHitscanManager's
|
||||
# process_fire() during _rollback_tick(), triggered by the shoot input.
|
||||
# This method just handles the client-side ammo tracking.
|
||||
return true
|
||||
|
||||
func reload() -> void:
|
||||
if current_weapon == null or current_weapon.magazine_size <= 0:
|
||||
return
|
||||
var entry = _inventory[current_slot]
|
||||
if entry["magazine"] >= current_weapon.magazine_size or entry["reserve"] <= 0:
|
||||
return
|
||||
var needed := current_weapon.magazine_size - entry["magazine"]
|
||||
var available := min(needed, entry["reserve"])
|
||||
entry["magazine"] += available
|
||||
entry["reserve"] -= available
|
||||
ammo_changed.emit(current_weapon.weapon_id, entry["magazine"], entry["reserve"])
|
||||
|
||||
func get_ammo() -> Vector2i:
|
||||
if current_weapon == null or not _inventory.has(current_slot):
|
||||
return Vector2i.ZERO
|
||||
var e = _inventory[current_slot]
|
||||
return Vector2i(e["magazine"], e["reserve"])
|
||||
|
||||
## Get the current weapon's maximum distance for raycasting.
|
||||
func get_max_distance() -> float:
|
||||
if current_weapon != null:
|
||||
return current_weapon.max_distance
|
||||
return 1000.0
|
||||
|
||||
## Get the current weapon ID.
|
||||
func get_weapon_id() -> int:
|
||||
if current_weapon != null:
|
||||
return current_weapon.weapon_id
|
||||
return 0
|
||||
|
||||
func get_current_weapon_data() -> WeaponData:
|
||||
return current_weapon
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Notify the RollbackHitscanManager when the weapon changes.
|
||||
func _notify_hitscan_mgr_of_weapon_change(wd: WeaponData) -> void:
|
||||
if rollback_hitscan_mgr != null and rollback_hitscan_mgr.has_method(&"set_weapon_data"):
|
||||
rollback_hitscan_mgr.set_weapon_data(wd)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _create_weapon(data: WeaponData) -> Node3D:
|
||||
var weapon := Node3D.new()
|
||||
weapon.name = data.weapon_name
|
||||
|
||||
if data.fire_mode != WeaponData.FireMode.MELEE:
|
||||
var hs: TacticalWeaponHitscan = _HitscanWeapon.new()
|
||||
hs.name = "Hitscan"
|
||||
hs.weapon_data = data
|
||||
hs.max_distance = data.max_distance
|
||||
weapon.add_child(hs, true)
|
||||
|
||||
return weapon
|
||||
|
||||
func _get_magazine() -> int:
|
||||
var e = _inventory.get(current_slot)
|
||||
return e["magazine"] if e else 0
|
||||
|
||||
func _check_fire_rate() -> bool:
|
||||
if current_weapon.fire_rate <= 0:
|
||||
return false
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
return (now - _last_fire_time) >= (1.0 / current_weapon.fire_rate)
|
||||
|
||||
func _remove_weapon(slot: int) -> void:
|
||||
var e = _inventory.get(slot)
|
||||
if e and e["node"]:
|
||||
e["node"].queue_free()
|
||||
_inventory.erase(slot)
|
||||
|
||||
func _switch_to_next_available() -> void:
|
||||
var slots := _inventory.keys()
|
||||
slots.sort()
|
||||
if slots.size() > 0:
|
||||
switch_to_slot(slots[0])
|
||||
else:
|
||||
current_weapon = null
|
||||
current_weapon_node = null
|
||||
_update_visibility()
|
||||
|
||||
func _update_visibility() -> void:
|
||||
for slot in _inventory:
|
||||
_inventory[slot]["node"].visible = (slot == current_slot)
|
||||
Reference in New Issue
Block a user