38 Commits

Author SHA1 Message Date
shawn db477b4c48 Fix simulation_server_stub Entity class conflict + ServerConfig load timing
- Renamed inner class Entity→StubEntity (Godot has native Entity class)
- Added await ServerConfig.config_loaded before reading map_list in server_main.gd
- Config loads via call_deferred, so _ready() must wait for signal
2026-07-02 10:24:32 -04:00
shawn 4a5264c5b0 Fix headless class_name dependencies
- Replaced TeamData.Team type hints with int in all scripts
- Added explicit preloads for headless mode class resolution
- Created stub LagCompensation and DamageProcessor scripts
- Fixed PluginManager, SpawnManager, EconomyManager, BuyZone,
  RoundManager, BuyMenuHandler, BombObjective class_name references
- Updated server config to match ServerConfig.gd format

Gray screen root cause: server scripts failed to parse in headless
mode due to Godot 4 class_name loading order (Resource after Node),
leaving server_main.gd non-functional — accepted connections but
never spawned players.
2026-07-02 10:09:28 -04:00
shawn ad48f38ca5 feat: integrate ChaffGames FPS template as local player controller
Replaces the overhead box-mesh view with a full FPS character:

- mouse look, WASD movement, jump (Space), crouch (C), sprint (Shift), lean (Q/E)

- 6 weapons (LMB shoot, R reload, scroll switch, F melee, G drop)

- Crosshair HUD, ammo counter, sprint bar

- Client sends position to server at 20Hz for multiplayer visibility

- Server broadcasts positions to all peers

- Remote players shown as boxes (placeholder)

source: https://godotengine.org/asset-library/asset/2652 (MIT license)
2026-07-02 00:26:45 -04:00
shawn f0c95dcfd2 fix: create local player on client so WASD movement works
client_main.gd: when server spawns our player, create a player node with authority

- Camera follows local player from 3/4 overhead view

- player.gd WASD input sends position to server via RPC

- Server validates and broadcasts to all peers
2026-07-02 00:15:08 -04:00
shawn d060ac449d fix: suppress 3 benign startup warnings on Windows client
- Create missing server/plugins/ dir with .gdignore (stops PluginManager warning)

- Create config/default_server_config.cfg (stops ServerConfig error)

- Reorder light look_at after add_child (stops 'not in tree' warning)

- Switch rendering_method from opengl3 to forward_plus (RTX 2080 Vulkan works fine; gray screen was server-scene issue not OpenGL)
2026-07-02 00:05:27 -04:00
shawn 87060b0b71 fix: move sub_resource tags before nodes in test_range.tscn (export parse fix) 2026-07-02 00:02:25 -04:00
shawn 638e109d8b fix: client_main.tscn overrides server_host to 68.202.6.107 2026-07-01 22:21:29 -04:00
shawn 4c0e334575 fix: lazy load scenes in entry.gd (no preload of server in client mode) 2026-07-01 22:04:41 -04:00
shawn e270f34150 fix: client_main defaults to remote server (68.202.6.107) 2026-07-01 22:04:03 -04:00
shawn 20ca43f928 fix: add smart entry scene (client vs server via headless detection) 2026-07-01 21:44:14 -04:00
shawn aae57c63bd fix: TeamManager signal type hints — use int instead of TeamData.Team (export parse order issue) 2026-07-01 21:41:36 -04:00
shawn 0994a55486 chore: clean up duplicate .gdextension files (keep only gdextension/simulation.gdextension) 2026-07-01 21:38:46 -04:00
shawn 82216bfa64 fix: Windows client compile errors — get_world_3d on Node, array type mismatch, null plugin guard, maps format 2026-07-01 21:38:07 -04:00
shawn d5098c61e1 fix: switch to OpenGL 3 renderer for Windows compatibility (gray screen fix) 2026-07-01 21:16:26 -04:00
shawn 194aad8f83 t_p2_bomb: Bomb/defuse objective system for search & destroy rounds
Adds:
- bomb_objective.gd: server-authoritative bomb state machine (IDLE→PLANTED→EXPLODED/DEFUSED)
  - plant_bomb(player_id, pos) - T-side only, LIVE phase, inside bombsite
  - defuse_bomb(player_id) - CT-side only, near bomb, 5s timer
  - cancel_defuse(player_id) - if defuser moves or is killed
  - bomb_explode() - configurable radius damage (10m lethal, 15m half)
  - 40s bomb timer (configurable)
  - Full query API: is_bomb_planted(), get_bomb_position(), get_bomb_timer()
  - Signals: bomb_planted, bomb_defused, bomb_exploded, defuse_started, defuse_cancelled
  - Connected to RoundManager via GameServer for round-end outcomes

