diff --git a/.pi/skills/panopticon/SKILL.md b/.pi/skills/panopticon/SKILL.md new file mode 100644 index 0000000..1b2afbb --- /dev/null +++ b/.pi/skills/panopticon/SKILL.md @@ -0,0 +1,24 @@ +--- +name: panopticon +description: >- + Auto-generated project overview for snow_trail. Structure, conventions, + and recent activity. Updated nightly by Panopticon. +--- + +# snow_trail — Project Overview + +Pure Rust retro-aesthetic 3D game using SDL3 windowing, wgpu rendering, and rapier3d physics. Implements a pure ECS architecture with intent-based cross-system communication, state machines via TypeId polymorphism, and compute-shader snow deformation. Content authored in Blender 5.0 (glTF meshes + EXR heightmaps). + +## Quick Reference + +- **Language:** Rust (stable) +- **Key dependencies:** sdl3, wgpu, rapier3d, glam, bladeink +- **Build:** `cargo build --release` +- **Test:** `cargo test` +- **Entry point:** `src/main.rs::main()` + +## Documentation + +- [Structure](structure.md) — modules, types, data flow, dependencies +- [Guide](guide.md) — conventions, patterns, anti-patterns, testing +- [Changelog](changelog.md) — recent changes, active areas, stability diff --git a/.pi/skills/panopticon/changelog.md b/.pi/skills/panopticon/changelog.md new file mode 100644 index 0000000..4fed7b5 --- /dev/null +++ b/.pi/skills/panopticon/changelog.md @@ -0,0 +1,83 @@ +# Changelog + +*Last updated: Friday, April 3, 2026* + +## Active Areas + +| Area | Changes (30d) | Status | +|------|---|---| +| Main loop & systems | 9 churn | Active — intent-based architecture solidifying | +| Dialog system | 10 churn | Active — projectiles, camera, rendering | +| Render core | 15 churn | Active — text pipeline, font atlas, globals | +| World state | 8 churn | Active — intent storage, component management | +| Player mechanics | 6 churn | Stable — character bundle, player states | +| Shader system | 4 churn | Active — gizmo lines, dissolve, snow light | + +## Recent Changes + +### Intent-Based Architecture +- **Completed major refactor** (3 weeks ago): Systems now communicate through one-frame `Storage` intents instead of direct function calls. +- **Intent types**: `FollowPlayerIntent`, `StopFollowingIntent`, `CameraTransitionIntent`, `SpawnParticleIntent` +- **Consumer pattern**: Systems read intents from world storage, act on them, then remove them. Producers don't import consumer modules. +- **Documented pattern** in `docs/intent-based-architecture.md` with full execution order contract. + +### Dialog System Suite +- **Dialog state machine**: Ink-based story progression with display timer, projectile phases, parry mechanics. +- **Projectile system**: Spawns physics-driven projectiles with parry windows; detects hits vs. evasion vs. parries (I/J/L buttons). +- **Dialog camera**: Auto-frames all dialog participants; transitions smoothly between follow-cam and dialog-cam via intent; computes centroid + spread. +- **Bubble rendering**: Billboard-based text with word-wrap, dynamic sizing (tail height → body dimensions), border/fill colors, RGBA text atlas. + +### Text & Font Rendering +- **Font atlas pipeline**: Pre-rasterized glyphs (16×8 grid, 124px cells) from Departure Mono font. +- **Text measurement**: Character-width aware layout with line wrapping against max bubble width. +- **Vertex-based text**: Custom `TextVertex` (position, UV) fed into dedicated text pipeline with view-proj uniform. + +### Particle System +- **Intent-driven spawning**: `SpawnParticleIntent` queues particle emissions; `particle_intent_system` transfers into persistent `ParticleBuffers`. +- **Configurable emitters**: Burst count, lifetime range, speed range, directional spread (cone), gravity, size range, color gradient. +- **Snow particle integration**: Spawned continuously at camera position for screen-fill effect. + +### Render Pipeline +- **Global renderer via thread-local**: `render::global` module centralizes `Device`/`Queue` access for loaders and systems; long-term goal is explicit `RenderContext` params. +- **Gizmo shader**: Fragment shader outputs world-normal as axis color (X=red, Y=green, Z=blue) for entity transform editing. +- **Snow light accumulation**: Persistent texture tracking deformation; blue-noise dithering maps snow brightness into tile pattern (brightness > threshold → dither step). +- **Dissolve shader**: Blue-noise masked alpha cutoff; controlled by `enable_dissolve` uniform. + +### Tree Occlusion & Instances +- **Tree dissolve system**: Per-instance dissolve amounts lerp toward targets at configurable speed. +- **Occlusion detection**: Instances between camera and player dissolve based on perpendicular distance to camera-to-player ray. +- **Instance buffer sync**: Tree instances updated into GPU buffer after dissolve state changes. + +### Trigger & Event Flow +- **Sphere-based triggers**: Continuous state tracking (Idle ↔ Inside); fires `TriggerEvent` with `Entered`/`Exited` kinds. +- **Activation filtering**: `TriggerFilter::Player` specifies which entity types can activate. +- **Event queue**: Events collected in `world.trigger_events` and consumed by dialog system to spawn bubbles. + +### Player State Machine +- **State hierarchy**: Idle, Walking, Jumping, Falling, Leaping (dash), Rolling. +- **Transition logic**: Grounded checks, input presence, airtime duration. +- **Physics per-state**: Damping on enter (Idle), horizontal input application (Walking), vertical velocity control (Jumping). + +### Editor & Inspector +- **Dear ImGui integration**: Inspector window with player state display, entity transform gizmos. +- **Entity selection**: Right-click picking; gizmo renders for selected entity when editor active. +- **UI mode gating**: Systems check `editor.wants_keyboard()`/`wants_mouse()` to suppress game input during inspector focus. + +## Stability + +| Area | Last Changed | Status | +|---|---|---| +| Physics manager | 80+ days | Stable | +| Camera follow | 30 days | Stable — intent system reduced coupling | +| Movement component | 30+ days | Stable | +| Mesh loader | 30+ days | Stable | +| Spotlight sync | 30 days | Stable | +| State machine physics | 30 days | Stable — core transitions unchanged | +| Debug colliders | 30+ days | Stable | + +## Open Threads + +- **Global renderer thread-local**: Marked for gradual migration to explicit `RenderContext` parameters on systems/loaders. Current implementation works; refactor is low-priority. +- **Text rendering completeness**: Font atlas + text pipeline in place; scaling and color support functional. No known issues. +- **Dialog parry mechanics**: Projectile system complete; integration with Ink outcomes working. Gameplay tuning (PARRY_WINDOW_RADIUS, HIT_RADIUS) ongoing. +- **Tree occlusion performance**: Occlusion system runs every frame; no visibility culling yet. Scalability unknown for >100 trees. \ No newline at end of file diff --git a/.pi/skills/panopticon/guide.md b/.pi/skills/panopticon/guide.md new file mode 100644 index 0000000..e0a0064 --- /dev/null +++ b/.pi/skills/panopticon/guide.md @@ -0,0 +1,354 @@ +# Guide + +## Conventions + +### Naming +- **Bundles**: `*Bundle` suffix (e.g., `PlayerBundle`, `TerrainBundle`) +- **Components**: `*Component` suffix (e.g., `MeshComponent`, `MovementComponent`) +- **States**: Plain names without suffix (e.g., `IdleState`, `WalkingState`); impl `State` trait +- **Intents**: `*Intent` suffix for one-frame events (e.g., `CameraTransitionIntent`, `FollowPlayerIntent`) +- **Systems**: `*_system` suffix for functions (e.g., `player_input_system`, `trigger_system`) +- **Storage containers**: lowercase plural (e.g., `world.transforms`, `world.movements`) +- **Tags** (marker components): bare unit type in `Storage<()>` (e.g., `player_tags`, `bubble_tags`) + +### Formatting +- Run `cargo fmt` with `brace_style = "AlwaysNextLine"`, `control_brace_style = "AlwaysNextLine"` +- **NO inline comments** unless absolutely necessary—code must be self-documenting +- Doc comments (`///`) **only** for public APIs and complex algorithms +- **NO inline paths** in code—always use `use` statements at file level +- **NO `use` statements inside functions or impl blocks**—all imports at module level + +### Imports +- Group imports: standard library, external crates, internal modules (in that order) +- Use fully-qualified module paths in `use` statements; never nest unnecessarily +- Example: + ```rust + use crate::components::{CameraComponent, FollowComponent}; + use crate::world::World; + use glam::Vec3; + ``` + +## Patterns + +### Intent-Based Communication (One-Frame Queues) +**Why**: Systems don't call each other directly. This decouples producer from consumer and maintains a flat pipeline. + +**How**: Intent is a simple struct in a queue (`Vec`). Producer inserts, consumer reads and removes. + +```rust +// components/intent.rs +pub struct CameraTransitionIntent { + pub duration: f32, +} + +// systems/camera.rs - consumer +pub fn camera_intent_system(world: &mut World) { + let transition_entities: Vec = world.camera_transition_intents.all(); + for entity in transition_entities { + let duration = world + .camera_transition_intents + .get(entity) + .map(|i| i.duration) + .unwrap_or(0.5); + start_camera_transition(world, entity, duration); + world.camera_transition_intents.remove(entity); // consumed + } +} +``` + +### Bundle Pattern (Entity Factory) +**Why**: Encapsulate all initialization logic for related components into one spawnable unit. + +```rust +// bundles/player.rs +pub struct PlayerBundle { + pub position: Vec3, +} + +impl Bundle for PlayerBundle { + fn spawn(self, world: &mut World) -> Result { + let entity = world.spawn(); + + // Physics + let rigidbody = RigidBodyBuilder::kinematic_position_based() + .translation(self.position.into()) + .build(); + let rigidbody_handle = PhysicsManager::add_rigidbody(rigidbody); + world.physics.insert(entity, PhysicsComponent { rigidbody: rigidbody_handle, .. }); + + // State machine with transitions + let mut state_machine = StateMachine::new::(); + state_machine.register_state(|w: &mut World| &mut w.falling_states); + state_machine.add_transition::(move |world| { + is_grounded(world, entity_id) && !has_input(world, entity_id) + }); + world.state_machines.insert(entity, state_machine); + + // Tags + world.player_tags.insert(entity, ()); + + Ok(entity) + } +} +``` + +### State Machine with Type-Safe Transitions +**Why**: Encapsulate state logic (on_enter, on_exit, physics_update) and guard transitions with closures. + +```rust +// states/player_states.rs - implement State trait +impl State for IdleState { + fn tick_time(&mut self, delta: f32) { + self.time_in_state += delta; + } + + fn on_enter(&mut self, world: &mut World, entity: EntityHandle) { + // Apply damping on enter + world.physics.with(entity, |physics| { + PhysicsManager::with_rigidbody_mut(physics.rigidbody, |rb| { + let current_velocity = *rb.linvel(); + rb.set_linvel(Vector::new(0.0, current_velocity.y, 0.0), true); + }); + }); + } + + fn on_physics_update(&mut self, world: &mut World, entity: EntityHandle, _delta: f32) { + // Ground snapping + let current_y = world.physics.with(entity, |p| { + PhysicsManager::with_rigidbody_mut(p.rigidbody, |rb| rb.translation().y) + }).flatten(); + // ... + } +} + +// bundles/player.rs - register transitions +state_machine.add_transition::(move |world| { + world + .inputs + .with(entity_id, |i| i.move_direction.length() > 0.01) + .unwrap_or(false) +}); +``` + +### Storage.with/with_mut Pattern (Closure-Based Access) +**Why**: Avoids holding mutable references across multiple accesses; satisfies borrow checker. + +```rust +// systems/spotlight_sync.rs +pub fn spotlight_sync_system(world: &World) -> Vec { + let mut spotlights = Vec::new(); + for entity in world.spotlights.all() { + // Closure captures immutably—safe to call multiple times + if let Some(spotlight_component) = world.spotlights.get(entity) { + if let Some(transform) = world.transforms.get(entity) { + let position = transform.position + spotlight_component.offset; + spotlights.push(Spotlight::new(position, ..)); + } + } + } + spotlights +} + +// With mutation +world.movements.with_mut(entity, |movement| { + movement.movement_context.is_floored = true; +}); +``` + +### System Function Signature +**Why**: Pass only the storages (or immutable `World`) that the system needs. Makes data dependencies explicit. + +```rust +// Good: explicit dependencies +pub fn camera_follow_system(world: &mut World) { + let camera_entities: Vec<_> = world.follows.all(); + for camera_entity in camera_entities { + // Access what's needed + if let Some(follow) = world.follows.get(camera_entity) { + world.transforms.with_mut(camera_entity, |t| { + t.position = target_position + new_offset; + }); + } + } +} + +// For read-only systems +pub fn spotlight_sync_system(world: &World) -> Vec { .. } +``` + +### Default Trait for Configuration Components +**Why**: Sensible defaults allow bundle code to be cleaner; override what's needed. + +```rust +// components/jump.rs +impl Default for JumpComponent { + fn default() -> Self { + Self { + jump_height: 5.0, + jump_duration: 0.5, + air_control_force: 100.0, + max_air_momentum: 3.0, + air_damping_active: 0.95, + air_damping_passive: 0.9, + jump_curve: CubicBez::new((0.0, 0.0), (0.4, 1.0), (0.6, 1.0), (1.0, 0.0)), + jump_context: JumpContext::default(), + } + } +} + +// Usage in bundle +world.jumps.insert(entity, JumpComponent::default()); +``` + +### Physics Closure Pattern +**Why**: Avoids lifetime tangles when borrowing rigidbody from PhysicsManager static storage. + +```rust +// states/player_states.rs +world.physics.with(entity, |physics| { + PhysicsManager::with_rigidbody_mut(physics.rigidbody, |rigidbody| { + let vel = *rigidbody.linvel(); + rigidbody.set_linvel(Vector::new(vel.x, 0.0, vel.z), true); + }); +}); +``` + +## Anti-Patterns + +### Direct System-to-System Calls +**Don't do this:** +```rust +// ❌ Bad: tightly coupled, hard to debug +fn system_a(world: &mut World) { + system_b_logic(world); // Hidden dependency +} +``` + +**Do this instead:** +```rust +// ✅ Good: intent in queue, flat pipeline +pub fn system_a(world: &mut World) { + world.some_intents.insert(entity, MyIntent { .. }); +} + +pub fn system_b(world: &mut World) { + for entity in world.some_intents.all() { + // process + world.some_intents.remove(entity); + } +} +``` + +### Holding Mutable References Across Multiple Storage Accesses +**Don't do this:** +```rust +// ❌ Bad: won't compile (borrow checker) +let mut movement = world.movements.get_mut(entity).unwrap(); +let mut transform = world.transforms.get_mut(entity).unwrap(); +movement.foo = transform.position.x; +``` + +**Do this instead:** +```rust +// ✅ Good: use closures +world.movements.with_mut(entity, |movement| { + world.transforms.with(entity, |transform| { + movement.foo = transform.position.x; + }); +}); +``` + +### Inline Paths in Code +**Don't do this:** +```rust +// ❌ Bad +let velocity = *crate::physics::PhysicsManager::with_rigidbody_mut(..); +``` + +**Do this instead:** +```rust +// ✅ Good: use statements at top +use crate::physics::PhysicsManager; +// ... in code +let velocity = *PhysicsManager::with_rigidbody_mut(..); +``` + +### Global or Context-Based Component Queries +**Don't do this:** +```rust +// ❌ Bad: loses intent, repeats queries +for entity in world.meshes.all() { + if let Some(mesh) = world.meshes.get(entity) { .. } +} +for entity in world.meshes.all() { + if let Some(mesh) = world.meshes.get(entity) { .. } +} +``` + +**Do this instead:** +```rust +// ✅ Good: query once, iterate clearly +let entities: Vec<_> = world.meshes.all(); +for entity in entities { + if let Some(mesh_comp) = world.meshes.get(entity) { + // process + } +} +``` + +### Overly Generic (World/&mut World) Parameters +**Don't do this:** +```rust +// ❌ Bad: implicit dependencies +fn update_position(world: &mut World, entity: EntityHandle) { + // What do we actually need? +} +``` + +**Do this instead:** +```rust +// ✅ Good: explicit dependencies +fn update_position( + transforms: &mut Storage, + entity: EntityHandle, +) { + transforms.with_mut(entity, |t| t.position.y += 1.0); +} +``` + +## Testing + +### Structure +Tests are inline with source (`#[cfg(test)] mod tests`). Focus on: +- Component initialization and state transitions +- Bundle spawning and validation +- Physics helper calculations +- Intent queue ordering + +### What to Test +- **State transitions**: Verify conditions gate transitions correctly + ```rust + #[test] + fn test_idle_to_walking_transition() { + let world = setup_test_world(); + world.inputs.with_mut(player, |i| i.move_direction = Vec3::X); + assert!(should_transition_to_walking(&world, player)); + } + ``` + +- **Bundle spawning**: Ensure all components are inserted +- **Default values**: Verify sensible defaults in `Default` impls +- **Physics calculations**: Unit-test slope calculations, terrain queries + +### Running Tests +```bash +cargo test +cargo test --lib # Unit tests only +cargo test -- --nocapture # Show output +``` + +## References +- See **CLAUDE.md** in project root for authoritative style rules (no inline comments, doc comments for public APIs only) +- See **docs/self-gating-systems.md** for detailed intent-based pipeline patterns +- See `src/states/player_states.rs` for canonical State impl examples +- See `src/bundles/player.rs` for complete Bundle pattern with state machine setup \ No newline at end of file diff --git a/.pi/skills/panopticon/structure.md b/.pi/skills/panopticon/structure.md new file mode 100644 index 0000000..5b12a97 --- /dev/null +++ b/.pi/skills/panopticon/structure.md @@ -0,0 +1,163 @@ +# Structure + +## Modules + +### `src/entity.rs` +Manages entity lifecycle with `EntityHandle` (u64 alias) and `EntityManager` tracking alive entities. Entities are opaque IDs; all data lives in component storages on `World`. + +### `src/world.rs` +Core ECS container holding `EntityManager`, 20+ typed `Storage` collections (one per component type), and intent queues (`follow_player_intents`, `stop_following_intents`, `camera_transition_intents`). One-frame intents are inserted and consumed within a single frame. World also holds singleton state: `snow_layer`, `debug_mode`, `gizmo_mesh`. + +### `src/components/` +Component types define entity data. Key storages: +- **Transforms** (`Transform`): position, rotation, scale +- **Physics** (`PhysicsComponent`): rapier3d rigidbody/collider handles +- **Movement** (`MovementComponent`): walking speed, acceleration, damping state +- **Jump** (`JumpComponent`): jump height, air control, jump curve +- **State machines** (stored per-entity): `IdleState`, `WalkingState`, `JumpingState`, `FallingState`, `LeapingState`, `RollingState` +- **Input** (`InputComponent`): current move direction, jump/parry key states +- **Camera** (`CameraComponent`): FOV, aspect, yaw/pitch; `CameraTransition` for animated transitions +- **Dialog** (`DialogBubbleComponent`, `DialogProjectileComponent`, `DialogSourceComponent`): Ink story state, projectile tracking, outcome events +- **Rendering** (`MeshComponent`): mesh reference, pipeline, instance buffer, dissolve/snow-light flags +- **Misc**: `FollowComponent`, `RotateComponent`, `DissolveComponent`, `TriggerComponent`, `ParticleEmitterConfig` + +### `src/states/state.rs` +Per-entity state machine: `StateMachine` holds current `TypeId`, registered state types, and transitions. `State` trait defines lifecycle (`on_enter`, `on_physics_update`, `on_exit`). Transitions are condition predicates checked each update. Player entity uses this for locomotion states. + +### `src/systems/` +Flat list of update functions called in main loop. No cross-system coupling; all communication via world state and intents. +- **Camera**: `camera_input_system` → generates intents; `camera_intent_system` consumes them; `camera_follow_system` (follows player), `camera_noclip_system`, `camera_transition_system`, `camera_ground_clamp_system` +- **Input**: `player_input_system` reads SDL3 `InputState`, writes `InputComponent` +- **Physics**: `state_machine_physics_system` (fixed-step), `PhysicsManager::physics_step()`, `physics_sync_system` (copies rapier bodies back to transforms), `trigger_system` (AABB overlap → events) +- **Dialog**: `dialog_system` (ticks story state), `dialog_projectile_system` (moves projectiles), `dialog_camera_system` (focuses camera on speaker), `dialog_bubble_render_system` (generates billboard/text draw calls) +- **Rendering**: `render_system` collects `DrawCall` from meshes/transforms; `spotlight_sync_system` syncs light positions to shader uniform +- **State machine**: `state_machine_system` (per-frame), `state_machine_physics_system` (fixed-step) tick state lifecycle +- **Trees**: `tree_occlusion_system` (culls trees behind camera), `tree_dissolve_update_system` (animates dissolve), `tree_instance_buffer_update_system` (writes GPU buffer) +- **Snow**: `snow_system` deforms snow layer at physics contacts; `particle_intent_system`/`particle_update_system` manage particle emitters +- **Rotate**: `rotate_system` rotates entities by delta + +### `src/bundles/` +Factory functions to spawn pre-configured entity groups: +- `PlayerBundle`: player character with all locomotion components +- `TestCharBundle`: test NPC +- `CameraBundle`: camera entity +- `TerrainBundle`: terrain mesh + collider +- `SpotlightBundle` + `spawn_spotlights()`: light entities from scene data + +### `src/render/` +GPU rendering pipeline via wgpu. Singleton `Renderer` stored in thread-local (global.rs). +- **`Renderer`**: wgpu device/queue/surface, framebuffer, pipelines, bind groups, shadow map texture, spotlight data +- **`DrawCall`** (types.rs): vertex/index buffers, model matrix, pipeline enum, instance buffer, entity ref +- **Pipelines** (pipeline.rs): `create_main_pipeline()`, `create_snow_clipmap_pipeline()`, `create_wireframe_pipeline()`, `create_shadow_pipeline()`, `create_debug_lines_pipeline()` +- **`Uniforms`** (types.rs): model/view/projection, 4 spotlights, camera position, height scale, player position, time, tile scale, debug mode +- **Shadow**: `render_shadow_pass()` renders scene depth to shadow map from each spotlight +- **Snow**: `SnowLayer` deforms snow heightfield via compute; `ClipmapConfig` manages multi-level clipmap grid; `deform_at_position()` marks terrain changed +- **Snow light**: `SnowLightAccumulation` ping-pong texture accumulates light contributions from spotlights onto snow surface +- **Billboard pipeline** + **Text pipeline**: render dialog bubbles and text overlays +- **Particle pipeline**: billboarded particles with per-instance color/velocity +- **Font atlas**: pre-rasterized glyph texture from embedded font file + +### `src/loaders/` +Load scene data from glTF files: +- **`scene.rs`** `Space::load_space()`: loads meshes, lights, player spawn, test char spawn from single glTF +- **`mesh.rs`** `Mesh::load_gltf_with_instances()`: parses glTF buffers, vertex/index data, multi-instance mesh batches; `InstanceData` (position/rotation/scale/dissolve); `Vertex` (position/normal/UV) +- **`lights.rs`**: extracts spotlight transforms/params from glTF nodes +- **`empty.rs`**: extracts named empty (spawn point) transforms from glTF +- **`heightmap.rs`**: loads EXR heightfield texture for terrain collision +- **`terrain.rs`**: builds rapier heightfield collider from EXR matrix + +### `src/physics.rs` +Thread-local `PhysicsManager` wrapping rapier3d. `physics_step()` runs one integration step; `add_rigidbody()`, `add_collider()` register bodies; `raycast()` queries. `HeightfieldData` caches terrain height matrix. + +### `src/utility/` +- **`transform.rs`** `Transform`: matrix conversions to/from nalgebra `Isometry3`; getters/setters for position/rotation/scale +- **`input.rs`** `InputState`: SDL3 key states (WASD, Space, Shift, Ctrl) and mouse delta; `handle_event()` updates state; `clear_just_pressed()` resets one-frame flags +- **`time.rs`**: `Time::get_time_elapsed()` returns seconds since init (static Instant) + +### `src/debug/` +- **`mode.rs`** `DebugMode` enum: None, Normals, UV, Depth, Wireframe, Colliders, ShadowMap, SnowLight; `cycle()` steps through +- **`collider_debug.rs`**: renders rapier collider AABBs as line meshes +- **`gizmo.rs`**: renders 3D transform gizmo (position/rotation/scale) for editor + +### `src/editor/` +- **`inspector.rs`** `Inspector`: wraps Dear ImGui context; `render()` draws frame to texture; `build_ui()` draws entity inspector panels +- **`mod.rs`** `EditorState`: manages editor active state, selected entity, mouse capture; `editor_loop()` calls inspector, handles picking + +### `src/picking.rs`, `src/postprocess.rs`, `src/texture.rs`, `src/paths.rs` +Utility modules: ray casting for mouse pick, fullscreen blit/framebuffer downsampling, dither/flowmap texture loading, paths to asset files. + +## Data Flow + +1. **Initialization** (`main.rs` `init()`): SDL3 window → wgpu renderer (Vulkan) → World creation → load scene (Space from glTF) → spawn bundles (player, terrain, camera, lights) → initialize physics +2. **Main loop** (`main.rs` `main()` with 60 Hz fixed physics + variable-rate graphics): + - **Per-frame** (delta): Input events → `player_input_system` (fills `InputComponent`) + `camera_input_system` (generates intents) + - **Intent processing**: `camera_intent_system` consumes intents, updates `CameraComponent`/`CameraTransition` + - **Camera systems**: follow player, noclip, transition, clamp to ground + - **Fixed physics** (1/60s accumulator): + - `state_machine_physics_system`: tick state's `on_physics_update()` + - `PhysicsManager::physics_step()`: rapier integration + - `physics_sync_system`: copy rigidbody poses to `Transform` + - `trigger_system`: detect collisions, emit events + - `dialog_system`, `dialog_projectile_system`: Ink story tick, projectile movement + - **Per-frame systems**: `state_machine_system` (non-physics update), rotate, particle, tree dissolve, snow deformation, spotlight sync + - **Render collection**: `render_system` → `Vec` from all meshes; snow layer adds clipmap draw calls; debug adds collider/gizmo calls + - **Submission**: `submit_frame()` renders draw calls, dialog bubbles/text, particles to framebuffer; blit to screen; optional ImGui overlay +3. **Frame cleanup**: `InputState::clear_just_pressed()` (flags reset for next frame) + +## Key Types + +| Type | Module | Description | +|------|--------|-------------| +| `EntityHandle` | entity | u64 opaque entity ID | +| `Storage` | world | HashMap storage for per-entity component data | +| `World` | world | ECS container: entities, 20+ storages, intent queues, singleton state | +| `Transform` | utility/transform | Position (Vec3), rotation (Quat), scale (Vec3); matrix conversions | +| `StateMachine` | states/state | Per-entity state machine: current state TypeId, registered states, transitions | +| `State` trait | states/state | Lifecycle: `on_enter`, `on_physics_update`, `on_exit`, `on_update` | +| `PhysicsComponent` | components/physics | Rapier3d rigidbody + optional collider handles | +| `MovementComponent` | components/movement | Walking speed, acceleration, damping, context (floored, last floor time) | +| `JumpComponent` | components/jump | Jump height, duration, air control, context (in progress, origin height) | +| `CameraComponent` | components/camera | FOV, aspect, yaw/pitch angles, is_active flag | +| `InputComponent` | components/input | Current frame: move direction, jump/parry key states (flags) | +| `MeshComponent` | components/mesh | Mesh ref, pipeline enum, instance buffer, dissolve/snow-light flags | +| `DialogBubbleComponent` | components/dialog | Ink story, current text, dialog phase (displaying/projectile in flight), parry button | +| `DrawCall` | render/types | GPU command: vertex/index buffers, model matrix, pipeline, instance count, entity ID | +| `Uniforms` | render/types | Per-frame shader data: matrices, spotlight array, debug flags | +| `Renderer` | render/mod | GPU state: device, queue, surface, all pipelines, framebuffer, texture samplers | +| `Space` | loaders/scene | Loaded scene: mesh batches, spotlight data, spawn positions | +| `Mesh` | loaders/mesh | GPU vertex/index buffers, AABB, CPU vertex data for physics | +| `SnowLayer` | render/snow | Snow heightfield: deform bind groups, depth texture, clipmap grid levels | +| `SnowLightAccumulation` | render/snow_light | Ping-pong textures + pipeline accumulating spotlight contributions onto snow | +| `InputState` | utility/input | SDL3 event state: key flags, mouse delta, relative mode | +| `PhysicsManager` | physics | Rapier3d bodies/colliders/pipeline; thread-local singleton | +| `DebugMode` | debug/mode | Enum: None, Normals, UV, Depth, Wireframe, Colliders, ShadowMap, SnowLight | + +## Entry Points + +1. **`main()` in src/main.rs** + - Calls `init()` → initializes SDL3, wgpu, loads world from scene glTF, spawns entities + - Runs infinite loop: event poll → systems (camera/input/physics/render) → frame submit → sleep to 60 Hz +2. **`Game::init()` in src/main.rs** + - SDL3 window + Vulkan surface + - `Renderer::new()` initializes all GPU pipelines and textures + - `init_world()` loads space, spawns bundles, sets up terrain/snow/lights +3. **`World::new()` in src/world.rs** + - Creates empty storages for all 20+ component types and intent queues + +## Dependencies + +- **Engine**: wgpu (GPU), rapier3d (physics), glam (math), nalgebra (nalgebra for physics conversions), SDL3 (windowing/input) +- **Content**: bladeink (Ink story scripting), image crate (EXR heightmaps), gltf (scene loading) +- **Editor**: Dear ImGui via imgui crate + SDL3 integration +- **Internal**: All systems read/write `World`; systems are called in fixed sequence from main loop; no circular dependencies +- **Thread-local singletons**: `Renderer` (render/global.rs), `PhysicsManager` (physics.rs), `GLOBAL_RENDERER`, `GLOBAL_PHYSICS` + +## Initialization Order + +1. SDL3 init → window creation → Vulkan adapter/device +2. `Renderer::new()` → wgpu device/queue, create all pipelines, load textures (dither, flowmap, font atlas, shadow map, blue noise) +3. Load scene from glTF → meshes, lights, spawns +4. Spawn bundles: player (with input/movement/jump/state machine), terrain (mesh + heightfield collider), camera (follows player), lights (spotlights) +5. Initialize snow layer (deform by tree positions) +6. Initialize snow light accumulation (bind to spotlight data) +7. Main loop: process events → run systems in sequence → submit frame \ No newline at end of file diff --git a/blender/addons/snow_trail_export.py b/blender/addons/snow_trail_export.py new file mode 100644 index 0000000..759a734 --- /dev/null +++ b/blender/addons/snow_trail_export.py @@ -0,0 +1,218 @@ +bl_info = { + "name": "Snow Trail Export", + "author": "Snow Trail", + "version": (1, 0, 0), + "blender": (5, 0, 0), + "location": "3D Viewport > Sidebar > Snow Trail", + "description": "One-click glTF export to project assets folder", + "category": "Import-Export", +} + +import bpy +from pathlib import Path + + +def find_project_root(): + blend_path = Path(bpy.data.filepath) + if not blend_path.exists(): + return None + candidate = blend_path.parent + while candidate != candidate.parent: + if (candidate / "assets").is_dir() and (candidate / "blender").is_dir(): + return candidate + candidate = candidate.parent + return None + + +def get_export_path(): + blend_path = Path(bpy.data.filepath) + project_root = find_project_root() + if project_root is None: + return None + stem = blend_path.stem + return project_root / "assets" / "meshes" / f"{stem}.gltf" + + +class SNOWTRAIL_OT_export_gltf(bpy.types.Operator): + bl_idname = "snow_trail.export_gltf" + bl_label = "Export to Project" + bl_description = "Export selected objects as glTF to assets/meshes/" + bl_options = {'REGISTER'} + + def execute(self, context): + if not bpy.data.filepath: + self.report({'ERROR'}, "Save the .blend file first") + return {'CANCELLED'} + + export_path = get_export_path() + if export_path is None: + self.report({'ERROR'}, "Could not find project root (looking for assets/ + blender/ dirs)") + return {'CANCELLED'} + + export_path.parent.mkdir(parents=True, exist_ok=True) + + selected = [obj for obj in context.selected_objects if obj.type == 'MESH'] + if not selected: + self.report({'ERROR'}, "No mesh objects selected") + return {'CANCELLED'} + + props = context.scene.snow_trail_export + + bpy.ops.export_scene.gltf( + filepath=str(export_path), + export_format='GLTF_SEPARATE', + use_selection=True, + export_apply=props.apply_modifiers, + export_yup=True, + export_texcoords=True, + export_normals=True, + export_colors=props.export_vertex_colors, + export_materials='NONE' if not props.export_materials else 'EXPORT', + export_animations=props.export_animations, + ) + + rel_path = export_path.relative_to(find_project_root()) + self.report({'INFO'}, f"Exported → {rel_path}") + return {'FINISHED'} + + +class SNOWTRAIL_OT_export_heightmap(bpy.types.Operator): + bl_idname = "snow_trail.export_heightmap" + bl_label = "Bake Heightmap" + bl_description = "Run heightmap bake script for selected terrain" + bl_options = {'REGISTER'} + + def execute(self, context): + project_root = find_project_root() + if project_root is None: + self.report({'ERROR'}, "Could not find project root") + return {'CANCELLED'} + + script_path = project_root / "blender" / "scripts" / "generate_heightmap.py" + if not script_path.exists(): + self.report({'ERROR'}, f"Script not found: {script_path}") + return {'CANCELLED'} + + import importlib.util + spec = importlib.util.spec_from_file_location("generate_heightmap", str(script_path)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + terrain_obj = context.active_object + if terrain_obj is None or terrain_obj.type != 'MESH': + self.report({'ERROR'}, "Select a mesh object first") + return {'CANCELLED'} + + output_path = project_root / "assets" / "textures" / "terrain_heightmap.exr" + mod.bake_heightmap( + terrain_obj=terrain_obj, + resolution=context.scene.snow_trail_export.heightmap_resolution, + output_path=str(output_path), + ) + + self.report({'INFO'}, f"Heightmap → assets/textures/terrain_heightmap.exr") + return {'FINISHED'} + + +class SNOWTRAIL_ExportProperties(bpy.types.PropertyGroup): + apply_modifiers: bpy.props.BoolProperty( + name="Apply Modifiers", + default=True, + description="Apply modifiers before export", + ) + export_vertex_colors: bpy.props.BoolProperty( + name="Vertex Colors", + default=True, + description="Export vertex colors", + ) + export_materials: bpy.props.BoolProperty( + name="Materials", + default=False, + description="Export materials (usually not needed for retro aesthetic)", + ) + export_animations: bpy.props.BoolProperty( + name="Animations", + default=False, + description="Export animations", + ) + heightmap_resolution: bpy.props.IntProperty( + name="Heightmap Resolution", + default=1024, + min=128, + max=4096, + description="Resolution for heightmap bake", + ) + + +class SNOWTRAIL_PT_export_panel(bpy.types.Panel): + bl_label = "Snow Trail Export" + bl_idname = "SNOWTRAIL_PT_export_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Snow Trail" + + def draw(self, context): + layout = self.layout + props = context.scene.snow_trail_export + + export_path = get_export_path() + if export_path: + project_root = find_project_root() + rel = export_path.relative_to(project_root) + layout.label(text=f"Target: {rel}", icon='FILE') + elif not bpy.data.filepath: + layout.label(text="Save .blend file first!", icon='ERROR') + else: + layout.label(text="Project root not found!", icon='ERROR') + + layout.separator() + + box = layout.box() + box.label(text="glTF Options:", icon='MESH_DATA') + box.prop(props, "apply_modifiers") + box.prop(props, "export_vertex_colors") + box.prop(props, "export_materials") + box.prop(props, "export_animations") + + selected_meshes = [o for o in context.selected_objects if o.type == 'MESH'] + row = layout.row() + row.scale_y = 1.5 + row.operator("snow_trail.export_gltf", icon='EXPORT') + if not selected_meshes: + row.enabled = False + + if selected_meshes: + layout.label(text=f"{len(selected_meshes)} mesh(es) selected") + else: + layout.label(text="Select mesh objects to export", icon='INFO') + + layout.separator() + + box = layout.box() + box.label(text="Terrain Tools:", icon='WORLD') + box.prop(props, "heightmap_resolution") + box.operator("snow_trail.export_heightmap", icon='IMAGE_DATA') + + +classes = ( + SNOWTRAIL_ExportProperties, + SNOWTRAIL_OT_export_gltf, + SNOWTRAIL_OT_export_heightmap, + SNOWTRAIL_PT_export_panel, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.snow_trail_export = bpy.props.PointerProperty(type=SNOWTRAIL_ExportProperties) + + +def unregister(): + del bpy.types.Scene.snow_trail_export + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register()