Phase 7: netfox + godot-jolt stack upgrade

Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

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