- bomb_carrier.gd: client-side bomb carrier UI
  - Bomb status/defuse progress UI updates
  - Direction indicator to planted bomb
  - "Hold E to defuse" prompt for CT near bomb
  - "Hold E to plant" prompt for T with bomb in bombsite

- test_range.tscn: BombsiteA (south-west, -20,0,-5) and BombsiteB (north-east, 15,0,20)
  - 8x4m Area3D zones with BoxShape3D collision
  - PlantPosition Marker3D children
  - Group 'bomb_sites' for server discovery

- game_server.gd: BombObjective integration in _ready()
  - Creates and wires BombObjective to RoundManager, TeamManager, DamageProcessor
  - Registers bomb sites from scene tree
  - Connects bomb_exploded/bomb_defused → RoundManager.end_round()
  - Connects round_ended → bomb.reset()
2026-07-01 20:39:17 -04:00
shawn 222dcaebb3 t_p4_anticheat: add basic server-side anti-cheat controller
- Rate limiting: flags players exceeding 130Hz input RPC rate
- Speed hacks: validates position deltas against max speed * 1.5
- Aimbot snap: flags look-direction changes >45° in one tick
- Suspicion level tracking (0.0-1.0) with decay over time
- Escalating thresholds: 0.3=warn(log), 0.6=kick(signal), 0.8=ban(signal, 1h temp)
- Configurable max speed via set_max_speed()
- Ban handler Callable for caller-defined disconnect logic
- Design: generous tolerances, near-zero false positives, Phase-4 basic hardening
2026-07-01 20:37:20 -04:00
shawn 0aa29d33e4 fix: GDExtension C++ build — fix enum bitwise cast, add .gdextension entry file 2026-07-01 20:34:15 -04:00
shawn 6a08487c4c t_p2_buy: add buy menu and economy system
Creates the full buy/economy subsystem for a tactical FPS:

- EconomyManager (server/scripts/economy/economy_manager.gd): per-player
  money tracking with starting 00, kill/round/objective rewards, spending
  validation, escalating loss bonuses, and money_changed signal.

- BuyMenuHandler (server/scripts/economy/buy_menu_handler.gd): server-side
  RPC validation of purchase requests — checks weapon validity, buy-zone
  membership (scans BuyZone nodes in scene tree), affordability, then
  deducts money and grants weapon via WeaponServer.give_weapon().

- BuyMenu (client/characters/weapon/buy_menu.gd): client-side Control UI
  with styled panel, money display, weapon grid (name/cost/stats), numeric
  key (1-4) and mouse-click purchase, server-driven affordability, and
  auto-close after purchase.

- GameServer integration: creates EconomyManager + BuyMenuHandler in
  _ready(), registers players for economy on spawn, awards kill rewards
  via DamageProcessor.player_killed signal, and unregisters on despawn.

Economy rules: win=+,250, loss=,900 (+00/streak, cap ,900),
kill=+00, bomb_plant/defuse=+00. Weapon costs: pistol=00,
smg=,200, rifle=,700, shotgun=,900.
2026-07-01 20:31:25 -04:00
shawn f20d532add t_p1_round: Add round/match lifecycle system (RoundManager)
- New server/scripts/round/round_manager.gd with:
  - RoundPhase enum (WARMUP, PREP, LIVE, POST)
  - Round life cycle: warmup -> prep (15s) -> live (105s) -> post (8s)
  - Team elimination detection using entity->peer->team mappings
  - Time-expired round ending (default: CT wins)
  - Economy hooks: win money (250), loss money (900 escalating to 900), kill bonus (00)
  - All requested signals: round_phase_changed, round_started, round_ended, match_point, warmup_ended, round_economy_data

