extends PanelContainer
## In-game sky debug overlay toggled with F2.
## Auto-discovers cloud layers and builds sliders dynamically.
## Sections are collapsible — click header to expand/collapse.
var sky3d: Sky3DExtended
var _sliders: Dictionary = {} # name → HSlider
var _labels: Dictionary = {} # name → LineEdit (value display, editable)
var _min_edits: Dictionary = {} # name → LineEdit (range min)
var _max_edits: Dictionary = {} # name → LineEdit (range max)
var _time_paused: bool = false
var _layer_toggles: Dictionary = {} # layer_name → CheckButton
var _vbox: VBoxContainer
## Where atmosphere settings live in GameState.
##
## PER ROOM **AND** PER MAP. Room isolation comes free — the StateBase is per
## room — but map isolation does not: `world:sky:` is listed in
## GameState.MAP_SCOPED_PREFIXES, which stores it as `map:<map_id>:world:sky:*`.
##
## I had this room-global at first, reasoning that the Sky3DExtended node hangs
## off Game rather than off a map so there is only one sky object. That confuses
## the NODE with the SETTING: a map is a contained world and its sky is part of
## what makes it one. A Quake level lit at noon and a procedural overworld at
## dusk are two worlds, not one world being re-graded. Room-global also meant
## setting dusk in one map silently re-lit every other map in the room.
##
## Consumers still use the plain key: _durable holds unscoped keys for the
## CURRENT map, _scoped() adds the namespace only when writing to the StateBase,
## and _unscope() drops other maps' keys on load.
##
## `world:sky:*` is routed DURABLE/"peer" in game.gd. THAT ROUTE IS LOAD-BEARING
## and had to land in the same change as the first write: GameState._resolve
## falls through to _DEFAULT_ROUTE {REALTIME, "peer"} for an unrouted key, which
## is never persisted and never replicated — the silent no-op that lost every
## world:level:* carve until 0.843.0 (hyper-games #27 §2).
##
## No author segment, so can_write returns true early: this is shared
## last-write-wins config, like terrain noise, not per-author content.
const KEY_PREFIX := "world:sky:"
## Sky params are shader uniforms — cheap to apply, unlike terrain noise which
## rebuilds the world. This debounce is only to keep a drag from writing a
## durable record per frame.
const WRITE_DELAY := 0.25
## How long after the last restore to re-assert it.
##
## RESTORES ARRIVE TOO EARLY TO STICK. Room state loads well before the scene has
## finished assembling, and things that run afterwards put the sky back: SkyDome
## finishes building and brings its authored current_time (19.6, dusk) with it, a
## map may force its own daylight, and AdminFacade re-indexes /world/sky. The
## restore was applied correctly and then overwritten — which is exactly why
## nudging the slider by hand a moment later shows the right sky for the SAME
## value. Applying later is the whole fix.
##
## A re-assert rather than a retry loop: it reads the saved values back out of
## GameState and applies them once, so it is idempotent and cannot fight a change
## the player made in the meantime (their change IS the saved value by then).
const SETTLE_DELAY := 1.5
## A LOCAL MIRROR OF THE SAVED SKY, so the first frame is already right.
##
## The authoritative value lives in the room's StateBase and cannot arrive until
## that has loaded — measured at ~5 s here, after DHT bootstrap and the room
## watch. Until then the sky shows whatever the scene authored (19.6, dusk), then
## snaps. That window is not a bug in persistence; it is the cost of the value
## being remote.
##
## So keep a local copy, keyed by room AND map exactly as the durable keys are,
## and apply it the moment the map is known. The room's value still wins when it
## lands — normally it is identical, so nothing visibly changes; when it differs
## (another peer edited it) the correction is the point.
##
## user:// rather than GameState: this is a rendering convenience for THIS
## install, not world state. It must never be the source of truth.
const CACHE_PATH := "user://sky_cache.cfg"
## HTPS_SKY_DEBUG=1 brings back the per-restore celestial dump.
##
## KEPT, NOT DELETED. That dump is what actually cracked this: printing every
## celestial input beside the resolved sun/moon angles showed identical inputs
## producing four different sun altitudes, which located the SkyDome's
## interpolation. Reasoning about the code had failed three times before it.
##
## But it prints on every re-emit of every key, so it is noise once the hunt is
## over. Off by default, one env var away. Mirrors GameState's HTPS_VERBOSE.
static var _verbose: bool = OS.has_environment("HTPS_SKY_DEBUG")
var _pending: Dictionary = {}
var _write_timer: Timer = null
var _applying: bool = false # our own writes come back via state_changed
## Scene-authored values, captured before any saved state is applied.
var _defaults: Dictionary = {}
## Re-assert saved values once the scene has stopped moving. See _settle().
var _settle_timer: Timer = null
## Sunshine Clouds section: its content box, and the effect it is currently
## showing. See _rebuild_sunshine().
var _sc_box: VBoxContainer = null
var _sc_fx: CompositorEffect = null
## Live GPU cost readout (the addon's own per-pass timestamps).
var _sc_timing: Label = null
var _sc_timing_t: float = 0.0
func setup(p_sky3d: Sky3DExtended) -> void:
sky3d = p_sky3d
_build_ui()
visibility_changed.connect(func() -> void:
if visible:
_resync_from_sky())
_write_timer = Timer.new()
_write_timer.one_shot = true
_write_timer.wait_time = WRITE_DELAY
_write_timer.timeout.connect(_flush_writes)
add_child(_write_timer)
_settle_timer = Timer.new()
_settle_timer.one_shot = true
_settle_timer.wait_time = SETTLE_DELAY
_settle_timer.timeout.connect(_settle)
add_child(_settle_timer)
# Snapshot the scene's own values BEFORE any saved state lands. This is the
# baseline a room with nothing saved must fall back to — see _on_map_changing.
for prop: String in _sliders:
_defaults[prop] = (_sliders[prop] as HSlider).value
if GameState:
GameState.state_changed.connect(_on_state_changed)
GameState.map_changing.connect(_on_map_changing)
# Local copy first so the first rendered frame is right, then whatever the
# room already has in memory.
_cache_apply()
_load_persisted()
## ROOM ISOLATION. Two ways this panel could leak one room's sky into another,
## and both are live without this.
##
## 1. A PENDING WRITE. set_state resolves the destination room at FLUSH time, not
## at drag time, so a value staged in room A and flushed 0.25 s later — after
## a switch — is written into room B. Dropping the queue is the whole fix.
##
## 2. VALUES LEFT APPLIED. GameState.reset_for_room_switch clears its mirrors and
## the new room re-emits what IT has saved, but a param the new room never
## saved is never re-emitted — so room A's value stays on the sky node and
## reads as room B's own setting. Restoring the scene baseline first means the
## new room's state lands on defaults, not on the last room's.
func _on_map_changing(map_id: String) -> void:
if _write_timer:
_write_timer.stop()
# A settle staged for the OUTGOING map must not fire against the incoming one.
if _settle_timer:
_settle_timer.stop()
_pending.clear()
for prop: String in _defaults:
_apply_external(prop, float(_defaults[prop]))
if _verbose:
print("[SkyPanel] map -> %s : dropped pending writes, reset %d setting(s) to scene defaults"
% [map_id, _defaults.size()])
print(_binding_line("MAPCHANGE " + map_id))
# Deferred: current_map_id is set before map_changing is emitted, but the
# cache section also needs the room, and this keeps the reset-then-apply
# ordering obvious rather than interleaved.
call_deferred("_cache_apply")
# The incoming map's state arrives asynchronously (load_room_state watches),
# so arm the settle here too: if this map has nothing saved the re-assert is a
# no-op, and if state arrives it pushes the timer out anyway.
if _settle_timer:
_settle_timer.start()
func _build_ui() -> void:
# NO CHROME AND NO ANCHORS — this is a DOCK PANEL now, not a floating overlay.
# EditorDock owns where it sits, what it is framed in and its collapsible
# header; a panel that also drew a background and anchored itself top-left
# would be a second answer to both, and it is what kept this thing from
# sitting next to any other panel.
add_theme_stylebox_override("panel", StyleBoxEmpty.new())
# NO INTERNAL SCROLL either. It used to force a 370x500 viewport and scroll
# its own contents, which inside a scrolling dock is two scrollbars racing for
# the same wheel event. The dock caps the column against the window; this just
# declares how tall it wants to be.
_vbox = VBoxContainer.new()
_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(_vbox)
# --- Time ---
var sec: VBoxContainer
sec = _add_section(_vbox, "Time")
_add_slider(sec, "current_time", 0.0, 24.0, 0.1, sky3d.current_time)
_add_slider(sec, "minutes_per_day", 1.0, 60.0, 0.5, sky3d.minutes_per_day)
_add_toggle(sec, "pause_time", _time_paused)
# --- Date & Location -------------------------------------------------------
# WHERE THE MOON ACTUALLY COMES FROM. TimeOfDay derives sun AND moon
# coordinates from (current_time, date, latitude, longitude, utc) — see
# _update_celestial_coords. Only current_time was reachable from this panel,
# so on a reconnect the time restored and the celestial maths did not, and the
# moon came back in a position belonging to a different date.
#
# These are world-authoring rather than atmosphere, which is why they are their
# own section: you set them once for a world, not while grading a sky.
sec = _add_section(_vbox, "Date & Location")
_add_slider(sec, "day", 1.0, 31.0, 1.0, float(_tod_get("day", 1)))
_add_slider(sec, "month", 1.0, 12.0, 1.0, float(_tod_get("month", 1)))
_add_slider(sec, "year", 1900.0, 2200.0, 1.0, float(_tod_get("year", 2025)))
# DEGREES ON THE SLIDER, RADIANS ON THE NODE. TimeOfDay stores these in
# radians (its defaults are 0.279 and 1.885 — 16 N, 108 E), so a raw -90..90
# slider would show 0.279 as "nearly zero" and, worse, write 16 DEGREES into a
# field read as 16 RADIANS the moment you dragged it. _tod_get/_tod_set convert
# for exactly these two.
_add_slider(sec, "latitude", -90.0, 90.0, 0.5, float(_tod_get("latitude", 0.0)))
_add_slider(sec, "longitude", -180.0, 180.0, 0.5, float(_tod_get("longitude", 0.0)))
_add_slider(sec, "utc", -12.0, 14.0, 1.0, float(_tod_get("utc", 0.0)))
# --- Sun ---
sec = _add_section(_vbox, "Sun")
_add_slider(sec, "sun_disk_size", 0.0, 0.2, 0.001, sky3d.sky.sun_disk_size)
_add_slider(sec, "sun_disk_intensity", 0.0, 100.0, 0.5, sky3d.sky.sun_disk_intensity)
_add_slider(sec, "sun_energy", 0.0, 4.0, 0.05, sky3d.sun_energy)
_add_slider(sec, "atm_sun_mie_intensity", 0.0, 5.0, 0.01, sky3d.sky.atm_sun_mie_intensity)
# --- Moon ---
sec = _add_section(_vbox, "Moon")
_add_slider(sec, "moon_phase", -1.0, 1.0, 0.01, sky3d.sky.moon_phase)
_add_slider(sec, "moon_size", 0.01, 0.3, 0.005, sky3d.sky.moon_size)
_add_slider(sec, "moon_intensity", 0.0, 10.0, 0.1, 2.0)
_add_slider(sec, "atm_moon_mie_intensity", 0.0, 2.0, 0.01, sky3d.sky.atm_moon_mie_intensity)
# --- Sky Layers (toggle each rendering layer independently) ---
_build_sky_layer_ui()
# --- Volumetric Clouds (sky shader) ---
_build_vol_cloud_ui()
# --- Cloud Shadow Layer ---
_build_cloud_layer_ui()
# --- Atmosphere ---
sec = _add_section(_vbox, "Atmosphere")
_add_slider(sec, "atm_thickness", 0.0, 2.0, 0.01, sky3d.sky.atm_thickness)
_add_slider(sec, "atm_mie", 0.0, 0.5, 0.001, sky3d.sky.atm_mie)
_add_slider(sec, "camera_exposure", 0.0, 4.0, 0.01, sky3d.camera_exposure)
_add_slider(sec, "tonemap_exposure", 0.0, 4.0, 0.01, sky3d.tonemap_exposure)
# AERIAL PERSPECTIVE — the two scattering media, exposed so the blue-then-pale
# behaviour can actually be dialled against the view.
#
# AtmFog.gdshader tints by clamp(linear_depth * fog_rayleigh_depth) and
# clamp(linear_depth * fog_mie_depth), with linear_depth in METRES, so each
# value is 1/(distance at which that medium saturates). The addon defaults --
# 0.115 and 0.0001 -- put Rayleigh at 8.7 m and Mie at 10 km, which is a
# sensible ratio for a kilometre-scale world and meaningless in a 917 km one:
# every surface past ~9 m gets an identical tint, i.e. the single-colour fade
# runevision's atmospheric-perspective post says cannot represent distance.
#
# Rayleigh saturating FIRST and Mie much later is what produces blue in the
# mid distance and pale at the far distance. map_core sets both from the map's
# extent; these sliders are for finding the ratio that reads correctly.
_add_slider(sec, "fog_rayleigh_depth", 0.0000005, 0.005, 0.0000005,
sky3d.sky.fog_rayleigh_depth)
_add_slider(sec, "fog_mie_depth", 0.0000005, 0.005, 0.0000005,
sky3d.sky.fog_mie_depth)
# HOW MUCH fog, as opposed to what colour it is. Exposed because the two
# distance terms MULTIPLY -- an exponential in fog_density and a linear ramp
# to fog_end -- so the visible result is not something you can reason your
# way to from either value alone. map_core sets both from the map's extent;
# these are for correcting it against the actual view.
#
# fog_falloff is a height term on the normalised view direction, not on
# altitude: it fades fog OUT as you look up, so the sky is not double-tinted.
_add_slider(sec, "fog_end", 1000.0, 500000.0, 1000.0, sky3d.sky.fog_end)
_add_slider(sec, "fog_density", 0.0000005, 0.001, 0.0000005, sky3d.sky.fog_density)
_add_slider(sec, "fog_start", 0.0, 100000.0, 100.0, sky3d.sky.fog_start)
_add_slider(sec, "fog_falloff", 0.0, 10.0, 0.05, sky3d.sky.fog_falloff)
# --- LAYERED FOG (AtmFog fog_model 1, 2026-10-06) ---
# Near / far / height as INDEPENDENT terms — the four controls above are the old
# multiplied model (fog_model 0), where one density moved both ends. map_core sizes
# these to the distance a map is played at; a saved value here wins.
if "fog_model" in sky3d.sky:
var lf := _add_section(_vbox, "Layered fog (near / far / height)")
_add_slider(lf, "fog_model", 0.0, 1.0, 1.0, float(sky3d.sky.fog_model))
_add_slider(lf, "fog_near_density", 0.000001, 0.001, 0.0, sky3d.sky.fog_near_density)
_add_slider(lf, "fog_far_begin", 10.0, 100000.0, 0.0, sky3d.sky.fog_far_begin)
_add_slider(lf, "fog_far_end", 100.0, 500000.0, 0.0, sky3d.sky.fog_far_end)
_add_slider(lf, "fog_far_curve", 0.25, 4.0, 0.01, sky3d.sky.fog_far_curve)
_add_slider(lf, "fog_far_max", 0.0, 1.0, 0.01, sky3d.sky.fog_far_max)
_add_slider(lf, "fog_height_base", -500.0, 3000.0, 1.0, sky3d.sky.fog_height_base)
_add_slider(lf, "fog_height_falloff", 10.0, 3000.0, 0.0, sky3d.sky.fog_height_falloff)
_add_slider(lf, "fog_height_density", 0.000001, 0.01, 0.0, sky3d.sky.fog_height_density)
_add_slider(lf, "fog_haze_color_depth", 10.0, 50000.0, 0.0, sky3d.sky.fog_haze_color_depth)
mouse_filter = Control.MOUSE_FILTER_STOP
## Build volumetric cloud controls (sky shader parameters)
func _build_vol_cloud_ui() -> void:
if not sky3d.sky:
return
# PROBE THE PROPERTY, not just the node. Sky3D used to render clouds itself and
# these vol_cloud_* properties lived on SkyDome; cloud rendering moved to the
# nubis_clouds addon and they were removed (SkyDome.gd has none). The old node
# check still passed, so this section built until the first access and threw
# "Invalid access to property 'vol_clouds_enabled'" — which aborted _build_ui
# half-way and cost the whole Atmosphere panel every section after it.
if not ("vol_clouds_enabled" in sky3d.sky):
# The clouds that DO render now are SunshineClouds2, a CompositorEffect a map
# appends to this sky's compositor (map_mountain.gd). The section is filled
# in _process when one appears — the panel is built before any map loads.
_sc_box = _add_section(_vbox, "Sunshine Clouds")
_rebuild_sunshine()
return
var sec := _add_section(_vbox, "Volumetric Clouds")
_add_toggle(sec, "vol_clouds_enabled", sky3d.sky.vol_clouds_enabled)
_add_slider(sec, "vol_cloud_coverage", 0.0, 1.0, 0.01, sky3d.sky.vol_cloud_coverage)
_add_slider(sec, "vol_cloud_density", 0.1, 5.0, 0.05, sky3d.sky.vol_cloud_density)
_add_slider(sec, "vol_cloud_scale", 0.1, 5.0, 0.05, sky3d.sky.vol_cloud_scale)
_add_slider(sec, "vol_cloud_base_altitude", 500.0, 5000.0, 50.0, sky3d.sky.vol_cloud_base_altitude)
_add_slider(sec, "vol_cloud_top_altitude", 1000.0, 8000.0, 50.0, sky3d.sky.vol_cloud_top_altitude)
_add_slider(sec, "vol_cloud_march_steps", 8.0, 64.0, 1.0, float(sky3d.sky.vol_cloud_march_steps))
_add_slider(sec, "vol_cloud_light_steps", 1.0, 8.0, 1.0, float(sky3d.sky.vol_cloud_light_steps))
_add_slider(sec, "vol_cloud_absorption", 0.5, 4.0, 0.05, sky3d.sky.vol_cloud_absorption)
_add_slider(sec, "vol_cloud_silver_intensity", 0.0, 3.0, 0.05, sky3d.sky.vol_cloud_silver_intensity)
_add_slider(sec, "vol_cloud_silver_spread", 1.0, 8.0, 0.1, sky3d.sky.vol_cloud_silver_spread)
## Build cloud shadow layer controls
func _build_cloud_layer_ui() -> void:
# `in` FIRST: cloud_system was removed from Sky3DExtended, and reading a
# nonexistent property throws rather than returning null — so the old
# `if not sky3d.cloud_system` guard was itself the error.
if not ("cloud_system" in sky3d) or not sky3d.cloud_system:
return
var sec := _add_section(_vbox, "Cloud Shadows")
_add_shadow_mode_dropdown(sec)
_add_slider(sec, "wind_speed", 0.0, 0.2, 0.005, sky3d.cloud_system.wind_speed)
# Shadow layer params only
# EXPLICIT Array, not `:=`. Sky3D.cloud_system is an untyped var, so
# `sky3d.cloud_system.get_layers()` is Variant to the analyser even though
# get_layers() itself declares `-> Array[CloudLayer]`, and an inferred
# declaration off a Variant is a PARSE error — which failed the whole script
# to load, so this panel never appeared at all. Same for get_params() below.
var layers: Array = sky3d.cloud_system.get_layers()
for layer in layers:
var layer_sec := _add_section(_vbox, layer.name)
_add_layer_toggle(layer_sec, layer)
var params: Array = layer.get_params()
for param in params:
var slider_key: String = layer.name + "/" + str(param["name"])
_add_slider(layer_sec, slider_key,
param["min"], param["max"], param["step"], param["value"])
## Build sky layer toggles and per-layer parameters
func _build_sky_layer_ui() -> void:
# --- Deep Space (milky way cubemap in sky shader) ---
var sec := _add_section(_vbox, "Deep Space")
_add_toggle(sec, "milky_way", sky3d.sky.mw_enabled)
_add_slider(sec, "mw_brightness", 0.0, 5.0, 0.01, sky3d.sky.mw_brightness)
## Create a collapsible section. Returns the content VBoxContainer to add controls to.
func _add_section(parent: VBoxContainer, title: String) -> VBoxContainer:
var sep := HSeparator.new()
parent.add_child(sep)
# Clickable header button
var header := Button.new()
header.text = "[-] " + title
header.flat = true
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
header.add_theme_font_size_override("font_size", 13)
header.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4))
header.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.6))
header.add_theme_color_override("font_pressed_color", Color(1.0, 0.85, 0.4))
parent.add_child(header)
# Content container (starts expanded)
var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
parent.add_child(content)
# Toggle visibility on click
header.pressed.connect(_on_section_toggle.bind(header, content, title))
return content
func _on_section_toggle(header: Button, content: VBoxContainer, title: String) -> void:
content.visible = not content.visible
if content.visible:
header.text = "[-] " + title
else:
header.text = "[+] " + title
func _add_slider(parent: VBoxContainer, param_name: String,
min_val: float, max_val: float, step: float, initial: float) -> void:
var hbox := HBoxContainer.new()
hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# Display just the param part (after "/") for layer params
var display_name := param_name
if "/" in param_name:
display_name = param_name.get_slice("/", 1)
var name_lbl := Label.new()
name_lbl.text = display_name
name_lbl.custom_minimum_size.x = 100
name_lbl.add_theme_font_size_override("font_size", 12)
hbox.add_child(name_lbl)
# Editable min range
var min_edit := _make_range_edit(min_val)
hbox.add_child(min_edit)
var slider := HSlider.new()
slider.min_value = min_val
slider.max_value = max_val
slider.step = step
# LOG SLIDER across three or more decades: linear, the mountain's 6 km fog end sat at
# 1 % of a 1-500 km slider's travel (atmosphere audit 2026-10-06).
if min_val > 0.0 and max_val / min_val >= 100.0:
slider.exp_edit = true
slider.value = initial
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.custom_minimum_size.x = 60
hbox.add_child(slider)
# Editable max range
var max_edit := _make_range_edit(max_val)
hbox.add_child(max_edit)
# Editable value display (highlighted border to distinguish from range edits)
var val_edit := _make_value_edit(initial)
hbox.add_child(val_edit)
parent.add_child(hbox)
_sliders[param_name] = slider
_labels[param_name] = val_edit
_min_edits[param_name] = min_edit
_max_edits[param_name] = max_edit
slider.value_changed.connect(_on_slider_changed.bind(param_name))
min_edit.text_submitted.connect(_on_range_min_submitted.bind(param_name))
max_edit.text_submitted.connect(_on_range_max_submitted.bind(param_name))
val_edit.text_submitted.connect(_on_value_submitted.bind(param_name))
## Create a small styled LineEdit for range min/max input.
func _make_range_edit(value: float) -> LineEdit:
var edit := LineEdit.new()
edit.text = _format_range(value)
edit.custom_minimum_size = Vector2(44, 22)
edit.add_theme_font_size_override("font_size", 11)
edit.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
edit.add_theme_color_override("font_selected_color", Color(1.0, 1.0, 1.0))
edit.add_theme_color_override("caret_color", Color(1.0, 1.0, 1.0))
edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
edit.mouse_filter = Control.MOUSE_FILTER_STOP
edit.select_all_on_focus = true
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.12, 0.12, 0.9)
style.border_color = Color(0.4, 0.4, 0.4, 0.6)
style.set_border_width_all(1)
style.set_content_margin_all(2)
edit.add_theme_stylebox_override("normal", style)
var focus_style := StyleBoxFlat.new()
focus_style.bg_color = Color(0.18, 0.18, 0.25, 0.95)
focus_style.border_color = Color(0.5, 0.7, 1.0, 0.8)
focus_style.set_border_width_all(1)
focus_style.set_content_margin_all(2)
edit.add_theme_stylebox_override("focus", focus_style)
return edit
## Create a styled LineEdit for the current value display.
func _make_value_edit(value: float) -> LineEdit:
var edit := LineEdit.new()
edit.text = _fmt(value)
edit.custom_minimum_size = Vector2(52, 22)
edit.add_theme_font_size_override("font_size", 11)
edit.add_theme_color_override("font_color", Color(0.9, 0.9, 0.9))
edit.add_theme_color_override("font_selected_color", Color(1.0, 1.0, 1.0))
edit.add_theme_color_override("caret_color", Color(1.0, 1.0, 1.0))
edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
edit.mouse_filter = Control.MOUSE_FILTER_STOP
edit.select_all_on_focus = true
var style := StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.2, 0.9)
style.border_color = Color(0.3, 0.5, 0.8, 0.7)
style.set_border_width_all(1)
style.set_content_margin_all(2)
edit.add_theme_stylebox_override("normal", style)
var focus_style := StyleBoxFlat.new()
focus_style.bg_color = Color(0.2, 0.2, 0.3, 0.95)
focus_style.border_color = Color(0.4, 0.7, 1.0, 0.9)
focus_style.set_border_width_all(1)
focus_style.set_content_margin_all(2)
edit.add_theme_stylebox_override("focus", focus_style)
return edit
## Format range values concisely: drop trailing zeros, use int form when possible.
func _format_range(value: float) -> String:
if is_equal_approx(value, roundf(value)):
return str(int(value))
return _fmt(value)
## THE SLIDERS SHOW WHAT THE SKY IS DOING. map_core writes the sky directly when a map
## loads (_scale_atmosphere); the sliders were read once at setup, so they showed the
## scene defaults while the shader ran the map's values, and the first drag jumped
## (atmosphere audit 2026-10-06). Re-read on every open. _applying: a re-read is not
## an edit, so nothing is written to the room.
func _resync_from_sky() -> void:
if sky3d == null or sky3d.sky == null:
return
_applying = true
for p: String in _sliders.keys():
if (p.begins_with("fog_") or p.begins_with("atm_")) and p in sky3d.sky:
var s: HSlider = _sliders[p]
var v := float(sky3d.sky.get(p))
if v < s.min_value: s.min_value = v
if v > s.max_value: s.max_value = v
s.value = v
_applying = false
## A value that READS at every magnitude (atmosphere audit 2026-10-06: "%.3f" showed a
## fog density of 3.3e-4 or a Rayleigh depth of 4e-4 as 0.000): scientific below 0.01,
## whole metres from 1000 up, three decimals between.
func _fmt(v: float) -> String:
if v == 0.0:
return "0"
if absf(v) < 0.01:
return String.num_scientific(v)
if absf(v) >= 1000.0:
return "%d" % int(roundf(v))
return "%.3f" % v
func _on_range_min_submitted(new_text: String, param_name: String) -> void:
var val: float = new_text.to_float()
if param_name in _sliders:
var slider: HSlider = _sliders[param_name]
if val < slider.max_value:
slider.min_value = val
if slider.value < val:
slider.value = val
# Unfocus so keyboard returns to game
_min_edits[param_name].release_focus()
func _on_range_max_submitted(new_text: String, param_name: String) -> void:
var val: float = new_text.to_float()
if param_name in _sliders:
var slider: HSlider = _sliders[param_name]
if val > slider.min_value:
slider.max_value = val
if slider.value > val:
slider.value = val
_max_edits[param_name].release_focus()
func _on_value_submitted(new_text: String, param_name: String) -> void:
var val: float = new_text.to_float()
if param_name in _sliders:
var slider: HSlider = _sliders[param_name]
# Expand range if typed value is outside current range
if val < slider.min_value:
slider.min_value = val
_min_edits[param_name].text = _format_range(val)
if val > slider.max_value:
slider.max_value = val
_max_edits[param_name].text = _format_range(val)
slider.value = val
_labels[param_name].release_focus()
func _add_toggle(parent: VBoxContainer, param_name: String, initial: bool) -> void:
var hbox := HBoxContainer.new()
var name_lbl := Label.new()
name_lbl.text = param_name
name_lbl.custom_minimum_size.x = 130
name_lbl.add_theme_font_size_override("font_size", 12)
hbox.add_child(name_lbl)
var btn := CheckButton.new()
btn.button_pressed = initial
btn.toggled.connect(_on_toggle_changed.bind(param_name))
hbox.add_child(btn)
parent.add_child(hbox)
func _add_layer_toggle(parent: VBoxContainer, layer: CloudLayer) -> void:
var hbox := HBoxContainer.new()
var name_lbl := Label.new()
name_lbl.text = "enabled"
name_lbl.custom_minimum_size.x = 130
name_lbl.add_theme_font_size_override("font_size", 12)
hbox.add_child(name_lbl)
var btn := CheckButton.new()
btn.button_pressed = layer.enabled
btn.toggled.connect(_on_layer_toggle.bind(layer))
hbox.add_child(btn)
_layer_toggles[layer.name] = btn
parent.add_child(hbox)
func _add_shadow_mode_dropdown(parent: VBoxContainer) -> void:
var hbox := HBoxContainer.new()
var name_lbl := Label.new()
name_lbl.text = "shadow_mode"
name_lbl.custom_minimum_size.x = 130
name_lbl.add_theme_font_size_override("font_size", 12)
hbox.add_child(name_lbl)
var dropdown := OptionButton.new()
# Order matches CloudSystem.ShadowMode enum: ANALYTICAL=0, DECAL=1, BOTH=2, NONE=3
dropdown.add_item("Analytical", 0)
dropdown.add_item("Ground Plane", 1)
dropdown.add_item("Both", 2)
dropdown.add_item("None", 3)
dropdown.selected = sky3d.cloud_system.shadow_mode as int
dropdown.item_selected.connect(_on_shadow_mode_changed)
hbox.add_child(dropdown)
parent.add_child(hbox)
## Read current param values from all layers and update sliders to match.
func _sync_layer_sliders() -> void:
if not sky3d.cloud_system:
return
for layer in sky3d.cloud_system.get_layers():
var params: Array = layer.get_params()
for param in params:
var slider_key: String = layer.name + "/" + str(param["name"])
if slider_key in _sliders:
var slider: HSlider = _sliders[slider_key]
var current_val: float = layer.get_param(str(param["name"]))
slider.set_value_no_signal(current_val)
if slider_key in _labels:
var val_edit: LineEdit = _labels[slider_key]
if not val_edit.has_focus():
val_edit.text = _fmt(current_val)
func _on_slider_changed(value: float, param_name: String) -> void:
if param_name in _labels:
var val_edit: LineEdit = _labels[param_name]
if not val_edit.has_focus():
val_edit.text = _fmt(value)
# Persist. Queued rather than written here so a drag is one durable record,
# not one per frame. _applying suppresses the write when the change ARRIVED
# from GameState — otherwise every remote update would echo straight back.
if not _applying:
_pending[param_name] = value
if _write_timer:
_write_timer.start()
# Check if this is a layer parameter (format: "LayerName/param_name")
if "/" in param_name:
var layer_name: String = param_name.get_slice("/", 0)
var p_name: String = param_name.get_slice("/", 1)
# `in` guard: cloud_system was removed from Sky3DExtended when rendering
# moved to nubis_clouds, and reading a missing property throws. Reachable
# only if layer sliders were built, which they no longer are — but an
# unreachable branch that would crash if reached is worth closing.
if not ("cloud_system" in sky3d) or not sky3d.cloud_system:
return
for layer in sky3d.cloud_system.get_layers():
if layer.name == layer_name:
layer.set_param(p_name, value)
# If weather changed, sync dependent sliders
if p_name == "weather":
_sync_layer_sliders()
return
return
# Built-in parameters
match param_name:
# Date & location — every one of these moves the sun and the moon, so each
# has to recompute. TimeOfDay's setters do not (game.gd documents the same
# gap for current_time), which is why _tod_set does it.
"day", "month", "year", "latitude", "longitude", "utc":
_tod_set(param_name, value)
# Time
"current_time":
if _verbose:
print(_binding_line("SLIDER current_time=%.2f" % value))
sky3d.current_time = value
# EXPLICITLY recompute, as _force_daylight and game.gd both do and both
# document: Sky3D's current_time setter does not move the sun or moon,
# and with game_time_enabled false _process never does either. Without
# this a restored time sets the NUMBER and nothing in the sky moves —
# which reads exactly like "it did not persist".
var _t := _tod()
if _t and _t.has_method("_update_celestial_coords"):
_t._update_celestial_coords()
"minutes_per_day":
sky3d.minutes_per_day = value
# Sun
"sun_disk_size":
sky3d.sky.sun_disk_size = value
"sun_disk_intensity":
sky3d.sky.sun_disk_intensity = value
"sun_energy":
sky3d.sun_energy = value
"atm_sun_mie_intensity":
sky3d.sky.atm_sun_mie_intensity = value
# Moon
"moon_phase":
sky3d.sky.moon_phase = value
"moon_size":
sky3d.sky.moon_size = value
"moon_intensity":
if sky3d.sky.sky_material:
sky3d.sky.sky_material.set_shader_parameter("moon_intensity", value)
"atm_moon_mie_intensity":
sky3d.sky.atm_moon_mie_intensity = value
# Atmosphere
"atm_thickness":
sky3d.sky.atm_thickness = value
"atm_mie":
sky3d.sky.atm_mie = value
"fog_rayleigh_depth":
sky3d.sky.fog_rayleigh_depth = value
"fog_mie_depth":
sky3d.sky.fog_mie_depth = value
"fog_end":
sky3d.sky.fog_end = value
"fog_density":
sky3d.sky.fog_density = value
"fog_start":
sky3d.sky.fog_start = value
"fog_falloff":
sky3d.sky.fog_falloff = value
"fog_model":
sky3d.sky.fog_model = int(roundf(value))
"fog_near_density", "fog_far_begin", "fog_far_end", "fog_far_curve", "fog_far_max", \
"fog_height_base", "fog_height_falloff", "fog_height_density", "fog_haze_color_depth":
sky3d.sky.set(param_name, value)
"camera_exposure":
sky3d.camera_exposure = value
"tonemap_exposure":
sky3d.tonemap_exposure = value
"mw_brightness":
sky3d.sky.mw_brightness = value
# Volumetric clouds
"vol_cloud_coverage":
sky3d.sky.vol_cloud_coverage = value
"vol_cloud_density":
sky3d.sky.vol_cloud_density = value
"vol_cloud_scale":
sky3d.sky.vol_cloud_scale = value
"vol_cloud_base_altitude":
sky3d.sky.vol_cloud_base_altitude = value
"vol_cloud_top_altitude":
sky3d.sky.vol_cloud_top_altitude = value
"vol_cloud_march_steps":
sky3d.sky.vol_cloud_march_steps = int(value)
"vol_cloud_light_steps":
sky3d.sky.vol_cloud_light_steps = int(value)
"vol_cloud_absorption":
sky3d.sky.vol_cloud_absorption = value
"vol_cloud_silver_intensity":
sky3d.sky.vol_cloud_silver_intensity = value
"vol_cloud_silver_spread":
sky3d.sky.vol_cloud_silver_spread = value
# Cloud system
"wind_speed":
if sky3d.cloud_system:
sky3d.cloud_system.wind_speed = value
func _on_toggle_changed(pressed: bool, param_name: String) -> void:
match param_name:
"pause_time":
_time_paused = pressed
if pressed:
sky3d.pause()
else:
sky3d.resume()
"milky_way":
sky3d.sky.mw_enabled = pressed
"vol_clouds_enabled":
sky3d.sky.vol_clouds_enabled = pressed
func _on_layer_toggle(pressed: bool, layer: CloudLayer) -> void:
layer.enabled = pressed
func _on_shadow_mode_changed(index: int) -> void:
# Dropdown indices match CloudSystem.ShadowMode enum directly
sky3d.cloud_system.shadow_mode = index as CloudSystem.ShadowMode
func _process(_delta: float) -> void:
# A map adds/removes its SunshineClouds effect on enter/exit; follow it.
if _sc_box != null:
var fx := _find_sunshine()
if fx != _sc_fx:
_sc_fx = fx
_rebuild_sunshine()
_sc_timing_t += _delta
if _sc_timing_t > 0.25 and _sc_fx != null and is_instance_valid(_sc_timing):
_sc_timing_t = 0.0
_sc_timing.text = _sc_timing_text()
# Keep current_time slider in sync with live time progression
if not _time_paused and "current_time" in _sliders:
var slider: HSlider = _sliders["current_time"]
if not slider.has_focus():
slider.set_value_no_signal(sky3d.current_time)
var val_edit: LineEdit = _labels["current_time"]
if not val_edit.has_focus():
val_edit.text = "%.3f" % sky3d.current_time
## The full celestial input tuple plus what it RESOLVED to, in one line.
##
## "I set something that looks like noon and come back to a different sky" cannot
## be diagnosed from current_time alone: TimeOfDay derives sun and moon angles
## from (time, date, latitude, longitude, utc, mode), and Sky3D carries
## current_time on BOTH the wrapper and its TimeOfDay child. Printing the inputs
## AND the outputs at save and at restore makes the two comparable directly,
## instead of inferring which of them moved.
func _celestial_line(tag: String) -> String:
var t := _tod()
var dome := sky3d.get_node_or_null("SkyDome") if sky3d else null
return ("[SkyPanel] %s map=%s wrapper=%.2f tod=%s date=%s/%s/%s lat=%s lon=%s utc=%s " +
"mode=%s sun(alt=%s az=%s) moon(alt=%s az=%s)") % [
tag,
GameState.current_map_id if GameState else "?",
sky3d.current_time if sky3d else -1.0,
str(_tod_get("current_time", "?")),
str(_tod_get("day", "?")), str(_tod_get("month", "?")), str(_tod_get("year", "?")),
str(_tod_get("latitude", "?")), str(_tod_get("longitude", "?")), str(_tod_get("utc", "?")),
str(_tod_get("celestials_calculations", "?")),
str(dome.sun_altitude) if dome and ("sun_altitude" in dome) else "?",
str(dome.sun_azimuth) if dome and ("sun_azimuth" in dome) else "?",
str(dome.moon_altitude) if dome and ("moon_altitude" in dome) else "?",
str(dome.moon_azimuth) if dome and ("moon_azimuth" in dome) else "?"]
## TimeOfDay lives under the Sky3D node and is not the same object as sky3d.sky.
## Both accessors degrade rather than throw: a Sky3D build without one of these
## properties should cost that slider, not the whole panel — which is exactly the
## failure the cloud sections caused.
func _tod() -> Node:
return sky3d.get_node_or_null("TimeOfDay") if sky3d else null
## Properties TimeOfDay keeps in radians but a person reads in degrees.
const ANGLE_PROPS: PackedStringArray = ["latitude", "longitude"]
func _tod_get(prop: String, fallback: Variant) -> Variant:
var t := _tod()
if t == null or not (prop in t):
return fallback
var v: Variant = t.get(prop)
return rad_to_deg(float(v)) if prop in ANGLE_PROPS else v
func _tod_set(prop: String, value: float) -> void:
var t := _tod()
if t == null or not (prop in t):
return
var out: Variant = deg_to_rad(value) if prop in ANGLE_PROPS else value
# int-typed on TimeOfDay (day/month/year); a float would round unpredictably.
t.set(prop, int(round(value)) if typeof(t.get(prop)) == TYPE_INT else out)
# Sky3D's setters do not recompute celestial coordinates, and with
# game_time_enabled false _process never does either — so without this the sun
# and moon simply do not move when the date changes.
if t.has_method("_update_celestial_coords"):
t._update_celestial_coords()
## Write the staged values. One durable record per changed param per drag.
func _flush_writes() -> void:
if GameState == null:
return
for prop: String in _pending:
GameState.set_state(KEY_PREFIX + prop, float(_pending[prop]))
_cache_write()
if _verbose and not _pending.is_empty():
print("[SkyPanel] persisted %d atmosphere setting(s)" % _pending.size())
print(_celestial_line("SAVE "))
_pending.clear()
## A value arriving from GameState — a peer's change, or this room's saved state
## on load. Driven through the SLIDER rather than applied directly, so the readout
## and the sky node cannot disagree: setting slider.value re-enters
## _on_slider_changed, which is the one place a param is applied.
func _apply_external(prop: String, v: float) -> void:
if not _sliders.has(prop):
return
_applying = true
# SNAP, DO NOT LERP. THIS is what was writing the sky after us.
#
# SkyDome INTERPOLATES its celestial positions. Its sun_altitude setter only
# stores a TARGET while _celestial_interpolation_active is true, and
# _interpolate_celestial_positions lerps toward it in _process. So a restore
# never moved the sun — it started it drifting from wherever it already was
# (the scene's authored 19.6, dusk), which is why the slider read 10.5 over a
# sunset sky, and why nudging the slider by hand a moment later looked right:
# that re-targeted a lerp which had by then had time to arrive.
#
# It is also what made the same inputs report four different sun altitudes —
# those samples were taken mid-interpolation, not from a drifting calculation.
#
# A restore is not an animation. Disabling interpolation makes the setter take
# the immediate branch (assign + _update_sun_coords), so the sky is correct on
# the frame it arrives.
var dome: Node = sky3d.get_node_or_null("SkyDome") if sky3d else null
var had_interp: bool = false
if dome and ("_celestial_interpolation_active" in dome):
had_interp = bool(dome.get("_celestial_interpolation_active"))
dome.set("_celestial_interpolation_active", false)
(_sliders[prop] as HSlider).value = v
if dome and had_interp:
dome.set("_celestial_interpolation_active", true)
_applying = false
func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
if not key.begins_with(KEY_PREFIX) or value == null:
return
var prop := key.substr(KEY_PREFIX.length())
# IGNORE A RE-EMIT OF THE SAME VALUE. GameState re-emits every durable key on
# its poll (~3 s), so without this every key re-applied and re-armed the
# settle forever: measured as a "<- 19.4 / re-asserted" pair every three
# seconds for the whole session. Nothing was wrong with the value; it was
# being re-delivered, and we treated each delivery as news.
if _sliders.has(prop) and is_equal_approx((_sliders[prop] as HSlider).value, float(value)):
return
# LOUD, because the failure mode is silence. Room state arrives well after
# setup() — _durable is empty at _ready — so almost every restore comes
# through here rather than through _load_persisted, and "nothing happened"
# has to be distinguishable from "nothing arrived".
# A dropped value is a real defect and always reports; a normal arrival is
# routine and does not.
if not _sliders.has(prop):
push_warning("[SkyPanel] %s = %s arrived with NO SLIDER — value dropped" % [prop, str(value)])
elif _verbose:
print("[SkyPanel] <- %s = %s" % [prop, str(value)])
_apply_external(prop, float(value))
if _verbose and prop == "current_time":
print(_celestial_line("RESTORE"))
# Every arriving restore pushes the settle out, so it fires once after the
# last one rather than once per key.
if _settle_timer:
_settle_timer.start()
## room/map section for the local cache. Empty when either is unknown, in which
## case the cache is skipped rather than written under a wrong name.
func _cache_section() -> String:
var room: String = ""
if KeetService and KeetService.has_method("get_current_room"):
room = String(KeetService.get_current_room())
var map: String = GameState.current_map_id if GameState else ""
return "%s/%s" % [room, map] if room != "" and map != "" else ""
func _cache_write() -> void:
var sec := _cache_section()
if sec == "":
return
var cf := ConfigFile.new()
cf.load(CACHE_PATH) # keep other rooms/maps
for prop: String in _pending:
cf.set_value(sec, prop, float(_pending[prop]))
cf.save(CACHE_PATH)
## Apply the local copy immediately. Returns how many it applied.
func _cache_apply() -> int:
var sec := _cache_section()
if sec == "":
return 0
var cf := ConfigFile.new()
if cf.load(CACHE_PATH) != OK or not cf.has_section(sec):
return 0
var n := 0
for prop: String in cf.get_section_keys(sec):
_apply_external(prop, float(cf.get_value(sec, prop, 0.0)))
n += 1
if _verbose and n > 0:
print("[SkyPanel] pre-applied %d setting(s) from local cache [%s]" % [n, sec])
return n
## Re-apply everything saved, after the scene has stopped overwriting it.
func _settle() -> void:
if GameState == null:
return
var n := _load_persisted()
if _verbose and n > 0:
print("[SkyPanel] re-asserted %d setting(s) after settle" % n)
print(_celestial_line("SETTLE "))
func _load_persisted() -> int:
var n := 0
for key: String in GameState.keys_with_prefix(KEY_PREFIX):
var v: Variant = GameState.get_state(key)
if v != null:
_apply_external(key.substr(KEY_PREFIX.length()), float(v))
n += 1
return n
## WHICH SKY AM I ACTUALLY DRIVING?
##
## The panel is handed a sky ONCE (game.gd:291) and never re-acquires it, while
## game.gd:637 DETACHES that very node whenever a map owns its own
## WorldEnvironment. So "the sliders do nothing on map X" has a specific,
## checkable shape: the bound node is out of the tree, or a DIFFERENT
## WorldEnvironment is the one being rendered. This prints both.
##
## HTPS_SKY_DEBUG=1 to enable.
func _binding_line(tag: String) -> String:
var envs: Array = []
var root := get_tree().root if get_tree() else null
if root != null:
var stack: Array[Node] = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is WorldEnvironment:
envs.append(String(n.get_path()))
for c in n.get_children():
stack.append(c)
var bound := "<null>"
var in_tree := false
if sky3d != null:
in_tree = sky3d.is_inside_tree()
bound = String(sky3d.get_path()) if in_tree else "(DETACHED) " + sky3d.name
return "[SkyPanel] %s bound=%s in_tree=%s tod=%s live_WorldEnvironments=%s" % [
tag, bound, str(in_tree), str(_tod() != null), str(envs)]
# ---- Sunshine Clouds ---------------------------------------------------------
#
# LIVE, NOT PERSISTED. These controls write straight onto the objects the clouds
# are made of — the SunshineCloudsGD resource in the compositor, the
# SunshineCloudsDriverGD node feeding it, and each SunshineCloudsEffector. The
# addon re-reads all of them every frame (update_matrices from _render_callback;
# the driver's _process), so a drag shows at once. They deliberately bypass
# _sliders/_pending: world:sky:* is room state, while a cloud look belongs to the
# map's .tres. "Save to .tres" writes the RESOURCE only; driver/effector values
# come from the map's exports (map_mountain.gd, "Cloud bank").
#
# Built from each object's own export list, so every setting the addon exposes
# appears here, grouped as the author grouped them — no hand-kept copy to drift.
## Groups that are wiring, not look: shader files, and the per-frame positions /
## light data the driver WRITES every frame (editing them would be overwritten).
const _SC_SKIP_GROUPS: PackedStringArray = ["Compute Shaders", "Internal Use"]
## Sections open on build; the rest start collapsed (there are ~70 controls).
const _SC_OPEN: PackedStringArray = ["Render", "Basic Settings"]
func _find_sunshine() -> CompositorEffect:
if sky3d == null or sky3d.compositor == null:
return null
for e in sky3d.compositor.compositor_effects:
if e is SunshineCloudsGD:
return e
return null
## The driver node feeding this effect (it lives under the map, not the sky).
func _find_sunshine_driver() -> Node:
for n in get_tree().root.find_children("*", "Node", true, false):
if n is SunshineCloudsDriverGD and n.clouds_resource == _sc_fx:
return n
return null
func _sc_section(title: String) -> VBoxContainer:
var content := _add_section(_sc_box, title)
if not (title in _SC_OPEN):
var header := _sc_box.get_child(content.get_index() - 1) as Button
_on_section_toggle(header, content, title)
return content
func _rebuild_sunshine() -> void:
for c in _sc_box.get_children():
c.queue_free()
if _sc_fx == null:
var note := Label.new()
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
note.add_theme_color_override("font_color", Color(0.75, 0.75, 0.8))
note.text = "This map has no SunshineClouds effect."
_sc_box.add_child(note)
return
var sec := _sc_section("Render")
_sc_add_bool(sec, _sc_fx, "enabled", _sc_fx.enabled)
# ORDER vs Sky3D's fog. The fog is a transparent screen quad (SkyDome.gd,
# render_priority 100) that tints by the DEPTH BUFFER, which clouds do not
# write. Pre-transparent (the addon default) = the fog is laid over the clouds
# at the depth of whatever is BEHIND them; Post-transparent = clouds drawn on
# top of the fog (and of water/particles).
_sc_add_enum(sec, _sc_fx, "effect_callback_type", _sc_fx.effect_callback_type,
PackedStringArray(["Pre-opaque", "Post-opaque", "Post-sky",
"Pre-transparent", "Post-transparent"]))
var row := HBoxContainer.new()
var save := Button.new()
save.text = "Save to .tres"
save.tooltip_text = "Write the cloud RESOURCE values into the map's .tres (not driver/effector)."
save.pressed.connect(_sc_save)
row.add_child(save)
var refresh := Button.new()
refresh.text = "Refresh Compute"
refresh.tooltip_text = "Rebind textures (needed after swapping a noise texture or the mask)."
refresh.pressed.connect(_sc_refresh)
row.add_child(refresh)
sec.add_child(row)
# COST, MEASURED. profile_gpu makes the addon capture GPU timestamps around
# each of its passes (SunshineClouds.gd read_gpu_timings); this shows them.
_sc_add_bool(sec, _sc_fx, "profile_gpu", bool(_sc_fx.get("profile_gpu")))
_sc_timing = Label.new()
_sc_timing.add_theme_font_size_override("font_size", 11)
_sc_timing.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
sec.add_child(_sc_timing)
_sc_build_props(_sc_fx, "")
var drv := _find_sunshine_driver()
if drv != null:
_sc_build_props(drv, "Driver")
var effs: Array = drv.tracked_point_effectors
for k in effs.size():
var eff: Node3D = effs[k]
if eff == null:
continue
var esec := _sc_section("Effector %d (%s)" % [k, eff.name])
_sc_add_vec3(esec, eff, "position", eff.position)
_sc_build_props(eff, "", esec)
## One control per editable export of `target`, in its declared groups.
## `into` puts them all in one given section instead of per-group sections.
func _sc_build_props(target: Object, prefix: String, into: VBoxContainer = null) -> void:
var scr := target.get_script() as Script
if scr == null:
return
var group := ""
var subgroup := ""
var cur: VBoxContainer = into
# The SCRIPT's own list, not target.get_property_list() gated on the script's
# category entry: Godot adds that category only in TOOLS builds
# (modules/gdscript/gdscript.cpp, #ifdef TOOLS_ENABLED), so in an exported game
# the gate never opened and every export was skipped -- dev-run.sh showed the
# whole panel, the installed game only Render and the effector's position.
# Groups and subgroups are compiled into the script's members in every build.
for p: Dictionary in scr.get_script_property_list():
var usage: int = p["usage"]
var pname: String = p["name"]
if usage & PROPERTY_USAGE_CATEGORY:
continue
if usage & PROPERTY_USAGE_GROUP:
group = pname
subgroup = ""
cur = into
continue
if usage & PROPERTY_USAGE_SUBGROUP:
subgroup = pname
cur = into
continue
if not (usage & PROPERTY_USAGE_EDITOR) or group in _SC_SKIP_GROUPS:
continue
var t: int = p["type"]
if t == TYPE_CALLABLE:
continue # tool buttons (Refresh Compute is in Render; Generate is editor-only)
if cur == null:
var parts: PackedStringArray = []
for x in [prefix, group, subgroup]:
if x != "":
parts.append(x)
cur = _sc_section(" / ".join(parts) if not parts.is_empty() else "General")
var v: Variant = target.get(pname)
var hint: int = p["hint"]
var hs: String = p["hint_string"]
if t == TYPE_BOOL:
_sc_add_bool(cur, target, pname, bool(v))
elif t == TYPE_COLOR:
_sc_add_color(cur, target, pname, v)
elif t == TYPE_VECTOR3:
_sc_add_vec3(cur, target, pname, v)
elif t == TYPE_STRING:
_sc_add_text(cur, target, pname, String(v))
elif t == TYPE_ARRAY and _sc_all_ints(v):
_sc_add_int_array(cur, target, pname, v)
elif t == TYPE_OBJECT or t == TYPE_ARRAY:
# Textures, light/effector node lists: shown, not editable in-game.
_sc_add_readonly(cur, pname, v)
elif t == TYPE_FLOAT or t == TYPE_INT:
if hint == PROPERTY_HINT_ENUM:
_sc_add_enum(cur, target, pname, int(v), hs.split(","))
else:
# No declared range (cloud_floor, step counts, wind speeds...):
# 0..4x the current value. Typing a value widens the slider.
var lo := 0.0
var hi := maxf(1.0, absf(float(v)) * 4.0)
if hint == PROPERTY_HINT_RANGE:
var r := hs.split(",")
lo = r[0].to_float()
hi = r[1].to_float()
# Floats: step 0 = no snapping, so a typed 0.3 on a -100..100 range stays 0.3.
var step := 1.0 if t == TYPE_INT else 0.0
_sc_add_slider(cur, target, pname, lo, hi, step, float(v))
func _sc_timing_text() -> String:
if not bool(_sc_fx.get("profile_gpu")):
return "GPU cost: turn on profile_gpu to measure"
var t: Dictionary = _sc_fx.get("gpu_timings_ms")
if not t.has("total"):
return "GPU cost: waiting for timestamps..."
return "GPU ms total %.2f | march %.2f prepass %.2f blur %.2f post %.2f display %.2f" % [
t["total"], t.get("march", 0.0), t.get("prepass", 0.0), t.get("blur", 0.0),
t.get("post", 0.0), t.get("display", 0.0)]
func _sc_all_ints(v: Variant) -> bool:
if not (v is Array) or (v as Array).is_empty():
return false
for e in v:
if not (e is int):
return false
return true
func _sc_label(text: String) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.custom_minimum_size.x = 100
lbl.add_theme_font_size_override("font_size", 12)
return lbl
func _sc_row(parent: VBoxContainer, prop: String) -> HBoxContainer:
var hbox := HBoxContainer.new()
hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(_sc_label(prop))
parent.add_child(hbox)
return hbox
func _sc_add_slider(parent: VBoxContainer, target: Object, prop: String, lo: float,
hi: float, step: float, v: float) -> void:
var hbox := _sc_row(parent, prop)
var slider := HSlider.new()
slider.min_value = minf(lo, v)
slider.max_value = maxf(hi, v)
slider.step = step
slider.value = v
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.custom_minimum_size.x = 60
hbox.add_child(slider)
var val_edit := _make_value_edit(v)
hbox.add_child(val_edit)
slider.value_changed.connect(_sc_on_slider.bind(target, prop, val_edit))
val_edit.text_submitted.connect(_sc_on_value_text.bind(slider, val_edit))
func _sc_add_bool(parent: VBoxContainer, target: Object, prop: String, v: bool) -> void:
var btn := CheckButton.new()
btn.button_pressed = v
btn.toggled.connect(_sc_on_set.bind(target, prop))
_sc_row(parent, prop).add_child(btn)
func _sc_add_color(parent: VBoxContainer, target: Object, prop: String, v: Color) -> void:
var pick := ColorPickerButton.new()
pick.color = v
pick.custom_minimum_size = Vector2(60, 22)
pick.size_flags_horizontal = Control.SIZE_EXPAND_FILL
pick.color_changed.connect(_sc_on_set.bind(target, prop))
_sc_row(parent, prop).add_child(pick)
func _sc_add_enum(parent: VBoxContainer, target: Object, prop: String, v: int,
items: PackedStringArray) -> void:
var opt := OptionButton.new()
for i in items.size():
# "Name:value" form is allowed in enum hint strings; show the name.
opt.add_item(items[i].get_slice(":", 0), i)
opt.selected = v
opt.item_selected.connect(_sc_on_set.bind(target, prop))
_sc_row(parent, prop).add_child(opt)
func _sc_add_text(parent: VBoxContainer, target: Object, prop: String, v: String) -> void:
var edit := LineEdit.new()
edit.text = v
edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
edit.text_submitted.connect(_sc_on_set.bind(target, prop))
_sc_row(parent, prop).add_child(edit)
## x / y / z value boxes; each submit writes the whole vector back.
func _sc_add_vec3(parent: VBoxContainer, target: Object, prop: String, v: Vector3) -> void:
var hbox := _sc_row(parent, prop)
for axis in 3:
var e := _make_value_edit(v[axis])
hbox.add_child(e)
e.text_submitted.connect(_sc_on_vec3.bind(target, prop, axis, e))
## Array[int] (the driver's per-light shadow steps): one box per element. The
## whole array is reassigned so the driver's setter re-syncs its light data.
func _sc_add_int_array(parent: VBoxContainer, target: Object, prop: String, v: Array) -> void:
var hbox := _sc_row(parent, prop)
for k in v.size():
var e := _make_value_edit(float(v[k]))
e.text = str(v[k])
hbox.add_child(e)
e.text_submitted.connect(_sc_on_int_elem.bind(target, prop, k, e))
func _sc_add_readonly(parent: VBoxContainer, prop: String, v: Variant) -> void:
var lbl := Label.new()
lbl.add_theme_font_size_override("font_size", 11)
lbl.add_theme_color_override("font_color", Color(0.65, 0.65, 0.7))
lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if v is Resource:
var r := v as Resource
lbl.text = r.resource_path.get_file() if r.resource_path != "" else r.get_class()
elif v is Array:
var names: PackedStringArray = []
for e in v:
names.append(str(e.name) if e is Node else str(e))
lbl.text = "[%s]" % ", ".join(names)
else:
lbl.text = "<none>" if v == null else str(v)
_sc_row(parent, prop).add_child(lbl)
func _sc_on_set(value: Variant, target: Object, prop: String) -> void:
if is_instance_valid(target):
target.set(prop, value)
func _sc_on_slider(value: float, target: Object, prop: String, val_edit: LineEdit) -> void:
if not val_edit.has_focus():
val_edit.text = _fmt(value)
if is_instance_valid(target):
var is_int := typeof(target.get(prop)) == TYPE_INT
target.set(prop, int(value) if is_int else value)
func _sc_on_value_text(text: String, slider: HSlider, val_edit: LineEdit) -> void:
var v := text.to_float()
# A typed value outside the slider's range widens it rather than clamping.
slider.min_value = minf(slider.min_value, v)
slider.max_value = maxf(slider.max_value, v)
slider.value = v
val_edit.release_focus()
func _sc_on_vec3(text: String, target: Object, prop: String, axis: int, e: LineEdit) -> void:
if is_instance_valid(target):
var cur: Vector3 = target.get(prop)
cur[axis] = text.to_float()
target.set(prop, cur)
e.release_focus()
func _sc_on_int_elem(text: String, target: Object, prop: String, k: int, e: LineEdit) -> void:
if is_instance_valid(target):
var arr: Array = (target.get(prop) as Array).duplicate()
arr[k] = int(text.to_int())
target.set(prop, arr)
e.release_focus()
func _sc_refresh() -> void:
if _sc_fx != null:
_sc_fx.call("refresh_compute")
func _sc_save() -> void:
if _sc_fx == null:
return
var path := _sc_fx.resource_path
if path == "" or path.contains("::"):
push_warning("[SkyPanel] this cloud effect was built in code, not loaded from a .tres — nothing to save to")
return
var err := ResourceSaver.save(_sc_fx, path)
print("[SkyPanel] saved Sunshine clouds -> %s (%s)" % [path, error_string(err)])