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: action [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 ref 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] dev toggle. ## Restore materials and clear [member _occluder_overrides] **while bodies are still live**, ## then the caller may [method Node.queue_free] the occluder. Avoids post-free occlusion touching a ## tearing-down [StaticBody3D] (semi-transparent faded box). func restore_all_occluder_materials_now() -> void: _restore_all_occluders()