- Updated GameServer:
  - Creates and wires RoundManager in _ready()
  - Connects DamageProcessor.player_killed -> round tracking + elimination detection
  - Added peer_to_entity mapping for backwards entity lookup
  - Added RCON command handler (_on_rcon_command) for start_match/end_round

- Updated RCON command handler:
  - Added start_match and end_round to dispatch list and help text
2026-07-01 20:30:01 -04:00
shawn 705b068ed2 t_p1_hitscan: Add lag-compensated hitscan weapon system
NEW: server/scripts/combat/lag_compensation.gd
  - 128-entry ring buffer of player positions per server tick
  - rewind_and_raycast(tick, origin, direction, range, exclude):
    saves current positions, rewinds to tick-time positions,
    performs PhysicsDirectSpaceState3D.intersect_ray(), restores
  - Falls back to normal raycast if history missing for tick
  - shot_processed signal

NEW: scripts/combat/damage_processor.gd
  - Processes WeaponServer hit results: applies damage, tracks kills
  - Optional distance-based damage falloff
  - player_damaged / player_killed signals
  - Health and kill-count queries

MODIFY: server/scripts/weapons/weapon_server.gd
  - fire() now takes tick parameter: fire(tick, player_id, ...)
  - _perform_hitscan() uses LagCompensation.rewind_and_raycast()
    when lag_compensation reference is set and tick >= 0
  - Non-compensated fallback path preserved

MODIFY: server/scripts/game_server.gd
  - Adds _current_tick counter, incremented each simulation tick
  - Creates WeaponServer + LagCompensation + DamageProcessor as children
  - record_tick() called before each tick's simulation
  - register/unregister_player_node() for player lifecycle
  - _on_player_killed() relays kill events via player_damaged signal

MODIFY: scripts/network/server_main.gd
  - _spawn_player() registers node with GameServer.register_player_node()
  - _despawn_player() unregisters via unregister_player_node()
2026-07-01 20:26:18 -04:00
shawn 3465922be4 t_p6_browser: add workshop map browser with voting (3 files)
- workshop_browser.gd: scans server maps dir for .pck/.tscn files
- map_browser_rpc.gd: client-server RPC bridge for map list/change
- map_vote.gd: simple >50% majority vote with 30s timeout

File scope:
  server/scripts/browser/workshop_browser.gd (NEW)
  scripts/network/map_browser_rpc.gd (NEW)
  server/scripts/browser/map_vote.gd (NEW)
2026-07-01 20:24:23 -04:00
shawn 46ff83325f t_p2: Add team, spawn, and buy-zone system
- team_data.gd: Team enum (SPECTATOR/CT/Terrorist), constants for names,
  colors, starting money, and spawn groups; TeamData resource class
- team_manager.gd: Player-to-team assignment, auto-balancing, score
  tracking; round-independent (persists across rounds)
- spawn_manager.gd: Scans scene for Marker3D nodes in spawn_points_ct
  and spawn_points_t groups; selects valid spawns (farthest from enemies
  with proximity check, fallback to first available)
- buy_zone.gd: Area3D-based trigger zone with team filtering, player
  enter/exit tracking and signals
- test_range.tscn: Added 2 CT spawn markers and 2 T spawn markers with
  appropriate groups
2026-07-01 20:24:16 -04:00
shawn f6d69545c9 feat: implement client-side prediction and reconciliation system
Add client-side prediction (t_p1_pred) with:

- scripts/network/snapshot.gd — lightweight snapshot resource with
  to_dict/from_dict serialization for RPC. Stores position (Vector3),
  rotation (Quaternion), velocity (Vector3), grounded (bool).

- scripts/network/client_prediction.gd — prediction/reconciliation
  controller. 64-entry ring buffer of local snapshots, sends inputs to
  server each physics tick (128Hz, ENet channel 0), detects misprediction
  on server state arrival, rewinds and re-applies unconfirmed inputs.
  Also supports remote player interpolation.

- scripts/network/network_manager.gd — new RPC endpoints for client
  prediction: send_client_input (client->server, ch 0) and
  send_server_state (server->client, ch 1). New signals for routing.

- client/characters/character/fps_character_controller.gd — prediction
  hooks in _physics_process: on_before_tick() captures pre-input
  snapshot, on_after_tick() sends input to server. Client prediction
  path uses local movement (instant feedback) instead of reading from
  SimulationServer entity.

