neon-sprawl/docs/plans/NEON-27-implementation-plan.md

116 lines
10 KiB
Markdown
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.

# NEON-27 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEON-27 |
| **Title** | E1.M2: OcclusionPolicy — keep player readable through geometry |
| **Jira** | [NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27) |
| **Parent** | [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) |
| **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) |
## Goal, scope, and out-of-scope
**Goal:** Implement `OcclusionPolicy` so static geometry between the camera and the player does not fully hide the player silhouette or combat telegraphs.
**In scope**
- Choose and implement a prototype-appropriate occlusion technique: **RayCast-based material fade** (see [Technical approach](#technical-approach)).
- New `OcclusionPolicy` **Resource** (`occlusion_policy.gd`) — data-driven config for fade alpha, fade duration, affected collision mask, cast depth cap, and optional perf-log threshold; follows the same no-`class_name` convention as `ZoomBandConfig`.
- Wire `OcclusionPolicy` into `isometric_follow_camera.gd` as an `@export` so it runs consistently in the same camera controller stack as follow + zoom.
- Tag the prototype district `Obstacle` node (a 2 × 2 × 2 box at `(6, 0, 5)`) with the **`"occluder"`** group; assign the new `.tres` on the `IsometricFollowCamera` rig in `main.tscn`.
- Optional perf marker: when `occluder_count_log_threshold > 0` and the active occluder count meets or exceeds the threshold, emit `push_warning` for later stress-test reference.
- Update `E1_M2_IsometricCameraController.md` with an implementation snapshot and the readability risk gate note required by acceptance criteria.
**Out of scope** (per Jira)
- Perfect transparency for all scene assets; full art pipeline integration.
- Server awareness of occlusion.
- Smooth occlusion handling for moving/dynamic objects (static geometry only for prototype).
- Yaw orbit or camera nudge approach (would break framing; fixed rig stays).
## Acceptance criteria checklist
- [ ] In a documented test setup, occlusion **no longer fully hides** the player in the common worst case (before/after screenshot or short clip note in PR).
- [ ] Policy is **configurable or data-driven** enough to iterate without rewriting core follow logic (`OcclusionPolicy` resource swappable in inspector; `fade_alpha` and `fade_duration` are exported).
- [ ] Risk called out in module doc: occlusion hiding telegraphs — link to **readability checklist** or gate note for prototype demo.
## Technical approach
### Technique: RayCast-based per-surface material fade
Each `_process` frame, after the camera eye is positioned, cast a ray from the smoothed eye position (`_smoothed_eye`) to the player's focus point. Any `StaticBody3D` (or similar) intersecting the ray that is tagged with the **`"occluder"`** group has its `MeshInstance3D` children' surfaces overridden with a **faded copy** of their materials (alpha reduced to `fade_alpha`). When a body leaves the ray path, its original surface materials are restored.
**Why this technique:** pure GDScript — no custom shader required for prototype; configurable; reversible; respects combat readability by making geometry semi-transparent rather than fully hiding it. Camera nudge would break the fixed isometric framing. A cutout shader would require art pipeline work outside the prototype scope.
**Why group-based (`"occluder"`) rather than collision-layer filtering alone:** the prototype `Floor` (`StaticBody3D`) shares the default collision layer 1 with the `Obstacle`. Filtering by group is explicit and avoids false hits from the floor geometry without requiring separate collision-layer assignments.
**Player exclusion:** the `Player` is on collision layer 2 (independent of layer 1); the default `occluder_collision_mask` of `1` already excludes the player body without needing to add it to the ray exclusion list. Confirmed from `main.tscn`: `Player.collision_layer = 2`.
**Material override strategy:** for each `MeshInstance3D` child of a blocking body, iterate surfaces. For each surface, save the current `surface_override_material` (may be null). Create a transparent override: if the surface's mesh material is a `StandardMaterial3D`, `duplicate()` it; otherwise create a new `StandardMaterial3D` inheriting only `albedo_color`. Set `transparency = BaseMaterial3D.TRANSPARENCY_ALPHA` and `albedo_color.a = fade_alpha`. Restore by setting `surface_override_material` back to the saved value (or `null`) when the body is no longer blocking.
**Fade timing:** use a `Tween` on the camera rig to animate the alpha from 1.0 → `fade_alpha` over `fade_duration` seconds (and back when restoring), avoiding jarring pop. State: `_occluder_overrides: Dictionary` mapping `Node3D → Array[{mesh, surface, original_mat, tween}]`.
### Step-by-step implementation
1. **`occlusion_policy.gd`** — `extends Resource`, no `class_name`. Exported fields:
- `enabled: bool = true`
- `fade_alpha: float = 0.25` — target alpha when occluding (0 = invisible, 1 = opaque).
- `fade_duration: float = 0.15` — tween duration in seconds for appear/disappear.
- `occluder_group: String = "occluder"` — group that tags scene nodes as potential occluders.
- `occluder_collision_mask: int = 1` — physics layers the ray checks.
- `max_occluder_cast_depth: int = 4` — max successive intersect_ray calls per frame (caps cost).
- `occluder_count_log_threshold: int = 0` — if > 0, push_warning when active count ≥ threshold.
- Validation helper: `func is_valid() -> bool` — returns `true` if `enabled` and `fade_alpha >= 0`.
2. **`isometric_follow_camera.gd`** changes:
- Add `@export var occlusion_policy: Resource`.
- Add private state: `_occluder_overrides: Dictionary = {}`.
- Add `_occlusion_policy_valid() -> bool` (mirrors `_zoom_config_valid`): checks script identity and `is_valid()`.
- Call `_update_occlusion(focus)` at end of `_process` after the eye / look_at update.
- `_update_occlusion(focus: Vector3) -> void`: if policy invalid, call `_restore_all_occluders()` and return. Otherwise, cast iterative rays (up to `max_occluder_cast_depth`), filter hits by `occluder_group`; restore bodies no longer blocking; fade newly blocking bodies. Emit perf warning when threshold exceeded.
- `_apply_occluder_fade(body: Node3D) -> void`: enumerate `MeshInstance3D` descendants, save overrides, create faded materials, tween alpha in.
- `_restore_occluder(body: Node3D) -> void`: tween alpha back to 1.0 then restore original materials, remove from `_occluder_overrides`.
- `_restore_all_occluders() -> void`: call `_restore_occluder` for all entries (on disable/null policy).
3. **`isometric_occlusion_policy.tres`** — default `OcclusionPolicy` resource (fade_alpha=0.25, fade_duration=0.15, mask=1, depth=4, threshold=0). Assign to `IsometricFollowCamera` in `main.tscn`.
4. **`main.tscn`** — Add `"occluder"` group to the `Obstacle` node; assign `occlusion_policy` resource on `IsometricFollowCamera`.
5. **`E1_M2_IsometricCameraController.md`** — Add NEON-27 implementation snapshot; add readability risk gate note (links to acceptance criteria: before-/after screenshot in PR required at prototype demo gate).
## Files to add
| Path | Purpose |
|------|---------|
| `client/scripts/occlusion_policy.gd` | `Resource` type holding fade and detection config; `is_valid()` helper; no `class_name`. |
| `client/resources/isometric_occlusion_policy.tres` | Default `OcclusionPolicy` instance for the prototype rig. |
| `client/test/occlusion_policy_test.gd` | GdUnit4 unit tests for the policy resource (see [Tests](#tests)). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/isometric_follow_camera.gd` | Add `occlusion_policy` export, `_update_occlusion` pipeline, occluder override state, fade/restore helpers, `_occlusion_policy_valid` guard. |
| `client/scenes/main.tscn` | Tag `Obstacle` with `"occluder"` group; assign `isometric_occlusion_policy.tres` on `IsometricFollowCamera`. |
| `client/test/isometric_follow_camera_test.gd` | Add test for `_occlusion_policy_valid`-equivalent static path (null policy → false; wrong-script resource → false; valid resource → true) via a static helper if possible; otherwise document as manual-only. |
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Add NEON-27 implementation snapshot; add readability risk gate note per AC. |
## Tests
| File | Coverage |
|------|----------|
| `client/test/occlusion_policy_test.gd` | **New.** `is_valid()` returns true with defaults; returns false when `enabled = false`; `fade_alpha` edge cases (0.0 valid, negative invalid if applicable). Default field values match resource. |
| `client/test/isometric_follow_camera_test.gd` | **Change.** Add a static-helper test for occlusion policy guard: confirm that a null resource and a wrong-script resource are rejected, and a correct-script `OcclusionPolicy` resource is accepted (no physics engine needed — tests the guard function logic only). |
**Manual verification:** Walk the player behind the `Obstacle` node in the running client. The obstacle should fade to semi-transparent (alpha ~0.25) as the player passes behind it, then restore when the player moves clear. Confirm `CameraState` / camera framing are unaffected. Capture a brief clip or before/after screenshot for the PR.
## Open questions / risks
- **Tween lifecycle:** `Tween`s created on the rig must be killed (`tween.kill()`) when a body is re-acquired as an occluder before the restore tween completes, to avoid competing animations on the same material. Track the active tween per body in `_occluder_overrides`.
- **Shared vs. per-instance materials:** if the `Obstacle` in the future uses a shared `StandardMaterial3D` from the mesh resource directly (no override set), `duplicate()` ensures we don't mutate the shared asset. This is already handled by the override strategy above, but must be verified at runtime.
- **`max_occluder_cast_depth` performance:** each `intersect_ray` call with growing exclusion list is O(scene complexity). Cap of 4 is conservative for the prototype district; tune if the perf log fires.
- **Scene grows (NEON-29):** when the prototype district expands (NEON-29), add representative occluders to the `"occluder"` group and rerun the manual verification pass.
- **Readability gate:** the risk documented in the module doc (occlusion hiding telegraphs) must be surfaced in the prototype demo checklist. Before-/after evidence in this PR's description serves as the first data point.