323 lines
11 KiB
GDScript
323 lines
11 KiB
GDScript
extends Node3D
|
|
|
|
## NEON-25: client-local isometric follow; updates [CameraState] each `_process`.
|
|
## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
|
|
## 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 this signal when schema exists.
|
|
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: Node3D occluder bodies. Values: Array of {mesh, surface, original} restore entries.
|
|
var _occluder_overrides: Dictionary = {}
|
|
|
|
@onready var camera: Camera3D = $Camera3D
|
|
|
|
|
|
func _ready() -> void:
|
|
_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 _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()
|
|
|
|
|
|
## 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
|
|
|
|
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 to_restore: Array = _occluder_overrides.keys()
|
|
for body in to_restore:
|
|
if body not in newly_blocking:
|
|
_restore_occluder(body)
|
|
|
|
for body in newly_blocking:
|
|
if body not in _occluder_overrides:
|
|
_apply_occluder_fade(body)
|
|
|
|
|
|
## 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] = overrides
|
|
|
|
|
|
## Restore saved surface override materials on [param body] and remove it from tracking.
|
|
func _restore_occluder(body: Node3D) -> void:
|
|
if not _occluder_overrides.has(body):
|
|
return
|
|
for entry in _occluder_overrides[body]:
|
|
var mi: MeshInstance3D = entry["mesh"]
|
|
if is_instance_valid(mi):
|
|
mi.set_surface_override_material(entry["surface"], entry["original"])
|
|
_occluder_overrides.erase(body)
|
|
|
|
|
|
## Restore all currently faded occluders (called on policy disable or node exit).
|
|
func _restore_all_occluders() -> void:
|
|
for body in _occluder_overrides.keys():
|
|
_restore_occluder(body)
|