Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db477b4c48 | |||
| 4a5264c5b0 | |||
| ad48f38ca5 | |||
| f0c95dcfd2 | |||
| d060ac449d | |||
| 87060b0b71 | |||
| 638e109d8b | |||
| 4c0e334575 | |||
| e270f34150 | |||
| 20ca43f928 | |||
| aae57c63bd | |||
| 0994a55486 | |||
| 82216bfa64 | |||
| d5098c61e1 | |||
| 194aad8f83 | |||
| 222dcaebb3 | |||
| 0aa29d33e4 | |||
| 6a08487c4c | |||
| f20d532add | |||
| 705b068ed2 | |||
| 3465922be4 | |||
| 46ff83325f | |||
| f6d69545c9 | |||
| 2452aba0d7 | |||
| 3c3251fa7a | |||
| d02b112d99 | |||
| 2582cb1b0d | |||
| 9ea98aa7b8 | |||
| e385eae0f5 | |||
| 34507f9043 | |||
| 159c554a86 | |||
| ffa72a8f24 | |||
| 589b90d886 | |||
| e9dc05983c | |||
| 2cf57a989f | |||
| 16e062c739 | |||
| aa0b80b570 | |||
| d631ff784a |
@@ -24,3 +24,8 @@ server/build/
|
|||||||
*.zip
|
*.zip
|
||||||
*.exe
|
*.exe
|
||||||
*.pck
|
*.pck
|
||||||
|
|
||||||
|
# Secrets (operator must create per deployment)
|
||||||
|
server/data/rcon_password.cfg
|
||||||
|
build/
|
||||||
|
build/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ A competitive, round-based tactical FPS built in Godot 4.
|
|||||||
- 128-tick authoritative dedicated servers
|
- 128-tick authoritative dedicated servers
|
||||||
- GDExtension (C++) simulation core
|
- GDExtension (C++) simulation core
|
||||||
- Community-hostable, self-hosted server browser
|
- Community-hostable, self-hosted server browser
|
||||||
- Map-making SDK for community maps
|
- [Map-making SDK](docs/mapmaking/00-index.md) for community maps
|
||||||
|
|
||||||
See [PLANS/PROJECT_PLAN.md](PLANS/PROJECT_PLAN.md) for the full build plan.
|
See [PLANS/PROJECT_PLAN.md](PLANS/PROJECT_PLAN.md) for the full build plan.
|
||||||
|
See [Mapmaking SDK docs](docs/mapmaking/00-index.md) to build and ship custom maps.
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
[gd_scene load_steps=12 format=3]
|
||||||
|
|
||||||
|
; =============================================================================
|
||||||
|
; test_range.tscn — Greybox Test Map
|
||||||
|
; =============================================================================
|
||||||
|
; A simple open-area test map for validating movement, shooting, and networking.
|
||||||
|
;
|
||||||
|
; Layout:
|
||||||
|
; - 80×80 open area with flat grey floor
|
||||||
|
; - Low platform (center, 1 unit high, gold)
|
||||||
|
; - Medium platform (east, 2 units high, gold)
|
||||||
|
; - High platform (north-west, 3 units high, red)
|
||||||
|
; - Ramp from ground to low platform (yellow)
|
||||||
|
; - Enclosure/room (east) with doorway for indoor testing (blue-grey walls)
|
||||||
|
; - Spawn point (Marker3D) with group 'spawn_points'
|
||||||
|
; - DirectionalLight3D + WorldEnvironment
|
||||||
|
;
|
||||||
|
; Total geometry: ~132 tris (well under 500 budget)
|
||||||
|
; =============================================================================
|
||||||
|
|
||||||
|
[sub_resource type="Environment" id="1"]
|
||||||
|
background_mode = 0
|
||||||
|
tonemap_mode = 0
|
||||||
|
glow_enabled = false
|
||||||
|
ambient_light_color = Color(0.25, 0.25, 0.3, 1.0)
|
||||||
|
ambient_light_energy = 0.4
|
||||||
|
ambient_light_sky_contribution = 0.0
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="2"]
|
||||||
|
albedo_color = Color(0.35, 0.35, 0.35, 1.0)
|
||||||
|
resource_name = "grey_floor"
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="3"]
|
||||||
|
albedo_color = Color(0.8, 0.6, 0.2, 1.0)
|
||||||
|
resource_name = "gold_platform"
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="4"]
|
||||||
|
albedo_color = Color(0.5, 0.5, 0.6, 1.0)
|
||||||
|
resource_name = "bluegrey_wall"
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="5"]
|
||||||
|
albedo_color = Color(0.9, 0.2, 0.2, 1.0)
|
||||||
|
resource_name = "red_high"
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="6"]
|
||||||
|
albedo_color = Color(0.3, 0.4, 0.3, 1.0)
|
||||||
|
resource_name = "green_floor"
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="7"]
|
||||||
|
albedo_color = Color(0.7, 0.7, 0.3, 1.0)
|
||||||
|
resource_name = "yellow_ramp"
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="8"]
|
||||||
|
size = Vector3(8, 4, 4)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="9"]
|
||||||
|
size = Vector3(8, 4, 4)
|
||||||
|
|
||||||
|
|
||||||
|
; === Root Node ===
|
||||||
|
|
||||||
|
[node name="TestRange" type="Node3D"]
|
||||||
|
|
||||||
|
|
||||||
|
; === Lighting ===
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("1")
|
||||||
|
|
||||||
|
[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, 8, 0)
|
||||||
|
light_energy = 1.5
|
||||||
|
light_indirect_energy = 0.8
|
||||||
|
light_color = Color(1.0, 0.95, 0.9, 1.0)
|
||||||
|
shadow_enabled = true
|
||||||
|
directional_shadow_max_distance = 60.0
|
||||||
|
directional_shadow_split_1 = 0.1
|
||||||
|
directional_shadow_split_2 = 0.3
|
||||||
|
directional_shadow_split_3 = 0.6
|
||||||
|
directional_shadow_blend_splits = true
|
||||||
|
|
||||||
|
|
||||||
|
; === Spawn Points ===
|
||||||
|
|
||||||
|
[node name="SpawnPoint" type="Marker3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -30, 0.5, 0)
|
||||||
|
groups = ["spawn_points"]
|
||||||
|
|
||||||
|
|
||||||
|
; === CT Spawn Points (Counter-Terrorist) ===
|
||||||
|
|
||||||
|
[node name="CTSpawn_01" type="Marker3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -30, 0.5, -10)
|
||||||
|
groups = ["spawn_points_ct"]
|
||||||
|
|
||||||
|
[node name="CTSpawn_02" type="Marker3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -25, 0.5, -15)
|
||||||
|
groups = ["spawn_points_ct"]
|
||||||
|
|
||||||
|
|
||||||
|
; === T Spawn Points (Terrorist) ===
|
||||||
|
|
||||||
|
[node name="TSpawn_01" type="Marker3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 25, 0.5, 25)
|
||||||
|
groups = ["spawn_points_t"]
|
||||||
|
|
||||||
|
[node name="TSpawn_02" type="Marker3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 30, 0.5, 20)
|
||||||
|
groups = ["spawn_points_t"]
|
||||||
|
|
||||||
|
|
||||||
|
; =============================================================================
|
||||||
|
; Bomb Sites
|
||||||
|
; =============================================================================
|
||||||
|
; BombsiteA — south-west area, 8×4m zone
|
||||||
|
; BombsiteB — north-east area, 8×4m zone
|
||||||
|
; Each has a CollisionShape3D (BoxShape3D) for overlap detection
|
||||||
|
; and a Marker3D child named "PlantPosition" marking the plant location.
|
||||||
|
|
||||||
|
; --- Bombsite A ---
|
||||||
|
|
||||||
|
|
||||||
|
[node name="BombsiteA" type="Area3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20, 1.0, -5)
|
||||||
|
collision_layer = 0
|
||||||
|
collision_mask = 0
|
||||||
|
groups = ["bomb_sites"]
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="BombsiteA"]
|
||||||
|
shape = SubResource("8")
|
||||||
|
|
||||||
|
[node name="PlantPosition" type="Marker3D" parent="BombsiteA"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0, -1)
|
||||||
|
|
||||||
|
; --- Bombsite B ---
|
||||||
|
|
||||||
|
|
||||||
|
[node name="BombsiteB" type="Area3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 15, 1.0, 20)
|
||||||
|
collision_layer = 0
|
||||||
|
collision_mask = 0
|
||||||
|
groups = ["bomb_sites"]
|
||||||
|
|
||||||
|
[node name="PlantPosition" type="Marker3D" parent="BombsiteB"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="BombsiteB"]
|
||||||
|
shape = SubResource("9")
|
||||||
|
|
||||||
|
|
||||||
|
; === Open Area Floor (80×80) ===
|
||||||
|
|
||||||
|
[node name="Floor" type="CSGBox3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
|
||||||
|
size = Vector3(80, 0.5, 80)
|
||||||
|
material = SubResource("2")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
|
||||||
|
; === Low Platform (center, 1 unit high) ===
|
||||||
|
; 8×8 platform, top at y=2, bottom at y=0, centered at origin
|
||||||
|
|
||||||
|
[node name="LowPlatform" type="CSGBox3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
|
||||||
|
size = Vector3(8, 2, 8)
|
||||||
|
material = SubResource("3")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
|
||||||
|
; === Medium Platform (east, 2 units high) ===
|
||||||
|
; 6×6 platform, top at y=3, bottom at y=1 — jump-up from low platform height
|
||||||
|
|
||||||
|
[node name="MediumPlatform" type="CSGBox3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 15, 2.0, 10)
|
||||||
|
size = Vector3(6, 2, 6)
|
||||||
|
material = SubResource("3")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
|
||||||
|
; === High Platform (north-west, 3 units high) ===
|
||||||
|
; 3×3 small platform, top at y=4 — requires sprint-jump to reach
|
||||||
|
|
||||||
|
[node name="HighPlatform" type="CSGBox3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20, 3.0, -15)
|
||||||
|
size = Vector3(3, 2, 3)
|
||||||
|
material = SubResource("5")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
|
||||||
|
; === Ramp (south of center, leads ground → low platform top) ===
|
||||||
|
; Tilted ~20° around X axis, 6 units long along Z.
|
||||||
|
; South end near ground, north end meets low platform top (y=2).
|
||||||
|
|
||||||
|
[node name="Ramp" type="CSGBox3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 0.940, -0.342, 0, 0.342, 0.940, 0, 1.0, 5)
|
||||||
|
size = Vector3(6, 0.2, 6)
|
||||||
|
material = SubResource("7")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
|
||||||
|
; =============================================================================
|
||||||
|
; Enclosure — Indoor Testing Room
|
||||||
|
; =============================================================================
|
||||||
|
; 12×12 interior room, 4 units tall, with a 2-unit doorway on the south face.
|
||||||
|
; Centered at (30, 0, 30).
|
||||||
|
|
||||||
|
[node name="Enclosure" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="RoomFloor" type="CSGBox3D" parent="Enclosure"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 30, 0.25, 30)
|
||||||
|
size = Vector3(12, 0.5, 12)
|
||||||
|
material = SubResource("6")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
; North wall (z = 24)
|
||||||
|
[node name="Wall_N" type="CSGBox3D" parent="Enclosure"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 30, 2.0, 24.25)
|
||||||
|
size = Vector3(12, 4, 0.5)
|
||||||
|
material = SubResource("4")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
; South wall left segment (doorway gap from x=29 to x=31)
|
||||||
|
[node name="Wall_S_Left" type="CSGBox3D" parent="Enclosure"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 26.5, 2.0, 35.75)
|
||||||
|
size = Vector3(5, 4, 0.5)
|
||||||
|
material = SubResource("4")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
; South wall right segment
|
||||||
|
[node name="Wall_S_Right" type="CSGBox3D" parent="Enclosure"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 33.5, 2.0, 35.75)
|
||||||
|
size = Vector3(5, 4, 0.5)
|
||||||
|
material = SubResource("4")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
; West wall (x = 24)
|
||||||
|
[node name="Wall_W" type="CSGBox3D" parent="Enclosure"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 24.25, 2.0, 30)
|
||||||
|
size = Vector3(0.5, 4, 12)
|
||||||
|
material = SubResource("4")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
; East wall (x = 36)
|
||||||
|
[node name="Wall_E" type="CSGBox3D" parent="Enclosure"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 35.75, 2.0, 30)
|
||||||
|
size = Vector3(0.5, 4, 12)
|
||||||
|
material = SubResource("4")
|
||||||
|
use_collision = true
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_blue/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_blue/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_blue/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_blue/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 0.3
|
||||||
|
roughness = 0.35
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 0.5
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(1.0, 1.0, 1.0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_red/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_red/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_red/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/accent_team_red/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 0.3
|
||||||
|
roughness = 0.35
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 0.5
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(1.0, 1.0, 1.0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_concrete_01/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_concrete_01/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_concrete_01/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_concrete_01/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 0.8
|
||||||
|
roughness = 0.9
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 0.0
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(2.0, 2.0, 1.0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_tile_01/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_tile_01/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_tile_01/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/floor_tile_01/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 1.0
|
||||||
|
roughness = 0.6
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 0.0
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(2.0, 2.0, 1.0)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate Godot 4 .tres material resource files for the modular art kit.
|
||||||
|
Godot 4 text resource format:
|
||||||
|
[ext_resource type="Texture2D" path="res://..." id="1"]
|
||||||
|
[resource]
|
||||||
|
property = value
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
MATERIALS_DIR = os.path.expanduser("/home/oplabs/tactical-shooter/client/assets/materials")
|
||||||
|
TEX_BASE = "res://assets/textures"
|
||||||
|
|
||||||
|
|
||||||
|
def make_tres(name, props):
|
||||||
|
textures = props.get("textures", {})
|
||||||
|
load_steps = 1 + len(textures)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'[gd_resource type="StandardMaterial3D" load_steps={load_steps} format=3]',
|
||||||
|
]
|
||||||
|
|
||||||
|
ext_id = 1
|
||||||
|
ext_map = {}
|
||||||
|
for slot, rel_path in textures.items():
|
||||||
|
eid = str(ext_id)
|
||||||
|
path = f"{TEX_BASE}/{name}/{rel_path}"
|
||||||
|
lines.append(f'[ext_resource type="Texture2D" path="{path}" id="{eid}"]')
|
||||||
|
ext_map[slot] = eid
|
||||||
|
ext_id += 1
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[resource]")
|
||||||
|
|
||||||
|
# Albedo
|
||||||
|
if "albedo" in ext_map:
|
||||||
|
lines.append(f'albedo_texture = ExtResource("{ext_map["albedo"]}")')
|
||||||
|
|
||||||
|
# Normal map
|
||||||
|
if "normal" in ext_map:
|
||||||
|
lines.append("normal_enabled = true")
|
||||||
|
lines.append(f'normal_texture = ExtResource("{ext_map["normal"]}")')
|
||||||
|
if "normal_scale" in props:
|
||||||
|
lines.append(f"normal_scale = {props['normal_scale']}")
|
||||||
|
|
||||||
|
# Roughness
|
||||||
|
lines.append(f"roughness = {props.get('roughness', 0.5)}")
|
||||||
|
if "roughness" in ext_map:
|
||||||
|
lines.append(f'roughness_texture = ExtResource("{ext_map["roughness"]}")')
|
||||||
|
|
||||||
|
# Metallic
|
||||||
|
lines.append(f"metallic = {props.get('metallic', 0.0)}")
|
||||||
|
if "metallic" in ext_map:
|
||||||
|
lines.append(f'metallic_texture = ExtResource("{ext_map["metallic"]}")')
|
||||||
|
|
||||||
|
# Texture filter (1 = linear)
|
||||||
|
if "texture_filter" in props:
|
||||||
|
lines.append(f"texture_filter = {props['texture_filter']}")
|
||||||
|
|
||||||
|
# UV tiling
|
||||||
|
if "uv_scale" in props:
|
||||||
|
s = props["uv_scale"]
|
||||||
|
lines.append(f'uv1_scale = Vector3({s[0]}, {s[1]}, {s[2]})')
|
||||||
|
|
||||||
|
content = "\n".join(lines) + "\n"
|
||||||
|
path = os.path.join(MATERIALS_DIR, f"{name}.tres")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f" Created {name}.tres ({len(textures)} textures, {load_steps} steps)")
|
||||||
|
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
os.makedirs(MATERIALS_DIR, exist_ok=True)
|
||||||
|
print("Generating material .tres files...")
|
||||||
|
|
||||||
|
# 1 - Wall concrete 01: primary wall, clean light concrete
|
||||||
|
make_tres("wall_concrete_01", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.85, "metallic": 0.0, "normal_scale": 0.5,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (1.0, 1.0, 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2 - Wall concrete 02: darker accent concrete
|
||||||
|
make_tres("wall_concrete_02", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.75, "metallic": 0.0, "normal_scale": 0.4,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (1.0, 1.0, 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3 - Floor tile 01: ceramic tile interior
|
||||||
|
make_tres("floor_tile_01", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.6, "metallic": 0.0, "normal_scale": 1.0,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (2.0, 2.0, 1.0), # repeat to show tile grid
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4 - Floor concrete 01: industrial floor
|
||||||
|
make_tres("floor_concrete_01", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.9, "metallic": 0.0, "normal_scale": 0.8,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (2.0, 2.0, 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5 - Metal structural 01: structural beams
|
||||||
|
make_tres("metal_structural_01", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.3, "metallic": 1.0, "normal_scale": 0.3,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (1.0, 1.0, 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 6 - Accent team blue: CT-side colored panels
|
||||||
|
make_tres("accent_team_blue", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.35, "metallic": 0.5, "normal_scale": 0.3,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (1.0, 1.0, 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 7 - Accent team red: T-side colored panels
|
||||||
|
make_tres("accent_team_red", {
|
||||||
|
"textures": {"albedo": "basecolor.png", "normal": "normal.png",
|
||||||
|
"roughness": "roughness.png", "metallic": "metallic.png"},
|
||||||
|
"roughness": 0.35, "metallic": 0.5, "normal_scale": 0.3,
|
||||||
|
"texture_filter": 1,
|
||||||
|
"uv_scale": (1.0, 1.0, 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("\nDone — 7 material .tres files created.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generate()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/metal_structural_01/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/metal_structural_01/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/metal_structural_01/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/metal_structural_01/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 0.3
|
||||||
|
roughness = 0.3
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 1.0
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(1.0, 1.0, 1.0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_01/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_01/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_01/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_01/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 0.5
|
||||||
|
roughness = 0.85
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 0.0
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(1.0, 1.0, 1.0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" load_steps=5 format=3]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_02/basecolor.png" id="1"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_02/normal.png" id="2"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_02/roughness.png" id="3"]
|
||||||
|
[ext_resource type="Texture2D" path="res://assets/textures/wall_concrete_02/metallic.png" id="4"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
albedo_texture = ExtResource("1")
|
||||||
|
normal_enabled = true
|
||||||
|
normal_texture = ExtResource("2")
|
||||||
|
normal_scale = 0.4
|
||||||
|
roughness = 0.75
|
||||||
|
roughness_texture = ExtResource("3")
|
||||||
|
metallic = 0.0
|
||||||
|
metallic_texture = ExtResource("4")
|
||||||
|
texture_filter = 1
|
||||||
|
uv1_scale = Vector3(1.0, 1.0, 1.0)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/accent_team_blue.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="BluePanel01" type="CSGBox3D"]
|
||||||
|
size = Vector3(1.28, 0.64, 0.08)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 20.0
|
||||||
|
visibility_range_end_margin = 2.0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/accent_team_red.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="RedPanel01" type="CSGBox3D"]
|
||||||
|
size = Vector3(1.28, 0.64, 0.08)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 20.0
|
||||||
|
visibility_range_end_margin = 2.0
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
@tool
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# art_kit.gd — Modular Art Kit Factory
|
||||||
|
# =============================================================================
|
||||||
|
# Provides factory methods for spawning modular environment pieces and a
|
||||||
|
# preview catalogue for map builders.
|
||||||
|
#
|
||||||
|
# Each piece is a standalone .tscn under pieces/ with:
|
||||||
|
# - CSG shapes (Box3D, Cylinder3D, Polygon3D, Combiner3D)
|
||||||
|
# - Solid-color StandardMaterial3D placeholders (roughness 0.8, metallic 0.0)
|
||||||
|
# - Collision enabled (use_collision = true or StaticBody3D + CollisionShape3D)
|
||||||
|
# - < 200 tris each
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# var piece = ArtKit.spawn_piece("crate", parent_node)
|
||||||
|
# var cat = ArtKit.preview_catalogue()
|
||||||
|
# var stack = ArtKit.random_crate_layout(3, 2) # returns parent Node3D
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
const PIECES_DIR := "res://client/assets/scenes/modular/pieces/"
|
||||||
|
|
||||||
|
# Catalogue definition: name, description, filename (without .tscn)
|
||||||
|
const CATALOGUE: Array[Dictionary] = [
|
||||||
|
{
|
||||||
|
"name": "floor_tile",
|
||||||
|
"description": "2×2×0.2m concrete slab — grey",
|
||||||
|
"file": "floor_tile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wall_segment",
|
||||||
|
"description": "4×0.2×3m wall panel — blue-grey",
|
||||||
|
"file": "wall_segment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "corner_wall",
|
||||||
|
"description": "L-shaped corner piece (2m each leg) — blue-grey",
|
||||||
|
"file": "corner_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pillar",
|
||||||
|
"description": "0.3×3×0.3m column — dark grey",
|
||||||
|
"file": "pillar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "crate",
|
||||||
|
"description": "1×1×1m storage box — brown/tan",
|
||||||
|
"file": "crate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "barrel",
|
||||||
|
"description": "0.6m radius × 1m tall cylinder — red",
|
||||||
|
"file": "barrel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cover_wall",
|
||||||
|
"description": "2×0.2×1.3m low wall (crouch-height cover) — grey",
|
||||||
|
"file": "cover_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ramp",
|
||||||
|
"description": "2×2m, 30° incline ramp — yellow",
|
||||||
|
"file": "ramp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stairs",
|
||||||
|
"description": "3-step staircase (2m wide, 0.25m rise per step) — grey",
|
||||||
|
"file": "stairs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "window_frame",
|
||||||
|
"description": "4×3m wall with 2×2m opening — blue-grey",
|
||||||
|
"file": "window_frame"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Spawns a piece by name, adds it as a child of `parent`, and returns it.
|
||||||
|
## Returns null if the piece name is unknown or the scene fails to load.
|
||||||
|
static func spawn_piece(piece_name: String, parent: Node) -> Node3D:
|
||||||
|
var entry := _find_entry(piece_name)
|
||||||
|
if entry.is_empty():
|
||||||
|
push_error("ArtKit: Unknown piece '%s'. Use preview_catalogue() to see available pieces." % piece_name)
|
||||||
|
return null
|
||||||
|
|
||||||
|
var path := PIECES_DIR + entry.file + ".tscn"
|
||||||
|
var scene := load(path)
|
||||||
|
if scene == null:
|
||||||
|
push_error("ArtKit: Failed to load scene at '%s'." % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
var instance: Node3D = scene.instantiate()
|
||||||
|
parent.add_child(instance)
|
||||||
|
instance.owner = parent if not Engine.is_editor_hint() else _get_editor_owner(parent)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
## Returns an array of { name, description, path } for all available pieces.
|
||||||
|
static func preview_catalogue() -> Array[Dictionary]:
|
||||||
|
var result: Array[Dictionary] = []
|
||||||
|
for entry in CATALOGUE:
|
||||||
|
result.append({
|
||||||
|
"name": entry.name,
|
||||||
|
"description": entry.description,
|
||||||
|
"path": PIECES_DIR + entry.file + ".tscn"
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Generates a random stack of crates for quick testing layouts.
|
||||||
|
## `width` — number of crates along X (default 3)
|
||||||
|
## `height` — number of crates stacked vertically (default 2)
|
||||||
|
## Returns a Node3D containing the crate instances.
|
||||||
|
static func random_crate_layout(width: int = 3, height: int = 2) -> Node3D:
|
||||||
|
var root := Node3D.new()
|
||||||
|
root.name = "CrateLayout_%dx%d" % [width, height]
|
||||||
|
|
||||||
|
var crate_scene := load(PIECES_DIR + "crate.tscn")
|
||||||
|
if crate_scene == null:
|
||||||
|
push_error("ArtKit: Could not load crate scene.")
|
||||||
|
return root
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
# Add slight random offset for natural look (±0.05m)
|
||||||
|
var offset_x := randf_range(-0.05, 0.05)
|
||||||
|
var offset_z := randf_range(-0.05, 0.05)
|
||||||
|
# Full rotation in 90° increments
|
||||||
|
var rot_y := randi() % 4 * 90.0
|
||||||
|
|
||||||
|
var crate: Node3D = crate_scene.instantiate()
|
||||||
|
crate.position = Vector3(x * 1.0 + offset_x, y * 1.0 + 0.5, offset_z)
|
||||||
|
crate.rotation_degrees = Vector3(0, rot_y, 0)
|
||||||
|
root.add_child(crate)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static func _find_entry(piece_name: String) -> Dictionary:
|
||||||
|
for entry in CATALOGUE:
|
||||||
|
if entry.name == piece_name:
|
||||||
|
return entry
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the editor's top-level owner so new nodes are treated as part of the scene.
|
||||||
|
static func _get_editor_owner(node: Node) -> Node:
|
||||||
|
var owner = node
|
||||||
|
while owner and owner.owner:
|
||||||
|
owner = owner.owner
|
||||||
|
return owner
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dnopn0wj58j83
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/metal_structural_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="Beam01" type="CSGBox3D"]
|
||||||
|
size = Vector3(3.84, 0.32, 0.32)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 30.0
|
||||||
|
visibility_range_end_margin = 3.0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/floor_concrete_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="FloorTile01" type="CSGBox3D"]
|
||||||
|
size = Vector3(2.56, 0.08, 2.56)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 60.0
|
||||||
|
visibility_range_end_margin = 10.0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/floor_tile_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="FloorTileCeramic01" type="CSGBox3D"]
|
||||||
|
size = Vector3(2.56, 0.08, 2.56)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 60.0
|
||||||
|
visibility_range_end_margin = 10.0
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate Godot 4 .tscn modular kit scenes using CSG nodes.
|
||||||
|
All dimensions in Godot meters, grid base = 2.56m (256cm).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
SCENES_DIR = os.path.expanduser("/home/oplabs/tactical-shooter/client/assets/scenes/modular")
|
||||||
|
MAT_BASE = "res://assets/materials"
|
||||||
|
|
||||||
|
# Grid constants
|
||||||
|
U = 2.56 # 1 modular unit (256cm)
|
||||||
|
W = 0.16 # wall thickness (16cm)
|
||||||
|
F = 0.08 # floor thickness (8cm)
|
||||||
|
|
||||||
|
# Material name shortcuts
|
||||||
|
MAT_WALL = "wall_concrete_01"
|
||||||
|
MAT_WALL2 = "wall_concrete_02"
|
||||||
|
MAT_FLOOR = "floor_concrete_01"
|
||||||
|
MAT_FLOOR_TILE = "floor_tile_01"
|
||||||
|
MAT_METAL = "metal_structural_01"
|
||||||
|
MAT_BLUE = "accent_team_blue"
|
||||||
|
MAT_RED = "accent_team_red"
|
||||||
|
|
||||||
|
|
||||||
|
def tsnode(name, type_name, parent=".", props=None):
|
||||||
|
"""Build a [node] entry for .tscn format."""
|
||||||
|
lines = [f'[node name="{name}" type="{type_name}" parent="{parent}"]']
|
||||||
|
if props:
|
||||||
|
for k, v in props.items():
|
||||||
|
lines.append(f"{k} = {v}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wall pieces ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_wall_straight():
|
||||||
|
"""Straight wall — 2.56m x 2.56m x 0.16m."""
|
||||||
|
lines = [
|
||||||
|
'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_WALL}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("WallStraight01", "CSGBox3D", ".", {
|
||||||
|
"size": f"Vector3({U}, {U}, {W})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_wall_corner():
|
||||||
|
"""L-corner — two CSG boxes in a CSGCombiner3D."""
|
||||||
|
lines = [
|
||||||
|
'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_WALL}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("WallCorner01", "CSGCombiner3D", ".", {"use_collision": "false"}),
|
||||||
|
tsnode("WallA", "CSGBox3D", "WallCorner01", {
|
||||||
|
"operation": "0",
|
||||||
|
"size": f"Vector3({U}, {U}, {W})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
"transform": f"Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, {W/2})",
|
||||||
|
}),
|
||||||
|
tsnode("WallB", "CSGBox3D", "WallCorner01", {
|
||||||
|
"operation": "0",
|
||||||
|
"size": f"Vector3({W}, {U}, {U})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
"transform": f"Transform3D(0, 0, -1, 0, 1, 0, 1, 0, 0, {W/2}, 0, 0)",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_wall_doorway():
|
||||||
|
"""Wall with door opening (0.80m x 2.00m cutout)."""
|
||||||
|
dw, dh = 0.80, 2.00
|
||||||
|
off_y = dh/2 - U/2 # bottom-aligned
|
||||||
|
lines = [
|
||||||
|
'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_WALL}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("WallDoorway01", "CSGCombiner3D", ".", {"use_collision": "false"}),
|
||||||
|
tsnode("MainWall", "CSGBox3D", "WallDoorway01", {
|
||||||
|
"operation": "0",
|
||||||
|
"size": f"Vector3({U}, {U}, {W})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
tsnode("DoorCutout", "CSGBox3D", "WallDoorway01", {
|
||||||
|
"operation": "1",
|
||||||
|
"size": f"Vector3({dw}, {dh}, {W + 0.04})",
|
||||||
|
"use_collision": "true",
|
||||||
|
"transform": f"Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, {off_y}, 0)",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_wall_window():
|
||||||
|
"""Wall with window opening (1.20m x 0.80m cutout, centered slightly above mid)."""
|
||||||
|
ww, wh = 1.20, 0.80
|
||||||
|
off_y = 0.30
|
||||||
|
lines = [
|
||||||
|
'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_WALL}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("WallWindow01", "CSGCombiner3D", ".", {"use_collision": "false"}),
|
||||||
|
tsnode("MainWall", "CSGBox3D", "WallWindow01", {
|
||||||
|
"operation": "0",
|
||||||
|
"size": f"Vector3({U}, {U}, {W})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
tsnode("WindowCutout", "CSGBox3D", "WallWindow01", {
|
||||||
|
"operation": "1",
|
||||||
|
"size": f"Vector3({ww}, {wh}, {W + 0.04})",
|
||||||
|
"use_collision": "true",
|
||||||
|
"transform": f"Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, {off_y}, 0)",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_wall_endcap():
|
||||||
|
"""Narrow edge cap — 0.32m x 2.56m x 0.16m."""
|
||||||
|
lines = [
|
||||||
|
'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_WALL}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("WallEndcap01", "CSGBox3D", ".", {
|
||||||
|
"size": "Vector3(0.32, 2.56, 0.16)",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Floor pieces ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_floor_tile():
|
||||||
|
"""2.56m x 0.08m x 2.56m floor slab — industrial concrete."""
|
||||||
|
lines = [
|
||||||
|
f'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_FLOOR}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("FloorTile01", "CSGBox3D", ".", {
|
||||||
|
"size": f"Vector3({U}, {F}, {U})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_floor_tile_ceramic():
|
||||||
|
"""Floor slab with ceramic tile material."""
|
||||||
|
lines = [
|
||||||
|
f'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_FLOOR_TILE}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("FloorTileCeramic01", "CSGBox3D", ".", {
|
||||||
|
"size": f"Vector3({U}, {F}, {U})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Structural ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_pillar():
|
||||||
|
"""Square pillar — 0.64m x 2.56m x 0.64m."""
|
||||||
|
lines = [
|
||||||
|
f'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_WALL2}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("Pillar01", "CSGBox3D", ".", {
|
||||||
|
"size": "Vector3(0.64, 2.56, 0.64)",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_beam():
|
||||||
|
"""Ceiling beam — 3.84m x 0.32m x 0.32m metal."""
|
||||||
|
lines = [
|
||||||
|
f'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{MAT_METAL}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode("Beam01", "CSGBox3D", ".", {
|
||||||
|
"size": f"Vector3({U * 1.5}, 0.32, 0.32)",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accent panels ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_accent_panel(name, mat_name, color_label):
|
||||||
|
"""A thin accent panel with team color material."""
|
||||||
|
lines = [
|
||||||
|
f'[gd_scene load_steps=2 format=3]',
|
||||||
|
f'[ext_resource type="Material" path="{MAT_BASE}/{mat_name}.tres" id="1"]',
|
||||||
|
'',
|
||||||
|
tsnode(f"{color_label}Panel01", "CSGBox3D", ".", {
|
||||||
|
"size": f"Vector3(1.28, 0.64, {W * 0.5})",
|
||||||
|
"material": 'ExtResource("1")',
|
||||||
|
"use_collision": "true",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kit demo scene ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_kit_demo():
|
||||||
|
"""Showcase scene — places every modular piece on a 3m-spaced strip."""
|
||||||
|
# Each entry: (scene_name, ext_material_name, label_for_node)
|
||||||
|
entries = [
|
||||||
|
("wall_straight_01", MAT_WALL, "Wall01"),
|
||||||
|
("wall_corner_01", MAT_WALL, "Corner01"),
|
||||||
|
("wall_doorway_01", MAT_WALL, "Doorway01"),
|
||||||
|
("wall_window_01", MAT_WALL, "Window01"),
|
||||||
|
("wall_endcap_01", MAT_WALL, "Endcap01"),
|
||||||
|
("floor_tile_01", MAT_FLOOR, "Floor01"),
|
||||||
|
("floor_tile_ceramic_01", MAT_FLOOR_TILE, "FloorCeramic01"),
|
||||||
|
("pillar_01", MAT_WALL2, "Pillar01"),
|
||||||
|
("beam_01", MAT_METAL, "Beam01"),
|
||||||
|
("accent_blue_panel", MAT_BLUE, "BluePanel01"),
|
||||||
|
("accent_red_panel", MAT_RED, "RedPanel01"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# All unique materials used
|
||||||
|
all_mats = sorted(set(e[1] for e in entries))
|
||||||
|
mat_scene_map = {} # mat_name -> {scene_name -> ext_id}
|
||||||
|
scene_map = {} # scene_name -> ext_id
|
||||||
|
|
||||||
|
ext_id_counter = 1
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# First pass: assign ext ids to scenes (PackedScene) and materials
|
||||||
|
scene_ext_ids = {}
|
||||||
|
mat_ext_ids = {}
|
||||||
|
for scene_name, mat_name, _ in entries:
|
||||||
|
if scene_name not in scene_ext_ids:
|
||||||
|
scene_ext_ids[scene_name] = ext_id_counter
|
||||||
|
ext_id_counter += 1
|
||||||
|
if mat_name not in mat_ext_ids:
|
||||||
|
mat_ext_ids[mat_name] = ext_id_counter
|
||||||
|
ext_id_counter += 1
|
||||||
|
|
||||||
|
total_ext = len(scene_ext_ids) + len(mat_ext_ids)
|
||||||
|
load_steps = total_ext + 1
|
||||||
|
|
||||||
|
lines = [f'[gd_scene load_steps={load_steps} format=3]']
|
||||||
|
|
||||||
|
# Ext resources: scenes
|
||||||
|
for scene_name, eid in scene_ext_ids.items():
|
||||||
|
lines.append(
|
||||||
|
f'[ext_resource type="PackedScene" '
|
||||||
|
f'path="res://assets/scenes/modular/{scene_name}.tscn" id="{eid}"]'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ext resources: materials (for any inline nodes)
|
||||||
|
for mat_name, eid in mat_ext_ids.items():
|
||||||
|
lines.append(
|
||||||
|
f'[ext_resource type="Material" '
|
||||||
|
f'path="{MAT_BASE}/{mat_name}.tres" id="{eid}"]'
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append('[node name="KitDemo" type="Node3D"]')
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
x = -5.0
|
||||||
|
for scene_name, mat_name, label in entries:
|
||||||
|
scene_eid = scene_ext_ids[scene_name]
|
||||||
|
lines.append(
|
||||||
|
f'[node name="{label}" type="Node3D" parent="."]'
|
||||||
|
)
|
||||||
|
lines.append(f'instance = ExtResource("{scene_eid}")')
|
||||||
|
lines.append(f'transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, {x:.1f}, 0, 0)')
|
||||||
|
lines.append("")
|
||||||
|
x += 3.0
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SCENE_BUILDERS = {
|
||||||
|
"wall_straight_01": build_wall_straight,
|
||||||
|
"wall_corner_01": build_wall_corner,
|
||||||
|
"wall_doorway_01": build_wall_doorway,
|
||||||
|
"wall_window_01": build_wall_window,
|
||||||
|
"wall_endcap_01": build_wall_endcap,
|
||||||
|
"floor_tile_01": build_floor_tile,
|
||||||
|
"floor_tile_ceramic_01": build_floor_tile_ceramic,
|
||||||
|
"pillar_01": build_pillar,
|
||||||
|
"beam_01": build_beam,
|
||||||
|
"accent_blue_panel": lambda: build_accent_panel("accent_blue_panel", MAT_BLUE, "Blue"),
|
||||||
|
"accent_red_panel": lambda: build_accent_panel("accent_red_panel", MAT_RED, "Red"),
|
||||||
|
"kit_demo": build_kit_demo,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(SCENES_DIR, exist_ok=True)
|
||||||
|
print("Generating modular .tscn scenes...")
|
||||||
|
for name, builder in SCENE_BUILDERS.items():
|
||||||
|
content = builder()
|
||||||
|
path = os.path.join(SCENES_DIR, f"{name}.tscn")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f" Created {name}.tscn ({len(content)} bytes)")
|
||||||
|
print(f"\nDone — {len(SCENE_BUILDERS)} .tscn files")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@tool
|
||||||
|
extends Node3D
|
||||||
|
|
||||||
|
# Provides baked lighting configuration guidance in the editor output panel.
|
||||||
|
# LightmapGI baking is editor-only in Godot 4.7 — use the Bake button.
|
||||||
|
#
|
||||||
|
# To bake:
|
||||||
|
# 1. Open this scene in the Godot editor
|
||||||
|
# 2. Select the LightmapGI node in the Scene dock
|
||||||
|
# 3. Click "Bake Lightmap" in the Inspector toolbar (top of inspector)
|
||||||
|
#
|
||||||
|
# Or: Scene menu > Bake LightmapGI (Ctrl+Shift+B)
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
if not Engine.is_editor_hint():
|
||||||
|
return
|
||||||
|
|
||||||
|
var lightmap = _find_lightmap_gi()
|
||||||
|
if not lightmap:
|
||||||
|
push_error("KitDemo: No LightmapGI node found in scene!")
|
||||||
|
return
|
||||||
|
|
||||||
|
if lightmap.light_data != null:
|
||||||
|
print("KitDemo: Lightmap baked ✓")
|
||||||
|
print(" To re-bake: Select LightmapGI → 'Bake Lightmap' button")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("KitDemo: LightmapGI configured but not baked.")
|
||||||
|
print(" To bake: Select LightmapGI → 'Bake Lightmap' in Inspector")
|
||||||
|
print(" Or: Scene menu → Bake LightmapGI (Ctrl+Shift+B)")
|
||||||
|
print("")
|
||||||
|
match lightmap.quality:
|
||||||
|
0: print(" Quality: Low")
|
||||||
|
1: print(" Quality: Medium")
|
||||||
|
2: print(" Quality: High")
|
||||||
|
3: print(" Quality: Ultra")
|
||||||
|
print(" Bounces: ", lightmap.bounces)
|
||||||
|
print(" Texel Scale: ", lightmap.texel_scale)
|
||||||
|
print(" Max Texture Size: ", lightmap.max_texture_size)
|
||||||
|
print(" Interior: ", lightmap.interior)
|
||||||
|
print(" Denoiser: ", lightmap.is_using_denoiser())
|
||||||
|
|
||||||
|
|
||||||
|
func _find_lightmap_gi() -> LightmapGI:
|
||||||
|
for child in get_children():
|
||||||
|
if child is LightmapGI:
|
||||||
|
return child
|
||||||
|
return null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c6dxl1qhan4yl
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
[gd_scene load_steps=21 format=3]
|
||||||
|
|
||||||
|
; === External Resources (modular kit pieces) ===
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/wall_straight_01.tscn" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/wall_corner_01.tscn" id="3"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/wall_doorway_01.tscn" id="4"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/wall_window_01.tscn" id="5"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/wall_endcap_01.tscn" id="6"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/floor_tile_01.tscn" id="7"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/floor_tile_ceramic_01.tscn" id="9"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/pillar_01.tscn" id="11"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/beam_01.tscn" id="13"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/accent_blue_panel.tscn" id="15"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/scenes/modular/accent_red_panel.tscn" id="17"]
|
||||||
|
|
||||||
|
; === External Resources (materials) ===
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_01.tres" id="2"]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/floor_concrete_01.tres" id="8"]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/floor_tile_01.tres" id="10"]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_02.tres" id="12"]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/metal_structural_01.tres" id="14"]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/accent_team_blue.tres" id="16"]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/accent_team_red.tres" id="18"]
|
||||||
|
; === Script ===
|
||||||
|
[ext_resource type="Script" path="res://assets/scenes/modular/kit_demo.gd" id="19"]
|
||||||
|
|
||||||
|
; === Subresources ===
|
||||||
|
[sub_resource type="Environment" id="Env"]
|
||||||
|
background_mode = 0
|
||||||
|
tonemap_mode = 0
|
||||||
|
glow_enabled = false
|
||||||
|
ambient_light_color = Color(0.25, 0.25, 0.3, 1.0)
|
||||||
|
ambient_light_energy = 0.4
|
||||||
|
ambient_light_sky_contribution = 0.0
|
||||||
|
|
||||||
|
; === Scene Root ===
|
||||||
|
[node name="KitDemo" type="Node3D"]
|
||||||
|
script = ExtResource("19")
|
||||||
|
|
||||||
|
; ============ FLOOR ============
|
||||||
|
; 2x2 grid of floor tiles (5.12 x 5.12)
|
||||||
|
[node name="FloorNW" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("7")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.28, 0, -1.28)
|
||||||
|
|
||||||
|
[node name="FloorNE" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("7")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.28, 0, -1.28)
|
||||||
|
|
||||||
|
[node name="FloorSW" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("9")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.28, 0, 1.28)
|
||||||
|
|
||||||
|
[node name="FloorSE" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("7")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.28, 0, 1.28)
|
||||||
|
|
||||||
|
; ============ WALLS ============
|
||||||
|
|
||||||
|
; --- South Wall (Z=2.56, along X from -2.56 to +2.56) ---
|
||||||
|
; Straight segment left of doorway
|
||||||
|
[node name="SouthWall" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("1")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.28, 0, 2.56)
|
||||||
|
|
||||||
|
; Doorway segment right
|
||||||
|
[node name="SouthDoorway" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("4")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.28, 0, 2.56)
|
||||||
|
|
||||||
|
; --- North Wall (Z=-2.56, along X from -2.56 to +2.56) ---
|
||||||
|
; Window segment left
|
||||||
|
[node name="NorthWindow" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("5")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.28, 0, -2.56)
|
||||||
|
|
||||||
|
; Straight segment right
|
||||||
|
[node name="NorthWall" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("1")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.28, 0, -2.56)
|
||||||
|
|
||||||
|
; --- West Wall (X=-2.56, along Z from -2.56 to +2.56) ---
|
||||||
|
; Rotated 90° Y so width runs along Z instead of X
|
||||||
|
; Basis: local X → global -Z, local Z → global X
|
||||||
|
[node name="WestLower" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("1")
|
||||||
|
transform = Transform3D(0, 0, -1, 0, 1, 0, 1, 0, 0, -2.56, 0, -1.28)
|
||||||
|
|
||||||
|
[node name="WestUpper" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("1")
|
||||||
|
transform = Transform3D(0, 0, -1, 0, 1, 0, 1, 0, 0, -2.56, 0, 1.28)
|
||||||
|
|
||||||
|
; --- East Wall (X=2.56, along Z from -2.56 to +2.56) ---
|
||||||
|
; Rotated 90° Y so width runs along Z, thickness along -X
|
||||||
|
[node name="EastLower" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("1")
|
||||||
|
transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 2.56, 0, -1.28)
|
||||||
|
|
||||||
|
[node name="EastUpper" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("1")
|
||||||
|
transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 2.56, 0, 1.28)
|
||||||
|
|
||||||
|
; ============ INTERIOR ============
|
||||||
|
|
||||||
|
; Pillar at room center
|
||||||
|
[node name="Pillar" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("11")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
|
||||||
|
|
||||||
|
; Beam overhead spanning the room
|
||||||
|
[node name="Beam" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("13")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.40, 0)
|
||||||
|
|
||||||
|
; Accent red panel on south wall straight segment
|
||||||
|
[node name="AccentRed" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("17")
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.28, 1.28, 2.52)
|
||||||
|
|
||||||
|
; Accent blue panel on east wall upper segment
|
||||||
|
[node name="AccentBlue" type="Node3D" parent="."]
|
||||||
|
instance = ExtResource("15")
|
||||||
|
transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 2.52, 1.28, 0)
|
||||||
|
|
||||||
|
; ============ LIGHTING ============
|
||||||
|
|
||||||
|
; --- WorldEnvironment: skybox + ambient ---
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("Env")
|
||||||
|
|
||||||
|
; --- DirectionalLight: sun/moon key light from high angle ---
|
||||||
|
; Tilted ~45° down, rotated ~30° Y for directional angle
|
||||||
|
[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.2
|
||||||
|
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
|
||||||
|
|
||||||
|
; --- OmniLight: warm interior fill ---
|
||||||
|
[node name="FillLight" type="OmniLight3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.5, 2.4, 1.5)
|
||||||
|
light_energy = 0.4
|
||||||
|
light_indirect_energy = 0.5
|
||||||
|
light_color = Color(1.0, 0.85, 0.7, 1.0)
|
||||||
|
light_bake_mode = 2
|
||||||
|
omni_range = 6.0
|
||||||
|
omni_attenuation = 0.8
|
||||||
|
shadow_enabled = false
|
||||||
|
|
||||||
|
; --- ReflectionProbe for interior specular reflections ---
|
||||||
|
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.28, 0)
|
||||||
|
box_projection = true
|
||||||
|
interior = true
|
||||||
|
extents = Vector3(2.8, 1.4, 2.8)
|
||||||
|
intensity = 1.0
|
||||||
|
max_distance = 10.0
|
||||||
|
|
||||||
|
; --- LightmapGI: baked global illumination ---
|
||||||
|
[node name="LightmapGI" type="LightmapGI" parent="."]
|
||||||
|
quality = 2
|
||||||
|
bounces = 3
|
||||||
|
bounce_indirect_energy = 1.0
|
||||||
|
texel_scale = 1.0
|
||||||
|
max_texture_size = 2048
|
||||||
|
use_denoiser = true
|
||||||
|
interior = true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.7, 0.15, 0.1, 1.0)
|
||||||
|
roughness = 0.6
|
||||||
|
metallic = 0.2
|
||||||
|
resource_name = "red_barrel"
|
||||||
|
|
||||||
|
[node name="Barrel" type="CSGCylinder3D"]
|
||||||
|
radius = 0.6
|
||||||
|
height = 1.0
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
slices = 16
|
||||||
|
visibility_range_end = 30.0
|
||||||
|
visibility_range_end_margin = 3.0
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.35, 0.4, 0.5, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "bluegrey_corner"
|
||||||
|
|
||||||
|
[node name="CornerWall" type="CSGCombiner3D"]
|
||||||
|
use_collision = false
|
||||||
|
visibility_range_end = 50.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
|
|
||||||
|
[node name="WallA" type="CSGBox3D" parent="CornerWall"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2, 3, 0.2)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.1)
|
||||||
|
|
||||||
|
[node name="WallB" type="CSGBox3D" parent="CornerWall"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(0.2, 3, 2)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.1, 0, 0)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.4, 0.4, 0.4, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "grey_cover"
|
||||||
|
|
||||||
|
[node name="CoverWall" type="CSGBox3D"]
|
||||||
|
size = Vector3(2, 1.3, 0.2)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 40.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.55, 0.35, 0.15, 1.0)
|
||||||
|
roughness = 0.85
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "brown_crate"
|
||||||
|
|
||||||
|
[node name="Crate" type="CSGBox3D"]
|
||||||
|
size = Vector3(1, 1, 1)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 40.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.5, 0.5, 0.5, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "concrete_grey"
|
||||||
|
|
||||||
|
[node name="FloorTile" type="CSGBox3D"]
|
||||||
|
size = Vector3(2, 0.2, 2)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 60.0
|
||||||
|
visibility_range_end_margin = 10.0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.2, 0.2, 0.22, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "darkgrey_pillar"
|
||||||
|
|
||||||
|
[node name="Pillar" type="CSGBox3D"]
|
||||||
|
size = Vector3(0.3, 3, 0.3)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 30.0
|
||||||
|
visibility_range_end_margin = 3.0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.7, 0.7, 0.3, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "yellow_ramp"
|
||||||
|
|
||||||
|
[node name="Ramp" type="CSGPolygon3D"]
|
||||||
|
polygon = PackedVector2Array(0, 0, 2, 0, 2, 1.155)
|
||||||
|
depth = 2.0
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
smooth_faces = true
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.45, 0.45, 0.45, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "grey_stairs"
|
||||||
|
|
||||||
|
[node name="Stairs" type="CSGCombiner3D"]
|
||||||
|
use_collision = false
|
||||||
|
visibility_range_end = 40.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
|
|
||||||
|
[node name="Step1" type="CSGBox3D" parent="Stairs"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2, 0.25, 0.5)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.125, 0.75)
|
||||||
|
|
||||||
|
[node name="Step2" type="CSGBox3D" parent="Stairs"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2, 0.25, 0.5)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.375, 0.25)
|
||||||
|
|
||||||
|
[node name="Step3" type="CSGBox3D" parent="Stairs"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2, 0.25, 0.5)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.625, -0.25)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.35, 0.4, 0.5, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "bluegrey_wall"
|
||||||
|
|
||||||
|
[node name="WallSegment" type="CSGBox3D"]
|
||||||
|
size = Vector3(4, 3, 0.2)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 50.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="1"]
|
||||||
|
albedo_color = Color(0.35, 0.4, 0.5, 1.0)
|
||||||
|
roughness = 0.8
|
||||||
|
metallic = 0.0
|
||||||
|
resource_name = "bluegrey_window"
|
||||||
|
|
||||||
|
[node name="WindowFrame" type="CSGCombiner3D"]
|
||||||
|
use_collision = false
|
||||||
|
visibility_range_end = 40.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
|
|
||||||
|
[node name="MainWall" type="CSGBox3D" parent="WindowFrame"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(4, 3, 0.2)
|
||||||
|
material = SubResource("1")
|
||||||
|
use_collision = true
|
||||||
|
|
||||||
|
[node name="WindowCutout" type="CSGBox3D" parent="WindowFrame"]
|
||||||
|
operation = 1
|
||||||
|
size = Vector3(2, 2, 0.25)
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_02.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="Pillar01" type="CSGBox3D"]
|
||||||
|
size = Vector3(0.64, 2.56, 0.64)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 30.0
|
||||||
|
visibility_range_end_margin = 3.0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="WallCorner01" type="CSGCombiner3D"]
|
||||||
|
use_collision = false
|
||||||
|
visibility_range_end = 50.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
|
[node name="WallA" type="CSGBox3D" parent="WallCorner01"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2.56, 2.56, 0.16)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.08)
|
||||||
|
[node name="WallB" type="CSGBox3D" parent="WallCorner01"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(0.16, 2.56, 2.56)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(0, 0, -1, 0, 1, 0, 1, 0, 0, 0.08, 0, 0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="WallDoorway01" type="CSGCombiner3D"]
|
||||||
|
use_collision = false
|
||||||
|
visibility_range_end = 40.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
|
[node name="MainWall" type="CSGBox3D" parent="WallDoorway01"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2.56, 2.56, 0.16)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
[node name="DoorCutout" type="CSGBox3D" parent="WallDoorway01"]
|
||||||
|
operation = 1
|
||||||
|
size = Vector3(0.8, 2.0, 0.2)
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.28, 0)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="WallEndcap01" type="CSGBox3D"]
|
||||||
|
size = Vector3(0.32, 2.56, 0.16)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 50.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="WallStraight01" type="CSGBox3D"]
|
||||||
|
size = Vector3(2.56, 2.56, 0.16)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
visibility_range_end = 50.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Material" path="res://assets/materials/wall_concrete_01.tres" id="1"]
|
||||||
|
|
||||||
|
[node name="WallWindow01" type="CSGCombiner3D"]
|
||||||
|
use_collision = false
|
||||||
|
visibility_range_end = 40.0
|
||||||
|
visibility_range_end_margin = 5.0
|
||||||
|
[node name="MainWall" type="CSGBox3D" parent="WallWindow01"]
|
||||||
|
operation = 0
|
||||||
|
size = Vector3(2.56, 2.56, 0.16)
|
||||||
|
material = ExtResource("1")
|
||||||
|
use_collision = true
|
||||||
|
[node name="WindowCutout" type="CSGBox3D" parent="WallWindow01"]
|
||||||
|
operation = 1
|
||||||
|
size = Vector3(1.2, 0.8, 0.2)
|
||||||
|
use_collision = true
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0)
|
||||||
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 973 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 539 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 973 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,493 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate procedural 1K PBR texture maps for the Tactical Shooter modular art kit.
|
||||||
|
Produces: basecolor, normal, roughness, metallic — all at 1024x1024.
|
||||||
|
Usage: python3 generate_textures.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from PIL import Image, ImageFilter, ImageChops, ImageDraw
|
||||||
|
|
||||||
|
TEXTURE_DIR = os.path.expanduser("/home/oplabs/tactical-shooter/client/assets/textures")
|
||||||
|
SIZE = 1024
|
||||||
|
|
||||||
|
random.seed(42)
|
||||||
|
|
||||||
|
def save_pbr(base_dir, name, basecolor, normal, roughness, metallic):
|
||||||
|
"""Save all PBR maps for one material."""
|
||||||
|
d = os.path.join(base_dir, name)
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
basecolor.save(os.path.join(d, "basecolor.png"))
|
||||||
|
normal.save(os.path.join(d, "normal.png"))
|
||||||
|
roughness.save(os.path.join(d, "roughness.png"))
|
||||||
|
metallic.save(os.path.join(d, "metallic.png"))
|
||||||
|
print(f" Generated {name}/ — 4 maps saved")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utility helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def gaussian_noise(size, scale=1.0, seed=None):
|
||||||
|
"""Generate a grayscale image with gaussian noise."""
|
||||||
|
if seed is not None:
|
||||||
|
random.seed(seed)
|
||||||
|
img = Image.new("L", (size, size))
|
||||||
|
pix = img.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
v = int(random.gauss(128, 64 * scale))
|
||||||
|
pix[x, y] = max(0, min(255, v))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def tile_blur(img, radius):
|
||||||
|
"""Blur with tileable boundary by mirroring edges before blur."""
|
||||||
|
w, h = img.size
|
||||||
|
big = Image.new(img.mode, (w * 3, h * 3))
|
||||||
|
for dx in range(3):
|
||||||
|
for dy in range(3):
|
||||||
|
if dx == 1 and dy == 1:
|
||||||
|
big.paste(img, (w, h))
|
||||||
|
else:
|
||||||
|
# Mirror the tile
|
||||||
|
mirror = img.transpose(Image.FLIP_LEFT_RIGHT) if dx % 2 == 0 else img
|
||||||
|
if dy % 2 == 0:
|
||||||
|
mirror = mirror.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
big.paste(mirror, (w * dx, h * dy))
|
||||||
|
big = big.filter(ImageFilter.GaussianBlur(radius))
|
||||||
|
return big.crop((w, h, w * 2, h * 2))
|
||||||
|
|
||||||
|
|
||||||
|
def normal_from_height(height_img, strength=2.0):
|
||||||
|
"""Generate an RGB normal map from a grayscale height map using Sobel."""
|
||||||
|
w, h = height_img.size
|
||||||
|
# Get pixel data
|
||||||
|
hpix = height_img.load()
|
||||||
|
normal = Image.new("RGB", (w, h))
|
||||||
|
npix = normal.load()
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
# Sample height with wrapping for tileability
|
||||||
|
sx1 = hpix[(x - 1) % w, y]
|
||||||
|
sx2 = hpix[(x + 1) % w, y]
|
||||||
|
sy1 = hpix[x, (y - 1) % h]
|
||||||
|
sy2 = hpix[x, (y + 1) % h]
|
||||||
|
dx = (sx2 - sx1) / 255.0
|
||||||
|
dy = (sy2 - sy1) / 255.0
|
||||||
|
dz = 1.0 / strength
|
||||||
|
length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
nx = dx / length
|
||||||
|
ny = dy / length
|
||||||
|
nz = dz / length
|
||||||
|
# Map from [-1,1] to [0,255]
|
||||||
|
npix[x, y] = (
|
||||||
|
int((nx * 0.5 + 0.5) * 255),
|
||||||
|
int((ny * 0.5 + 0.5) * 255),
|
||||||
|
int((nz * 0.5 + 0.5) * 255),
|
||||||
|
)
|
||||||
|
return normal
|
||||||
|
|
||||||
|
|
||||||
|
def make_cellular(size, cell_size=32, seed=None):
|
||||||
|
"""Generate a cellular/Voronoi-like pattern as grayscale."""
|
||||||
|
if seed is not None:
|
||||||
|
random.seed(seed)
|
||||||
|
img = Image.new("L", (size, size))
|
||||||
|
pix = img.load()
|
||||||
|
|
||||||
|
# Generate cell centers with random heights
|
||||||
|
cells = {}
|
||||||
|
for cx in range(0, size + cell_size, cell_size):
|
||||||
|
for cy in range(0, size + cell_size, cell_size):
|
||||||
|
ox = random.randint(0, cell_size - 1)
|
||||||
|
oy = random.randint(0, cell_size - 1)
|
||||||
|
h = random.randint(40, 200)
|
||||||
|
cells[(cx + ox, cy + oy)] = h
|
||||||
|
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
min_dist = float("inf")
|
||||||
|
best_h = 128
|
||||||
|
for (cx, cy), h in cells.items():
|
||||||
|
d = (cx - x) ** 2 + (cy - y) ** 2
|
||||||
|
if d < min_dist:
|
||||||
|
min_dist = d
|
||||||
|
best_h = h
|
||||||
|
pix[x, y] = best_h
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def concrete_base(size, base_rgb=(180, 175, 168), noise_scale=1.0):
|
||||||
|
"""Generate concrete-like basecolor with aggregate variation."""
|
||||||
|
# Start with light noise
|
||||||
|
noise = gaussian_noise(size, scale=0.15, seed=42)
|
||||||
|
noise = tile_blur(noise, 4)
|
||||||
|
|
||||||
|
# Add larger variation
|
||||||
|
big = gaussian_noise(size, scale=0.08, seed=43)
|
||||||
|
big = tile_blur(big, 24)
|
||||||
|
|
||||||
|
# Add some darker speckles (aggregate)
|
||||||
|
speckle = Image.new("L", (size, size), 0)
|
||||||
|
spix = speckle.load()
|
||||||
|
for _ in range(800):
|
||||||
|
x = random.randint(0, size - 1)
|
||||||
|
y = random.randint(0, size - 1)
|
||||||
|
spix[x, y] = random.randint(30, 80)
|
||||||
|
speckle = tile_blur(speckle, 1.5)
|
||||||
|
|
||||||
|
# Combine
|
||||||
|
combined = Image.new("L", (size, size))
|
||||||
|
cpix = combined.load()
|
||||||
|
npix = noise.load()
|
||||||
|
bpix = big.load()
|
||||||
|
skpix = speckle.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
v = npix[x, y] + bpix[x, y] - 128 + skpix[x, y] - 30
|
||||||
|
cpix[x, y] = max(0, min(255, v))
|
||||||
|
|
||||||
|
# Tint and apply
|
||||||
|
result = Image.new("RGB", (size, size))
|
||||||
|
rpix = result.load()
|
||||||
|
cp = combined.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
f = cp[x, y] / 255.0
|
||||||
|
rpix[x, y] = (
|
||||||
|
max(0, min(255, int(base_rgb[0] * f / 180))),
|
||||||
|
max(0, min(255, int(base_rgb[1] * f / 175))),
|
||||||
|
max(0, min(255, int(base_rgb[2] * f / 168))),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Material definitions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_wall_concrete_01():
|
||||||
|
"""Light clean concrete — primary wall material."""
|
||||||
|
size = SIZE
|
||||||
|
# Basecolor
|
||||||
|
bc = concrete_base(size, base_rgb=(200, 195, 185))
|
||||||
|
bc = bc.filter(ImageFilter.SMOOTH)
|
||||||
|
|
||||||
|
# Height map for normal (slight texture)
|
||||||
|
height = gaussian_noise(size, scale=0.1, seed=100)
|
||||||
|
height = tile_blur(height, 3)
|
||||||
|
normal = normal_from_height(height, strength=1.5)
|
||||||
|
|
||||||
|
# Roughness — concrete is fairly rough
|
||||||
|
rough = gaussian_noise(size, scale=0.08, seed=200)
|
||||||
|
rough = tile_blur(rough, 6)
|
||||||
|
# Brighten a bit (rougher = lighter in roughness map)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
rpix[x, y] = min(255, rpix[x, y] + 40)
|
||||||
|
|
||||||
|
# Metallic — none
|
||||||
|
metallic = Image.new("L", (size, size), 0)
|
||||||
|
|
||||||
|
save_pbr(TEXTURE_DIR, "wall_concrete_01", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_wall_concrete_02():
|
||||||
|
"""Darker, smoother concrete — accent walls."""
|
||||||
|
size = SIZE
|
||||||
|
bc = concrete_base(size, base_rgb=(145, 140, 135))
|
||||||
|
bc = bc.filter(ImageFilter.SMOOTH_MORE)
|
||||||
|
|
||||||
|
height = gaussian_noise(size, scale=0.06, seed=101)
|
||||||
|
height = tile_blur(height, 5)
|
||||||
|
normal = normal_from_height(height, strength=1.2)
|
||||||
|
|
||||||
|
rough = gaussian_noise(size, scale=0.06, seed=201)
|
||||||
|
rough = tile_blur(rough, 8)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
rpix[x, y] = min(255, rpix[x, y] + 30)
|
||||||
|
|
||||||
|
metallic = Image.new("L", (size, size), 0)
|
||||||
|
save_pbr(TEXTURE_DIR, "wall_concrete_02", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_floor_tile_01():
|
||||||
|
"""Checkerboard tile floor — interior spaces."""
|
||||||
|
size = SIZE
|
||||||
|
tile_size = 64 # 64px tiles at 1024 = 16 tiles across
|
||||||
|
|
||||||
|
bc = Image.new("RGB", (size, size))
|
||||||
|
bpix = bc.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
tx = x // tile_size
|
||||||
|
ty = y // tile_size
|
||||||
|
if (tx + ty) % 2 == 0:
|
||||||
|
# Light tile
|
||||||
|
v = 200 + random.randint(-10, 10)
|
||||||
|
bpix[x, y] = (v, v - 5, v - 10)
|
||||||
|
else:
|
||||||
|
# Dark tile
|
||||||
|
v = 160 + random.randint(-8, 8)
|
||||||
|
bpix[x, y] = (v, v - 3, v - 5)
|
||||||
|
|
||||||
|
# Add grout lines
|
||||||
|
draw = ImageDraw.Draw(bc)
|
||||||
|
for i in range(0, size + 1, tile_size):
|
||||||
|
draw.line([(i, 0), (i, size)], fill=(70, 65, 60), width=2)
|
||||||
|
draw.line([(0, i), (size, i)], fill=(70, 65, 60), width=2)
|
||||||
|
|
||||||
|
# Height map for normal
|
||||||
|
height = Image.new("L", (size, size), 128)
|
||||||
|
hpix = height.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
tx = x // tile_size
|
||||||
|
ty = y // tile_size
|
||||||
|
gx = x % tile_size
|
||||||
|
gy = y % tile_size
|
||||||
|
# Slight bevel around edges (tile center higher)
|
||||||
|
d = min(gx, tile_size - gx, gy, tile_size - gy)
|
||||||
|
if d < 3:
|
||||||
|
hpix[x, y] = 100 # Grout is lower
|
||||||
|
else:
|
||||||
|
hpix[x, y] = 140 + (d % 4) * 5
|
||||||
|
|
||||||
|
# Apply bevel and tile variation
|
||||||
|
hpix = height.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
tx = x // tile_size
|
||||||
|
ty = y // tile_size
|
||||||
|
gx = x % tile_size
|
||||||
|
gy = y % tile_size
|
||||||
|
d_edge = min(gx, tile_size - gx, gy, tile_size - gy)
|
||||||
|
if d_edge < 2:
|
||||||
|
hpix[x, y] = 90
|
||||||
|
elif d_edge < 5:
|
||||||
|
hpix[x, y] = 100 + d_edge * 8
|
||||||
|
else:
|
||||||
|
# Gentle tile surface variation
|
||||||
|
hpix[x, y] = 140 + ((tx * 13 + ty * 7 + x + y) % 8) * 3
|
||||||
|
|
||||||
|
normal = normal_from_height(height, strength=3.0)
|
||||||
|
|
||||||
|
# Roughness — tiles are smooth
|
||||||
|
rough = Image.new("L", (size, size), 80)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
tx = x // tile_size
|
||||||
|
ty = y // tile_size
|
||||||
|
gx = x % tile_size
|
||||||
|
gy = y % tile_size
|
||||||
|
d_edge = min(gx, tile_size - gx, gy, tile_size - gy)
|
||||||
|
if d_edge < 2:
|
||||||
|
rpix[x, y] = 200 # Grout is rough
|
||||||
|
else:
|
||||||
|
rpix[x, y] = 60 + ((tx * 17 + ty * 11) % 10) * 4
|
||||||
|
|
||||||
|
metallic = Image.new("L", (size, size), 0)
|
||||||
|
save_pbr(TEXTURE_DIR, "floor_tile_01", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_floor_concrete_01():
|
||||||
|
"""Industrial dark concrete floor — warehouse/industrial areas."""
|
||||||
|
size = SIZE
|
||||||
|
|
||||||
|
# Base
|
||||||
|
bc = concrete_base(size, base_rgb=(130, 128, 123))
|
||||||
|
bc = bc.filter(ImageFilter.SMOOTH)
|
||||||
|
|
||||||
|
# Add surface scratches/wear marks
|
||||||
|
draw = ImageDraw.Draw(bc)
|
||||||
|
for _ in range(50):
|
||||||
|
x1 = random.randint(0, size)
|
||||||
|
y1 = random.randint(0, size)
|
||||||
|
length = random.randint(10, 100)
|
||||||
|
angle = random.uniform(0, math.pi * 2)
|
||||||
|
x2 = x1 + int(math.cos(angle) * length)
|
||||||
|
y2 = y1 + int(math.sin(angle) * length)
|
||||||
|
c = random.randint(100, 140)
|
||||||
|
draw.line([(x1, y1), (x2, y2)], fill=(c, c - 3, c - 5), width=1)
|
||||||
|
|
||||||
|
height = gaussian_noise(size, scale=0.15, seed=102)
|
||||||
|
height = tile_blur(height, 3)
|
||||||
|
hpix = height.load()
|
||||||
|
# Add groove lines (expansion joints)
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
gx = x % 256
|
||||||
|
gy = y % 256
|
||||||
|
if min(gx, 256 - gx) < 2 or min(gy, 256 - gy) < 2:
|
||||||
|
hpix[x, y] = max(0, hpix[x, y] - 40)
|
||||||
|
normal = normal_from_height(height, strength=2.5)
|
||||||
|
|
||||||
|
rough = Image.new("L", (size, size), 160)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
gx = x % 256
|
||||||
|
gy = y % 256
|
||||||
|
if min(gx, 256 - gx) < 2 or min(gy, 256 - gy) < 2:
|
||||||
|
rpix[x, y] = 220 # Grooves are rougher
|
||||||
|
else:
|
||||||
|
rpix[x, y] = 150 + (hash((x // 8, y // 8)) % 20)
|
||||||
|
|
||||||
|
metallic = Image.new("L", (size, size), 0)
|
||||||
|
save_pbr(TEXTURE_DIR, "floor_concrete_01", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_metal_structural_01():
|
||||||
|
"""Structural metal — beams, supports, vents."""
|
||||||
|
size = SIZE
|
||||||
|
# Base — grey metallic
|
||||||
|
bc = Image.new("RGB", (size, size))
|
||||||
|
bpix = bc.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
v = 160 + int(random.gauss(0, 4))
|
||||||
|
bpix[x, y] = (max(0, min(255, v)), max(0, min(255, v - 2)), max(0, min(255, v - 4)))
|
||||||
|
|
||||||
|
# Add horizontal brush marks
|
||||||
|
draw = ImageDraw.Draw(bc)
|
||||||
|
for _ in range(200):
|
||||||
|
bx = random.randint(0, size - 1)
|
||||||
|
by = random.randint(0, size - 1)
|
||||||
|
c = random.randint(-6, 6)
|
||||||
|
draw.line([(bx, by), (bx + random.randint(10, 60), by)], fill=(bpix[bx, by][0] + c,) * 3, width=1)
|
||||||
|
|
||||||
|
# Height map for normal
|
||||||
|
height = gaussian_noise(size, scale=0.05, seed=103)
|
||||||
|
height = tile_blur(height, 2)
|
||||||
|
hpix = height.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
hpix[x, y] = max(0, min(255, hpix[x, y] - 10)) # Flatter than concrete
|
||||||
|
normal = normal_from_height(height, strength=1.0) # Subtle
|
||||||
|
|
||||||
|
# Roughness — smooth metal
|
||||||
|
rough = Image.new("L", (size, size), 40)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
hp = hpix[x, y]
|
||||||
|
rpix[x, y] = 30 + (hp - 128) // 8
|
||||||
|
|
||||||
|
# Metallic — yes!
|
||||||
|
metallic = Image.new("L", (size, size), 255)
|
||||||
|
save_pbr(TEXTURE_DIR, "metal_structural_01", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_accent_team_blue():
|
||||||
|
"""Blue team accent — CT side colored panels."""
|
||||||
|
size = SIZE
|
||||||
|
# Deep blue
|
||||||
|
bc = Image.new("RGB", (size, size))
|
||||||
|
bpix = bc.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
noise = random.randint(-8, 8)
|
||||||
|
bpix[x, y] = (
|
||||||
|
max(0, min(255, 40 + noise)),
|
||||||
|
max(0, min(255, 80 + noise)),
|
||||||
|
max(0, min(255, 200 + noise)),
|
||||||
|
)
|
||||||
|
bc = bc.filter(ImageFilter.SMOOTH)
|
||||||
|
# Slight brushed pattern
|
||||||
|
draw = ImageDraw.Draw(bc)
|
||||||
|
for y in range(0, size, 4):
|
||||||
|
v = random.randint(-4, 4)
|
||||||
|
draw.line([(0, y), (size, y)], fill=(40 + v, 80 + v, 200 + v), width=1)
|
||||||
|
|
||||||
|
height = Image.new("L", (size, size), 128)
|
||||||
|
hpix = height.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
hpix[x, y] = 128 + int(random.gauss(0, 3))
|
||||||
|
hpix = height.load()
|
||||||
|
for y in range(0, size, 4):
|
||||||
|
for x in range(size):
|
||||||
|
hpix[x, y] = 110 # Slight panel line
|
||||||
|
normal = normal_from_height(height, strength=0.8)
|
||||||
|
|
||||||
|
rough = Image.new("L", (size, size), 50)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
rpix[x, y] = 40 + (hash((x // 16, y // 16)) % 15)
|
||||||
|
|
||||||
|
# Slightly metallic (painted metal)
|
||||||
|
metallic = Image.new("L", (size, size), 128)
|
||||||
|
save_pbr(TEXTURE_DIR, "accent_team_blue", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_accent_team_red():
|
||||||
|
"""Red/orange team accent — T side colored panels."""
|
||||||
|
size = SIZE
|
||||||
|
bc = Image.new("RGB", (size, size))
|
||||||
|
bpix = bc.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
noise = random.randint(-8, 8)
|
||||||
|
bpix[x, y] = (
|
||||||
|
max(0, min(255, 200 + noise)),
|
||||||
|
max(0, min(255, 60 + noise)),
|
||||||
|
max(0, min(255, 30 + noise)),
|
||||||
|
)
|
||||||
|
bc = bc.filter(ImageFilter.SMOOTH)
|
||||||
|
draw = ImageDraw.Draw(bc)
|
||||||
|
for y in range(0, size, 4):
|
||||||
|
v = random.randint(-4, 4)
|
||||||
|
draw.line([(0, y), (size, y)], fill=(200 + v, 60 + v, 30 + v), width=1)
|
||||||
|
|
||||||
|
height = Image.new("L", (size, size), 128)
|
||||||
|
hpix = height.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
hpix[x, y] = 128 + int(random.gauss(0, 3))
|
||||||
|
for y in range(0, size, 4):
|
||||||
|
for x in range(size):
|
||||||
|
hpix[x, y] = 110
|
||||||
|
normal = normal_from_height(height, strength=0.8)
|
||||||
|
|
||||||
|
rough = Image.new("L", (size, size), 50)
|
||||||
|
rpix = rough.load()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
rpix[x, y] = 40 + (hash((x // 16, y // 16)) % 15)
|
||||||
|
|
||||||
|
metallic = Image.new("L", (size, size), 128)
|
||||||
|
save_pbr(TEXTURE_DIR, "accent_team_red", bc, normal, rough, metallic)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.makedirs(TEXTURE_DIR, exist_ok=True)
|
||||||
|
print("Generating PBR textures (1K)…")
|
||||||
|
|
||||||
|
print(" [1/7] wall_concrete_01")
|
||||||
|
generate_wall_concrete_01()
|
||||||
|
|
||||||
|
print(" [2/7] wall_concrete_02")
|
||||||
|
generate_wall_concrete_02()
|
||||||
|
|
||||||
|
print(" [3/7] floor_tile_01")
|
||||||
|
generate_floor_tile_01()
|
||||||
|
|
||||||
|
print(" [4/7] floor_concrete_01")
|
||||||
|
generate_floor_concrete_01()
|
||||||
|
|
||||||
|
print(" [5/7] metal_structural_01")
|
||||||
|
generate_metal_structural_01()
|
||||||
|
|
||||||
|
print(" [6/7] accent_team_blue")
|
||||||
|
generate_accent_team_blue()
|
||||||
|
|
||||||
|
print(" [7/7] accent_team_red")
|
||||||
|
generate_accent_team_red()
|
||||||
|
|
||||||
|
print("\nDone — 7 materials × 4 maps = 28 texture files generated at 1K.")
|
||||||
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,119 @@
|
|||||||
|
# FPS Character Controller — Tactical Shooter
|
||||||
|
|
||||||
|
First-person character controller for the tactical shooter GDExtension simulation core.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The character controller is a **GDScript bridge** between Godot's input/physics system and the C++ `SimulationServer` (built in task `t_aca0f251`).
|
||||||
|
|
||||||
|
```
|
||||||
|
Godot Input
|
||||||
|
│
|
||||||
|
├─► FPSCharacterController._physics_process()
|
||||||
|
│ ├─ capture input (WASD, mouse, jump, sprint, crouch, shoot)
|
||||||
|
│ ├─ build EntityInput Dictionary
|
||||||
|
│ ├─ SimulationServer.apply_input(entity_id, input_dict)
|
||||||
|
│ ├─ SimulationServer.fire_weapon(entity_id) [if shoot]
|
||||||
|
│ ├─ read entity position from server → apply to CharacterBody3D
|
||||||
|
│ └─ apply mouse look (yaw on body, pitch on camera)
|
||||||
|
│
|
||||||
|
├─► FpsCamera._process()
|
||||||
|
│ ├─ view bobbing (head bob from movement)
|
||||||
|
│ ├─ weapon sway
|
||||||
|
│ └─ sprint FOV kick
|
||||||
|
│
|
||||||
|
└─► SimulationServer (C++ GDExtension)
|
||||||
|
├─ process_tick() @ 128Hz
|
||||||
|
├─ MovementComponent.update()
|
||||||
|
├─ HitDetection.process_shot()
|
||||||
|
└─→ returns serialized snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `character/fps_character_controller.gd` | Main controller (CharacterBody3D). Input capture, simulation bridge, walk toggle, crouch, jump. |
|
||||||
|
| `character/fps_camera.gd` | First-person camera (Camera3D). Mouse look pitch, view bobbing, weapon sway, FOV kick. |
|
||||||
|
| `input/input_handler.gd` | Documentation of expected input map bindings. |
|
||||||
|
|
||||||
|
## Scene Setup
|
||||||
|
|
||||||
|
1. **Create a CharacterBody3D scene:**
|
||||||
|
```
|
||||||
|
FPSCharacterController (CharacterBody3D) — script: fps_character_controller.gd
|
||||||
|
├── FpsCamera (Camera3D) — script: fps_camera.gd
|
||||||
|
├── CollisionShape3D (CapsuleShape3D) — radius: 0.4, height: 1.8
|
||||||
|
└── Weapon (Node3D, optional) — child of camera, rest transform tracked
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure exports** on the root node:
|
||||||
|
- `mouse_sensitivity`: 0.1–0.2 for typical tactical feel
|
||||||
|
- `walk_toggle`: `true` = tap Shift to toggle sprint (CS-style)
|
||||||
|
- `crouch_toggle`: `false` = hold Ctrl to crouch (tactical-style)
|
||||||
|
- `eye_height_stand` / `eye_height_crouch`: match your capsule height
|
||||||
|
|
||||||
|
3. **Set up Input Map** in Project Settings:
|
||||||
|
- `move_forward` → W
|
||||||
|
- `move_backward` → S
|
||||||
|
- `move_left` → A
|
||||||
|
- `move_right` → D
|
||||||
|
- `jump` → Space
|
||||||
|
- `sprint` → Shift
|
||||||
|
- `crouch` → Ctrl
|
||||||
|
- `shoot` → Mouse LMB
|
||||||
|
- `aim` → Mouse RMB
|
||||||
|
- `ui_cancel` → Esc (built-in, releases mouse capture)
|
||||||
|
|
||||||
|
4. **Integration with SimulationServer:**
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# In your game manager (autoload or scene root):
|
||||||
|
var server = SimulationServer.new()
|
||||||
|
server.set_tick_rate(128)
|
||||||
|
server.start()
|
||||||
|
var player_id = server.spawn_entity(Vector3(0, 0, 0))
|
||||||
|
|
||||||
|
# When player scene is ready:
|
||||||
|
$Player/FPSCharacterController.set_entity_id(player_id)
|
||||||
|
|
||||||
|
# In _process(delta):
|
||||||
|
while server.can_tick(delta):
|
||||||
|
var snapshot = server.tick()
|
||||||
|
# send snapshot to network layer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Walk Toggle Behavior
|
||||||
|
|
||||||
|
- **`walk_toggle = true`** (default): Press Shift to toggle between walk and sprint. Press again to toggle off. Same feel as Counter-Strike's walk/run toggle.
|
||||||
|
- **`walk_toggle = false`**: Hold Shift for temporary sprint. Release to decelerate to walk speed.
|
||||||
|
|
||||||
|
## Crouch Behavior
|
||||||
|
|
||||||
|
- **`crouch_toggle = false`** (default): Hold Ctrl to crouch. Release to stand up.
|
||||||
|
- **`crouch_toggle = true`**: Press Ctrl once to crouch, press again to stand.
|
||||||
|
|
||||||
|
Crouch transitions are smooth lerps over `crouch_transition_time` seconds. The collision capsule height and camera eye height both lerp simultaneously.
|
||||||
|
|
||||||
|
## Standalone Mode
|
||||||
|
|
||||||
|
When no `SimulationServer` singleton is available, the controller falls back to built-in `CharacterBody3D` movement (`_move_local()`). This is useful for:
|
||||||
|
- Testing the character controller in isolation
|
||||||
|
- Prototyping without the GDExtension compiled
|
||||||
|
- Single-player scenarios without the full simulation stack
|
||||||
|
|
||||||
|
Configure `local_walk_speed`, `local_sprint_speed`, `local_crouch_speed`, etc. for standalone tuning.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Godot 4.2+** (for `Input.get_vector`, `move_toward`, `StringName` literals)
|
||||||
|
- **SimulationServer GDExtension** (from task `t_aca0f251`) for server-authoritative mode
|
||||||
|
- Input map actions listed above
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- Scaffold: `t_aca0f251` — `src/movement_component.h` (C++ Quake/CS movement physics)
|
||||||
|
- Greybox map: `t_p1_greybox` — test environment for this controller
|
||||||
|
- Hitscan weapon: `t_p1_hitscan` — weapon firing + lag compensation
|
||||||
|
- Client prediction: `t_p1_pred` — client-side prediction + server reconciliation
|
||||||
|
- Round system: `t_p1_round` — win/loss/sudden death on top of character controller
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
## FpsCamera — first-person camera view effects for tactical FPS.
|
||||||
|
##
|
||||||
|
## Manages view bobbing (head bob from movement), weapon sway, and FOV kick.
|
||||||
|
## The parent FPSCharacterController handles mouse look (yaw/pitch).
|
||||||
|
##
|
||||||
|
## Place as a Camera3D child of FPSCharacterController.
|
||||||
|
class_name FpsCamera
|
||||||
|
extends Camera3D
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Enable view bobbing (head bob when walking/running).
|
||||||
|
@export var view_bob_enabled: bool = true
|
||||||
|
|
||||||
|
## How much the camera bobs horizontally (units).
|
||||||
|
@export_range(0.0, 0.5) var bob_amplitude_h: float = 0.04
|
||||||
|
|
||||||
|
## How much the camera bobs vertically.
|
||||||
|
@export_range(0.0, 0.5) var bob_amplitude_v: float = 0.04
|
||||||
|
|
||||||
|
## Frequency multiplier for bobbing (higher = faster).
|
||||||
|
@export_range(0.5, 5.0) var bob_frequency: float = 2.5
|
||||||
|
|
||||||
|
## Sprint bob multiplier (more aggressive).
|
||||||
|
@export_range(1.0, 3.0) var bob_sprint_mult: float = 1.6
|
||||||
|
|
||||||
|
## Crouch bob multiplier (less movement).
|
||||||
|
@export_range(0.1, 1.0) var bob_crouch_mult: float = 0.4
|
||||||
|
|
||||||
|
## FOV kick on sprint (tactical FOV increase).
|
||||||
|
@export_range(0.0, 10.0) var sprint_fov_kick: float = 3.0
|
||||||
|
|
||||||
|
## FOV kick when firing weapon.
|
||||||
|
@export_range(-5.0, 10.0) var shoot_fov_kick: float = 0.5
|
||||||
|
|
||||||
|
## How fast FOV recovers.
|
||||||
|
@export var fov_recovery_speed: float = 6.0
|
||||||
|
|
||||||
|
## Enable weapon sway (subtle rotation from movement).
|
||||||
|
@export var weapon_sway_enabled: bool = true
|
||||||
|
|
||||||
|
## Maximum weapon sway rotation (degrees).
|
||||||
|
@export_range(0.0, 5.0) var sway_max_rotation: float = 1.5
|
||||||
|
|
||||||
|
## Sway responsiveness (higher = snappier).
|
||||||
|
@export_range(1.0, 20.0) var sway_response: float = 8.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Reference to parent controller.
|
||||||
|
var _controller: FPSCharacterController = null
|
||||||
|
|
||||||
|
## Bob time accumulator.
|
||||||
|
var _bob_time: float = 0.0
|
||||||
|
|
||||||
|
## Current bob offset.
|
||||||
|
var _bob_offset: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
## Current weapon node (child named "Weapon" or first MeshInstance3D).
|
||||||
|
var _weapon: Node3D = null
|
||||||
|
|
||||||
|
## Weapon rest position (local transform when no sway).
|
||||||
|
var _weapon_rest: Transform3D
|
||||||
|
|
||||||
|
## FOV state.
|
||||||
|
var _base_fov: float = 75.0
|
||||||
|
var _current_fov_offset: float = 0.0
|
||||||
|
|
||||||
|
## Sway state (used as both current value and interpolation target).
|
||||||
|
var _sway_current: Vector2 = Vector2.ZERO
|
||||||
|
var _sway_target: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
## Base eye Y set by parent controller's crouch logic (for additive bob).
|
||||||
|
var _base_eye_y: float = 0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_controller = get_parent() as FPSCharacterController
|
||||||
|
if _controller == null:
|
||||||
|
push_warning("FpsCamera: Parent is not an FPSCharacterController. View features disabled.")
|
||||||
|
|
||||||
|
_base_fov = fov
|
||||||
|
|
||||||
|
# Find weapon node (for sway)
|
||||||
|
for child in get_children():
|
||||||
|
if child is Node3D:
|
||||||
|
_weapon = child
|
||||||
|
_weapon_rest = child.transform
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _controller == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Capture the eye height set by parent controller's crouch logic
|
||||||
|
# before we apply view bob offsets (which must be additive).
|
||||||
|
_base_eye_y = position.y
|
||||||
|
|
||||||
|
# FOV management
|
||||||
|
_update_fov(delta)
|
||||||
|
|
||||||
|
# View bobbing
|
||||||
|
if view_bob_enabled:
|
||||||
|
_update_bob(delta)
|
||||||
|
|
||||||
|
# Weapon sway
|
||||||
|
if weapon_sway_enabled and _weapon:
|
||||||
|
_update_sway(delta)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FOV
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _update_fov(delta: float) -> void:
|
||||||
|
var target_offset: float = 0.0
|
||||||
|
|
||||||
|
# Sprint FOV kick — sustained while sprinting (not just on transition)
|
||||||
|
if _controller.is_sprinting():
|
||||||
|
target_offset = sprint_fov_kick
|
||||||
|
|
||||||
|
# Shoot FOV kick is triggered externally via trigger_shoot_fov()
|
||||||
|
# and decays toward the sprint/walk target naturally via move_toward.
|
||||||
|
_current_fov_offset = move_toward(_current_fov_offset, target_offset, fov_recovery_speed * delta)
|
||||||
|
fov = _base_fov + _current_fov_offset
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# View bobbing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _update_bob(delta: float) -> void:
|
||||||
|
var crouch_amount: float = _controller.get_crouch_amount()
|
||||||
|
var sprinting: bool = _controller.is_sprinting()
|
||||||
|
|
||||||
|
# Calculate bob speed multiplier from controller state
|
||||||
|
var speed_mult: float = 1.0
|
||||||
|
if sprinting:
|
||||||
|
speed_mult = bob_sprint_mult
|
||||||
|
elif crouch_amount > 0.0:
|
||||||
|
speed_mult = lerpf(1.0, bob_crouch_mult, crouch_amount)
|
||||||
|
|
||||||
|
if speed_mult < 0.01:
|
||||||
|
# Stationary — reset to zero
|
||||||
|
_bob_offset = _bob_offset.move_toward(Vector2.ZERO, delta * 10.0)
|
||||||
|
else:
|
||||||
|
_bob_time += delta * bob_frequency * speed_mult
|
||||||
|
_bob_offset = Vector2(
|
||||||
|
sin(_bob_time * 2.0) * bob_amplitude_h * speed_mult,
|
||||||
|
sin(_bob_time) * bob_amplitude_v * speed_mult
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply bob to camera position (relative to parent's eye offset)
|
||||||
|
position.x = _bob_offset.x
|
||||||
|
# Bob is additive on Y so it does NOT overwrite the crouch eye height
|
||||||
|
# set by the parent FPSCharacterController._update_crouch().
|
||||||
|
position.y = _base_eye_y + _bob_offset.y
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Weapon sway
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
# 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),
|
||||||
|
move_toward(_sway_target.y, sway_v, sway_response * delta)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply to weapon node as small rotation offsets
|
||||||
|
if _weapon:
|
||||||
|
var target := Transform3D(
|
||||||
|
Basis.from_euler(Vector3(
|
||||||
|
deg_to_rad(_sway_target.y),
|
||||||
|
deg_to_rad(_sway_target.x),
|
||||||
|
0.0
|
||||||
|
)),
|
||||||
|
_weapon_rest.origin
|
||||||
|
)
|
||||||
|
_weapon.transform = _weapon.transform.interpolate_with(target, sway_response * delta * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Trigger a shoot FOV kick. Call from weapon script.
|
||||||
|
func trigger_shoot_fov() -> void:
|
||||||
|
_current_fov_offset += shoot_fov_kick
|
||||||
|
|
||||||
|
|
||||||
|
## Reset view to resting state (for respawn).
|
||||||
|
func reset_view() -> void:
|
||||||
|
_bob_time = 0.0
|
||||||
|
_bob_offset = Vector2.ZERO
|
||||||
|
_current_fov_offset = 0.0
|
||||||
|
_sway_current = Vector2.ZERO
|
||||||
|
_sway_target = Vector2.ZERO
|
||||||
|
fov = _base_fov
|
||||||
|
if _weapon:
|
||||||
|
_weapon.transform = _weapon_rest
|
||||||
|
|
||||||
|
|
||||||
|
## Get the current bob offset for external effects.
|
||||||
|
func get_bob_offset() -> Vector2:
|
||||||
|
return _bob_offset
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bjtf5jiedmwx8
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
## FPSCharacterController — first-person character for a networked tactical shooter.
|
||||||
|
##
|
||||||
|
## Bridges Godot Input → SimulationServer (GDExtension C++ core).
|
||||||
|
## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump.
|
||||||
|
##
|
||||||
|
## Usage (scene tree):
|
||||||
|
## FPSCharacterController (CharacterBody3D)
|
||||||
|
## ├── FpsCamera (Camera3D) — script: fps_camera.gd
|
||||||
|
## ├── CollisionShape3D (capsule)
|
||||||
|
## └── (optional weapons/arms child)
|
||||||
|
##
|
||||||
|
## Connects to SimulationServer via `apply_input()` each frame.
|
||||||
|
## Reads server snapshot data and updates the node transform.
|
||||||
|
##
|
||||||
|
## Walk Toggle: Tap Shift to toggle sprint on/off.
|
||||||
|
## Crouch: Hold Ctrl to crouch. Smooth lerp transition.
|
||||||
|
## Jump: Press Space.
|
||||||
|
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
|
||||||
|
class_name FPSCharacterController
|
||||||
|
extends CharacterBody3D
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports — tune per weapon / game feel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation.
|
||||||
|
@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005
|
||||||
|
|
||||||
|
## Mouse vertical sensitivity multiplier (1.0 = same as horizontal).
|
||||||
|
@export_range(0.1, 2.0, 0.05) var mouse_vertical_ratio: float = 0.8
|
||||||
|
|
||||||
|
## Invert vertical look.
|
||||||
|
@export var invert_y: bool = false
|
||||||
|
|
||||||
|
## Maximum pitch angle (degrees) — prevents looking upside down.
|
||||||
|
@export_range(1, 89) var max_pitch: float = 89.0
|
||||||
|
|
||||||
|
## Entity ID assigned by SimulationServer. Set via spawn_entity() response.
|
||||||
|
@export var entity_id: int = -1
|
||||||
|
|
||||||
|
## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint.
|
||||||
|
@export var walk_toggle: bool = true
|
||||||
|
|
||||||
|
## If true, crouch toggles on press. If false, hold Ctrl to crouch.
|
||||||
|
@export var crouch_toggle: bool = false
|
||||||
|
|
||||||
|
## Time in seconds for crouch height transition (lerp).
|
||||||
|
@export var crouch_transition_time: float = 0.15
|
||||||
|
|
||||||
|
## Eye height when standing (Godot units, from feet).
|
||||||
|
@export var eye_height_stand: float = 1.7
|
||||||
|
|
||||||
|
## Eye height when crouching.
|
||||||
|
@export var eye_height_crouch: float = 0.9
|
||||||
|
|
||||||
|
## Collision shape height when standing.
|
||||||
|
@export var collision_height_stand: float = 1.8
|
||||||
|
|
||||||
|
## Collision shape height when crouching.
|
||||||
|
@export var collision_height_crouch: float = 1.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Reference to SimulationServer singleton (set in _ready).
|
||||||
|
var _server: Object = null
|
||||||
|
|
||||||
|
## Current look rotation.
|
||||||
|
var _yaw: float = 0.0
|
||||||
|
var _pitch: float = 0.0
|
||||||
|
|
||||||
|
## Crouch lerp target (0.0 = standing, 1.0 = full crouch).
|
||||||
|
var _crouch_target: float = 0.0
|
||||||
|
var _crouch_current: float = 0.0
|
||||||
|
|
||||||
|
## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates.
|
||||||
|
var _sprint_active: bool = false
|
||||||
|
var _sprint_pressed_last: bool = false
|
||||||
|
|
||||||
|
## Crouch toggle state.
|
||||||
|
var _crouch_active: bool = false
|
||||||
|
var _crouch_pressed_last: bool = false
|
||||||
|
|
||||||
|
## Input sequence counter — increment each frame for server reconciliation.
|
||||||
|
var _input_sequence: int = 0
|
||||||
|
|
||||||
|
## Cached input dictionary (avoids alloc per frame).
|
||||||
|
var _input_dict: Dictionary = {}
|
||||||
|
|
||||||
|
## Prediction controller reference (set by ClientPrediction system).
|
||||||
|
## When non-null and prediction_enabled, the controller uses client-side
|
||||||
|
## prediction with local movement for instant feedback instead of
|
||||||
|
## reading position from the SimulationServer entity directly.
|
||||||
|
var _prediction: Node = null
|
||||||
|
|
||||||
|
## Mouse capture state.
|
||||||
|
var _mouse_captured: bool = false
|
||||||
|
var _mouse_clicked_this_frame: bool = false
|
||||||
|
|
||||||
|
## Weapon manager reference.
|
||||||
|
var _weapon: WeaponManager = null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Node references (set in _ready)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _camera: Node3D = null
|
||||||
|
var _collision_shape: CollisionShape3D = null
|
||||||
|
var _capsule_shape: CapsuleShape3D = null
|
||||||
|
|
||||||
|
# Cached standing/crouching shape parameters.
|
||||||
|
var _stand_capsule_height: float = 0.0
|
||||||
|
var _crouch_capsule_height: float = 0.0
|
||||||
|
var _capsule_radius: float = 0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Find SimulationServer
|
||||||
|
if Engine.has_singleton("SimulationServer"):
|
||||||
|
_server = Engine.get_singleton("SimulationServer")
|
||||||
|
else:
|
||||||
|
push_warning("FPSCharacterController: SimulationServer singleton not found. " +
|
||||||
|
"Running in standalone (local physics) mode.")
|
||||||
|
|
||||||
|
# Locate camera
|
||||||
|
_camera = get_node_or_null("%FpsCamera")
|
||||||
|
if _camera == null:
|
||||||
|
# Try to find any Camera3D child
|
||||||
|
for child in get_children():
|
||||||
|
if child is Camera3D:
|
||||||
|
_camera = child
|
||||||
|
break
|
||||||
|
if _camera == null:
|
||||||
|
push_error("FPSCharacterController: No Camera3D child found. Mouse look disabled.")
|
||||||
|
|
||||||
|
# Locate collision shape
|
||||||
|
for child in get_children():
|
||||||
|
if child is CollisionShape3D:
|
||||||
|
_collision_shape = child
|
||||||
|
if child.shape is CapsuleShape3D:
|
||||||
|
_capsule_shape = child.shape
|
||||||
|
_capsule_radius = _capsule_shape.radius
|
||||||
|
_stand_capsule_height = collision_height_stand - 2.0 * _capsule_radius
|
||||||
|
_crouch_capsule_height = collision_height_crouch - 2.0 * _capsule_radius
|
||||||
|
_capsule_shape.height = _stand_capsule_height
|
||||||
|
break
|
||||||
|
|
||||||
|
# Start with mouse captured
|
||||||
|
_capture_mouse(true)
|
||||||
|
|
||||||
|
# Initialize yaw/pitch from current transform
|
||||||
|
_yaw = rotation.y
|
||||||
|
if _camera:
|
||||||
|
_pitch = _camera.rotation.x
|
||||||
|
|
||||||
|
# Initialize crouch animation state
|
||||||
|
_crouch_current = 0.0
|
||||||
|
_update_crouch(0.0)
|
||||||
|
|
||||||
|
# Find weapon manager child
|
||||||
|
for child in get_children():
|
||||||
|
if child is WeaponManager:
|
||||||
|
_weapon = child
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
# Mouse look
|
||||||
|
if event is InputEventMouseMotion and _mouse_captured:
|
||||||
|
var rel: Vector2 = event.relative
|
||||||
|
# Yaw (body rotation)
|
||||||
|
_yaw -= rel.x * mouse_sensitivity
|
||||||
|
# Pitch (camera rotation) — negative = look down, positive = look up
|
||||||
|
var invert: float = -1.0 if invert_y else 1.0
|
||||||
|
_pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert
|
||||||
|
_pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||||
|
|
||||||
|
# Mouse capture toggle
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||||
|
if not _mouse_captured:
|
||||||
|
_capture_mouse(true)
|
||||||
|
else:
|
||||||
|
# LMB fires weapon — handled in _physics_process via shoot_pressed
|
||||||
|
_mouse_clicked_this_frame = true
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
# Escape to release mouse
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
_capture_mouse(false)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
# PREDICTION HOOK: capture pre-input state snapshot.
|
||||||
|
# Must be called BEFORE any input processing or movement so the
|
||||||
|
# snapshot represents the state at the start of this tick.
|
||||||
|
if _prediction and _prediction.prediction_enabled:
|
||||||
|
_prediction.on_before_tick()
|
||||||
|
|
||||||
|
# 1. Capture input state
|
||||||
|
var input_dir := _get_move_direction()
|
||||||
|
var jump_pressed := Input.is_action_just_pressed(&"jump")
|
||||||
|
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
||||||
|
var crouch_pressed := Input.is_action_pressed(&"crouch")
|
||||||
|
var shoot_pressed := _mouse_clicked_this_frame or Input.is_action_pressed(&"shoot")
|
||||||
|
var aim_pressed := Input.is_action_pressed(&"aim")
|
||||||
|
|
||||||
|
_mouse_clicked_this_frame = false
|
||||||
|
|
||||||
|
# 2. Walk toggle logic
|
||||||
|
if walk_toggle:
|
||||||
|
if sprint_pressed and not _sprint_pressed_last:
|
||||||
|
_sprint_active = not _sprint_active
|
||||||
|
_sprint_pressed_last = sprint_pressed
|
||||||
|
else:
|
||||||
|
_sprint_active = sprint_pressed
|
||||||
|
|
||||||
|
# 3. Crouch toggle logic
|
||||||
|
if crouch_toggle:
|
||||||
|
if crouch_pressed and not _crouch_pressed_last:
|
||||||
|
_crouch_active = not _crouch_active
|
||||||
|
_crouch_pressed_last = crouch_pressed
|
||||||
|
else:
|
||||||
|
_crouch_active = crouch_pressed
|
||||||
|
|
||||||
|
# 4. Crouch height transition
|
||||||
|
var target_crouch: float = 1.0 if _crouch_active else 0.0
|
||||||
|
_crouch_target = target_crouch
|
||||||
|
|
||||||
|
var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
|
||||||
|
if _crouch_current < _crouch_target:
|
||||||
|
_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
|
||||||
|
elif _crouch_current > _crouch_target:
|
||||||
|
_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)
|
||||||
|
|
||||||
|
_update_crouch(_crouch_current)
|
||||||
|
|
||||||
|
# 5. Apply rotation
|
||||||
|
rotation.y = _yaw
|
||||||
|
if _camera:
|
||||||
|
_camera.rotation.x = _pitch
|
||||||
|
|
||||||
|
# 5b. Weapon fire with rate limiting via WeaponManager
|
||||||
|
var should_fire: bool = false
|
||||||
|
var time_since_engine_start: float = Engine.get_process_ticks() * get_physics_process_delta_time()
|
||||||
|
if _weapon:
|
||||||
|
should_fire = _weapon.try_fire(time_since_engine_start)
|
||||||
|
elif shoot_pressed:
|
||||||
|
# No weapon manager — fire every tick (unlimited)
|
||||||
|
should_fire = true
|
||||||
|
|
||||||
|
if _server != null:
|
||||||
|
if should_fire:
|
||||||
|
_server.fire_weapon(entity_id)
|
||||||
|
|
||||||
|
# 6. Build input dictionary (all modes — mutate cached dict to avoid alloc)
|
||||||
|
_input_dict["move_direction"] = input_dir
|
||||||
|
_input_dict["look_yaw"] = rad_to_deg(_yaw)
|
||||||
|
_input_dict["look_pitch"] = rad_to_deg(_pitch)
|
||||||
|
_input_dict["jump"] = jump_pressed
|
||||||
|
_input_dict["crouch"] = _crouch_active
|
||||||
|
_input_dict["sprint"] = _sprint_active
|
||||||
|
_input_dict["shoot"] = should_fire
|
||||||
|
_input_dict["aim"] = aim_pressed
|
||||||
|
_input_dict["input_sequence"] = _input_sequence
|
||||||
|
|
||||||
|
# 6b. Route input to appropriate handler.
|
||||||
|
if _prediction and _prediction.prediction_enabled:
|
||||||
|
# Client prediction: input sending is handled by the prediction
|
||||||
|
# controller's on_after_tick() call at the end of this function.
|
||||||
|
pass
|
||||||
|
elif _server != null and entity_id >= 0:
|
||||||
|
# Listen server: send to local SimulationServer.
|
||||||
|
_server.apply_input(entity_id, _input_dict)
|
||||||
|
|
||||||
|
# Check for hit feedback from last tick
|
||||||
|
if Engine.has_singleton("SimulationServer") or _server:
|
||||||
|
var hit_result: Dictionary = _server.get_last_hit_result()
|
||||||
|
if hit_result.get("hit", false) and _weapon:
|
||||||
|
var hit_pos := Vector3.ZERO # approximate — we don't have exact world hit position from server yet
|
||||||
|
_weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false))
|
||||||
|
|
||||||
|
_input_sequence += 1
|
||||||
|
|
||||||
|
# 7. Update position.
|
||||||
|
# Architecture:
|
||||||
|
# - Client prediction: do local CharacterBody3D movement (instant feedback),
|
||||||
|
# the prediction controller handles reconciliation when authoritative
|
||||||
|
# server state arrives.
|
||||||
|
# - Listen server: read authoritative position from SimulationServer entity.
|
||||||
|
# - Standalone (no server): fallback CharacterBody3D movement.
|
||||||
|
if _prediction and _prediction.prediction_enabled:
|
||||||
|
# Client prediction path — local movement for instant feedback.
|
||||||
|
_move_local(input_dir, delta, jump_pressed)
|
||||||
|
elif _server != null and entity_id >= 0:
|
||||||
|
# Listen server: read from SimulationServer entity.
|
||||||
|
var entity = _server.get_entity(entity_id)
|
||||||
|
if entity != null and entity.is_alive():
|
||||||
|
# Server position is ground truth — apply it to the visual body
|
||||||
|
# (only correct on listen-server; pure clients must use replication)
|
||||||
|
global_position = entity.position
|
||||||
|
else:
|
||||||
|
# Standalone mode: do local CharacterBody3D physics
|
||||||
|
_move_local(input_dir, delta, jump_pressed)
|
||||||
|
|
||||||
|
# PREDICTION HOOK: post-tick — send input to server via RPC.
|
||||||
|
# Must be called AFTER local movement so the prediction controller
|
||||||
|
# can send the input that produced this tick's movement.
|
||||||
|
if _prediction and _prediction.prediction_enabled:
|
||||||
|
_prediction.on_after_tick(_input_dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local movement (standalone / no-server fallback)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Walk speed (standalone mode).
|
||||||
|
@export var local_walk_speed: float = 5.0
|
||||||
|
## Sprint speed (standalone mode).
|
||||||
|
@export var local_sprint_speed: float = 8.0
|
||||||
|
## Crouch speed (standalone mode).
|
||||||
|
@export var local_crouch_speed: float = 2.5
|
||||||
|
## Jump velocity (standalone mode).
|
||||||
|
@export var local_jump_velocity: float = 4.5
|
||||||
|
## Gravity (standalone mode).
|
||||||
|
@export var local_gravity: float = 15.0
|
||||||
|
## Acceleration (standalone mode).
|
||||||
|
@export var local_acceleration: float = 12.0
|
||||||
|
|
||||||
|
func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
|
||||||
|
# Simple CharacterBody3D movement for standalone testing
|
||||||
|
var target_speed: float = local_walk_speed
|
||||||
|
if _sprint_active:
|
||||||
|
target_speed = local_sprint_speed
|
||||||
|
if _crouch_active:
|
||||||
|
target_speed = local_crouch_speed
|
||||||
|
|
||||||
|
var wish_dir := (transform.basis * input_dir).normalized()
|
||||||
|
var target_vel := wish_dir * target_speed
|
||||||
|
|
||||||
|
# Horizontal acceleration
|
||||||
|
var h_vel := Vector3(velocity.x, 0.0, velocity.z)
|
||||||
|
h_vel = h_vel.move_toward(target_vel, local_acceleration * delta)
|
||||||
|
velocity.x = h_vel.x
|
||||||
|
velocity.z = h_vel.z
|
||||||
|
|
||||||
|
# Gravity
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= local_gravity * delta
|
||||||
|
|
||||||
|
# Jump
|
||||||
|
if jump and is_on_floor():
|
||||||
|
velocity.y = local_jump_velocity
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crouch height management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _update_crouch(amount: float) -> void:
|
||||||
|
## amount: 0.0 = standing, 1.0 = fully crouched
|
||||||
|
# Eye height — camera position relative to body origin (character feet at origin)
|
||||||
|
if _camera:
|
||||||
|
_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)
|
||||||
|
|
||||||
|
# Collision shape — capsule height (cylindrical section)
|
||||||
|
if _capsule_shape:
|
||||||
|
_capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount)
|
||||||
|
|
||||||
|
# Move the collision shape center so the capsule base stays on the ground:
|
||||||
|
# shift = (crouch_total - stand_total) / 2, negative = moves down
|
||||||
|
if _collision_shape:
|
||||||
|
_collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _get_move_direction() -> Vector3:
|
||||||
|
var dir := Vector3.ZERO
|
||||||
|
if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"):
|
||||||
|
dir.z -= 1.0
|
||||||
|
if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"):
|
||||||
|
dir.z += 1.0
|
||||||
|
if Input.is_action_pressed(&"move_left"):
|
||||||
|
dir.x -= 1.0
|
||||||
|
if Input.is_action_pressed(&"move_right"):
|
||||||
|
dir.x += 1.0
|
||||||
|
# Normalize for analog stick deadzone
|
||||||
|
if dir.length_squared() > 0.0:
|
||||||
|
dir = dir.normalized()
|
||||||
|
return dir
|
||||||
|
|
||||||
|
|
||||||
|
func _capture_mouse(capture: bool) -> void:
|
||||||
|
if capture == _mouse_captured:
|
||||||
|
return
|
||||||
|
_mouse_captured = capture
|
||||||
|
if capture:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
else:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Set the entity ID assigned by SimulationServer after spawning.
|
||||||
|
func set_entity_id(id: int) -> void:
|
||||||
|
entity_id = id
|
||||||
|
|
||||||
|
|
||||||
|
## Set the prediction controller for client-side prediction/reconciliation.
|
||||||
|
## The prediction node must have on_before_tick() / on_after_tick() methods.
|
||||||
|
## Typically a ClientPrediction instance added as a child or sibling.
|
||||||
|
func set_prediction(prediction_node: Node) -> void:
|
||||||
|
_prediction = prediction_node
|
||||||
|
|
||||||
|
|
||||||
|
## Get current crouch amount (0.0 = standing, 1.0 = fully crouched).
|
||||||
|
func get_crouch_amount() -> float:
|
||||||
|
return _crouch_current
|
||||||
|
|
||||||
|
|
||||||
|
## Is the player currently sprinting?
|
||||||
|
func is_sprinting() -> bool:
|
||||||
|
return _sprint_active
|
||||||
|
|
||||||
|
|
||||||
|
## Is the player currently crouching?
|
||||||
|
func is_crouching() -> bool:
|
||||||
|
return _crouch_active
|
||||||
|
|
||||||
|
|
||||||
|
## Force look direction (useful for spectator / spawn reset).
|
||||||
|
func set_look(yaw_rad: float, pitch_rad: float) -> void:
|
||||||
|
_yaw = yaw_rad
|
||||||
|
_pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||||
|
|
||||||
|
|
||||||
|
## Reset to default standing state.
|
||||||
|
func reset_pose() -> void:
|
||||||
|
_sprint_active = false
|
||||||
|
_crouch_active = false
|
||||||
|
_sprint_pressed_last = false
|
||||||
|
_crouch_pressed_last = false
|
||||||
|
_crouch_current = 0.0
|
||||||
|
_crouch_target = 0.0
|
||||||
|
_update_crouch(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the look direction as a normalized Vector3 in world space.
|
||||||
|
## Uses the current yaw/pitch to compute a forward vector.
|
||||||
|
func get_look_direction() -> Vector3:
|
||||||
|
var yaw_rad: float = _yaw
|
||||||
|
var pitch_rad: float = _pitch
|
||||||
|
return Vector3(
|
||||||
|
cos(pitch_rad) * sin(yaw_rad),
|
||||||
|
-sin(pitch_rad),
|
||||||
|
cos(pitch_rad) * cos(yaw_rad)
|
||||||
|
).normalized()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cpbvk1itjonm
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
## InputHandler — maps Godot Input Actions to FPS character controls.
|
||||||
|
##
|
||||||
|
## This script is NOT required at runtime — it exists to document the input
|
||||||
|
## map bindings that FPSCharacterController expects. Add these actions to
|
||||||
|
## your Project → Input Map (or load this script as an autoload for reference).
|
||||||
|
##
|
||||||
|
## Bindings (tactical FPS layout):
|
||||||
|
##
|
||||||
|
## Action Key Description
|
||||||
|
## ───────────────────────────────────────────────────────
|
||||||
|
## move_forward W Walk forward
|
||||||
|
## move_backward S Walk backward
|
||||||
|
## move_left A Strafe left
|
||||||
|
## move_right D Strafe right
|
||||||
|
## jump Space Jump
|
||||||
|
## sprint Shift Sprint / walk-toggle
|
||||||
|
## crouch Ctrl Crouch (hold or toggle)
|
||||||
|
## shoot Mouse LMB Fire weapon
|
||||||
|
## aim Mouse RMB Aim down sights
|
||||||
|
## reload R Reload weapon
|
||||||
|
## interact E Use/interact
|
||||||
|
## toggle_walk_toggle (not bound) Toggle walk-toggle mode
|
||||||
|
## toggle_crouch_toggle (not bound) Toggle crouch-toggle mode
|
||||||
|
## ui_cancel Esc Release mouse capture
|
||||||
|
##
|
||||||
|
## Implementation note:
|
||||||
|
## FPSCharacterController reads input directly via Input.is_action_pressed()
|
||||||
|
## in _physics_process. No separate InputHandler node is needed unless you
|
||||||
|
## want to add rebinding, analog input smoothing, or input buffering.
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# This script documents bindings only — no runtime logic.
|
||||||
|
# To enforce default bindings at startup, uncomment the next lines:
|
||||||
|
|
||||||
|
# _ensure_action(&"move_forward", KEY_W)
|
||||||
|
# _ensure_action(&"move_backward", KEY_S)
|
||||||
|
# _ensure_action(&"move_left", KEY_A)
|
||||||
|
# _ensure_action(&"move_right", KEY_D)
|
||||||
|
# _ensure_action(&"jump", KEY_SPACE)
|
||||||
|
# _ensure_action(&"sprint", KEY_SHIFT)
|
||||||
|
# _ensure_action(&"crouch", KEY_CTRL)
|
||||||
|
# _ensure_action(&"shoot", MOUSE_BUTTON_LEFT)
|
||||||
|
# _ensure_action(&"aim", MOUSE_BUTTON_RIGHT)
|
||||||
|
# _ensure_action(&"reload", KEY_R)
|
||||||
|
# _ensure_action(&"interact", KEY_E)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
## Ensure an input action exists with the given default key binding.
|
||||||
|
## Only sets the binding if the action does not already exist.
|
||||||
|
func _ensure_action(action_name: StringName, key: Key) -> void:
|
||||||
|
if InputMap.has_action(action_name):
|
||||||
|
return
|
||||||
|
var ev := InputEventKey.new()
|
||||||
|
ev.keycode = key
|
||||||
|
InputMap.add_action(action_name)
|
||||||
|
InputMap.action_add_event(action_name, ev)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bvkhxr1bv6lth
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
## BombCarrier — Client-side bomb status UI and prompts.
|
||||||
|
##
|
||||||
|
## Attached to the local T player who carries the bomb.
|
||||||
|
## Provides visual feedback for:
|
||||||
|
## - Bomb status (planted/defusing/timer)
|
||||||
|
## - Direction indicator to planted bomb
|
||||||
|
## - "Hold E to defuse" prompt (CT, near bomb)
|
||||||
|
## - "Hold E to plant" prompt (T, in bombsite, LIVE phase)
|
||||||
|
##
|
||||||
|
## This script is a Node that should be added as a child of the local
|
||||||
|
## player's character node (FPSCharacterController) on the client side.
|
||||||
|
## It communicates with the server BombObjective via RPC or state sync.
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
class_name BombCarrier
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when the nearby interaction prompt changes.
|
||||||
|
signal prompt_changed(prompt_text: String, visible: bool)
|
||||||
|
|
||||||
|
## Emitted when bomb status UI should update.
|
||||||
|
signal bomb_status_updated(planted: bool, timer: float, defusing: bool, defuse_progress: float)
|
||||||
|
|
||||||
|
## Emitted when the bomb direction indicator should update.
|
||||||
|
signal bomb_direction_updated(bearing_degrees: float, distance: float)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The "Use/Interact" action name (matched in Input Map).
|
||||||
|
@export var interact_action: String = "interact"
|
||||||
|
|
||||||
|
## Interaction distance for planting (in Godot units).
|
||||||
|
@export var plant_distance: float = 2.0
|
||||||
|
|
||||||
|
## Interaction distance for defusing (in Godot units).
|
||||||
|
@export var defuse_distance: float = 3.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Whether this player has the bomb (T-side bomb carrier).
|
||||||
|
var has_bomb: bool = false
|
||||||
|
|
||||||
|
## Whether the bomb is currently planted.
|
||||||
|
var _bomb_planted: bool = false
|
||||||
|
|
||||||
|
## Position of the planted bomb (server-authoritative).
|
||||||
|
var _bomb_position: Vector3 = Vector3.ZERO
|
||||||
|
|
||||||
|
## Remaining bomb timer (received from server).
|
||||||
|
var _bomb_timer: float = 0.0
|
||||||
|
|
||||||
|
## Whether the local player is currently defusing.
|
||||||
|
var _is_defusing: bool = false
|
||||||
|
|
||||||
|
## Current defuse progress (0.0 to 1.0).
|
||||||
|
var _defuse_progress: float = 0.0
|
||||||
|
|
||||||
|
## Current prompt text to show.
|
||||||
|
var _current_prompt: String = ""
|
||||||
|
|
||||||
|
## Whether the prompt is currently visible.
|
||||||
|
var _prompt_visible: bool = false
|
||||||
|
|
||||||
|
## Whether we are inside a bombsite.
|
||||||
|
var _in_bombsite: bool = false
|
||||||
|
|
||||||
|
## Whether we are near the planted bomb.
|
||||||
|
var _near_planted_bomb: bool = false
|
||||||
|
|
||||||
|
## Whether the interact key is being held.
|
||||||
|
var _interact_held: bool = false
|
||||||
|
|
||||||
|
## Hold-down timer for plant/defuse (E key held long enough).
|
||||||
|
var _interact_hold_time: float = 0.0
|
||||||
|
|
||||||
|
## Time required to hold E to start action (for UX consistency).
|
||||||
|
var _interact_hold_duration: float = 0.0 # Immediate on press
|
||||||
|
|
||||||
|
## Reference to the local player's parent/character controller.
|
||||||
|
var _character_controller: Node = null
|
||||||
|
|
||||||
|
## Reference to the bomb objective on the server (for RPC calls).
|
||||||
|
## Set by the game manager after instantiation.
|
||||||
|
var bomb_objective_path: NodePath = NodePath()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_character_controller = get_parent()
|
||||||
|
if _character_controller == null:
|
||||||
|
push_warning("[BombCarrier] No parent node — prompts will not work")
|
||||||
|
|
||||||
|
# Try to set up the interact action if it doesn't exist
|
||||||
|
if not InputMap.has_action(interact_action):
|
||||||
|
var event := InputEventKey.new()
|
||||||
|
event.keycode = KEY_E
|
||||||
|
InputMap.add_action(interact_action)
|
||||||
|
InputMap.action_add_event(interact_action, event)
|
||||||
|
|
||||||
|
print("[BombCarrier] Initialised — has_bomb=%s" % has_bomb)
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_update_interaction(delta)
|
||||||
|
_emit_status_updates()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — Server state sync
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Called by the game manager or network sync to update bomb state.
|
||||||
|
func sync_bomb_state(planted: bool, position: Vector3, timer: float,
|
||||||
|
defusing: bool, defuse_progress: float) -> void:
|
||||||
|
_bomb_planted = planted
|
||||||
|
_bomb_position = position
|
||||||
|
_bomb_timer = timer
|
||||||
|
_is_defusing = defusing
|
||||||
|
_defuse_progress = defuse_progress
|
||||||
|
|
||||||
|
## Set whether this player has the bomb (assigned by server).
|
||||||
|
func set_has_bomb(value: bool) -> void:
|
||||||
|
has_bomb = value
|
||||||
|
print("[BombCarrier] Has bomb: %s" % value)
|
||||||
|
|
||||||
|
## Set whether the local player is inside a bombsite Area3D.
|
||||||
|
## Called by the bombsite Area3D signal handlers on the client.
|
||||||
|
func set_in_bombsite(value: bool) -> void:
|
||||||
|
_in_bombsite = value
|
||||||
|
|
||||||
|
## Set whether the local player is near the planted bomb.
|
||||||
|
## Called by the bomb area proximity check.
|
||||||
|
func set_near_planted_bomb(value: bool) -> void:
|
||||||
|
_near_planted_bomb = value
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — RPC calls to server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Request to plant the bomb at the current position.
|
||||||
|
## Called when the T player presses E inside a bombsite with the bomb.
|
||||||
|
func request_plant() -> void:
|
||||||
|
if not has_bomb or not _in_bombsite:
|
||||||
|
return
|
||||||
|
if _bomb_planted:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send plant request to server
|
||||||
|
# In real implementation, this would be an RPC call:
|
||||||
|
# _send_plant_request.rpc_id(1, _character_controller.global_position)
|
||||||
|
_send_plant_request(_character_controller.global_position)
|
||||||
|
|
||||||
|
## Request to start defusing the bomb.
|
||||||
|
## Called when the CT player presses E near the planted bomb.
|
||||||
|
func request_defuse() -> void:
|
||||||
|
if not _near_planted_bomb or not _bomb_planted:
|
||||||
|
return
|
||||||
|
if _is_defusing:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send defuse request to server
|
||||||
|
# _send_defuse_request.rpc_id(1)
|
||||||
|
_send_defuse_request()
|
||||||
|
|
||||||
|
## Request to cancel defuse (when player moves or is interrupted).
|
||||||
|
func request_cancel_defuse() -> void:
|
||||||
|
if not _is_defusing:
|
||||||
|
return
|
||||||
|
_send_cancel_defuse()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RPC stubs (to be replaced with actual RPC calls)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _send_plant_request(position: Vector3) -> void:
|
||||||
|
# Placeholder: in real implementation, send RPC to server
|
||||||
|
print("[BombCarrier] Plant request at (%.1f, %.1f, %.1f)" %
|
||||||
|
[position.x, position.y, position.z])
|
||||||
|
|
||||||
|
func _send_defuse_request() -> void:
|
||||||
|
# Placeholder: in real implementation, send RPC to server
|
||||||
|
print("[BombCarrier] Defuse request")
|
||||||
|
|
||||||
|
func _send_cancel_defuse() -> void:
|
||||||
|
# Placeholder: in real implementation, send RPC to server
|
||||||
|
print("[BombCarrier] Cancel defuse request")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal — Interaction logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _update_interaction(delta: float) -> void:
|
||||||
|
var interact_pressed: bool = Input.is_action_pressed(interact_action)
|
||||||
|
var prompt_text: String = ""
|
||||||
|
var show_prompt: bool = false
|
||||||
|
|
||||||
|
# Determine what prompt to show
|
||||||
|
if _bomb_planted and _near_planted_bomb and not _is_defusing:
|
||||||
|
# CT player near planted bomb — defuse prompt
|
||||||
|
var team: int = _get_local_player_team()
|
||||||
|
if team == TeamData.Team.COUNTER_TERRORIST:
|
||||||
|
prompt_text = "Hold E to defuse"
|
||||||
|
show_prompt = true
|
||||||
|
|
||||||
|
elif has_bomb and _in_bombsite and not _bomb_planted:
|
||||||
|
# T player with bomb inside bombsite — plant prompt
|
||||||
|
if _is_round_live():
|
||||||
|
prompt_text = "Hold E to plant"
|
||||||
|
show_prompt = true
|
||||||
|
|
||||||
|
elif _bomb_planted and _is_defusing:
|
||||||
|
# Currently defusing — show progress
|
||||||
|
var progress_pct: int = int(_defuse_progress * 100.0)
|
||||||
|
prompt_text = "Defusing... %d%%" % progress_pct
|
||||||
|
show_prompt = true
|
||||||
|
|
||||||
|
# Handle interact hold
|
||||||
|
if interact_pressed and show_prompt:
|
||||||
|
if not _interact_held:
|
||||||
|
_interact_held = true
|
||||||
|
_interact_hold_time = 0.0
|
||||||
|
|
||||||
|
_interact_hold_time += delta
|
||||||
|
|
||||||
|
# Check if we should trigger the action
|
||||||
|
if _interact_hold_time >= _interact_hold_duration:
|
||||||
|
_interact_hold_time = 0.0
|
||||||
|
if prompt_text.contains("defuse"):
|
||||||
|
request_defuse()
|
||||||
|
elif prompt_text.contains("plant"):
|
||||||
|
request_plant()
|
||||||
|
else:
|
||||||
|
# Cancel any pending interaction
|
||||||
|
if _interact_held:
|
||||||
|
_interact_held = false
|
||||||
|
_interact_hold_time = 0.0
|
||||||
|
# If we were defusing and released, cancel
|
||||||
|
if _is_defusing:
|
||||||
|
request_cancel_defuse()
|
||||||
|
|
||||||
|
# Update prompt if changed
|
||||||
|
if prompt_text != _current_prompt or show_prompt != _prompt_visible:
|
||||||
|
_current_prompt = prompt_text
|
||||||
|
_prompt_visible = show_prompt
|
||||||
|
prompt_changed.emit(prompt_text, show_prompt)
|
||||||
|
|
||||||
|
func _emit_status_updates() -> void:
|
||||||
|
# Emit bomb status updates (rate-limited is handled by engine)
|
||||||
|
bomb_status_updated.emit(_bomb_planted, _bomb_timer, _is_defusing, _defuse_progress)
|
||||||
|
|
||||||
|
# Emit direction indicator if bomb is planted
|
||||||
|
if _bomb_planted and _character_controller != null:
|
||||||
|
var player_pos: Vector3 = _character_controller.global_position
|
||||||
|
var direction: Vector3 = _bomb_position - player_pos
|
||||||
|
var distance: float = direction.length()
|
||||||
|
if distance > 0.01:
|
||||||
|
var bearing: float = atan2(direction.x, -direction.z) # Relative to forward (Z)
|
||||||
|
bomb_direction_updated.emit(rad_to_deg(bearing), distance)
|
||||||
|
else:
|
||||||
|
bomb_direction_updated.emit(0.0, 0.0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal — Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Get the local player's team (from TeamManager singleton if available).
|
||||||
|
func _get_local_player_team() -> int:
|
||||||
|
var team_manager: TeamManager = get_node_or_null("/root/TeamManager")
|
||||||
|
if team_manager:
|
||||||
|
var peer_id: int = multiplayer.get_unique_id() if multiplayer.has_method(&\"get_unique_id\") else -1
|
||||||
|
if peer_id >= 0:
|
||||||
|
return team_manager.get_player_team(peer_id)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
## Check if the current round is in LIVE phase.
|
||||||
|
func _is_round_live() -> bool:
|
||||||
|
var round_manager: RoundManager = get_node_or_null("/root/RoundManager")
|
||||||
|
if round_manager:
|
||||||
|
return round_manager.get_phase() == RoundManager.RoundPhase.LIVE
|
||||||
|
return false
|
||||||
|
|
||||||
|
## Get the multiplayer API (stub for compatibility).
|
||||||
|
func get_multiplayer() -> MultiplayerAPI:
|
||||||
|
if multiplayer and multiplayer.has_method(&\"get_unique_id\"):
|
||||||
|
return multiplayer
|
||||||
|
return null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dtvf72mhgdat7
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
## BuyMenu — Client-side buy menu UI for weapon and equipment purchases.
|
||||||
|
##
|
||||||
|
## Displays a grid of purchasable weapons with name, cost, and key stats.
|
||||||
|
## Highlights weapons the player can afford, shows current money at the top,
|
||||||
|
## and supports both keyboard (numeric keys) and mouse-click selection.
|
||||||
|
##
|
||||||
|
## Opens during the PREP (buy) phase when the player is inside a buy zone.
|
||||||
|
## Automatically closes when the buy phase ends or the player leaves the zone.
|
||||||
|
##
|
||||||
|
## Usage (add as child of the player's UI root or HUD):
|
||||||
|
## var buy_menu = preload("res://client/characters/weapon/buy_menu.gd").new()
|
||||||
|
## add_child(buy_menu)
|
||||||
|
##
|
||||||
|
## # Show it
|
||||||
|
## buy_menu.open()
|
||||||
|
## buy_menu.populate(affordable_weapons_list, current_money)
|
||||||
|
##
|
||||||
|
## Signals:
|
||||||
|
## buy_menu_opened()
|
||||||
|
## buy_menu_closed()
|
||||||
|
## purchase_completed(weapon_id)
|
||||||
|
## purchase_failed(reason: String)
|
||||||
|
##
|
||||||
|
class_name BuyMenu
|
||||||
|
extends Control
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
## Emitted when the buy menu opens.
|
||||||
|
signal buy_menu_opened()
|
||||||
|
|
||||||
|
## Emitted when the buy menu closes.
|
||||||
|
signal buy_menu_closed()
|
||||||
|
|
||||||
|
## Emitted when a purchase is confirmed by the server.
|
||||||
|
signal purchase_completed(weapon_id: String)
|
||||||
|
|
||||||
|
## Emitted when a purchase is denied by the server.
|
||||||
|
signal purchase_failed(reason: String)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports — Layout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
## Background panel colour.
|
||||||
|
@export var panel_color: Color = Color(0.1, 0.1, 0.15, 0.92)
|
||||||
|
|
||||||
|
## Accent colour for headers and affordable items.
|
||||||
|
@export var accent_color: Color = Color(0.2, 0.7, 0.3, 1.0)
|
||||||
|
|
||||||
|
## Colour for items the player cannot afford.
|
||||||
|
@export var unaffordable_color: Color = Color(0.5, 0.5, 0.5, 1.0)
|
||||||
|
|
||||||
|
## Text colour.
|
||||||
|
@export var text_color: Color = Color(0.95, 0.95, 0.95, 1.0)
|
||||||
|
|
||||||
|
## Key binding for opening the buy menu (default: B).
|
||||||
|
@export var open_key: Key = KEY_B
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
## Reference to the BuyMenuHandler on the server (for sending RPCs).
|
||||||
|
## Injected by the UI manager or found at runtime.
|
||||||
|
var buy_menu_handler_path: NodePath = NodePath()
|
||||||
|
|
||||||
|
var _is_open: bool = false
|
||||||
|
var _weapon_buttons: Array[Button] = []
|
||||||
|
var _populated: bool = false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UI Nodes (built in _build_ui)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
var _panel: Panel = null
|
||||||
|
var _money_label: Label = null
|
||||||
|
var _title_label: Label = null
|
||||||
|
var _weapon_container: Container = null
|
||||||
|
var _close_button: Button = null
|
||||||
|
var _status_label: Label = null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_build_ui()
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
# Listen for server purchase confirmations / denials
|
||||||
|
# The BuyMenuHandler sends RPCs that land on this node.
|
||||||
|
# We connect to them via the multiplayer API — they're named to match
|
||||||
|
# the RPC names defined in BuyMenuHandler.
|
||||||
|
|
||||||
|
# Connect input for toggle key
|
||||||
|
set_process_input(true)
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("buy_menu") or (
|
||||||
|
event is InputEventKey and
|
||||||
|
event.keycode == open_key and
|
||||||
|
event.pressed and
|
||||||
|
not event.echo
|
||||||
|
):
|
||||||
|
if _is_open:
|
||||||
|
close()
|
||||||
|
else:
|
||||||
|
open()
|
||||||
|
accept_event()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UI Construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
# Main panel — centred, sized to fit content
|
||||||
|
_panel = Panel.new()
|
||||||
|
_panel.anchor_left = 0.15
|
||||||
|
_panel.anchor_right = 0.85
|
||||||
|
_panel.anchor_top = 0.1
|
||||||
|
_panel.anchor_bottom = 0.9
|
||||||
|
_panel.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
add_child(_panel)
|
||||||
|
|
||||||
|
# Panel background style
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = panel_color
|
||||||
|
style.corner_radius_top_left = 8
|
||||||
|
style.corner_radius_top_right = 8
|
||||||
|
style.corner_radius_bottom_left = 8
|
||||||
|
style.corner_radius_bottom_right = 8
|
||||||
|
_panel.add_theme_stylebox_override("panel", style)
|
||||||
|
|
||||||
|
# Vertical layout inside the panel
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.anchor_left = 0.0
|
||||||
|
vbox.anchor_right = 1.0
|
||||||
|
vbox.anchor_top = 0.0
|
||||||
|
vbox.anchor_bottom = 1.0
|
||||||
|
vbox.add_theme_constant_override("separation", 12)
|
||||||
|
_panel.add_child(vbox)
|
||||||
|
|
||||||
|
# ── Title ──
|
||||||
|
_title_label = Label.new()
|
||||||
|
_title_label.text = "BUY MENU"
|
||||||
|
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_title_label.add_theme_font_size_override("font_size", 24)
|
||||||
|
_title_label.add_theme_color_override("font_color", accent_color)
|
||||||
|
vbox.add_child(_title_label)
|
||||||
|
|
||||||
|
# ── Money display ──
|
||||||
|
_money_label = Label.new()
|
||||||
|
_money_label.text = "$0"
|
||||||
|
_money_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_money_label.add_theme_font_size_override("font_size", 20)
|
||||||
|
_money_label.add_theme_color_override("font_color", text_color)
|
||||||
|
vbox.add_child(_money_label)
|
||||||
|
|
||||||
|
# ── Weapon grid ──
|
||||||
|
_weapon_container = GridContainer.new()
|
||||||
|
_weapon_container.columns = 2
|
||||||
|
_weapon_container.add_theme_constant_override("h_separation", 16)
|
||||||
|
_weapon_container.add_theme_constant_override("v_separation", 8)
|
||||||
|
_weapon_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_weapon_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
vbox.add_child(_weapon_container)
|
||||||
|
|
||||||
|
# ── Status / feedback line ──
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.text = ""
|
||||||
|
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_status_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2, 1.0))
|
||||||
|
_status_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
vbox.add_child(_status_label)
|
||||||
|
|
||||||
|
# ── Close hint ──
|
||||||
|
var close_hint := Label.new()
|
||||||
|
close_hint.text = "Press B or ESC to close | Click or press 1-4 to buy"
|
||||||
|
close_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
close_hint.add_theme_font_size_override("font_size", 11)
|
||||||
|
close_hint.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0))
|
||||||
|
vbox.add_child(close_hint)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Open and populate the buy menu.
|
||||||
|
func open() -> void:
|
||||||
|
if _is_open:
|
||||||
|
return
|
||||||
|
|
||||||
|
_is_open = true
|
||||||
|
visible = true
|
||||||
|
_populated = false
|
||||||
|
_clear_weapons()
|
||||||
|
_status_label.text = ""
|
||||||
|
|
||||||
|
# Request affordable weapons from the server via an RPC call
|
||||||
|
# (The server pushes the data back via RPC response.)
|
||||||
|
_request_menu_data()
|
||||||
|
|
||||||
|
buy_menu_opened.emit()
|
||||||
|
|
||||||
|
## Close the buy menu.
|
||||||
|
func close() -> void:
|
||||||
|
if not _is_open:
|
||||||
|
return
|
||||||
|
_is_open = false
|
||||||
|
visible = false
|
||||||
|
buy_menu_closed.emit()
|
||||||
|
|
||||||
|
## Returns true if the menu is currently open.
|
||||||
|
func is_open() -> bool:
|
||||||
|
return _is_open
|
||||||
|
|
||||||
|
## Update the displayed money amount.
|
||||||
|
func set_money(amount: int) -> void:
|
||||||
|
_money_label.text = "$%d" % amount
|
||||||
|
|
||||||
|
## Populate the weapon grid with purchasable items.
|
||||||
|
## `weapons` is an Array[Dictionary] with keys:
|
||||||
|
## weapon_id, display_name, cost, damage, fire_rate, mag_size
|
||||||
|
## `current_money` is the player's balance.
|
||||||
|
func populate(weapons: Array[Dictionary], current_money: int) -> void:
|
||||||
|
_clear_weapons()
|
||||||
|
_populated = false
|
||||||
|
set_money(current_money)
|
||||||
|
|
||||||
|
var index: int = 1
|
||||||
|
for w in weapons:
|
||||||
|
var affordable: bool = (current_money >= w.get("cost", 0))
|
||||||
|
_add_weapon_button(index, w, affordable)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
_populated = true
|
||||||
|
|
||||||
|
## Show a temporary status message in the menu.
|
||||||
|
func show_status(message: String, duration: float = 3.0) -> void:
|
||||||
|
_status_label.text = message
|
||||||
|
if duration > 0:
|
||||||
|
get_tree().create_timer(duration).timeout.connect(func():
|
||||||
|
if is_instance_valid(_status_label) and _status_label.text == message:
|
||||||
|
_status_label.text = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: weapon button creation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _add_weapon_button(index: int, data: Dictionary, affordable: bool) -> void:
|
||||||
|
var weapon_id: String = data.get("weapon_id", "")
|
||||||
|
var display_name: String = data.get("display_name", weapon_id)
|
||||||
|
var cost: int = data.get("cost", 0)
|
||||||
|
var damage: float = data.get("damage", 0.0)
|
||||||
|
var fire_rate: float = data.get("fire_rate", 0.0)
|
||||||
|
var mag_size: int = data.get("mag_size", 0)
|
||||||
|
|
||||||
|
# Build the weapon card as a styled Button
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
btn.custom_minimum_size = Vector2(240, 80)
|
||||||
|
|
||||||
|
# Text: name + cost on first line, stats on second
|
||||||
|
var stats_line: String = "%.0f dmg | %.1f rps | %d rds" % [damage, fire_rate, mag_size]
|
||||||
|
btn.text = "[%d] %s — $%d\n%s" % [index, display_name, cost, stats_line]
|
||||||
|
btn.flat = false
|
||||||
|
btn.tooltip_text = "%s — $%d\nDamage: %.0f\nFire Rate: %.1f Hz\nMagazine: %d rounds" % [
|
||||||
|
display_name, cost, damage, fire_rate, mag_size
|
||||||
|
]
|
||||||
|
|
||||||
|
# Style
|
||||||
|
var fg_color: Color = accent_color if affordable else unaffordable_color
|
||||||
|
btn.add_theme_color_override("font_color", fg_color)
|
||||||
|
btn.add_theme_color_override("font_hover_color", text_color)
|
||||||
|
btn.add_theme_color_override("font_pressed_color", Color.WHITE)
|
||||||
|
btn.add_theme_font_size_override("font_size", 14)
|
||||||
|
|
||||||
|
if not affordable:
|
||||||
|
btn.disabled = true
|
||||||
|
|
||||||
|
btn.pressed.connect(_on_weapon_button_pressed.bind(weapon_id, cost, btn))
|
||||||
|
_weapon_container.add_child(btn)
|
||||||
|
_weapon_buttons.append(btn)
|
||||||
|
|
||||||
|
func _clear_weapons() -> void:
|
||||||
|
for child in _weapon_buttons:
|
||||||
|
if is_instance_valid(child):
|
||||||
|
child.queue_free()
|
||||||
|
_weapon_buttons.clear()
|
||||||
|
_populated = false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: purchase flow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_weapon_button_pressed(weapon_id: String, cost: int, _button: Button) -> void:
|
||||||
|
_request_purchase(weapon_id)
|
||||||
|
|
||||||
|
## Send a purchase request RPC to the server.
|
||||||
|
func _request_purchase(weapon_id: String) -> void:
|
||||||
|
_status_label.text = "Purchasing %s..." % weapon_id
|
||||||
|
|
||||||
|
# Find the BuyMenuHandler and call its buy_request RPC.
|
||||||
|
# The handler is typically a child of GameServer.
|
||||||
|
if multiplayer.is_server():
|
||||||
|
# Listen server / standalone — call directly
|
||||||
|
_call_buy_handler(weapon_id)
|
||||||
|
else:
|
||||||
|
# Dedicated client — send RPC
|
||||||
|
_call_buy_handler_rpc(weapon_id)
|
||||||
|
|
||||||
|
func _call_buy_handler(_weapon_id: String) -> void:
|
||||||
|
# On a listen server, the handler is local
|
||||||
|
var handler: BuyMenuHandler = _find_buy_handler()
|
||||||
|
if handler:
|
||||||
|
handler.buy_request.rpc(_weapon_id)
|
||||||
|
|
||||||
|
func _call_buy_handler_rpc(weapon_id: String) -> void:
|
||||||
|
# Find the handler path and send the RPC
|
||||||
|
var handler_path: NodePath = buy_menu_handler_path
|
||||||
|
if handler_path.is_empty():
|
||||||
|
# Try to find it dynamically
|
||||||
|
handler_path = "/root/GameServer/BuyMenuHandler"
|
||||||
|
var node: Node = get_node_or_null(handler_path) if not handler_path.is_empty() else null
|
||||||
|
if node and node.has_method(&"buy_request"):
|
||||||
|
node.buy_request.rpc(weapon_id)
|
||||||
|
else:
|
||||||
|
# Fallback: call directly on the handler node path
|
||||||
|
rpc_id(1, "_remote_buy_request", weapon_id)
|
||||||
|
|
||||||
|
## Fallback: send buy request to server's handler.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _remote_buy_request(weapon_id: String) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var handler: BuyMenuHandler = _find_buy_handler()
|
||||||
|
if handler:
|
||||||
|
var sender: int = multiplayer.get_remote_sender_id()
|
||||||
|
handler.call_deferred("_process_buy_request", sender, weapon_id)
|
||||||
|
|
||||||
|
func _find_buy_handler() -> BuyMenuHandler:
|
||||||
|
# Try common locations
|
||||||
|
var game_server: Node = get_node_or_null("/root/GameServer")
|
||||||
|
if game_server:
|
||||||
|
for child in game_server.get_children():
|
||||||
|
if child is BuyMenuHandler:
|
||||||
|
return child
|
||||||
|
return null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RPC handlers — called by BuyMenuHandler (server → client)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Called by the server when a purchase succeeds.
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _on_buy_confirmed(weapon_id: String, cost: int) -> void:
|
||||||
|
show_status("Purchased %s for $%d" % [weapon_id, cost], 2.0)
|
||||||
|
purchase_completed.emit(weapon_id)
|
||||||
|
# Close after a brief delay so the player sees the confirmation
|
||||||
|
get_tree().create_timer(0.6).timeout.connect(close)
|
||||||
|
|
||||||
|
## Called by the server when a purchase is denied.
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _on_buy_denied(reason: String) -> void:
|
||||||
|
show_status("Purchase failed: %s" % reason, 3.0)
|
||||||
|
purchase_failed.emit(reason)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request affordable weapon data from the server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _request_menu_data() -> void:
|
||||||
|
# For now, the client populates from the weapon definitions directly,
|
||||||
|
# filtering by what the server says is affordable.
|
||||||
|
# In a more advanced version, the server sends the curated list.
|
||||||
|
#
|
||||||
|
# Since we don't know the player's money on the client, we send an
|
||||||
|
# RPC to the server to get the affordable list. But for simplicity,
|
||||||
|
# we populate all weapons and let the server deny if unaffordable.
|
||||||
|
#
|
||||||
|
# The server pushes the affordable list back via get_affordable_weapons().
|
||||||
|
# For now, just show all weapons — the server will reject unaffordable ones.
|
||||||
|
|
||||||
|
# Try to fetch from server
|
||||||
|
if multiplayer.is_server():
|
||||||
|
_populate_from_local(player_id())
|
||||||
|
else:
|
||||||
|
_request_affordable_weapons.rpc_id(1)
|
||||||
|
|
||||||
|
## Get the player's peer ID.
|
||||||
|
func player_id() -> int:
|
||||||
|
return multiplayer.get_unique_id()
|
||||||
|
|
||||||
|
## Client → Server: request affordable weapon list.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _request_affordable_weapons() -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var sender: int = multiplayer.get_remote_sender_id()
|
||||||
|
var handler: BuyMenuHandler = _find_buy_handler()
|
||||||
|
if handler:
|
||||||
|
var weapons: Array[Dictionary] = handler.get_affordable_weapons(sender)
|
||||||
|
var money: int = handler.get_player_money(sender)
|
||||||
|
_send_menu_data.rpc_id(sender, weapons, money)
|
||||||
|
|
||||||
|
## Server → Client: send affordable weapon list and money.
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _send_menu_data(weapons: Array[Dictionary], current_money: int) -> void:
|
||||||
|
populate(weapons, current_money)
|
||||||
|
|
||||||
|
func _populate_from_local(pid: int) -> void:
|
||||||
|
var handler: BuyMenuHandler = _find_buy_handler()
|
||||||
|
if handler:
|
||||||
|
var weapons: Array[Dictionary] = handler.get_affordable_weapons(pid)
|
||||||
|
var money: int = handler.get_player_money(pid)
|
||||||
|
populate(weapons, money)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _unhandled_key_input(event: InputEventKey) -> void:
|
||||||
|
if not _is_open or not _populated:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Numeric keys 1-4 to buy
|
||||||
|
var key: Key = event.keycode
|
||||||
|
var index: int = -1
|
||||||
|
match key:
|
||||||
|
KEY_1, KEY_KP_1: index = 0
|
||||||
|
KEY_2, KEY_KP_2: index = 1
|
||||||
|
KEY_3, KEY_KP_3: index = 2
|
||||||
|
KEY_4, KEY_KP_4: index = 3
|
||||||
|
|
||||||
|
if index >= 0 and index < _weapon_buttons.size():
|
||||||
|
var btn: Button = _weapon_buttons[index]
|
||||||
|
if btn and not btn.disabled and event.pressed:
|
||||||
|
btn.emit_signal("pressed")
|
||||||
|
accept_event()
|
||||||
|
|
||||||
|
# ESC to close
|
||||||
|
if event.keycode == KEY_ESCAPE and event.pressed:
|
||||||
|
close()
|
||||||
|
accept_event()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cdsyn134gsca8
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
## WeaponManager — hitscan weapon system for tactical FPS.
|
||||||
|
##
|
||||||
|
## Manages a single hitscan weapon: fire rate limiting, ammo, reload.
|
||||||
|
## Communicates with SimulationServer for server-authoritative hit detection.
|
||||||
|
##
|
||||||
|
## Architecture:
|
||||||
|
## - Client-side: fire rate limiting, muzzle VFX, hit marker feedback
|
||||||
|
## - Server-side (via SimulationServer): hit detection, damage, lag compensation
|
||||||
|
## - Both sides: ammo tracking (server-authoritative, client-predicted)
|
||||||
|
##
|
||||||
|
## Usage (attach as child of FPSCharacterController):
|
||||||
|
##
|
||||||
|
## FPSCharacterController
|
||||||
|
## ├── WeaponManager — fire rate, ammo, VFX
|
||||||
|
## │ ├── MuzzleFlash — instantiated VFX
|
||||||
|
## │ └── HitMarker — instantiated VFX
|
||||||
|
## └── FpsCamera
|
||||||
|
##
|
||||||
|
class_name WeaponManager
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when the weapon fires (for visual feedback).
|
||||||
|
signal weapon_fired(origin: Vector3, direction: Vector3)
|
||||||
|
|
||||||
|
## Emitted when this weapon hits an entity.
|
||||||
|
signal hit_marked(hit_pos: Vector3, damage: float, killed: bool)
|
||||||
|
|
||||||
|
## Emitted when ammo changes.
|
||||||
|
signal ammo_changed(current: int, max: int)
|
||||||
|
|
||||||
|
## Emitted when player is killed.
|
||||||
|
signal player_killed()
|
||||||
|
|
||||||
|
## Emitted when the player switches weapons (for animation/UI).
|
||||||
|
signal weapon_switched(index: int, weapon_id: String)
|
||||||
|
|
||||||
|
## Emitted when the current weapon fires (for animation/visual sync).
|
||||||
|
## Connected by animation players or VFX systems on the weapon model.
|
||||||
|
signal fire_animation_triggered()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports — Assault Rifle Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Maximum ammo capacity.
|
||||||
|
@export var max_ammo: int = 30
|
||||||
|
|
||||||
|
## Starting ammo (including one magazine).
|
||||||
|
@export var start_ammo: int = 90
|
||||||
|
|
||||||
|
## Time between shots (seconds). 10 Hz = 0.1s between shots.
|
||||||
|
@export var fire_rate: float = 0.1
|
||||||
|
|
||||||
|
## Reload time in seconds.
|
||||||
|
@export var reload_time: float = 2.0
|
||||||
|
|
||||||
|
## Spread increases while firing (degrees).
|
||||||
|
@export var spread_per_shot: float = 0.5
|
||||||
|
|
||||||
|
## Max spread when firing continuously (degrees).
|
||||||
|
@export var max_spread: float = 4.0
|
||||||
|
|
||||||
|
## Spread recovery per second (degrees/s).
|
||||||
|
@export var spread_recovery: float = 8.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var current_ammo: int = 30
|
||||||
|
var reserve_ammo: int = 90
|
||||||
|
var is_reloading: bool = false
|
||||||
|
var _last_shot_time: float = 0.0
|
||||||
|
var _current_spread: float = 0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Multi-weapon inventory state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Array of weapon IDs the player carries (the inventory).
|
||||||
|
var _weapon_inventory: Array[String] = []
|
||||||
|
|
||||||
|
## Index into _weapon_inventory for the currently equipped weapon.
|
||||||
|
var _current_weapon_index: int = -1
|
||||||
|
|
||||||
|
## Per-weapon persistent state saved when switching away, restored on switch-back.
|
||||||
|
## weapon_id -> { ammo, reserve, is_reloading, last_shot_time, current_spread }
|
||||||
|
var _weapon_states: Dictionary = {}
|
||||||
|
|
||||||
|
## Reference to the parent FPSCharacterController (set in _ready).
|
||||||
|
var _controller: FPSCharacterController = null
|
||||||
|
|
||||||
|
## Reference to SimulationServer (if available).
|
||||||
|
var _server: Object = null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_controller = get_parent() as FPSCharacterController
|
||||||
|
if _controller == null:
|
||||||
|
push_warning("WeaponManager: Parent is not FPSCharacterController. Disabled.")
|
||||||
|
set_process(false)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find SimulationServer singleton
|
||||||
|
if Engine.has_singleton("SimulationServer"):
|
||||||
|
_server = Engine.get_singleton("SimulationServer")
|
||||||
|
elif Engine.get_main_loop() and Engine.get_main_loop().root:
|
||||||
|
# Try finding it in the scene tree
|
||||||
|
var game_server = Engine.get_main_loop().root.find_child("GameServer", true, false)
|
||||||
|
if game_server and game_server.has_method(&"get_simulation_server"):
|
||||||
|
_server = game_server.get_simulation_server()
|
||||||
|
|
||||||
|
# Initialize ammo
|
||||||
|
current_ammo = max_ammo
|
||||||
|
reserve_ammo = start_ammo
|
||||||
|
|
||||||
|
# Signal that ammo changed
|
||||||
|
ammo_changed.emit(current_ammo, max_ammo)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main loop — called from FPSCharacterController._physics_process per tick
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Try to fire the weapon. Returns true if a shot was fired.
|
||||||
|
## Called by the controller when shoot input is detected.
|
||||||
|
func try_fire(time: float) -> bool:
|
||||||
|
# Check fire rate
|
||||||
|
if time - _last_shot_time < fire_rate:
|
||||||
|
return false
|
||||||
|
|
||||||
|
# Check ammo
|
||||||
|
if current_ammo <= 0:
|
||||||
|
# Auto-reload on empty — or play empty-click sound
|
||||||
|
if not is_reloading:
|
||||||
|
start_reload()
|
||||||
|
return false
|
||||||
|
|
||||||
|
# Check reloading
|
||||||
|
if is_reloading:
|
||||||
|
return false
|
||||||
|
|
||||||
|
# Deduct ammo
|
||||||
|
current_ammo -= 1
|
||||||
|
_last_shot_time = time
|
||||||
|
|
||||||
|
# Increase spread
|
||||||
|
_current_spread = min(_current_spread + spread_per_shot, max_spread)
|
||||||
|
|
||||||
|
# Persist state back to per-weapon dict
|
||||||
|
_save_current_weapon_state()
|
||||||
|
|
||||||
|
# Emit fire signal (for visual/hit marker)
|
||||||
|
# Direction comes from the controller's look direction
|
||||||
|
if _controller:
|
||||||
|
var origin: Vector3 = _controller.global_position
|
||||||
|
origin.y += _controller.eye_height_stand
|
||||||
|
var direction: Vector3 = _controller.get_look_direction()
|
||||||
|
weapon_fired.emit(origin, direction)
|
||||||
|
|
||||||
|
ammo_changed.emit(current_ammo, max_ammo)
|
||||||
|
fire_animation_triggered.emit()
|
||||||
|
return true
|
||||||
|
|
||||||
|
## Called every frame to recover spread and handle spread animation.
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
# Recover spread
|
||||||
|
if _current_spread > 0.0:
|
||||||
|
_current_spread = max(0.0, _current_spread - spread_recovery * delta)
|
||||||
|
|
||||||
|
## Start reloading.
|
||||||
|
func start_reload() -> void:
|
||||||
|
if is_reloading or current_ammo >= max_ammo or reserve_ammo <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_reloading = true
|
||||||
|
await get_tree().create_timer(reload_time).timeout
|
||||||
|
|
||||||
|
# Reload logic
|
||||||
|
var needed: int = max_ammo - current_ammo
|
||||||
|
var from_reserve: int = min(needed, reserve_ammo)
|
||||||
|
current_ammo += from_reserve
|
||||||
|
reserve_ammo -= from_reserve
|
||||||
|
is_reloading = false
|
||||||
|
|
||||||
|
ammo_changed.emit(current_ammo, max_ammo)
|
||||||
|
_save_current_weapon_state()
|
||||||
|
|
||||||
|
## Called when a hit is confirmed by the server.
|
||||||
|
func on_hit_confirmed(hit_pos: Vector3, damage: float, killed: bool) -> void:
|
||||||
|
hit_marked.emit(hit_pos, damage, killed)
|
||||||
|
if killed:
|
||||||
|
player_killed.emit()
|
||||||
|
|
||||||
|
## Get current spread (degrees).
|
||||||
|
func get_current_spread() -> float:
|
||||||
|
return _current_spread
|
||||||
|
|
||||||
|
## Get the current weapon fire direction including spread cone.
|
||||||
|
func get_spread_direction(base_direction: Vector3) -> Vector3:
|
||||||
|
if _current_spread <= 0.001:
|
||||||
|
return base_direction
|
||||||
|
|
||||||
|
var spread_rad: float = deg_to_rad(_current_spread)
|
||||||
|
var theta: float = randf() * TAU
|
||||||
|
var phi: float = randf() * spread_rad
|
||||||
|
|
||||||
|
# Create a random perpendicular vector
|
||||||
|
var up := Vector3.UP
|
||||||
|
if abs(base_direction.dot(up)) > 0.99:
|
||||||
|
up = Vector3.RIGHT
|
||||||
|
var right := base_direction.cross(up).normalized()
|
||||||
|
up = right.cross(base_direction).normalized()
|
||||||
|
|
||||||
|
var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi)
|
||||||
|
return (base_direction + offset).normalized()
|
||||||
|
|
||||||
|
## Reset weapon state (for respawn).
|
||||||
|
func reset() -> void:
|
||||||
|
# Re-initialise all weapon states in inventory
|
||||||
|
for wid in _weapon_inventory:
|
||||||
|
_initialize_weapon_state(wid)
|
||||||
|
# Apply current weapon (or use defaults if no inventory)
|
||||||
|
if _current_weapon_index >= 0 and _current_weapon_index < _weapon_inventory.size():
|
||||||
|
_apply_current_weapon()
|
||||||
|
else:
|
||||||
|
current_ammo = max_ammo
|
||||||
|
reserve_ammo = start_ammo
|
||||||
|
is_reloading = false
|
||||||
|
_current_spread = 0.0
|
||||||
|
_last_shot_time = 0.0
|
||||||
|
ammo_changed.emit(current_ammo, max_ammo)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Weapon inventory management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Add a weapon to the player's inventory.
|
||||||
|
## If no weapon is currently equipped, this one is automatically selected.
|
||||||
|
func add_weapon(weapon_id: String) -> void:
|
||||||
|
if weapon_id in _weapon_inventory:
|
||||||
|
return
|
||||||
|
_weapon_inventory.append(weapon_id)
|
||||||
|
_initialize_weapon_state(weapon_id)
|
||||||
|
if _current_weapon_index < 0:
|
||||||
|
_current_weapon_index = _weapon_inventory.size() - 1
|
||||||
|
_apply_current_weapon()
|
||||||
|
|
||||||
|
## Switch to a weapon by its inventory index.
|
||||||
|
## Returns true if the switch was performed.
|
||||||
|
func switch_weapon(index: int) -> bool:
|
||||||
|
if index < 0 or index >= _weapon_inventory.size():
|
||||||
|
return false
|
||||||
|
if index == _current_weapon_index:
|
||||||
|
return false
|
||||||
|
_save_current_weapon_state()
|
||||||
|
_current_weapon_index = index
|
||||||
|
_apply_current_weapon()
|
||||||
|
weapon_switched.emit(index, _weapon_inventory[index])
|
||||||
|
return true
|
||||||
|
|
||||||
|
## Return the WeaponData for the currently equipped weapon, or null.
|
||||||
|
func current_weapon_data() -> WeaponData:
|
||||||
|
var wid := get_current_weapon_id()
|
||||||
|
if wid.is_empty():
|
||||||
|
return null
|
||||||
|
return WeaponDefinitions.get_weapon(wid)
|
||||||
|
|
||||||
|
## Return the current weapon's ID string, or empty string.
|
||||||
|
func get_current_weapon_id() -> String:
|
||||||
|
if _current_weapon_index >= 0 and _current_weapon_index < _weapon_inventory.size():
|
||||||
|
return _weapon_inventory[_current_weapon_index]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Return the number of weapons in the inventory.
|
||||||
|
func get_weapon_count() -> int:
|
||||||
|
return _weapon_inventory.size()
|
||||||
|
|
||||||
|
## Return the list of weapon IDs in the inventory.
|
||||||
|
func get_inventory() -> Array[String]:
|
||||||
|
return _weapon_inventory.duplicate()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: per-weapon state management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Initialise state dict for a weapon with default values.
|
||||||
|
func _initialize_weapon_state(weapon_id: String) -> void:
|
||||||
|
var data := WeaponDefinitions.get_weapon(weapon_id)
|
||||||
|
if data == null:
|
||||||
|
return
|
||||||
|
_weapon_states[weapon_id] = {
|
||||||
|
ammo = data.mag_size,
|
||||||
|
reserve = data.mag_size * 3,
|
||||||
|
is_reloading = false,
|
||||||
|
last_shot_time = 0.0,
|
||||||
|
current_spread = 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
## Save the current weapon's runtime state back into _weapon_states.
|
||||||
|
func _save_current_weapon_state() -> void:
|
||||||
|
var wid := get_current_weapon_id()
|
||||||
|
if wid.is_empty() or not (wid in _weapon_states):
|
||||||
|
return
|
||||||
|
_weapon_states[wid] = {
|
||||||
|
ammo = current_ammo,
|
||||||
|
reserve = reserve_ammo,
|
||||||
|
is_reloading = is_reloading,
|
||||||
|
last_shot_time = _last_shot_time,
|
||||||
|
current_spread = _current_spread,
|
||||||
|
}
|
||||||
|
|
||||||
|
## Load the weapon at _current_weapon_index and apply its data + state
|
||||||
|
## to the live export properties and state variables.
|
||||||
|
func _apply_current_weapon() -> void:
|
||||||
|
var wid := get_current_weapon_id()
|
||||||
|
if wid.is_empty():
|
||||||
|
return
|
||||||
|
var data := WeaponDefinitions.get_weapon(wid)
|
||||||
|
if data == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set export defaults from weapon data
|
||||||
|
max_ammo = data.mag_size
|
||||||
|
start_ammo = data.mag_size * 4 # 1 in mag + 3 reserve = 4x
|
||||||
|
fire_rate = 1.0 / max(data.fire_rate, 0.001)
|
||||||
|
reload_time = data.reload_time
|
||||||
|
spread_per_shot = data.spread_degrees * 0.1
|
||||||
|
max_spread = data.spread_degrees * 3.0
|
||||||
|
spread_recovery = data.spread_degrees * 8.0
|
||||||
|
|
||||||
|
# Restore per-weapon state
|
||||||
|
var st: Dictionary = _weapon_states.get(wid, {})
|
||||||
|
if st.is_empty():
|
||||||
|
_initialize_weapon_state(wid)
|
||||||
|
st = _weapon_states.get(wid, {})
|
||||||
|
current_ammo = st.get("ammo", data.mag_size)
|
||||||
|
reserve_ammo = st.get("reserve", data.mag_size * 3)
|
||||||
|
is_reloading = st.get("is_reloading", false)
|
||||||
|
_last_shot_time = st.get("last_shot_time", 0.0)
|
||||||
|
_current_spread = st.get("current_spread", 0.0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dywlbha8xivx4
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Check LightmapGI config on kit_demo.tscn scene.
|
||||||
|
# No bake possible at runtime in Godot 4.7 — LightmapGI baking is editor-only.
|
||||||
|
# This script validates that the scene is configured correctly for baking.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# xvfb-run godot --display-driver x11 --rendering-driver opengl3 \
|
||||||
|
# --audio-driver Dummy --script scripts/bake_lighting.gd
|
||||||
|
#
|
||||||
|
# Bake from editor:
|
||||||
|
# Open kit_demo.tscn in Godot editor, select LightmapGI node,
|
||||||
|
# click "Bake Lightmap" in the Inspector toolbar.
|
||||||
|
|
||||||
|
const SCENE_PATH := "res://assets/scenes/modular/kit_demo.tscn"
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
print("=== LightmapGI Config Check ===")
|
||||||
|
print("Note: Godot 4.7 LightmapGI baking is editor-only.")
|
||||||
|
print("This script validates configuration only.")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
var scene = ResourceLoader.load(SCENE_PATH)
|
||||||
|
if not scene:
|
||||||
|
printerr("ERROR: Failed to load scene!")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var instance = scene.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
|
||||||
|
var lightmap = _find_node(instance, "LightmapGI")
|
||||||
|
if not lightmap or not (lightmap is LightmapGI):
|
||||||
|
printerr("ERROR: LightmapGI node not found!")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("LightmapGI configuration:")
|
||||||
|
match lightmap.quality:
|
||||||
|
0: print(" Quality: Low")
|
||||||
|
1: print(" Quality: Medium")
|
||||||
|
2: print(" Quality: High")
|
||||||
|
3: print(" Quality: Ultra")
|
||||||
|
print(" Bounces: ", lightmap.bounces)
|
||||||
|
print(" Texel Scale: ", lightmap.texel_scale)
|
||||||
|
print(" Max Texture Size: ", lightmap.max_texture_size)
|
||||||
|
print(" Interior: ", lightmap.interior)
|
||||||
|
print(" Denoiser: ", lightmap.is_using_denoiser())
|
||||||
|
print("")
|
||||||
|
|
||||||
|
if lightmap.light_data != null:
|
||||||
|
print("LightmapGI has baked data!")
|
||||||
|
else:
|
||||||
|
print("LightmapGI NOT YET BAKED.")
|
||||||
|
print("To bake: Open scene in Godot editor > Select LightmapGI >")
|
||||||
|
print("click 'Bake Lightmap' button in the Inspector toolbar.")
|
||||||
|
print("")
|
||||||
|
print("Or use the Godot editor menu: Scene > Bake LightmapGI")
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("=== Config check complete ===")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _find_node(parent: Node, name: String) -> Node:
|
||||||
|
for child in parent.get_children():
|
||||||
|
if child.name == name:
|
||||||
|
return child
|
||||||
|
var found = _find_node(child, name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://q8nutj781fyf
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
@tool
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# bake_lightmaps.gd — LightmapGI Configuration Checker
|
||||||
|
# =============================================================================
|
||||||
|
# Validates LightmapGI configuration for baking readiness in a Godot 4 scene.
|
||||||
|
# Checks:
|
||||||
|
# - LightmapGI node exists and is properly configured
|
||||||
|
# - Quality setting (Low/Medium/High/Ultra)
|
||||||
|
# - Bounce count (recommended ≥ 2)
|
||||||
|
# - Texel scale (recommended 0.5–4.0, default 1.0)
|
||||||
|
# - Interior flag (true for indoor maps)
|
||||||
|
# - Denoiser enabled
|
||||||
|
# - Max texture size (recommended ≤ 1024)
|
||||||
|
# - Light data presence (already baked or not)
|
||||||
|
# - Geometry with UV2 (required for baking)
|
||||||
|
#
|
||||||
|
# Note: Godot 4 LightmapGI baking is editor-only. This script validates
|
||||||
|
# configuration readiness; it does NOT perform the bake itself.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# godot --headless --script client/scripts/bake_lightmaps.gd -- res://path/to/map.tscn
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 — All config checks passed
|
||||||
|
# 1 — One or more config checks failed
|
||||||
|
# 2 — Scene file not found or cannot be loaded
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
const RECOMMENDED_BOUNCES := 2
|
||||||
|
const MIN_BOUNCES := 1
|
||||||
|
const MAX_BOUNCES := 8
|
||||||
|
const RECOMMENDED_TEXEL_SCALE := 1.0
|
||||||
|
const MIN_TEXEL_SCALE := 0.1
|
||||||
|
const MAX_TEXEL_SCALE := 10.0
|
||||||
|
const MAX_TEXTURE_SIZE := 1024
|
||||||
|
const RECOMMENDED_QUALITY := 2 # High
|
||||||
|
|
||||||
|
var _pass_count := 0
|
||||||
|
var _fail_count := 0
|
||||||
|
var _warning_count := 0
|
||||||
|
var _scene_path := ""
|
||||||
|
var _scene_instance: Node = null
|
||||||
|
var _lightmap_node: Node = null
|
||||||
|
|
||||||
|
# UV2 tracking
|
||||||
|
var _total_meshes := 0
|
||||||
|
var _meshes_with_uv2 := 0
|
||||||
|
var _meshes_without_uv2: Array[String] = []
|
||||||
|
var _mesh_details: Array[Dictionary] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_parse_arguments()
|
||||||
|
|
||||||
|
if _scene_path.is_empty():
|
||||||
|
printerr("ERROR: No scene path provided. Usage: godot --headless --script bake_lightmaps.gd -- res://path/to/map.tscn")
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("==========================================")
|
||||||
|
print(" LIGHTMAP CONFIGURATION CHECKER")
|
||||||
|
print(" Scene: ", _scene_path)
|
||||||
|
print("==========================================")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
print(" Note: Godot 4 LightmapGI baking is editor-only.")
|
||||||
|
print(" This tool validates lightmap configuration readiness only.")
|
||||||
|
print(" To bake: Open scene in editor > Scene > Bake LightmapGI")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
var scene: PackedScene = _load_scene(_scene_path)
|
||||||
|
if scene == null:
|
||||||
|
printerr("ERROR: Scene not found: ", _scene_path)
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
_scene_instance = _instantiate_scene(scene)
|
||||||
|
if _scene_instance == null:
|
||||||
|
printerr("ERROR: Failed to instantiate scene: ", _scene_path)
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
root.add_child(_scene_instance)
|
||||||
|
|
||||||
|
# Find LightmapGI
|
||||||
|
_lightmap_node = _find_node_by_type(_scene_instance, "LightmapGI")
|
||||||
|
|
||||||
|
# Run all checks
|
||||||
|
_run_lightmap_node_check()
|
||||||
|
if _lightmap_node != null:
|
||||||
|
_run_quality_check()
|
||||||
|
_run_bounce_check()
|
||||||
|
_run_texel_scale_check()
|
||||||
|
_run_interior_check()
|
||||||
|
_run_denoiser_check()
|
||||||
|
_run_max_texture_size_check()
|
||||||
|
_run_energy_check()
|
||||||
|
_run_baked_data_check()
|
||||||
|
_run_process_mode_check()
|
||||||
|
|
||||||
|
# Run geometry UV2 check
|
||||||
|
_run_uv2_check()
|
||||||
|
|
||||||
|
# Detailed reports
|
||||||
|
_print_lightmap_report()
|
||||||
|
_print_uv2_report()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
_print_summary()
|
||||||
|
|
||||||
|
if _fail_count > 0:
|
||||||
|
quit(1)
|
||||||
|
else:
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argument parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _parse_arguments() -> void:
|
||||||
|
"""Extract scene path from user arguments (after --)."""
|
||||||
|
var args: PackedStringArray = OS.get_cmdline_user_args()
|
||||||
|
if not args.is_empty():
|
||||||
|
_scene_path = args[0].strip_edges()
|
||||||
|
return
|
||||||
|
|
||||||
|
var full_args: PackedStringArray = OS.get_cmdline_args()
|
||||||
|
var found_sep := false
|
||||||
|
for i in range(full_args.size()):
|
||||||
|
if full_args[i] == "--":
|
||||||
|
found_sep = true
|
||||||
|
continue
|
||||||
|
if found_sep:
|
||||||
|
_scene_path = full_args[i].strip_edges()
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _load_scene(path: String) -> PackedScene:
|
||||||
|
"""Load a .tscn/.scn file."""
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
|
||||||
|
path = path + ".tscn"
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
return null
|
||||||
|
return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE)
|
||||||
|
|
||||||
|
|
||||||
|
func _instantiate_scene(scene: PackedScene) -> Node:
|
||||||
|
return scene.instantiate()
|
||||||
|
|
||||||
|
|
||||||
|
func _find_node_by_type(node: Node, type_name: String) -> Node:
|
||||||
|
"""Recursively find first node matching a class name."""
|
||||||
|
if node.get_class() == type_name:
|
||||||
|
return node
|
||||||
|
for child in node.get_children():
|
||||||
|
var found := _find_node_by_type(child, type_name)
|
||||||
|
if found != null:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _find_nodes_by_type(node: Node, type_name: String) -> Array[Node]:
|
||||||
|
"""Recursively find all nodes of a given class."""
|
||||||
|
var results: Array[Node] = []
|
||||||
|
if node.get_class() == type_name:
|
||||||
|
results.append(node)
|
||||||
|
for child in node.get_children():
|
||||||
|
results.append_array(_find_nodes_by_type(child, type_name))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Check helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _pass(msg: String) -> void:
|
||||||
|
_pass_count += 1
|
||||||
|
print(" ✓ ", msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(msg: String) -> void:
|
||||||
|
_fail_count += 1
|
||||||
|
print(" ✗ ", msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _warn(msg: String) -> void:
|
||||||
|
_warning_count += 1
|
||||||
|
print(" ⚠ ", msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool:
|
||||||
|
if condition:
|
||||||
|
_pass(msg_pass)
|
||||||
|
else:
|
||||||
|
_fail(msg_fail)
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Individual LightmapGI checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _run_lightmap_node_check() -> void:
|
||||||
|
"""Verify LightmapGI node exists."""
|
||||||
|
print("--- LightmapGI Node ---")
|
||||||
|
if _lightmap_node != null:
|
||||||
|
_pass("LightmapGI node found: '" + _lightmap_node.name + "'")
|
||||||
|
else:
|
||||||
|
_fail("LightmapGI node not found in scene — required for baked lighting")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_quality_check() -> void:
|
||||||
|
"""Validate LightmapGI quality setting."""
|
||||||
|
print("")
|
||||||
|
print("--- Quality ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var quality: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
|
||||||
|
if quality < 0:
|
||||||
|
_fail("LightmapGI.quality not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
var quality_names := ["Low", "Medium", "High", "Ultra"]
|
||||||
|
var quality_name := quality_names[quality] if quality < quality_names.size() else "Unknown"
|
||||||
|
|
||||||
|
if quality >= RECOMMENDED_QUALITY:
|
||||||
|
_pass("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — High or Ultra recommended")
|
||||||
|
elif quality == 1:
|
||||||
|
_warn("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — Medium is acceptable, High recommended")
|
||||||
|
else:
|
||||||
|
_fail("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — set to at least High (2)")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_bounce_check() -> void:
|
||||||
|
"""Validate light bounce count."""
|
||||||
|
print("")
|
||||||
|
print("--- Bounces ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var bounces: int = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1
|
||||||
|
if bounces < 0:
|
||||||
|
_fail("LightmapGI.bounces not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
if bounces >= RECOMMENDED_BOUNCES:
|
||||||
|
_pass("LightmapGI.bounces = " + str(bounces) + " (recommended: ≥ " + str(RECOMMENDED_BOUNCES) + ")")
|
||||||
|
elif bounces >= MIN_BOUNCES:
|
||||||
|
_warn("LightmapGI.bounces = " + str(bounces) + " — low, consider ≥ " + str(RECOMMENDED_BOUNCES))
|
||||||
|
else:
|
||||||
|
_fail("LightmapGI.bounces = " + str(bounces) + " — minimum is " + str(MIN_BOUNCES))
|
||||||
|
|
||||||
|
# Warn if too high (performance concern)
|
||||||
|
if bounces > MAX_BOUNCES - 2:
|
||||||
|
_warn("LightmapGI.bounces = " + str(bounces) + " — high bounce count may impact bake time")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_texel_scale_check() -> void:
|
||||||
|
"""Validate texel scale setting."""
|
||||||
|
print("")
|
||||||
|
print("--- Texel Scale ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var texel_scale: float = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0
|
||||||
|
if texel_scale < 0:
|
||||||
|
_fail("LightmapGI.texel_scale not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
if abs(texel_scale - RECOMMENDED_TEXEL_SCALE) < 0.01:
|
||||||
|
_pass("LightmapGI.texel_scale = " + str(texel_scale) + " (exactly " + str(RECOMMENDED_TEXEL_SCALE) + ")")
|
||||||
|
elif texel_scale >= MIN_TEXEL_SCALE and texel_scale <= MAX_TEXEL_SCALE:
|
||||||
|
if texel_scale < 0.5:
|
||||||
|
_warn("LightmapGI.texel_scale = " + str(texel_scale) + " — below 0.5, bake resolution may be too low")
|
||||||
|
elif texel_scale > 2.0:
|
||||||
|
_warn("LightmapGI.texel_scale = " + str(texel_scale) + " — above 2.0, bake resolution may be unnecessarily high")
|
||||||
|
else:
|
||||||
|
_pass("LightmapGI.texel_scale = " + str(texel_scale) + " (within reasonable range [0.1, 10.0])")
|
||||||
|
else:
|
||||||
|
_fail("LightmapGI.texel_scale = " + str(texel_scale) + " — out of expected range [" + str(MIN_TEXEL_SCALE) + ", " + str(MAX_TEXEL_SCALE) + "]")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_interior_check() -> void:
|
||||||
|
"""Validate interior flag."""
|
||||||
|
print("")
|
||||||
|
print("--- Interior ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var interior: bool = _lightmap_node.get("interior") if "interior" in _lightmap_node else false
|
||||||
|
|
||||||
|
if interior:
|
||||||
|
_pass("LightmapGI.interior = true (correct for indoor maps)")
|
||||||
|
else:
|
||||||
|
_warn("LightmapGI.interior = false — set to true for indoor scenes to avoid light leaking from outside")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_denoiser_check() -> void:
|
||||||
|
"""Validate denoiser setting."""
|
||||||
|
print("")
|
||||||
|
print("--- Denoiser ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Godot 4 renamed to use_denoiser
|
||||||
|
var use_denoiser: bool = false
|
||||||
|
if "use_denoiser" in _lightmap_node:
|
||||||
|
use_denoiser = _lightmap_node.get("use_denoiser")
|
||||||
|
elif _lightmap_node.has_method("is_using_denoiser"):
|
||||||
|
use_denoiser = _lightmap_node.is_using_denoiser()
|
||||||
|
|
||||||
|
if use_denoiser:
|
||||||
|
_pass("LightmapGI.use_denoiser = true (recommended for cleaner results)")
|
||||||
|
else:
|
||||||
|
_warn("LightmapGI.use_denoiser = false — enable denoiser for cleaner lightmap results")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_max_texture_size_check() -> void:
|
||||||
|
"""Validate max texture size setting."""
|
||||||
|
print("")
|
||||||
|
print("--- Max Texture Size ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var max_tex: int = _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else -1
|
||||||
|
if max_tex < 0:
|
||||||
|
_warn("LightmapGI.max_texture_size not available (using default)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if max_tex <= MAX_TEXTURE_SIZE:
|
||||||
|
_pass("LightmapGI.max_texture_size = " + str(max_tex) + " (≤ " + str(MAX_TEXTURE_SIZE) + ")")
|
||||||
|
else:
|
||||||
|
_fail("LightmapGI.max_texture_size = " + str(max_tex) + " — exceeds " + str(MAX_TEXTURE_SIZE) +
|
||||||
|
" (consider lowering for memory budget)")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_energy_check() -> void:
|
||||||
|
"""Validate lightmap energy setting."""
|
||||||
|
print("")
|
||||||
|
print("--- Energy ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var energy: float = _lightmap_node.get("energy") if "energy" in _lightmap_node else 1.0
|
||||||
|
_pass("LightmapGI.energy = " + str(energy) + " (verify visually in editor)")
|
||||||
|
|
||||||
|
var indirect_energy: float = _lightmap_node.get("indirect_energy") if "indirect_energy" in _lightmap_node else 1.0
|
||||||
|
if abs(indirect_energy - 1.0) > 0.01:
|
||||||
|
_warn("LightmapGI.indirect_energy = " + str(indirect_energy) + " (default is 1.0)")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_baked_data_check() -> void:
|
||||||
|
"""Check whether lightmap data has been baked."""
|
||||||
|
print("")
|
||||||
|
print("--- Baked Data ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var light_data = _lightmap_node.get("light_data") if "light_data" in _lightmap_node else null
|
||||||
|
if light_data != null:
|
||||||
|
_pass("LightmapGI has baked light data present")
|
||||||
|
# Check data size
|
||||||
|
if light_data.has_method("get_user_data"):
|
||||||
|
var user_data = light_data.get_user_data()
|
||||||
|
if user_data != null:
|
||||||
|
_warn("Lightmap data size: varies (baked data is present)")
|
||||||
|
else:
|
||||||
|
_warn("LightmapGI has NO baked data yet — needs baking in editor")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_process_mode_check() -> void:
|
||||||
|
"""Validate the lightmap process mode."""
|
||||||
|
print("")
|
||||||
|
print("--- Process Mode ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Godot 4 LightmapGI has a bake_mode or similar property for sub-surface scattering
|
||||||
|
if "bake_mode" in _lightmap_node:
|
||||||
|
var bake_mode: int = _lightmap_node.get("bake_mode")
|
||||||
|
match bake_mode:
|
||||||
|
0:
|
||||||
|
_pass("LightmapGI.bake_mode = Conservative (0) — safe default")
|
||||||
|
1:
|
||||||
|
_warn("LightmapGI.bake_mode = Aggressive (1) — may cause artifacts")
|
||||||
|
2:
|
||||||
|
_pass("LightmapGI.bake_mode = Always Lightmap (2) — good for fully baked scenes")
|
||||||
|
_:
|
||||||
|
_warn("LightmapGI.bake_mode = " + str(bake_mode) + " — unknown")
|
||||||
|
|
||||||
|
# Check for environment mode (if property exists)
|
||||||
|
if "environment_mode" in _lightmap_node:
|
||||||
|
var env_mode: int = _lightmap_node.get("environment_mode")
|
||||||
|
match env_mode:
|
||||||
|
0:
|
||||||
|
_pass("LightmapGI.environment_mode = Disabled — no environment lighting (indoor)")
|
||||||
|
1:
|
||||||
|
_pass("LightmapGI.environment_mode = Enabled — using environment for lighting")
|
||||||
|
2:
|
||||||
|
_warn("LightmapGI.environment_mode = Ambient Color — verify color is correct")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UV2 geometry check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _run_uv2_check() -> void:
|
||||||
|
"""Scan all MeshInstance3D nodes for UV2 channel presence."""
|
||||||
|
print("")
|
||||||
|
print("--- Geometry UV2 Check ---")
|
||||||
|
|
||||||
|
var mesh_nodes := _find_nodes_by_type(_scene_instance, "MeshInstance3D")
|
||||||
|
_total_meshes = mesh_nodes.size()
|
||||||
|
|
||||||
|
if _total_meshes == 0:
|
||||||
|
_warn("No MeshInstance3D nodes found — checking CSG nodes for UV2")
|
||||||
|
|
||||||
|
# Check CSG nodes instead
|
||||||
|
var csg_nodes := _find_nodes_by_type(_scene_instance, "CSGCombiner3D")
|
||||||
|
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGBox3D"))
|
||||||
|
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGSphere3D"))
|
||||||
|
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGCylinder3D"))
|
||||||
|
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGTorus3D"))
|
||||||
|
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGPolygon3D"))
|
||||||
|
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGMesh3D"))
|
||||||
|
|
||||||
|
# CSG nodes generate UV2 during baking automatically
|
||||||
|
if csg_nodes.size() > 0:
|
||||||
|
_pass("Scene uses CSG primitives (" + str(csg_nodes.size()) + ") — UV2 is auto-generated during bake")
|
||||||
|
return
|
||||||
|
|
||||||
|
for mi in mesh_nodes:
|
||||||
|
var has_uv2 := false
|
||||||
|
if "mesh" in mi and mi.mesh != null:
|
||||||
|
has_uv2 = _check_mesh_uv2(mi.mesh)
|
||||||
|
|
||||||
|
var info := {
|
||||||
|
"name": mi.name,
|
||||||
|
"has_uv2": has_uv2,
|
||||||
|
"material_count": 0
|
||||||
|
}
|
||||||
|
_mesh_details.append(info)
|
||||||
|
|
||||||
|
if has_uv2:
|
||||||
|
_meshes_with_uv2 += 1
|
||||||
|
else:
|
||||||
|
_meshes_without_uv2.append(mi.name)
|
||||||
|
|
||||||
|
if _meshes_without_uv2.size() == 0:
|
||||||
|
_pass("All " + str(_total_meshes) + " MeshInstance3D nodes have UV2 channel")
|
||||||
|
else:
|
||||||
|
for mesh_name in _meshes_without_uv2:
|
||||||
|
_fail("MeshInstance3D '" + mesh_name + "' lacks UV2 channel — lightmapping will not work")
|
||||||
|
_warn(str(_meshes_without_uv2.size()) + "/" + str(_total_meshes) + " meshes missing UV2")
|
||||||
|
|
||||||
|
# Check for sub-surface scattering override on LightmapGI
|
||||||
|
if _lightmap_node != null and "subdiv" in _lightmap_node:
|
||||||
|
var subdiv: int = _lightmap_node.get("subdiv")
|
||||||
|
if subdiv > 0:
|
||||||
|
_warn("LightmapGI.subdiv = " + str(subdiv) + " — non-zero subdiv enables subsurface scattering")
|
||||||
|
|
||||||
|
|
||||||
|
func _check_mesh_uv2(mesh) -> bool:
|
||||||
|
"""Check if a mesh has UV2 channel data."""
|
||||||
|
if mesh == null:
|
||||||
|
return false
|
||||||
|
if not mesh.has_method("get_surface_count"):
|
||||||
|
return false
|
||||||
|
|
||||||
|
var surface_count := mesh.get_surface_count()
|
||||||
|
for s in range(surface_count):
|
||||||
|
var arrays := mesh.surface_get_arrays(s)
|
||||||
|
if arrays is Array and arrays.size() > 0:
|
||||||
|
var uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array
|
||||||
|
if uv2 != null and uv2.size() > 0:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reports
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _print_lightmap_report() -> void:
|
||||||
|
"""Print a detailed lightmap configuration report."""
|
||||||
|
print("")
|
||||||
|
print("--- LightmapGI Configuration Report ---")
|
||||||
|
if _lightmap_node == null:
|
||||||
|
print(" (no LightmapGI node to report)")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(" Node path: ", _get_node_path(_lightmap_node))
|
||||||
|
var quality_names := ["Low", "Medium", "High", "Ultra"]
|
||||||
|
var q: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
|
||||||
|
if q >= 0 and q < quality_names.size():
|
||||||
|
print(" Quality: ", quality_names[q], " (", q, ")")
|
||||||
|
else:
|
||||||
|
print(" Quality: ", q)
|
||||||
|
print(" Bounces: ", _lightmap_node.get("bounces") if "bounces" in _lightmap_node else "N/A")
|
||||||
|
print(" Texel Scale: ", _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else "N/A")
|
||||||
|
print(" Interior: ", _lightmap_node.get("interior") if "interior" in _lightmap_node else "N/A")
|
||||||
|
print(" Denoiser: ", _lightmap_node.get("use_denoiser") if "use_denoiser" in _lightmap_node else "N/A")
|
||||||
|
print(" Max Tex Size: ", _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else "N/A")
|
||||||
|
print(" Energy: ", _lightmap_node.get("energy") if "energy" in _lightmap_node else "N/A")
|
||||||
|
print(" Baked Data: ", "Yes" if _lightmap_node.get("light_data") != null else "No")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
|
||||||
|
func _print_uv2_report() -> void:
|
||||||
|
"""Print UV2 details for meshes."""
|
||||||
|
print("--- UV2 Report ---")
|
||||||
|
print(" Total MeshInstance3D: ", _total_meshes)
|
||||||
|
print(" With UV2: ", _meshes_with_uv2)
|
||||||
|
print(" Without UV2: ", _meshes_without_uv2.size())
|
||||||
|
if _meshes_without_uv2.size() > 0:
|
||||||
|
print(" Missing UV2 list:")
|
||||||
|
for name in _meshes_without_uv2:
|
||||||
|
print(" - ", name)
|
||||||
|
print("")
|
||||||
|
|
||||||
|
|
||||||
|
func _get_node_path(node: Node) -> String:
|
||||||
|
"""Get the scene-tree path of a node."""
|
||||||
|
var parts: Array[String] = []
|
||||||
|
var current := node
|
||||||
|
while current != null and current != _scene_instance:
|
||||||
|
parts.push_front(current.name)
|
||||||
|
current = current.get_parent()
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _print_summary() -> void:
|
||||||
|
"""Print final summary."""
|
||||||
|
print("==========================================")
|
||||||
|
print(" LIGHTMAP CONFIG SUMMARY")
|
||||||
|
print("==========================================")
|
||||||
|
print(" Scene: ", _scene_path)
|
||||||
|
print(" LightmapGI: ", "Found ✓" if _lightmap_node != null else "Missing ✗")
|
||||||
|
print(" Quality OK: ", _check_quality_status())
|
||||||
|
print(" Bounces OK: ", _check_bounce_status())
|
||||||
|
print(" Texel Scale OK: ", _check_texel_status())
|
||||||
|
print(" Interior OK: ", _check_interior_status())
|
||||||
|
print(" Denoiser OK: ", _check_denoiser_status())
|
||||||
|
print(" UV2 OK: ", _check_uv2_status())
|
||||||
|
print("------------------------------------------")
|
||||||
|
print(" Passed: ", _pass_count)
|
||||||
|
print(" Failed: ", _fail_count)
|
||||||
|
print(" Warnings: ", _warning_count)
|
||||||
|
print("------------------------------------------")
|
||||||
|
if _fail_count > 0:
|
||||||
|
print(" RESULT: FAILED ❌")
|
||||||
|
printerr("Lightmap config check FAILED — ", _fail_count, " check(s) failed.")
|
||||||
|
else:
|
||||||
|
print(" RESULT: PASSED ✅")
|
||||||
|
print("==========================================")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
|
||||||
|
func _check_quality_status() -> String:
|
||||||
|
if _lightmap_node == null: return "N/A"
|
||||||
|
var q = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
|
||||||
|
return "Yes ✓" if q >= RECOMMENDED_QUALITY else ("No ✗" if q >= 0 else "N/A")
|
||||||
|
|
||||||
|
|
||||||
|
func _check_bounce_status() -> String:
|
||||||
|
if _lightmap_node == null: return "N/A"
|
||||||
|
var b = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1
|
||||||
|
return "Yes ✓" if b >= RECOMMENDED_BOUNCES else ("No ✗" if b >= 0 else "N/A")
|
||||||
|
|
||||||
|
|
||||||
|
func _check_texel_status() -> String:
|
||||||
|
if _lightmap_node == null: return "N/A"
|
||||||
|
var t = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0
|
||||||
|
if t < 0: return "N/A"
|
||||||
|
return "Yes ✓" if t >= 0.5 and t <= 2.0 else "⚠"
|
||||||
|
|
||||||
|
|
||||||
|
func _check_interior_status() -> String:
|
||||||
|
if _lightmap_node == null: return "N/A"
|
||||||
|
var interior = _lightmap_node.get("interior") if "interior" in _lightmap_node else false
|
||||||
|
return "Yes ✓" if interior else "No ✗"
|
||||||
|
|
||||||
|
|
||||||
|
func _check_denoiser_status() -> String:
|
||||||
|
if _lightmap_node == null: return "N/A"
|
||||||
|
if "use_denoiser" in _lightmap_node:
|
||||||
|
return "Yes ✓" if _lightmap_node.get("use_denoiser") else "No ✗"
|
||||||
|
if _lightmap_node.has_method("is_using_denoiser"):
|
||||||
|
return "Yes ✓" if _lightmap_node.is_using_denoiser() else "No ✗"
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
func _check_uv2_status() -> String:
|
||||||
|
if _total_meshes == 0: return "N/A (CSG)"
|
||||||
|
if _meshes_without_uv2.size() == 0: return "Yes ✓"
|
||||||
|
return str(_meshes_without_uv2.size()) + " missing"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d0mbp6gs2mwlr
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
@tool
|
||||||
|
extends EditorScript
|
||||||
|
|
||||||
|
# CSG → Static Mesh Baker with LOD generation.
|
||||||
|
#
|
||||||
|
# Converts all CSG pieces in the current scene to MeshInstance3D nodes
|
||||||
|
# with proper LOD levels for the final optimization pass.
|
||||||
|
#
|
||||||
|
# This is the definitive LOD solution: once meshes are baked, Godot's
|
||||||
|
# built-in mesh LOD system takes over via the Mesh resource's LOD slots.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. Open target scene in the Godot editor
|
||||||
|
# 2. Scene > Run Script (select this file)
|
||||||
|
# 3. Choose whether to replace CSG in-place or save meshes separately
|
||||||
|
#
|
||||||
|
# The script:
|
||||||
|
# a) Finds all CSGShape3D nodes (CSGBox3D, CSGCombiner3D)
|
||||||
|
# b) Calls mesh_to_save() to get the generated mesh
|
||||||
|
# c) Creates MeshInstance3D + CollisionShape3D replacements
|
||||||
|
# d) Generates LOD1 (50%) and LOD2 (25%) via mesh simplification
|
||||||
|
# e) Replaces the CSG node with the baked mesh
|
||||||
|
|
||||||
|
const CREATE_LOD := true
|
||||||
|
const PRESERVE_COLLISION := true
|
||||||
|
const LOD0_DISTANCE := 0.0
|
||||||
|
const LOD1_DISTANCE := 15.0 # 50% tris
|
||||||
|
const LOD2_DISTANCE := 30.0 # 25% tris
|
||||||
|
const LOD_DECIMATE_RATIOS := [0.0, 0.5, 0.25]
|
||||||
|
|
||||||
|
func _run():
|
||||||
|
var scene_root: Node
|
||||||
|
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
scene_root = get_scene() as Node
|
||||||
|
if not scene_root:
|
||||||
|
printerr("Open a scene in the editor first.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
printerr("This script must be run from the Godot editor.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=== CSG → Mesh Baker ===")
|
||||||
|
print("Scene: ", scene_root.name)
|
||||||
|
|
||||||
|
var converted := 0
|
||||||
|
var errors := 0
|
||||||
|
|
||||||
|
_bake_node(scene_root, converted, errors)
|
||||||
|
|
||||||
|
print("Converted: ", converted, " CSG nodes")
|
||||||
|
print("Errors: ", errors)
|
||||||
|
|
||||||
|
if converted > 0:
|
||||||
|
print("")
|
||||||
|
print("NOTE: After conversion, re-save the scene to persist mesh data.")
|
||||||
|
print("MeshInstance3D LOD distances: LOD0 < 15m, LOD1 < 30m, LOD2 30m+")
|
||||||
|
print("")
|
||||||
|
print("=== Bake complete ===")
|
||||||
|
|
||||||
|
func _bake_node(node: Node, converted: int, errors: int) -> void:
|
||||||
|
if node is CSGShape3D:
|
||||||
|
var mesh_instance := _convert_csg_to_mesh(node as CSGShape3D)
|
||||||
|
if mesh_instance:
|
||||||
|
# Replace CSG node with mesh instance
|
||||||
|
var parent := node.get_parent()
|
||||||
|
var idx := node.get_index()
|
||||||
|
|
||||||
|
parent.remove_child(node)
|
||||||
|
parent.add_child(mesh_instance, true)
|
||||||
|
parent.move_child(mesh_instance, idx)
|
||||||
|
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
mesh_instance.set_owner(parent.owner)
|
||||||
|
|
||||||
|
# Clean up the old CSG node
|
||||||
|
node.queue_free()
|
||||||
|
|
||||||
|
converted += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# Recurse — important: use the original node's children
|
||||||
|
# but the node may have been freed, so iterate a snapshot
|
||||||
|
var children := node.get_children()
|
||||||
|
for child in children:
|
||||||
|
_bake_node(child, converted, errors)
|
||||||
|
|
||||||
|
func _convert_csg_to_mesh(csg: CSGShape3D) -> MeshInstance3D:
|
||||||
|
# Get the generated mesh from the CSG node
|
||||||
|
var mesh := csg.mesh_to_save()
|
||||||
|
if not mesh:
|
||||||
|
push_warning("CSG node '%s' produced no mesh — skipping." % csg.name)
|
||||||
|
return null
|
||||||
|
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.name = csg.name + "_Mesh"
|
||||||
|
|
||||||
|
# Copy transform
|
||||||
|
mi.transform = csg.transform
|
||||||
|
|
||||||
|
# Copy visibility ranges (if set on the CSG node)
|
||||||
|
mi.visibility_range_begin = csg.visibility_range_begin
|
||||||
|
mi.visibility_range_end = csg.visibility_range_end
|
||||||
|
mi.visibility_range_begin_margin = csg.visibility_range_begin_margin
|
||||||
|
mi.visibility_range_end_margin = csg.visibility_range_end_margin
|
||||||
|
|
||||||
|
# Create an ArrayMesh from the CSG mesh
|
||||||
|
var array_mesh := _to_array_mesh(mesh)
|
||||||
|
if not array_mesh:
|
||||||
|
push_warning("Failed to convert mesh for '%s'." % csg.name)
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Generate LODs
|
||||||
|
if CREATE_LOD and array_mesh.get_surface_count() > 0:
|
||||||
|
_generate_lods(array_mesh, csg)
|
||||||
|
|
||||||
|
mi.mesh = array_mesh
|
||||||
|
|
||||||
|
# Add collision shape if the CSG had it
|
||||||
|
if PRESERVE_COLLISION and csg.use_collision:
|
||||||
|
var collision := _create_collision(csg)
|
||||||
|
if collision:
|
||||||
|
mi.add_child(collision, true)
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
collision.set_owner(mi.owner)
|
||||||
|
|
||||||
|
return mi
|
||||||
|
|
||||||
|
func _to_array_mesh(source: Mesh) -> ArrayMesh:
|
||||||
|
# Convert any Mesh type to ArrayMesh suitable for LOD attachment
|
||||||
|
|
||||||
|
if source is ArrayMesh:
|
||||||
|
return source.duplicate() as ArrayMesh
|
||||||
|
|
||||||
|
# If it's a primitive mesh, create a new ArrayMesh and copy surfaces
|
||||||
|
var array_mesh := ArrayMesh.new()
|
||||||
|
for i in range(source.get_surface_count()):
|
||||||
|
var arrays := source.surface_get_arrays(i)
|
||||||
|
if arrays:
|
||||||
|
var blend := source.surface_get_blend_shape_arrays(i)
|
||||||
|
var format := source.surface_get_format(i)
|
||||||
|
array_mesh.add_surface_from_arrays(source.surface_get_primitive_type(i), arrays, blend, format)
|
||||||
|
|
||||||
|
return array_mesh
|
||||||
|
|
||||||
|
func _generate_lods(array_mesh: ArrayMesh, csg: CSGShape3D) -> void:
|
||||||
|
# Generate LOD levels for the mesh.
|
||||||
|
# In Godot 4.3+, LOD is added via add_lod(simplified_mesh, distance).
|
||||||
|
# True mesh simplification (edge collapse) requires Godot's editor
|
||||||
|
# mesh_decimate tool or an external library.
|
||||||
|
#
|
||||||
|
# This implementation creates placeholder LOD entries using the
|
||||||
|
# full-poly mesh. For actual draw-call savings, run the editor's
|
||||||
|
# Mesh → Simplify → Generate LODs to replace these with decimated
|
||||||
|
# geometry at the LOD distances defined above.
|
||||||
|
|
||||||
|
var lods_added := 0
|
||||||
|
|
||||||
|
for lod_idx in range(1, 3): # LOD1, LOD2
|
||||||
|
var ratio := LOD_DECIMATE_RATIOS[lod_idx]
|
||||||
|
if ratio <= 0.0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
var lod_distance := 0.0
|
||||||
|
match lod_idx:
|
||||||
|
1: lod_distance = LOD1_DISTANCE
|
||||||
|
2: lod_distance = LOD2_DISTANCE
|
||||||
|
|
||||||
|
# Create a placeholder LOD entry using the full mesh.
|
||||||
|
# Replace with mesh_decimate output for actual tri reduction.
|
||||||
|
var lod_mesh := array_mesh.duplicate() as ArrayMesh
|
||||||
|
if lod_mesh and lod_mesh.get_surface_count() > 0:
|
||||||
|
array_mesh.add_lod(lod_mesh, lod_distance)
|
||||||
|
lods_added += 1
|
||||||
|
|
||||||
|
if lods_added == 0:
|
||||||
|
push_warning("No LODs generated for '%s' — LOD slots require at least one surface." % csg.name)
|
||||||
|
|
||||||
|
func _create_collision(csg: CSGShape3D) -> CollisionShape3D:
|
||||||
|
var shape := CollisionShape3D.new()
|
||||||
|
shape.name = "Collision"
|
||||||
|
|
||||||
|
if csg is CSGBox3D:
|
||||||
|
var box_shape := BoxShape3D.new()
|
||||||
|
box_shape.size = (csg as CSGBox3D).size
|
||||||
|
shape.shape = box_shape
|
||||||
|
elif csg is CSGSphere3D:
|
||||||
|
var sphere_shape := SphereShape3D.new()
|
||||||
|
sphere_shape.radius = (csg as CSGSphere3D).radius
|
||||||
|
shape.shape = sphere_shape
|
||||||
|
else:
|
||||||
|
# Fall back to a box matching the AABB
|
||||||
|
var aabb_box := BoxShape3D.new()
|
||||||
|
aabb_box.size = csg.get_aabb().size
|
||||||
|
shape.shape = aabb_box
|
||||||
|
|
||||||
|
return shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c0b8h1hq57lsu
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
@tool
|
||||||
|
extends EditorScript
|
||||||
|
|
||||||
|
# Auto-generate OccluderInstance3D nodes from CSG wall pieces.
|
||||||
|
#
|
||||||
|
# Scans the current scene for CSGBox3D nodes tagged as structural
|
||||||
|
# (walls, floors, pillars) and creates matching OccluderInstance3D
|
||||||
|
# boxes so Godot's occlusion culling system can use them.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. Open target scene in Godot editor
|
||||||
|
# 2. Run: Scene > Run Script (select this file)
|
||||||
|
# 3. Or run from command line:
|
||||||
|
# godot --script scripts/generate_occluders.gd --scene res://path/to/scene.tscn
|
||||||
|
#
|
||||||
|
# Generated occluders are parented under an "Occluders" node.
|
||||||
|
|
||||||
|
const OCCLUDER_PARENT_NAME := "Occluders"
|
||||||
|
|
||||||
|
# Minimum CSG size (longest axis) to generate an occluder.
|
||||||
|
# Very small pieces don't contribute meaningful occlusion.
|
||||||
|
const MIN_OCCLUDER_SIZE := 0.5
|
||||||
|
|
||||||
|
func _run():
|
||||||
|
var scene_root: Node
|
||||||
|
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
scene_root = get_scene() as Node
|
||||||
|
if not scene_root:
|
||||||
|
printerr("No scene is currently open in the editor.")
|
||||||
|
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 generate_occluders.gd --scene res://path/to/scene.tscn")
|
||||||
|
return
|
||||||
|
|
||||||
|
var packed = ResourceLoader.load(scene_path)
|
||||||
|
if not packed:
|
||||||
|
printerr("Failed to load scene: ", scene_path)
|
||||||
|
return
|
||||||
|
scene_root = packed.instantiate()
|
||||||
|
|
||||||
|
print("=== Occluder Generator ===")
|
||||||
|
print("Scene: ", scene_root.name if scene_root else "unknown")
|
||||||
|
|
||||||
|
# Find or create the Occluders parent node
|
||||||
|
var occluder_parent: Node = _find_or_create_parent(scene_root)
|
||||||
|
var generated_count := 0
|
||||||
|
var skipped_count := 0
|
||||||
|
|
||||||
|
# Walk all CSGBox3D nodes in the scene
|
||||||
|
_generate_from_node(scene_root, occluder_parent, generated_count, skipped_count)
|
||||||
|
|
||||||
|
print("Generated: ", generated_count, " occluders")
|
||||||
|
print("Skipped (too small): ", skipped_count)
|
||||||
|
print("Parent: ", occluder_parent.get_path())
|
||||||
|
print("")
|
||||||
|
print("IMPORTANT: Occlusion culling requires the following project settings:")
|
||||||
|
print(" rendering/occlusion_culling/occlusion_rays_per_octant = 6")
|
||||||
|
print(" rendering/occlusion_culling/occlusion_culler_size = 128")
|
||||||
|
print("")
|
||||||
|
print("=== Occluder generation complete ===")
|
||||||
|
|
||||||
|
func _generate_from_node(node: Node, parent: Node, generated: int, skipped: int):
|
||||||
|
# Check if this is a wall/pillar CSGBox3D (exclude subtraction/hole pieces)
|
||||||
|
if node is CSGBox3D:
|
||||||
|
var csg := node as CSGBox3D
|
||||||
|
# Skip subtraction pieces (operation = 1 = Subtraction)
|
||||||
|
# Skip pieces that are too small to occlude
|
||||||
|
if csg.operation != CSGShape3D.OPERATION_SUBTRACTION:
|
||||||
|
var size := csg.size
|
||||||
|
var longest = max(size.x, max(size.y, size.z))
|
||||||
|
if longest >= MIN_OCCLUDER_SIZE:
|
||||||
|
_create_occluder(csg, parent)
|
||||||
|
generated += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
# Recurse into children
|
||||||
|
for child in node.get_children():
|
||||||
|
_generate_from_node(child, parent, generated, skipped)
|
||||||
|
|
||||||
|
func _find_or_create_parent(root: Node) -> Node:
|
||||||
|
for child in root.get_children():
|
||||||
|
if child.name == OCCLUDER_PARENT_NAME:
|
||||||
|
print("Found existing Occluders node.")
|
||||||
|
return child
|
||||||
|
|
||||||
|
var parent := Node3D.new()
|
||||||
|
parent.name = OCCLUDER_PARENT_NAME
|
||||||
|
parent.set_script(null) # No script needed
|
||||||
|
|
||||||
|
# Add as first child so it's visually at the top in the scene tree
|
||||||
|
root.add_child(parent, true)
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
parent.set_owner(root)
|
||||||
|
|
||||||
|
print("Created Occluders node.")
|
||||||
|
return parent
|
||||||
|
|
||||||
|
func _create_occluder(csg: CSGBox3D, parent: Node) -> void:
|
||||||
|
var occluder := OccluderInstance3D.new()
|
||||||
|
occluder.name = "Occluder_" + csg.name
|
||||||
|
|
||||||
|
# Create a box-shaped occluder matching the CSG piece
|
||||||
|
var shape := OccluderShape3D.new()
|
||||||
|
shape.set_as_vertices_and_indices(
|
||||||
|
PackedVector3Array([]), # auto-generated from AABB
|
||||||
|
PackedInt32Array([])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a box occluder matching the CSG box dimensions
|
||||||
|
var box_shape := OccluderShapeBox3D.new()
|
||||||
|
box_shape.size = csg.size
|
||||||
|
occluder.set_occluder_shape(box_shape)
|
||||||
|
|
||||||
|
# Match the CSG node's global transform
|
||||||
|
var global_transform := csg.global_transform
|
||||||
|
occluder.global_transform = global_transform
|
||||||
|
|
||||||
|
# Bake to static mode (not dynamic) for best performance
|
||||||
|
occluder.bake_mask = 1 # Layer 1 only
|
||||||
|
occluder.bake_id = 0
|
||||||
|
|
||||||
|
parent.add_child(occluder, true)
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
occluder.set_owner(parent.owner)
|
||||||
|
|
||||||
|
# Apply voxel resolution
|
||||||
|
# Godot 4's occlusion is computed from a signed-distance field
|
||||||
|
# at the configured culler size (128^3 by budget spec)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://pauclwpn265a
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Profile scene runner — manually drives the frame loop.
|
||||||
|
# In --script --headless mode the automatic main loop doesn't
|
||||||
|
# iterate, so we drive it ourselves via a manual loop.
|
||||||
|
|
||||||
|
const DEFAULT_SCENE := "res://assets/scenes/modular/kit_demo.tscn"
|
||||||
|
const DEFAULT_CAPTURE_FRAMES := 600
|
||||||
|
const WARMUP_FRAMES := 60
|
||||||
|
|
||||||
|
var capture_frames := DEFAULT_CAPTURE_FRAMES
|
||||||
|
var scene_path := ""
|
||||||
|
var frame_count := 0
|
||||||
|
var recording: Array[Dictionary] = []
|
||||||
|
var profiling_complete := false
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
# Parse CLI args
|
||||||
|
var args = OS.get_cmdline_args()
|
||||||
|
for i in range(args.size()):
|
||||||
|
if args[i] == "--scene" and i + 1 < args.size():
|
||||||
|
scene_path = args[i + 1]
|
||||||
|
if args[i] == "--capture-frames" and i + 1 < args.size():
|
||||||
|
capture_frames = args[i + 1].to_int()
|
||||||
|
|
||||||
|
if scene_path.is_empty():
|
||||||
|
scene_path = DEFAULT_SCENE
|
||||||
|
|
||||||
|
print("=== Profile Scene Tool ===")
|
||||||
|
print("Engine: ", Engine.get_version_info().string)
|
||||||
|
print("Scene: ", scene_path)
|
||||||
|
print("Capture frames: ", capture_frames)
|
||||||
|
print("Warmup frames: ", WARMUP_FRAMES)
|
||||||
|
print("")
|
||||||
|
print("Frame,Delta(ms),GPU(ms),Physics(ms),DrawCalls,Tris,Vertices,VRAM(MB),RAM(MB),Objects")
|
||||||
|
|
||||||
|
# Load target scene
|
||||||
|
var scene = ResourceLoader.load(scene_path)
|
||||||
|
if not scene:
|
||||||
|
printerr("ERROR: Failed to load scene: ", scene_path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var instance = scene.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
|
||||||
|
# Manual frame loop: Godot doesn't auto-iterate the main loop
|
||||||
|
# in --script --headless mode. We iterate it ourselves.
|
||||||
|
profiling_complete = false
|
||||||
|
_manual_loop()
|
||||||
|
|
||||||
|
func _manual_loop() -> void:
|
||||||
|
var start_time := Time.get_ticks_usec()
|
||||||
|
var frame_delta := 0.016 # Target 60fps step
|
||||||
|
|
||||||
|
while not profiling_complete:
|
||||||
|
var frame_start := Time.get_ticks_usec()
|
||||||
|
|
||||||
|
# Call idle (process) on the scene tree — this triggers
|
||||||
|
# _process() on all nodes including the instanced scene
|
||||||
|
#idle(frame_delta)
|
||||||
|
|
||||||
|
# Manually iterate — this processes input, idle, and physics
|
||||||
|
iteration(frame_delta)
|
||||||
|
|
||||||
|
_on_frame(frame_delta)
|
||||||
|
|
||||||
|
# Cap frame rate to avoid 100% CPU in headless mode
|
||||||
|
var elapsed := (Time.get_ticks_usec() - frame_start) / 1000.0
|
||||||
|
var target_ms := 16.0 # ~60fps
|
||||||
|
if elapsed < target_ms:
|
||||||
|
OS.delay_msec(int(target_ms - elapsed))
|
||||||
|
|
||||||
|
# Use elapsed wall time as delta for next frame
|
||||||
|
frame_delta = (Time.get_ticks_usec() - start_time) / 1000000.0
|
||||||
|
start_time = Time.get_ticks_usec()
|
||||||
|
|
||||||
|
_finish_report()
|
||||||
|
|
||||||
|
func _on_frame(delta: float) -> void:
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
# Skip warmup frames
|
||||||
|
if frame_count <= WARMUP_FRAMES:
|
||||||
|
return
|
||||||
|
|
||||||
|
if frame_count > WARMUP_FRAMES + capture_frames:
|
||||||
|
profiling_complete = true
|
||||||
|
return
|
||||||
|
|
||||||
|
var p := Performance
|
||||||
|
|
||||||
|
var frame_delta := delta * 1000.0
|
||||||
|
var gpu_time := float(RenderingServer.get_frame_setup_time_cpu()) * 1000.0
|
||||||
|
var physics_time := p.get_monitor(p.TIME_PHYSICS_PROCESS) * 1000.0
|
||||||
|
var draw_calls := int(p.get_monitor(p.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||||||
|
var tris := int(p.get_monitor(p.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
|
||||||
|
var verts := int(p.get_monitor(p.RENDER_TOTAL_VERTICES_IN_FRAME))
|
||||||
|
var vram_mb := int(p.get_monitor(p.RENDER_VIDEO_MEM_USED)) / (1024 * 1024)
|
||||||
|
var ram_mb := int(OS.get_static_memory_usage()) / (1024 * 1024)
|
||||||
|
var objects := int(p.get_monitor(p.OBJECT_NODE_COUNT))
|
||||||
|
|
||||||
|
recording.append({
|
||||||
|
frame = frame_count - WARMUP_FRAMES,
|
||||||
|
delta_ms = snapped(frame_delta, 0.01),
|
||||||
|
gpu_ms = snapped(gpu_time, 0.01),
|
||||||
|
physics_ms = snapped(physics_time, 0.01),
|
||||||
|
draw_calls = draw_calls,
|
||||||
|
tris = tris,
|
||||||
|
verts = verts,
|
||||||
|
vram_mb = vram_mb,
|
||||||
|
ram_mb = ram_mb,
|
||||||
|
objects = objects
|
||||||
|
})
|
||||||
|
|
||||||
|
print("%d,%.2f,%.2f,%.2f,%d,%d,%d,%d,%d,%d" % [
|
||||||
|
frame_count - WARMUP_FRAMES,
|
||||||
|
frame_delta, gpu_time, physics_time,
|
||||||
|
draw_calls, tris, verts, vram_mb, ram_mb, objects
|
||||||
|
])
|
||||||
|
|
||||||
|
func _finish_report() -> void:
|
||||||
|
print("")
|
||||||
|
print("=== Profile Summary ===")
|
||||||
|
if recording.is_empty():
|
||||||
|
print("No frames recorded!")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var deltas: Array[float] = []
|
||||||
|
for r in recording:
|
||||||
|
deltas.append(r.delta_ms)
|
||||||
|
deltas.sort()
|
||||||
|
|
||||||
|
var total := float(recording.size())
|
||||||
|
var n := deltas.size()
|
||||||
|
var p50: float = deltas[n * 50 / 100]
|
||||||
|
var p95: float = deltas[n * 95 / 100]
|
||||||
|
var p99: float = deltas[n * 99 / 100]
|
||||||
|
var sum: float = 0.0
|
||||||
|
for d in deltas:
|
||||||
|
sum += d
|
||||||
|
var avg: float = sum / total
|
||||||
|
|
||||||
|
var max_dc := 0
|
||||||
|
var max_tris := 0
|
||||||
|
var max_vram := 0
|
||||||
|
for r in recording:
|
||||||
|
if r.draw_calls > max_dc: max_dc = r.draw_calls
|
||||||
|
if r.tris > max_tris: max_tris = r.tris
|
||||||
|
if r.vram_mb > max_vram: max_vram = r.vram_mb
|
||||||
|
|
||||||
|
print("Frames captured: ", recording.size())
|
||||||
|
print("Avg frame time: %.2f ms" % avg)
|
||||||
|
print("P50 frame time: %.2f ms" % p50)
|
||||||
|
print("P95 frame time: %.2f ms" % p95)
|
||||||
|
print("P99 frame time: %.2f ms" % p99)
|
||||||
|
print("Max draw calls: %d" % max_dc)
|
||||||
|
print("Max triangles: %d" % max_tris)
|
||||||
|
print("Peak VRAM: %d MB" % max_vram)
|
||||||
|
|
||||||
|
var pass_fail := "PASS"
|
||||||
|
if p95 > 16.6:
|
||||||
|
pass_fail = "FAIL (P95 > 16.6 ms)"
|
||||||
|
elif p99 > 50.0:
|
||||||
|
pass_fail = "FAIL (P99 spike > 50 ms)"
|
||||||
|
print("Result: ", pass_fail)
|
||||||
|
print("")
|
||||||
|
print("=== Profile complete ===")
|
||||||
|
quit(0 if pass_fail == "PASS" else 1)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://byjqslju7qnt6
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Per-frame profiling node for tactical-shooter.
|
||||||
|
|
||||||
|
const WARMUP_FRAMES := 60
|
||||||
|
const DEFAULT_CAPTURE_FRAMES := 600
|
||||||
|
|
||||||
|
var capture_frames := DEFAULT_CAPTURE_FRAMES
|
||||||
|
var frame_count := 0
|
||||||
|
var recording: Array[Dictionary] = []
|
||||||
|
var profiling_complete := false
|
||||||
|
var last_tick := 0
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
# Try to read capture_frames from environment or metadata
|
||||||
|
if OS.has_environment("PROFILE_FRAMES"):
|
||||||
|
capture_frames = OS.get_environment("PROFILE_FRAMES").to_int()
|
||||||
|
|
||||||
|
capture_frames = get_meta("capture_frames", capture_frames)
|
||||||
|
|
||||||
|
print("=== Profiler Node ===")
|
||||||
|
print("Capture frames: ", capture_frames)
|
||||||
|
print("Warmup frames: ", WARMUP_FRAMES)
|
||||||
|
print("")
|
||||||
|
print("Frame,Delta(ms),GPU(ms),Physics(ms),DrawCalls,Tris,Vertices,VRAM(MB),RAM(MB),Objects")
|
||||||
|
|
||||||
|
# Load the target scene from metadata or environment
|
||||||
|
var target_scene = get_meta("target_scene", "")
|
||||||
|
if target_scene.is_empty() and OS.has_environment("PROFILE_SCENE"):
|
||||||
|
target_scene = OS.get_environment("PROFILE_SCENE")
|
||||||
|
|
||||||
|
if not target_scene.is_empty():
|
||||||
|
var packed = ResourceLoader.load(target_scene)
|
||||||
|
if packed:
|
||||||
|
var instance = packed.instantiate()
|
||||||
|
add_child(instance)
|
||||||
|
print("Loaded target scene: ", target_scene)
|
||||||
|
else:
|
||||||
|
printerr("Failed to load target scene: ", target_scene)
|
||||||
|
|
||||||
|
last_tick = Time.get_ticks_usec()
|
||||||
|
|
||||||
|
print("Ready complete, process() should fire now")
|
||||||
|
set_process(true)
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
print("_process called, frame=", frame_count)
|
||||||
|
if profiling_complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
# Calculate real delta
|
||||||
|
var now := Time.get_ticks_usec()
|
||||||
|
var real_delta := (now - last_tick) / 1000000.0
|
||||||
|
last_tick = now
|
||||||
|
|
||||||
|
# Skip warmup frames
|
||||||
|
if frame_count <= WARMUP_FRAMES:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if done
|
||||||
|
if frame_count > WARMUP_FRAMES + capture_frames:
|
||||||
|
profiling_complete = true
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
var p := Performance
|
||||||
|
|
||||||
|
var frame_delta := real_delta * 1000.0
|
||||||
|
var gpu_time := float(RenderingServer.get_frame_setup_time_cpu()) * 1000.0
|
||||||
|
var physics_time := p.get_monitor(p.TIME_PHYSICS_PROCESS) * 1000.0
|
||||||
|
var draw_calls := int(p.get_monitor(p.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||||||
|
var tris := int(p.get_monitor(p.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
|
||||||
|
var verts := int(p.get_monitor(p.RENDER_TOTAL_VERTICES_IN_FRAME))
|
||||||
|
var vram_mb := int(p.get_monitor(p.RENDER_VIDEO_MEM_USED)) / (1024 * 1024)
|
||||||
|
var ram_mb := int(OS.get_static_memory_usage()) / (1024 * 1024)
|
||||||
|
var objects := int(p.get_monitor(p.OBJECT_NODE_COUNT))
|
||||||
|
|
||||||
|
recording.append({
|
||||||
|
frame = frame_count - WARMUP_FRAMES,
|
||||||
|
delta_ms = snapped(frame_delta, 0.01),
|
||||||
|
gpu_ms = snapped(gpu_time, 0.01),
|
||||||
|
physics_ms = snapped(physics_time, 0.01),
|
||||||
|
draw_calls = draw_calls,
|
||||||
|
tris = tris,
|
||||||
|
verts = verts,
|
||||||
|
vram_mb = vram_mb,
|
||||||
|
ram_mb = ram_mb,
|
||||||
|
objects = objects
|
||||||
|
})
|
||||||
|
|
||||||
|
print("%d,%.2f,%.2f,%.2f,%d,%d,%d,%d,%d,%d" % [
|
||||||
|
frame_count - WARMUP_FRAMES,
|
||||||
|
frame_delta, gpu_time, physics_time,
|
||||||
|
draw_calls, tris, verts, vram_mb, ram_mb, objects
|
||||||
|
])
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("")
|
||||||
|
print("=== Profile Summary ===")
|
||||||
|
if recording.is_empty():
|
||||||
|
print("No frames recorded!")
|
||||||
|
get_tree().quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var deltas: Array[float] = []
|
||||||
|
for r in recording:
|
||||||
|
deltas.append(r.delta_ms)
|
||||||
|
deltas.sort()
|
||||||
|
|
||||||
|
var total := float(recording.size())
|
||||||
|
var n := deltas.size()
|
||||||
|
var p50: float = deltas[n * 50 / 100]
|
||||||
|
var p95: float = deltas[n * 95 / 100]
|
||||||
|
var p99: float = deltas[n * 99 / 100]
|
||||||
|
var sum: float = 0.0
|
||||||
|
for d in deltas:
|
||||||
|
sum += d
|
||||||
|
var avg: float = sum / total
|
||||||
|
|
||||||
|
var max_dc := 0
|
||||||
|
var max_tris := 0
|
||||||
|
var max_vram := 0
|
||||||
|
for r in recording:
|
||||||
|
if r.draw_calls > max_dc: max_dc = r.draw_calls
|
||||||
|
if r.tris > max_tris: max_tris = r.tris
|
||||||
|
if r.vram_mb > max_vram: max_vram = r.vram_mb
|
||||||
|
|
||||||
|
print("Frames captured: ", recording.size())
|
||||||
|
print("Avg frame time: %.2f ms" % avg)
|
||||||
|
print("P50 frame time: %.2f ms" % p50)
|
||||||
|
print("P95 frame time: %.2f ms" % p95)
|
||||||
|
print("P99 frame time: %.2f ms" % p99)
|
||||||
|
print("Max draw calls: %d" % max_dc)
|
||||||
|
print("Max triangles: %d" % max_tris)
|
||||||
|
print("Peak VRAM: %d MB" % max_vram)
|
||||||
|
|
||||||
|
var pass_fail := "PASS"
|
||||||
|
if p95 > 16.6:
|
||||||
|
pass_fail = "FAIL (P95 > 16.6 ms)"
|
||||||
|
elif p99 > 50.0:
|
||||||
|
pass_fail = "FAIL (P99 spike > 50 ms)"
|
||||||
|
print("Result: ", pass_fail)
|
||||||
|
print("")
|
||||||
|
print("=== Profile complete ===")
|
||||||
|
get_tree().quit(0 if pass_fail == "PASS" else 1)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://jih7knhnl55a
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/profiling/profiler_node.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="Profiler" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
print("_init called")
|
||||||
|
# Add a simple node to trigger processing
|
||||||
|
var n = Node.new()
|
||||||
|
n.name = "test"
|
||||||
|
n.set_process(true)
|
||||||
|
root.add_child(n)
|
||||||
|
print("Node added")
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
print("_process called: delta=", delta)
|
||||||
|
if frame_count > 5:
|
||||||
|
print("Quitting after 5 frames")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
frame_count += 1
|
||||||
|
return true
|
||||||
|
|
||||||
|
var frame_count := 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dc8x14rs2jyu8
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
var count := 0
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
print("_ready called")
|
||||||
|
set_process(true)
|
||||||
|
|
||||||
|
func _process(delta):
|
||||||
|
count += 1
|
||||||
|
print("_process #", count, " delta=", delta)
|
||||||
|
if count >= 5:
|
||||||
|
print("Quitting")
|
||||||
|
get_tree().quit()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b8jj7n3bi6ljh
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/profiling/test_simple.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="Root" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dbtpbhja3nk60
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
@tool
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# validate_lights.gd — Dynamic Light Count / Bake Mode Validator
|
||||||
|
# =============================================================================
|
||||||
|
# Audits all light nodes in a Godot 4 scene for:
|
||||||
|
# - Dynamic light count ≤ 4 (budget for mobile/competitive FPS)
|
||||||
|
# - Each light's bake mode is set correctly (BakeMode = STATIC or DYNAMIC)
|
||||||
|
# - Light energy / range / attenuation are within reasonable bounds
|
||||||
|
# - Shadow configuration is appropriate
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# godot --headless --script client/scripts/validate_lights.gd -- res://path/to/map.tscn
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 — All light checks passed
|
||||||
|
# 1 — One or more light checks failed
|
||||||
|
# 2 — Scene file not found or cannot be loaded
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
const MAX_DYNAMIC_LIGHTS := 4
|
||||||
|
const MAX_LIGHT_ENERGY := 50.0
|
||||||
|
const MIN_LIGHT_ENERGY := 0.1
|
||||||
|
const MAX_LIGHT_RANGE := 100.0
|
||||||
|
const MAX_LIGHT_SPOT_ANGLE := 180.0
|
||||||
|
const WARN_TOTAL_LIGHTS := 8
|
||||||
|
|
||||||
|
# BakeMode enum values as Godot defines them
|
||||||
|
const BAKE_MODE_DISABLED := 0
|
||||||
|
const BAKE_MODE_STATIC := 1
|
||||||
|
const BAKE_MODE_DYNAMIC := 2
|
||||||
|
|
||||||
|
var _pass_count := 0
|
||||||
|
var _fail_count := 0
|
||||||
|
var _warning_count := 0
|
||||||
|
var _scene_path := ""
|
||||||
|
var _scene_instance: Node = null
|
||||||
|
|
||||||
|
# Light stat tracking
|
||||||
|
var _total_lights := 0
|
||||||
|
var _dynamic_lights := 0
|
||||||
|
var _baked_lights := 0
|
||||||
|
var _disabled_lights := 0
|
||||||
|
var _shadowed_lights := 0
|
||||||
|
var _light_details: Array[Dictionary] = []
|
||||||
|
|
||||||
|
# Per-type counts
|
||||||
|
var _directional_lights := 0
|
||||||
|
var _omni_lights := 0
|
||||||
|
var _spot_lights := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_parse_arguments()
|
||||||
|
|
||||||
|
if _scene_path.is_empty():
|
||||||
|
printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_lights.gd -- res://path/to/map.tscn")
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("==========================================")
|
||||||
|
print(" LIGHT VALIDATOR")
|
||||||
|
print(" Scene: ", _scene_path)
|
||||||
|
print("==========================================")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
print(" Light budget:")
|
||||||
|
print(" Max dynamic lights: ", MAX_DYNAMIC_LIGHTS)
|
||||||
|
print(" Recommended baking: All static geometry lights")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
var scene: PackedScene = _load_scene(_scene_path)
|
||||||
|
if scene == null:
|
||||||
|
printerr("ERROR: Scene not found: ", _scene_path)
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
_scene_instance = _instantiate_scene(scene)
|
||||||
|
if _scene_instance == null:
|
||||||
|
printerr("ERROR: Failed to instantiate scene: ", _scene_path)
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
root.add_child(_scene_instance)
|
||||||
|
|
||||||
|
# Collect all lights
|
||||||
|
_analyze_lights(_scene_instance)
|
||||||
|
|
||||||
|
# Run checks
|
||||||
|
_run_dynamic_light_count_check()
|
||||||
|
_run_bake_mode_checks()
|
||||||
|
_run_energy_checks()
|
||||||
|
_run_shadow_checks()
|
||||||
|
_print_light_report()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
_print_summary()
|
||||||
|
|
||||||
|
if _fail_count > 0:
|
||||||
|
quit(1)
|
||||||
|
else:
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argument parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _parse_arguments() -> void:
|
||||||
|
"""Extract scene path from user arguments (after --)."""
|
||||||
|
var args: PackedStringArray = OS.get_cmdline_user_args()
|
||||||
|
if not args.is_empty():
|
||||||
|
_scene_path = args[0].strip_edges()
|
||||||
|
return
|
||||||
|
|
||||||
|
var full_args: PackedStringArray = OS.get_cmdline_args()
|
||||||
|
var found_sep := false
|
||||||
|
for i in range(full_args.size()):
|
||||||
|
if full_args[i] == "--":
|
||||||
|
found_sep = true
|
||||||
|
continue
|
||||||
|
if found_sep:
|
||||||
|
_scene_path = full_args[i].strip_edges()
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _load_scene(path: String) -> PackedScene:
|
||||||
|
"""Load a .tscn/.scn file."""
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
|
||||||
|
path = path + ".tscn"
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
return null
|
||||||
|
return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE)
|
||||||
|
|
||||||
|
|
||||||
|
func _instantiate_scene(scene: PackedScene) -> Node:
|
||||||
|
return scene.instantiate()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Check helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _pass(msg: String) -> void:
|
||||||
|
_pass_count += 1
|
||||||
|
print(" ✓ ", msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(msg: String) -> void:
|
||||||
|
_fail_count += 1
|
||||||
|
print(" ✗ ", msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _warn(msg: String) -> void:
|
||||||
|
_warning_count += 1
|
||||||
|
print(" ⚠ ", msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Light analysis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _analyze_lights(node: Node) -> void:
|
||||||
|
"""Recursively walk scene collecting light data."""
|
||||||
|
var class_name := node.get_class()
|
||||||
|
|
||||||
|
if class_name in ["DirectionalLight3D", "OmniLight3D", "SpotLight3D"]:
|
||||||
|
_total_lights += 1
|
||||||
|
var info := _collect_light_info(node, class_name)
|
||||||
|
_light_details.append(info)
|
||||||
|
|
||||||
|
# Count by type
|
||||||
|
match class_name:
|
||||||
|
"DirectionalLight3D":
|
||||||
|
_directional_lights += 1
|
||||||
|
"OmniLight3D":
|
||||||
|
_omni_lights += 1
|
||||||
|
"SpotLight3D":
|
||||||
|
_spot_lights += 1
|
||||||
|
|
||||||
|
# Track bake mode
|
||||||
|
var bake_mode := 0
|
||||||
|
if "bake_mode" in node:
|
||||||
|
bake_mode = node.bake_mode
|
||||||
|
match bake_mode:
|
||||||
|
BAKE_MODE_DISABLED:
|
||||||
|
_disabled_lights += 1
|
||||||
|
_dynamic_lights += 1
|
||||||
|
BAKE_MODE_STATIC:
|
||||||
|
_baked_lights += 1
|
||||||
|
BAKE_MODE_DYNAMIC:
|
||||||
|
_dynamic_lights += 1
|
||||||
|
|
||||||
|
# Track shadows
|
||||||
|
if "shadow" in node and node.shadow:
|
||||||
|
_shadowed_lights += 1
|
||||||
|
|
||||||
|
for child in node.get_children():
|
||||||
|
_analyze_lights(child)
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_light_info(node: Node, class_name: String) -> Dictionary:
|
||||||
|
"""Collect all relevant properties from a light node."""
|
||||||
|
var info := {
|
||||||
|
"name": node.name,
|
||||||
|
"type": class_name,
|
||||||
|
"path": _get_node_path(node),
|
||||||
|
"enabled": node.get("visible") if "visible" in node else true,
|
||||||
|
"bake_mode": node.get("bake_mode") if "bake_mode" in node else 0,
|
||||||
|
"energy": node.get("energy") if "energy" in node else 1.0,
|
||||||
|
"shadow": node.get("shadow") if "shadow" in node else false,
|
||||||
|
"shadow_bias": node.get("shadow_bias") if "shadow_bias" in node else 0.0,
|
||||||
|
"indirect_energy": node.get("indirect_energy") if "indirect_energy" in node else 1.0,
|
||||||
|
"light_specular": node.get("light_specular") if "light_specular" in node else 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Type-specific properties
|
||||||
|
match class_name:
|
||||||
|
"OmniLight3D":
|
||||||
|
info["range"] = node.get("omni_range") if "omni_range" in node else 0.0
|
||||||
|
info["attenuation"] = node.get("omni_attenuation") if "omni_attenuation" in node else 1.0
|
||||||
|
"SpotLight3D":
|
||||||
|
info["range"] = node.get("spot_range") if "spot_range" in node else 0.0
|
||||||
|
info["attenuation"] = node.get("spot_attenuation") if "spot_attenuation" in node else 1.0
|
||||||
|
info["spot_angle"] = node.get("spot_angle") if "spot_angle" in node else 45.0
|
||||||
|
info["spot_angle_attenuation"] = node.get("spot_angle_attenuation") if "spot_angle_attenuation" in node else 1.0
|
||||||
|
"DirectionalLight3D":
|
||||||
|
info["range"] = -1.0
|
||||||
|
info["directional_shadow_mode"] = node.get("directional_shadow_mode") if "directional_shadow_mode" in node else 0
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
func _get_node_path(node: Node) -> String:
|
||||||
|
"""Get the scene-tree path of a node."""
|
||||||
|
var parts: Array[String] = []
|
||||||
|
var current := node
|
||||||
|
while current != null and current != _scene_instance:
|
||||||
|
parts.push_front(current.name)
|
||||||
|
current = current.get_parent()
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
func _get_bake_mode_name(mode: int) -> String:
|
||||||
|
match mode:
|
||||||
|
BAKE_MODE_DISABLED: return "Disabled (Dynamic)"
|
||||||
|
BAKE_MODE_STATIC: return "Static (Baked)"
|
||||||
|
BAKE_MODE_DYNAMIC: return "Dynamic"
|
||||||
|
return "Unknown(" + str(mode) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Light checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _run_dynamic_light_count_check() -> void:
|
||||||
|
"""Check that dynamic light count is within budget."""
|
||||||
|
print("--- Dynamic Light Count ---")
|
||||||
|
|
||||||
|
if _total_lights == 0:
|
||||||
|
_warn("No lights found in scene at all (verify map lighting)")
|
||||||
|
return
|
||||||
|
|
||||||
|
_pass("Total lights: " + str(_total_lights) + " (Dir: " + str(_directional_lights) +
|
||||||
|
", Omni: " + str(_omni_lights) + ", Spot: " + str(_spot_lights) + ")")
|
||||||
|
|
||||||
|
if _dynamic_lights <= MAX_DYNAMIC_LIGHTS:
|
||||||
|
_pass("Dynamic lights: " + str(_dynamic_lights) + " (budget: ≤ " + str(MAX_DYNAMIC_LIGHTS) + ")")
|
||||||
|
else:
|
||||||
|
_fail("Too many dynamic lights: " + str(_dynamic_lights) +
|
||||||
|
" (budget: ≤ " + str(MAX_DYNAMIC_LIGHTS) + ")" +
|
||||||
|
" — consider baking " + str(_dynamic_lights - MAX_DYNAMIC_LIGHTS) + " light(s)")
|
||||||
|
|
||||||
|
# Warn if total lights (including baked) is very high
|
||||||
|
if _total_lights > WARN_TOTAL_LIGHTS:
|
||||||
|
_warn("Total light count (" + str(_total_lights) + ") exceeds " + str(WARN_TOTAL_LIGHTS) +
|
||||||
|
" — verify all lights are necessary")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_bake_mode_checks() -> void:
|
||||||
|
"""Check that each light has an appropriate bake mode."""
|
||||||
|
print("")
|
||||||
|
print("--- Bake Mode Validation ---")
|
||||||
|
|
||||||
|
var proper_bake := 0
|
||||||
|
var improper_bake := 0
|
||||||
|
|
||||||
|
for info in _light_details:
|
||||||
|
var bake_mode := info.bake_mode as int
|
||||||
|
var light_name := info.name as String
|
||||||
|
var light_type := info.type as String
|
||||||
|
var bake_name := _get_bake_mode_name(bake_mode)
|
||||||
|
|
||||||
|
if bake_mode == BAKE_MODE_STATIC:
|
||||||
|
proper_bake += 1
|
||||||
|
_pass(light_name + " (" + light_type + "): bake_mode = Static ✓")
|
||||||
|
elif bake_mode == BAKE_MODE_DISABLED:
|
||||||
|
# Dynamic lights are OK if count is within budget
|
||||||
|
improper_bake += 1
|
||||||
|
_warn(light_name + " (" + light_type + "): bake_mode = Disabled (fully dynamic)")
|
||||||
|
elif bake_mode == BAKE_MODE_DYNAMIC:
|
||||||
|
improper_bake += 1
|
||||||
|
_warn(light_name + " (" + light_type + "): bake_mode = Dynamic")
|
||||||
|
|
||||||
|
if improper_bake > 0:
|
||||||
|
_warn(str(improper_bake) + " light(s) are not set to Static bake mode" +
|
||||||
|
" — use Static for baked geometry lights")
|
||||||
|
|
||||||
|
# DirectionalLight3D should generally be Baked (Static) for main sun
|
||||||
|
for info in _light_details:
|
||||||
|
if info.type == "DirectionalLight3D" and info.bake_mode == BAKE_MODE_DISABLED:
|
||||||
|
_warn("DirectionalLight3D '" + info.name + "' is set to Disabled bake mode" +
|
||||||
|
" — set to Static (Baked) for performance")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_energy_checks() -> void:
|
||||||
|
"""Check that light energy values are within reasonable ranges."""
|
||||||
|
print("")
|
||||||
|
print("--- Light Energy / Range Checks ---")
|
||||||
|
|
||||||
|
for info in _light_details:
|
||||||
|
var energy := info.energy as float
|
||||||
|
var light_name := info.name as String
|
||||||
|
var light_type := info.type as String
|
||||||
|
|
||||||
|
if energy > MAX_LIGHT_ENERGY:
|
||||||
|
_warn(light_name + " (" + light_type + "): energy = " + str(energy) +
|
||||||
|
" — very high (max recommended: " + str(MAX_LIGHT_ENERGY) + ")")
|
||||||
|
elif energy < MIN_LIGHT_ENERGY:
|
||||||
|
_warn(light_name + " (" + light_type + "): energy = " + str(energy) +
|
||||||
|
" — very low (min recommended: " + str(MIN_LIGHT_ENERGY) + ")")
|
||||||
|
else:
|
||||||
|
_pass(light_name + " (" + light_type + "): energy = " + str(energy) + " (within [0.1, 50.0])")
|
||||||
|
|
||||||
|
# Range check (not applicable to DirectionalLight3D)
|
||||||
|
if info.has("range") and info.range > 0:
|
||||||
|
var range_val := info.range as float
|
||||||
|
if range_val > MAX_LIGHT_RANGE:
|
||||||
|
_warn(light_name + " (" + light_type + "): range = " + str(range_val) +
|
||||||
|
" — very large (max recommended: " + str(MAX_LIGHT_RANGE) + ")")
|
||||||
|
|
||||||
|
# Spot angle check
|
||||||
|
if info.has("spot_angle"):
|
||||||
|
var angle := info.spot_angle as float
|
||||||
|
if angle > MAX_LIGHT_SPOT_ANGLE:
|
||||||
|
_warn(light_name + " (SpotLight3D): spot_angle = " + str(angle) +
|
||||||
|
" — exceeds 180° (will project backwards)")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_shadow_checks() -> void:
|
||||||
|
"""Validate shadow configuration on lights."""
|
||||||
|
print("")
|
||||||
|
print("--- Shadow Configuration ---")
|
||||||
|
|
||||||
|
for info in _light_details:
|
||||||
|
var has_shadow := info.shadow as bool
|
||||||
|
var light_name := info.name as String
|
||||||
|
|
||||||
|
if has_shadow:
|
||||||
|
# Shadowed lights are more expensive
|
||||||
|
if info.bake_mode == BAKE_MODE_STATIC:
|
||||||
|
_pass(light_name + ": shadows enabled, bake_mode = Static (good — shadows will be baked)")
|
||||||
|
else:
|
||||||
|
_warn(light_name + ": dynamic shadows enabled — expensive for real-time rendering")
|
||||||
|
|
||||||
|
# Count shadowed dynamic lights
|
||||||
|
var dynamic_shadow_count := 0
|
||||||
|
for info in _light_details:
|
||||||
|
if info.shadow and info.bake_mode != BAKE_MODE_STATIC:
|
||||||
|
dynamic_shadow_count += 1
|
||||||
|
|
||||||
|
if dynamic_shadow_count > 2:
|
||||||
|
_warn(str(dynamic_shadow_count) + " lights have dynamic shadows — recommended max 2 for performance")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Light report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _print_light_report() -> void:
|
||||||
|
"""Print a comprehensive light report."""
|
||||||
|
print("")
|
||||||
|
print("--- Light Report ---")
|
||||||
|
print(" Total lights: ", _total_lights)
|
||||||
|
print(" DirectionalLight3D: ", _directional_lights)
|
||||||
|
print(" OmniLight3D: ", _omni_lights)
|
||||||
|
print(" SpotLight3D: ", _spot_lights)
|
||||||
|
print(" Dynamic (non-baked): ", _dynamic_lights)
|
||||||
|
print(" Baked (Static): ", _baked_lights)
|
||||||
|
print(" Disabled: ", _disabled_lights)
|
||||||
|
print(" Shadowed: ", _shadowed_lights)
|
||||||
|
print("")
|
||||||
|
|
||||||
|
# Light detail table
|
||||||
|
if _light_details.size() > 0:
|
||||||
|
print(" Light Details:")
|
||||||
|
print(" " + "-".repeat(90))
|
||||||
|
print(" %-25s %-18s %-12s %-8s %-6s %-8s" % ["Name", "Type", "Bake Mode", "Energy", "Shadow", "Range"])
|
||||||
|
print(" " + "-".repeat(90))
|
||||||
|
for info in _light_details:
|
||||||
|
var bake_mode_name := _get_bake_mode_name(info.bake_mode)
|
||||||
|
var range_str := str(info.range) if info.has("range") and info.range > 0 else "N/A"
|
||||||
|
var shadow_str := "Yes" if info.shadow else "No"
|
||||||
|
print(" %-25s %-18s %-12s %-8.1f %-6s %-8s" % [
|
||||||
|
info.name, info.type, bake_mode_name, info.energy, shadow_str, range_str
|
||||||
|
])
|
||||||
|
print(" " + "-".repeat(90))
|
||||||
|
print("")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _print_summary() -> void:
|
||||||
|
"""Print final summary of all light checks."""
|
||||||
|
print("==========================================")
|
||||||
|
print(" LIGHT VALIDATION SUMMARY")
|
||||||
|
print("==========================================")
|
||||||
|
print(" Scene: ", _scene_path)
|
||||||
|
print(" Total lights: ", _total_lights)
|
||||||
|
print(" Dynamic lights: ", _dynamic_lights)
|
||||||
|
print(" Baked (Static): ", _baked_lights)
|
||||||
|
print(" Dynamic shadowed: ", _shadowed_lights)
|
||||||
|
print("------------------------------------------")
|
||||||
|
print(" Passed: ", _pass_count)
|
||||||
|
print(" Failed: ", _fail_count)
|
||||||
|
print(" Warnings: ", _warning_count)
|
||||||
|
print("------------------------------------------")
|
||||||
|
if _fail_count > 0:
|
||||||
|
print(" RESULT: FAILED ❌")
|
||||||
|
printerr("Validation FAILED — ", _fail_count, " light check(s) failed.")
|
||||||
|
else:
|
||||||
|
print(" RESULT: PASSED ✅")
|
||||||
|
print("==========================================")
|
||||||
|
print("")
|
||||||