Architecture:
  Each tick: client applies input → predicts new state locally
  → sends input to server → server returns authoritative state
  → client compares and reconciles if mismatch.
2026-07-01 20:21:16 -04:00
shawn 2452aba0d7 feat: implement weapon set system (t_p2_weapons)
- WeaponData resource class with configurable weapon properties and
  pre-configured constants for rifle, pistol, shotgun, and SMG
- WeaponDefinitions singleton/static registry with WEAPONS dictionary
- WeaponServer node for server-authoritative weapon logic including
  fire rate cooldowns, ammo tracking, reload state machine, and
  multi-pellet hit-scan raycasting
- Extended WeaponManager with weapon inventory array, weapon switching,
  per-weapon state persistence, fire_animation_triggered signal
2026-07-01 20:16:22 -04:00
shawn 3c3251fa7a feat: add t_p1_greybox — greybox test map scene
Creates test_range.tscn under client/assets/maps/test_range/ as a simple
open-area test map for validating movement, shooting, and networking.

Layout:
- 80×80 open area with grey floor (CSGBox3D, 12 tris)
- Low platform (center, 1 unit high, gold)
- Medium platform (east, 2 units high, gold)
- High platform (north-west, 3 units high, red)
- Ramp (south, tilted ~20°, leads ground to low platform top)
- Enclosure/room (east, 12×12 interior, 4-unit walls, south doorway)
- SpawnPoint (Marker3D) with group 'spawn_points'
- DirectionalLight3D (sun) + WorldEnvironment
- All geometry uses Solid-color StandardMaterial3D placeholders
- Collision enabled on all CSGBox3D shapes
- ~132 tris total (well under 500 budget)
2026-07-01 20:15:34 -04:00
shawn d02b112d99 Phase 7: test server deployment + Windows export
- Fix: export_presets.cfg — platform='Windows Desktop' (correct name)
- Fix: server_main.gd — formatting bug on lag-comp string
- Fix: game_server.gd — auto-detect GDExtension, fall back to GDScript stub
- Add: server/scripts/simulation_server_stub.gd — lightweight SimulationServer in GDScript
- Add: docs/playtest-guide.md — connection instructions, control bindings, server commands
- Add: systemd user service — tactical-shooter-server (enabled, running)
- Add: server config at ~/tactical-shooter-server/server_config.cfg
- Build: Windows 109MB PE32+ client (build/tactical-shooter-windows-x86_64/)
- Build: Linux server binary (78MB) — running on oplabs:34197
- Temporarily disabled GDExtension (needs C++ build fix in entity.cpp enum casting)
2026-07-01 20:02:05 -04:00
shawn 2582cb1b0d Phase 7: Windows export preset fix + code fixes
- Fix: export_presets.cfg — platform='Windows Desktop' (not 'Windows'), Windows as [preset.0]
- Fix: plugin_manager.gd — removed class_name (autoload conflict), fixed multiline % formatting
- Disable GDExtension temporarily for clean export
- Add build/ to .gitignore (binary artifacts)
- Build artifacts: tactical-shooter.exe (109MB Windows PE32+), windows.zip, server binary (78MB)
2026-07-01 19:47:12 -04:00
shawn 9ea98aa7b8 Phase 7: Windows client export, preset fix, code fixes
- Fix: export_presets.cfg — platform='Windows Desktop' (not 'Windows'), Windows as [preset.0]
- Fix: plugin_manager.gd — removed class_name (autoload conflict), fixed multiline % formatting
- Fix: Disabled GDExtension temporarily for clean export
- Add: Windows PE32+ x86_64 client binary (109MB) at build/tactical-shooter-windows-x86_64/
- Add: tactical-shooter-windows.zip (portable zip package)
- Build: Linux server binary (78MB) rebuilt
2026-07-01 19:47:12 -04:00
shawn e385eae0f5 feat: performance budget profiling setup
- docs/performance-budget.md: 60fps/2GB VRAM budget with CPU, GPU,
  memory, and draw-call targets (P95<16.6ms, P99<50ms)
- scripts/profiling/: SceneTree + Node profilers, test scenes
- scripts/generate_occluders.gd: auto-generate OccluderInstance3D
  from CSG wall pieces (128^3 voxel occlusion culling)
