neon-sprawl/docs/decomposition/modules/E1_M2_IsometricCameraContro...

122 lines
12 KiB
Markdown
Raw Permalink 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.

# E1.M2 — IsometricCameraController
## Summary
| Field | Value |
|--------|--------|
| **Module ID** | E1.M2 |
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
| **Stage target** | Prototype |
| **Status** | **Ready** (prototype slice) — follow + `CameraState` + **discrete zoom bands** + **occlusion** + pick-through ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)[NEO-17](https://linear.app/neon-sprawl/issue/NEO-17), [NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)); see [dependency register](module_dependency_register.md) |
| **Linear** | Project [E1.M2 — IsometricCameraController](https://linear.app/neon-sprawl/project/e1m2-isometriccameracontroller-deac8ef10395); prototype backlog issues under [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) — [Linear backlog](#linear-backlog) |
## Purpose
**Authority:** [Client vs server](client_server_authority.md#e1m2-isometriccameracontroller) — camera is client-local for prototype; server does not use camera pose for gameplay checks.
Delivers an isometric follow camera that keeps the player readable during motion: follow behavior, discrete zoom bands, occlusion handling, and **yaw held at a fixed default for players in early prototypes** so combat telegraphs and UI stay easy to reason about. **After release, the shipped camera model is settled** (fixed presentation vs product setting that allows yaw)—we do **not** plan to revisit that post-launch. **Before release**, we may still decide to expose **yaw orbit** for comfort; the implementation follows the [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise) so that decision stays mid-project scope, not a rewrite.
## Responsibilities
- Locked isometric follow for **player-facing controls in prototype**: **yaw = 0** (no orbit bound in UI) while the rig and **`CameraState`** still model **yaw explicitly** so optional orbit can be enabled later via config and input binding.
- Zoom bands and configuration-driven limits.
- Occlusion policy so critical gameplay elements remain visible where possible.
- Pitch / roll remain fixed for the isometric presentation; only **yaw** is the optional future degree of freedom.
## Mid-project rotation policy (practical compromise)
**Intent:** Ship prototypes and early milestones **as if** the camera were fully fixed from the players perspective, without closing the door to **yaw orbit** before release.
| Topic | Direction |
|--------|-----------|
| **When this applies** | **Mid-project only.** We may enable or keep disabled **yaw orbit** while still in development. **Post-release**, treat the chosen shipped behavior as **final**—no planned pivot between “fixed” and “orbit” for live players. |
| **Implementation** | Single seam: e.g. **`allow_yaw`**, **`max_yaw_deg`**, or equivalent; default keeps **yaw at 0** and **no rotate input** until product + combat readability agree. `CameraState` carries **yaw** (default `0`) so consumers do not assume “camera yaw does not exist.” |
| **Gameplay semantics** | Prefer **world-anchored** movement, targeting, and server checks—not **camera-relative** aim—so turning yaw on later is mostly **presentation + UI**, not a combat authority redesign. |
| **Telegraphs & VFX** | Design **now** for a world where yaw might exist: ground shapes remain **world-accurate**; plan **screen-space or height** readability affordances where foreshortening under yaw would otherwise lie (outlines, rings, bars—exact art TBD). |
| **Migration risk** | If we add yaw mid-project, expect a **telegraph + minimap / compass** pass. **Fixed → yaw** is usually lower risk than **yaw → fixed** (content and players may already assume spin for sightlines). |
## Key contracts
| Contract | Role |
|----------|------|
| `CameraState` | Per-frame snapshot: follow path, effective distance, zoom band index, focus, **yaw** (orbit delta; prototype `0`). **Policy / presentation exports are not duplicated** in this type for the prototype — read the rig when needed ([Consumer contract](#consumer-contract-neo-18)). |
| `ZoomBandConfig` | Data-driven min/max or discrete zoom steps. |
| `OcclusionPolicy` | Rules for fading, dithering, or offset when geometry blocks the view. |
## Consumer contract (NEO-18)
Single place for dependents (e.g. **E6.M2**): what is on `CameraState` vs what stays on the rig. **Authority:** [E1.M2 — client vs server](client_server_authority.md#e1m2-isometriccameracontroller).
### `CameraState` fields (`client/scripts/camera_state.gd`)
Refreshed **every `_physics_process`** tick on `IsometricFollowCamera` after the follow target has moved (`process_physics_priority` after `Player`). Same object each tick (rig-owned).
| Field | Meaning |
|--------|---------|
| `yaw` | Orbit delta (rad) around the follow target **relative to** `presentation_yaw_deg`**not** full compass. **0** while `allow_yaw` is false (prototype UX). |
| `follow_target_path` | `NodePath` to the follow anchor (e.g. player). |
| `distance` | Effective follow distance this tick (active band or fallback). |
| `zoom_band_index` | Active discrete band index, or **0** when zoom config is invalid / unused. |
| `focus_world` | World look-at focus last tick. |
### Rig-only reads (`client/scripts/isometric_follow_camera.gd`, `World/IsometricFollowCamera` in `main.tscn`)
| Export / node | Meaning |
|----------------|---------|
| `allow_yaw`, `max_yaw_deg` | Orbit seam (no rotate input bound in prototype). |
| `presentation_yaw_deg`, `pitch_elevation_deg`, `follow_distance`, `focus_vertical_offset` | Fixed isometric presentation (pitch/roll not exposed as free axes). |
| `zoom_band_config`, `occlusion_policy` | Resources driving bands and occlusion. |
| Child `Camera3D` | Active render camera (`ground_pick` uses viewport / `fallback_camera` from `main.gd`). |
**Telemetry:** Throttled `camera_zoom_changed` and occlusion/perf hooks are **TODO(E9.M1)** until an event schema exists (`zoom_band_changed` signal on the rig today).
## Module dependencies
- **E1.M1** — InputAndMovementRuntime: camera follows the player anchor derived from movement/position.
## Dependents (by design)
- **E6.M2** — Consent and risk UX may use camera-adjacent presentation; epic lists E1.M2 as a dependency for in-zone risk signaling and readability.
## Related implementation slices
See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, occlusion policy, **yaw fixed for players in prototype** (implementation still models yaw per [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)); optional telemetry such as throttled `camera_zoom_changed` and perf stress markers for occluders.
## Implementation snapshot (NEO-15 + NEO-16 + NEO-17 + NEO-20 + NEO-18, 2026-04-08 / 2026-04-10)
- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`** = effective follow distance; **`zoom_band_index`**; **`focus_world`**, **`follow_target_path`**).
- **Seam:** **`allow_yaw`** / **`max_yaw_deg`** on the rig; orbit would adjust **`_orbit_yaw_rad`** (no input bound yet). **Presentation** compass uses **`presentation_yaw_deg`** separately from orbit **`yaw`** in state.
- **Zoom:** **`ZoomBandConfig`** resource (`client/scripts/zoom_band_config.gd`); default **`client/resources/isometric_zoom_bands.tres`** on main rig. Bands are **distance-only** (ascending near→far); **pitch / FOV** unchanged vs NEO-15. Input: **`camera_zoom_in`** / **`camera_zoom_out`** in `client/project.godot` (mouse wheel + `=` / `-` + keypad **+** / ****). **`follow_distance`** remains **fallback** when config is null or has no bands; state **`zoom_band_index`** is **0** in that case. **`zoom_band_changed`** signal on rig; **TODO(E9.M1)** for throttled `camera_zoom_changed` telemetry.
- **Occlusion:** **`OcclusionPolicy`** resource (`client/scripts/occlusion_policy.gd`); default **`client/resources/isometric_occlusion_policy.tres`** on main rig. **Technique:** each **`_physics_process`** tick, iterative `intersect_ray` from `_smoothed_eye` to player focus (up to `max_occluder_cast_depth` calls). Bodies tagged `"occluder"` that intersect the ray have their `MeshInstance3D` surfaces overridden with a transparent `StandardMaterial3D` copy (`fade_alpha = 0.25`). Materials restored instantly when the body clears the ray. **Null-material surfaces** (no mesh or override material set) receive a plain transparent `StandardMaterial3D`; other non-`StandardMaterial3D` surfaces are skipped with `push_warning`. **Prototype district:** `Obstacle` node in `main.tscn` tagged `"occluder"`. **Perf marker:** `occluder_count_log_threshold` export (default 0 = disabled) emits `push_warning` for stress-test reference. See [risks and telemetry](#risks-and-telemetry) for readability gate.
> **Prototype demo readability gate:** before shipping the prototype demo, the PR for NEO-17 must include a before/after screenshot or clip demonstrating that the player is no longer fully hidden by the `Obstacle`. This serves as the first data point for the occlusion-hiding-telegraphs risk documented below.
- **Click-through input (NEO-20):** `scripts/ground_pick.gd` unconditionally skips bodies in the `"occluder"` group during click-to-move ground-pick raycasts. When a cast hits an occluder the ray origin advances `OCCLUDER_PICK_THROUGH` meters past the hit point and continues, independent of whether `OcclusionPolicy` is currently fading that body. This keeps the `"occluder"` group as the sole tagging convention shared between the camera occlusion system and ground-pick input — no dedicated collision layer is added for this case.
- **Integration hardening (NEO-18):** [Consumer contract](#consumer-contract-neo-18) + `camera_state.gd` header; **E6.M2** adjacency notes ([E6_M2_ConsentAndRiskUxSignals.md](E6_M2_ConsentAndRiskUxSignals.md)); invalid / freed occluder bodies are **dropped** from `_occluder_overrides` before each occlusion ray pass and before full restore (`occluder_override_key_is_valid`, tests in `isometric_follow_camera_test.gd`) — **no** material restore on that path (freed subtree would error); live bodies still restore via `_restore_occluder` when they leave the ray. **Follow / state / occlusion** run in **`_physics_process`** with **`process_physics_priority = 1`** so the rig updates **after** `Player` (default **0**) and tracks **`move_and_slide`** without display-vs-physics jitter.
## Linear backlog
Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016).
| Key | Type | Summary |
|-----|------|---------|
| [E1.M2](https://linear.app/neon-sprawl/project/e1m2-isometriccameracontroller-deac8ef10395) | Project | E1.M2 — IsometricCameraController (module umbrella) |
| [NEO-15](https://linear.app/neon-sprawl/issue/NEO-15) | Story | Isometric follow camera (fixed yaw prototype; CameraState seam) |
| [NEO-19](https://linear.app/neon-sprawl/issue/NEO-19) | Story | Expand prototype client district for camera/nav stress QA |
| [NEO-16](https://linear.app/neon-sprawl/issue/NEO-16) | Story | ZoomBandConfig + discrete zoom input (clamped) |
| [NEO-17](https://linear.app/neon-sprawl/issue/NEO-17) | Story | OcclusionPolicy — keep player readable through geometry |
| [NEO-18](https://linear.app/neon-sprawl/issue/NEO-18) | Story | Camera integration hardening + dependent contract notes |
Suggested order: NEO-15 → NEO-16 → NEO-17 → NEO-18. [NEO-19](https://linear.app/neon-sprawl/issue/NEO-19) may run in parallel with or after NEO-15 (scene/nav geography; does not require zoom/occlusion).
## Risks and telemetry
- Occlusion hiding telegraphs: tune policy early and include readability checklist in prototype gates.
- Enabling **yaw orbit** mid-project without a telegraph/UI/minimap pass can undermine readability; design ground effects for **world accuracy** plus **screen-space or height** cues early (see [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Core Epic Map, Epic 1.
- [Module dependency register](module_dependency_register.md)