neon-sprawl/client/scripts/isometric_follow_camera.gd

386 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

extends Node3D
## NEON-25: client-local isometric follow; updates [CameraState] each physics tick.
## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
## NEON-28: occluder overrides are keyed by [method Object.get_instance_id] only — never [Object]
## keys, or [method Dictionary.keys] / [method Dictionary.erase] can freeze after an occluder is
## [method Node.queue_free]'d (dev smoke: [InputMap] [code]dev_toggle_occluder_obstacle[/code] in [code]main.gd[/code]).
## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while
## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here,
## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg].
## TODO(E9.M1): map throttled product telemetry (for example `camera_zoom_changed`) to
## [signal zoom_band_changed] when schema exists; optional occlusion/perf counters per module doc.
signal zoom_band_changed(new_index: int, distance: float)
const CameraStateScript := preload("res://scripts/camera_state.gd")
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
## When [member zoom_band_config] is null or has no bands, this is the sole follow distance.
@export var follow_target_path: NodePath = NodePath("../Player")
@export var follow_distance: float = 25.709
@export var pitch_elevation_deg: float = 20.693
@export var presentation_yaw_deg: float = 45.0
@export var focus_vertical_offset: float = 0.0
## Designer-tunable discrete bands (`res://resources/isometric_zoom_bands.tres` on main rig).
@export var zoom_band_config: Resource
## RayCast-based occluder fade config (`res://resources/isometric_occlusion_policy.tres`).
@export var occlusion_policy: Resource
@export var allow_yaw: bool = false
@export var max_yaw_deg: float = 45.0
## Higher = snappier follow (`1 - exp(-smoothness * delta)`).
@export var follow_smoothness: float = 12.0
## When eye-to-desired distance exceeds this, snap (server snap / huge teleports).
@export var snap_distance: float = 24.0
## Latest [CameraState] tick; same object each frame (see NEON-25 plan).
var camera_state:
get:
return _state
var _state = null
var _smoothed_eye: Vector3 = Vector3.ZERO
var _orbit_yaw_rad: float = 0.0
var _zoom_band_index: int = 0
var _warned_missing_follow_target: bool = false
## Keys: [code]int[/code] [method Object.get_instance_id] of the occluder body. Values: Array of
## {mesh, surface, original} restore entries.
var _occluder_overrides: Dictionary = {}
@onready var camera: Camera3D = $Camera3D
func _ready() -> void:
# After `Player` (default priority 0): read `global_position` post`move_and_slide` this tick.
process_physics_priority = 1
_state = CameraStateScript.new()
_init_zoom_band_index()
if camera == null:
push_error("IsometricFollowCamera: expected child Camera3D")
return
var t: Node3D = _resolve_target()
if t != null:
if not allow_yaw:
_orbit_yaw_rad = 0.0
else:
_orbit_yaw_rad = clampf(
_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg)
)
var focus0: Vector3 = t.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
var yaw0: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
var dist0: float = _current_follow_distance()
_smoothed_eye = desired_eye_world(focus0, dist0, deg_to_rad(pitch_elevation_deg), yaw0)
global_position = _smoothed_eye
camera.look_at(focus0, Vector3.UP)
_sync_camera_state(focus0, dist0)
else:
_smoothed_eye = global_position
_warn_missing_follow_target_once()
func _physics_process(delta: float) -> void:
if camera == null or _state == null:
_restore_all_occluders()
return
var target: Node3D = _resolve_target()
if target == null:
# No eye/state update until the path resolves — avoids chasing a freed or miswired node.
_restore_all_occluders()
_warn_missing_follow_target_once()
return
_warned_missing_follow_target = false
if not allow_yaw:
_orbit_yaw_rad = 0.0
else:
_orbit_yaw_rad = clampf(_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg))
var focus: Vector3 = target.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
var yaw_total: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
var effective_dist: float = _current_follow_distance()
var desired: Vector3 = desired_eye_world(
focus, effective_dist, deg_to_rad(pitch_elevation_deg), yaw_total
)
if _smoothed_eye.distance_to(desired) >= snap_distance:
_smoothed_eye = desired
else:
var k: float = 1.0 - exp(-follow_smoothness * delta)
_smoothed_eye = _smoothed_eye.lerp(desired, k)
global_position = _smoothed_eye
camera.look_at(focus, Vector3.UP)
_sync_camera_state(focus, effective_dist)
_update_occlusion(focus)
func _exit_tree() -> void:
_restore_all_occluders()
func _unhandled_input(event: InputEvent) -> void:
if event.is_echo():
return
if event.is_action_pressed("camera_zoom_in"):
_apply_zoom_step(-1)
elif event.is_action_pressed("camera_zoom_out"):
_apply_zoom_step(1)
## **Near** = lower band index. Zoom in → closer → decrease index (clamped).
func _apply_zoom_step(delta_bands: int) -> void:
if not _zoom_config_valid():
return
var cfg: Resource = zoom_band_config
var new_idx: int = cfg.clamp_index(_zoom_band_index + delta_bands)
if new_idx == _zoom_band_index:
return
_zoom_band_index = new_idx
var d: float = _current_follow_distance()
zoom_band_changed.emit(_zoom_band_index, d)
func _init_zoom_band_index() -> void:
_zoom_band_index = 0
if not _zoom_config_valid():
return
var cfg: Resource = zoom_band_config
_zoom_band_index = cfg.clamp_index(cfg.default_band_index)
func _zoom_config_valid() -> bool:
return (
zoom_band_config != null
and zoom_band_config.get_script() == ZoomBandConfigScript
and zoom_band_config.band_count() > 0
and zoom_band_config.all_band_distances_positive()
)
func _current_follow_distance() -> float:
return effective_follow_distance(zoom_band_config, _zoom_band_index, follow_distance)
static func effective_follow_distance(
zoom_cfg: Resource, band_index: int, fallback_distance: float
) -> float:
if zoom_cfg == null or zoom_cfg.get_script() != ZoomBandConfigScript:
return fallback_distance
if zoom_cfg.band_count() == 0:
return fallback_distance
if not zoom_cfg.all_band_distances_positive():
return fallback_distance
return zoom_cfg.distance_at(band_index)
func _sync_camera_state(focus: Vector3, effective_distance: float) -> void:
# `CameraState.yaw` = orbit delta only; world-fixed diagonal framing =
# `presentation_yaw_deg`.
_state.follow_target_path = follow_target_path
_state.distance = effective_distance
_state.zoom_band_index = _zoom_band_index if _zoom_config_valid() else 0
_state.focus_world = focus
_state.yaw = _orbit_yaw_rad
func _resolve_target() -> Node3D:
if follow_target_path.is_empty():
return null
var n: Node = get_node_or_null(follow_target_path)
if n is Node3D:
return n as Node3D
return null
func _warn_missing_follow_target_once() -> void:
if _warned_missing_follow_target:
return
_warned_missing_follow_target = true
push_warning(
(
"IsometricFollowCamera: follow_target_path is empty or does not resolve to a Node3D; "
+ "camera not updating."
)
)
static func desired_eye_world(
focus: Vector3, distance: float, pitch_elevation_rad: float, yaw_rad: float
) -> Vector3:
var h: float = distance * cos(pitch_elevation_rad)
return focus + Vector3(h * sin(yaw_rad), distance * sin(pitch_elevation_rad), h * cos(yaw_rad))
## Returns true when [member occlusion_policy] is a valid, enabled [OcclusionPolicy].
## Mirrors [method _zoom_config_valid]; delegates to the static helper so tests can call
## it without a live Node.
func _occlusion_policy_valid() -> bool:
return occlusion_policy_is_valid(occlusion_policy)
## Static guard — safe to call from tests without a live scene.
static func occlusion_policy_is_valid(policy: Resource) -> bool:
return policy != null and policy.get_script() == OcclusionPolicyScript and policy.is_valid()
## True if [param body] is a live [Node3D] suitable as an [member _occluder_overrides] key.
## Used before occlusion ray work and from tests (NEON-28).
## [code]is_instance_valid[/code] first: [code]is Node3D[/code] on a freed reference errors at runtime.
static func occluder_override_key_is_valid(body: Variant) -> bool:
if not is_instance_valid(body):
return false
return body is Node3D
func _restore_occluder_overrides_list(overrides: Array) -> void:
for entry in overrides:
var mi = entry.get("mesh")
if not is_instance_valid(mi) or not mi is MeshInstance3D:
continue
var s: int = int(entry.get("surface", -1))
if s < 0:
continue
var original: Variant = entry.get("original")
if original != null:
if typeof(original) != TYPE_OBJECT or not is_instance_valid(original):
continue
(mi as MeshInstance3D).set_surface_override_material(s, original)
## Drop stale entries (freed nodes or non-int legacy keys) without touching meshes.
func _purge_invalid_occluder_override_keys() -> void:
var next: Dictionary = {}
for k in _occluder_overrides.keys():
if typeof(k) != TYPE_INT:
continue
var occ_id: int = k as int
var node := instance_from_id(occ_id)
if not is_instance_valid(node) or not node is Node3D:
continue
next[occ_id] = _occluder_overrides[k]
_occluder_overrides = next
## Cast iterative rays from the smoothed eye to the player focus.
## Bodies in the [member occluder_group] that intersect the ray are faded;
## bodies that leave the path have their materials restored.
func _update_occlusion(focus: Vector3) -> void:
if not _occlusion_policy_valid():
_restore_all_occluders()
return
_purge_invalid_occluder_override_keys()
var policy = occlusion_policy
var space_state := get_world_3d().direct_space_state
var newly_blocking: Array[Node3D] = []
var excluded: Array[RID] = []
for _i in range(policy.max_occluder_cast_depth):
var params := PhysicsRayQueryParameters3D.create(
_smoothed_eye, focus, policy.occluder_collision_mask
)
params.exclude = excluded
var result := space_state.intersect_ray(params)
if result.is_empty():
break
var body = result.get("collider")
if body is Node3D and (body as Node3D).is_in_group(policy.occluder_group):
newly_blocking.append(body as Node3D)
excluded.append(result.get("rid"))
if (
policy.occluder_count_log_threshold > 0
and newly_blocking.size() >= policy.occluder_count_log_threshold
):
push_warning(
(
"OcclusionPolicy: %d occluder(s) active (threshold %d)"
% [newly_blocking.size(), policy.occluder_count_log_threshold]
)
)
var blocking_ids: Dictionary = {}
for body in newly_blocking:
if occluder_override_key_is_valid(body):
blocking_ids[(body as Node3D).get_instance_id()] = true
for occ_id in _occluder_overrides.keys():
if typeof(occ_id) != TYPE_INT:
continue
var id: int = occ_id as int
if blocking_ids.has(id):
continue
_restore_occluder_id(id)
for body in newly_blocking:
if not occluder_override_key_is_valid(body):
continue
var bid: int = (body as Node3D).get_instance_id()
if not _occluder_overrides.has(bid):
_apply_occluder_fade(body as Node3D)
## Apply per-surface fade overrides to all [MeshInstance3D] children of [param body].
## - [StandardMaterial3D]: duplicated and alpha-set to [member OcclusionPolicy.fade_alpha].
## - null (no material): a plain [StandardMaterial3D] is created so the surface fades.
## - Anything else (e.g. [ShaderMaterial]): skipped with [method push_warning].
func _apply_occluder_fade(body: Node3D) -> void:
var policy = occlusion_policy
var overrides: Array = []
for mesh_node in body.find_children("*", "MeshInstance3D", true, false):
var mi: MeshInstance3D = mesh_node
var n: int = mi.get_surface_override_material_count()
for s in range(n):
var saved: Material = mi.get_surface_override_material(s)
var effective: Material = saved
if effective == null and mi.mesh != null:
effective = mi.mesh.surface_get_material(s)
var faded: StandardMaterial3D
if effective is StandardMaterial3D:
faded = (effective as StandardMaterial3D).duplicate()
elif effective == null:
faded = StandardMaterial3D.new()
else:
push_warning(
(
"OcclusionPolicy: %s surface %d is %s — skipping fade."
% [mi.get_path(), s, effective.get_class()]
)
)
continue
faded.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
faded.albedo_color.a = policy.fade_alpha
mi.set_surface_override_material(s, faded)
overrides.append({"mesh": mi, "surface": s, "original": saved})
_occluder_overrides[body.get_instance_id()] = overrides
## Restore saved surface override materials for occluder [param occ_id] and remove it from tracking.
func _restore_occluder_id(occ_id: int) -> void:
if not _occluder_overrides.has(occ_id):
return
_restore_occluder_overrides_list(_occluder_overrides[occ_id])
_occluder_overrides.erase(occ_id)
## Restore all currently faded occluders (called on policy disable or node exit).
func _restore_all_occluders() -> void:
_purge_invalid_occluder_override_keys()
for occ_id in _occluder_overrides.keys():
if typeof(occ_id) == TYPE_INT:
_restore_occluder_id(occ_id as int)
## Public hook for [code]dev_toggle_occluder_obstacle[/code] smoke ([code]main.gd[/code]): restore materials and clear [member _occluder_overrides] **while bodies
## are still live**, then the caller may [method Node.queue_free] the occluder. Avoids any post-free
## occlusion pass touching a tearing-down [StaticBody3D] (the semi-transparent faded box case).
func restore_all_occluder_materials_now() -> void:
_restore_all_occluders()