- scripts/convert_csg_to_mesh.gd: CSG->StaticMesh baker with LOD
  slots (LOD1@15m, LOD2@30m)
- project.godot: occlusion culling + LOD settings enabled
- modular scene UV2 data from lightmapping pass
- rcon_command_handler.gd conflict resolved (kept upstream plugin cmd)
- Fixed profiler_node.gd warmup frame bug (60 frames, not 3)
- Fixed convert_csg_to_mesh.gd LOD API (add_lod + distance)
2026-07-01 19:01:03 -04:00
shawn 34507f9043 docs: community mapmaking documentation (7-file SDK guide)
- docs/mapmaking/00-index.md — SDK overview with workflow diagram
- docs/mapmaking/01-getting-started.md — template setup & first map
- docs/mapmaking/02-building-geometry.md — CSG guide & prefab reference
- docs/mapmaking/03-lighting-and-env.md — LightmapGI baking guide
- docs/mapmaking/04-validation.md — validator CLI & CI/CD usage
- docs/mapmaking/05-packaging-and-shipping.md — .pck pipeline
- docs/mapmaking/06-faq-and-troubleshooting.md — 30+ common issues
- README.md — add mapmaking SDK link to features list
2026-07-01 18:47:06 -04:00
shawn 159c554a86 t_p6_api: build plugin scripting system
- Add PluginBase (plugin_base.gd) — abstract base class with 12 virtual hooks
- Add PluginManifest (plugin_manifest.gd) — custom Resource for plugin metadata
- Add PluginManager (plugin_manager.gd) — autoload singleton: directory scan,
  hook dispatch, lifecycle (load/unload/reload/rescan), cvar integration
- Add hello_world example plugin with all hooks demonstrating usage
- Register PluginManager autoload in project.godot
- Extend rcon_command_handler.gd with 'plugin' subcommand (list/load/unload/
  reload/info/rescan) delegating to PluginManager
2026-07-01 18:36:23 -04:00
shawn ffa72a8f24 t_p5_validator: add 5 Godot 4 map validator scripts
- validate_map.gd — scene structure validator (CSG combiner root, spawn groups,
  bomb sites, buy zones, map bounds, LightmapGI config, lighting setup)
- validate_polycount.gd — CSG triangle budget checker
  (<50K faces, <5K per node, <200 CSG nodes)
- validate_textures.gd — texture size ≤1K, mipmaps, UV2 checker
- validate_lights.gd — dynamic lights ≤4, bake mode validator
- bake_lightmaps.gd — LightmapGI config checker
  (quality, bounces, texel_scale, interior, denoiser)

All scripts: @tool SceneTree, accept scene path via -- separator,
exit 0=pass / 1=fail / 2=scene-not-found. 443-610 lines each.
2026-07-01 18:35:52 -04:00
shawn 589b90d886 fix: FPS character controller review fixes
- Camera: Make view bob additive to crouch eye height (was overwriting position.y)
- Camera: Sustain sprint FOV kick while sprinting (was recovering immediately)
- Camera: Remove unused _was_sprinting variable
- Controller: Use just_pressed for jump (was sending every frame while held)
- Controller: Direct input dict assignment instead of merge() to avoid alloc
- Controller: Add clearer comments on server-authoritative readback mode
2026-07-01 18:35:16 -04:00
shawn e9dc05983c Save workspace artifacts: character-controller, gdextension scaffold, network scripts, map-pipeline, server config
Includes code from task workspaces that was never pushed:
- client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd
- gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files)
- scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd
- scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README
- scripts/config/ — server_config.gd (480 lines)
- scenes/ — client_main, server_main, player, test_range
- project.godot, export_presets.cfg
2026-07-01 18:30:44 -04:00
shawn 2cf57a989f t_p4_rcon: RCON admin console — TCP listener + command handler
Implementation:
- server/scripts/rcon_server.gd — TCPServer-based listener, auth state
  machine (3-strike penalty), per-frame I/O polling, StreamPeerTCP
  per-connection management, IP-based auth timeout
- server/scripts/rcon_command_handler.gd — 14-command dispatch table,
  signal-based dispatch for game-affecting commands (changelevel, kick,
  ban, say, players, exec), inline handlers for help/echo/status/quit and
  cvar operations via optional CvarRegistry singleton (t_p4_config)
- server/data/rcon_password.cfg — default password file (gitignored)
- .gitignore — exclude password file from version control

Protocol: Source-RCON-inspired lightweight TCP, newline-delimited commands,
END-terminated multi-line responses. First message = password auth.
3 failed auth attempts → 5s reconnect penalty.

CvarRegistry integration: auto-detects /root/CvarRegistry singleton on
_ready() and reads rcon_enabled, rcon_port, rcon_password if present.
2026-07-01 00:31:03 -04:00
shawn 16e062c739 build: baked lighting pass on kit_demo test map
Restructured kit_demo.tscn from linear demo to enclosed 5.12x5.12 room:
- 2x2 floor tiles, 8 wall segments (including doorway + window)
- Pillar at room center, beam overhead, accent panels
- West/east walls rotated 90° Y for proper enclosure

Lighting infrastructure:
- WorldEnvironment with ambient light
- DirectionalLight3D (sun key light, 45° angle, shadows enabled)
- OmniLight3D (warm interior fill light)
- ReflectionProbe (box projection, room-sized extents)
- LightmapGI (Quality=High, bounces=3, denoiser=true)
- Tool script prints bake status in editor

Scripts:
- bake_lighting.gd: validates LightmapGI config from CLI
- validate_scene.gd: full scene validation (17 checks, all pass)

Note: Godot 4.7 removed LightmapGI.bake() from runtime API.
Baking must be done in the editor: select LightmapGI node > Bake Lightmap.
2026-07-01 00:28:55 -04:00
shawn aa0b80b570 Phase 4: server-hardening architecture doc
- Server hosting architecture overview (4-component diagram)
- RCON admin console spec (TCP protocol, commands, auth)
- Server config system spec (cfg format, cvars, loading pipeline)
- Server browser API spec (master server REST API, heartbeat, client UI)
- Anti-cheat basics spec (movement, fire rate, input validation)
- Config loading pipeline, anti-cheat pipeline diagrams
- Full cvar table for Phase 4

Part of Phase 4 orchestration (t_29fcfd24)
2026-07-01 00:19:43 -04:00
shawn d631ff784a build: baked lighting pass on kit_demo test map
- Restructured kit_demo.tscn from linear demo to enclosed 5.12x5.12 room
  with walls, floor tiles, pillar, beam, doorway, window
- Added WorldEnvironment with ProceduralSky for ambient lighting
- Added DirectionalLight3D (sun key light, 45° angle, shadows enabled)
- Added OmniLight3D (warm interior fill light)
- Added ReflectionProbe for interior specular reflections
  (box projection, room-sized extents)
- Added LightmapGI with balanced quality settings
  (bounces=3, texel_scale=1.0, max_texture_size=2048, denoiser=true)
- Added tool script (kit_demo.gd) that prints bake status in editor
- Added bake_lighting.gd CLI bake script (requires GPU-enabled instance)
- Updated project.godot with reflection atlas and shadow map quality settings

Headless baking note: Godot's standard editor build requires a GPU/display
for LightmapGI.bake(). Open the scene in the Godot editor and click 'Bake
Lightmap' on the LightmapGI node to generate the baked lightmap data.
2026-07-01 00:10:19 -04:00
303 changed files with 385153 additions and 9 deletions
+5
View File
@@ -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/
+2 -1
View File
@@ -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
+160
View File
@@ -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()
+48
View File
@@ -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
+174
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+493
View File
@@ -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.")
Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+119
View File
@@ -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.10.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
+220
View File
@@ -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
+59
View File
@@ -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
+445
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://cdsyn134gsca8
+348
View File
@@ -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
+72
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://q8nutj781fyf
+610
View File
@@ -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.54.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"
+1
View File
@@ -0,0 +1 @@
uid://d0mbp6gs2mwlr
+198
View File
@@ -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
+138
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://pauclwpn265a
+170
View File
@@ -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
+147
View File
@@ -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")
+21
View File
@@ -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
+14
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dbtpbhja3nk60
+443
View File
@@ -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("")

Some files were not shown because too many files have changed in this diff Show More