diff --git a/crates/misaligned-assets/src/bin/misaligned-effects.rs b/crates/misaligned-assets/src/bin/misaligned-effects.rs new file mode 100644 index 0000000..708496a --- /dev/null +++ b/crates/misaligned-assets/src/bin/misaligned-effects.rs @@ -0,0 +1,435 @@ +//! Focused liquid/dust laboratory using the same effects plugin as the game. +//! +//! Controls: 1 liquid, 2 dust, 3 both, 4 stress; Q/W/E force close/medium/far; +//! A automatic LOD; Space pause; arrows change amounts; drag orbit; scroll zoom. + +use bevy::core_pipeline::tonemapping::Tonemapping; +use bevy::input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll}; +use bevy::prelude::*; +use bevy::render::view::screenshot::{Screenshot, save_to_disk}; +use bevy::window::{CursorGrabMode, CursorOptions, WindowResolution}; +use misaligned_assets::effects::{ + EffectLod, EffectsControl, MaterialEffects, MaterialEffectsPlugin, spawn_material_effects, +}; +use misaligned_assets::palette::{BONE, GUNMETAL, NEAR_BLACK, PORCELAIN, scaled}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Resource)] +enum LabPreset { + Liquid, + Dust, + #[default] + Both, + Stress, +} + +impl LabPreset { + fn label(self) -> &'static str { + match self { + Self::Liquid => "liquid", + Self::Dust => "dust", + Self::Both => "both", + Self::Stress => "stress", + } + } +} + +#[derive(Resource)] +struct Orbit { + yaw: f32, + pitch: f32, + distance: f32, + target: Vec3, +} + +impl Default for Orbit { + fn default() -> Self { + Self { + yaw: 0.42, + pitch: 0.27, + distance: 5.8, + target: Vec3::new(0.0, 0.65, 0.25), + } + } +} + +#[derive(Component)] +struct LabEffectRoot; + +#[derive(Component)] +struct Hud; + +#[derive(Resource)] +struct ShotHarness { + path: String, + frames: u32, + taken: bool, +} + +fn main() { + let shot = shot_settings(); + let (preset, forced_lod) = shot + .as_ref() + .map(|(preset, lod, _)| (*preset, Some(*lod))) + .unwrap_or((LabPreset::Both, None)); + let control = EffectsControl { + forced_lod, + fixed_step: shot.as_ref().map(|_| 1.0 / 60.0), + ..default() + }; + let hidden = shot.is_some(); + + let mut app = App::new(); + app.add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Misaligned — liquid / dust effects lab".into(), + resolution: WindowResolution::new(1280, 800), + visible: !hidden, + ..default() + }), + ..default() + })) + .add_plugins(MaterialEffectsPlugin) + .insert_resource(ClearColor(NEAR_BLACK)) + .insert_resource(GlobalAmbientLight { + color: Color::srgb(0.60, 0.66, 0.76), + brightness: 20.0, + ..default() + }) + .insert_resource(control) + .insert_resource(preset) + .insert_resource(Orbit::default()) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + handle_quit, + handle_controls, + rebuild_lab_effects, + orbit_camera, + update_hud, + shot_harness_system, + ) + .chain(), + ); + if let Some((_, _, path)) = shot { + app.insert_resource(ShotHarness { + path, + frames: 90, + taken: false, + }); + } + app.run(); +} + +fn shot_settings() -> Option<(LabPreset, EffectLod, String)> { + let kind = std::env::var("MISALIGNED_SHOT").ok()?; + let mut parts = kind.rsplitn(2, '_'); + let suffix = parts.next().unwrap_or_default(); + let (base, lod) = match suffix { + "close" => (parts.next().unwrap_or("both"), EffectLod::Close), + "medium" => (parts.next().unwrap_or("both"), EffectLod::Medium), + "far" => (parts.next().unwrap_or("both"), EffectLod::Far), + _ => (kind.as_str(), EffectLod::Close), + }; + let preset = match base { + "liquid" => LabPreset::Liquid, + "dust" => LabPreset::Dust, + "both" => LabPreset::Both, + "stress" => LabPreset::Stress, + _ => return None, + }; + let path = std::env::var("MISALIGNED_SHOT_PATH") + .unwrap_or_else(|_| format!("misaligned_shot_{kind}.png")); + Some((preset, lod, path)) +} + +fn setup( + mut commands: Commands, + orbit: Res, + mut meshes: ResMut>, + mut materials: ResMut>, + harness: Option>, +) { + let eye = orbit_eye(&orbit); + commands.spawn(( + Camera3d::default(), + Tonemapping::AcesFitted, + Transform::from_translation(eye).looking_at(orbit.target, Vec3::Y), + )); + + commands.spawn(( + DirectionalLight { + color: Color::srgb(0.95, 0.97, 1.0), + illuminance: 8_500.0, + shadows_enabled: true, + ..default() + }, + Transform::from_xyz(-4.0, 6.0, 4.0).looking_at(Vec3::new(0.0, 0.6, 0.0), Vec3::Y), + )); + commands.spawn(( + PointLight { + color: Color::srgb(0.50, 0.62, 0.86), + intensity: 460_000.0, + range: 16.0, + shadows_enabled: false, + ..default() + }, + Transform::from_xyz(3.0, 3.2, 2.8), + )); + commands.spawn(( + PointLight { + color: Color::srgb(0.90, 0.91, 0.94), + intensity: 650_000.0, + range: 18.0, + shadows_enabled: false, + ..default() + }, + Transform::from_xyz(-1.0, 4.6, -4.0), + )); + + let floor = materials.add(StandardMaterial { + base_color: scaled(GUNMETAL, 0.35), + metallic: 0.10, + perceptual_roughness: 0.88, + ..default() + }); + commands.spawn(( + Mesh3d(meshes.add(Cuboid::new(7.5, 0.08, 5.0))), + MeshMaterial3d(floor), + Transform::from_xyz(0.0, -0.06, 0.0), + Name::new("clinical_floor"), + )); + + // Two matte chalk forms provide reflections and make wet/dry contrast + // judgeable without borrowing a game machine. + let chalk = materials.add(StandardMaterial { + base_color: PORCELAIN, + metallic: 0.0, + perceptual_roughness: 0.92, + ..default() + }); + for x in [-1.65, 1.55] { + commands.spawn(( + Mesh3d(meshes.add(Cuboid::new(0.42, 1.20, 0.62))), + MeshMaterial3d(chalk.clone()), + Transform::from_xyz(x, 0.58, 0.0), + Name::new("chalk_effect_anchor"), + )); + } + + if harness.is_none() { + commands.spawn(( + Text::new(String::new()), + TextFont { + font_size: 17.0, + ..default() + }, + TextColor(BONE), + Node { + position_type: PositionType::Absolute, + top: Val::Px(16.0), + left: Val::Px(18.0), + ..default() + }, + Hud, + )); + } +} + +fn apply_preset(preset: LabPreset, effects: &mut MaterialEffects) { + effects.show_thought = matches!( + preset, + LabPreset::Liquid | LabPreset::Both | LabPreset::Stress + ); + effects.show_exposure = matches!( + preset, + LabPreset::Dust | LabPreset::Both | LabPreset::Stress + ); +} + +fn handle_quit(keys: Res>, mut exit: MessageWriter) { + if keys.just_pressed(KeyCode::Escape) { + exit.write(AppExit::Success); + } +} + +fn handle_controls( + keys: Res>, + mut preset: ResMut, + mut control: ResMut, + mut effects: Query<&mut MaterialEffects, With>, +) { + let next_preset = if keys.just_pressed(KeyCode::Digit1) { + Some(LabPreset::Liquid) + } else if keys.just_pressed(KeyCode::Digit2) { + Some(LabPreset::Dust) + } else if keys.just_pressed(KeyCode::Digit3) { + Some(LabPreset::Both) + } else if keys.just_pressed(KeyCode::Digit4) { + Some(LabPreset::Stress) + } else { + None + }; + if let Some(next) = next_preset { + *preset = next; + } + + if keys.just_pressed(KeyCode::KeyQ) { + control.forced_lod = Some(EffectLod::Close); + } + if keys.just_pressed(KeyCode::KeyW) { + control.forced_lod = Some(EffectLod::Medium); + } + if keys.just_pressed(KeyCode::KeyE) { + control.forced_lod = Some(EffectLod::Far); + } + if keys.just_pressed(KeyCode::KeyA) { + control.forced_lod = None; + } + if keys.just_pressed(KeyCode::Space) { + control.paused = !control.paused; + } + + for mut effect in &mut effects { + if keys.just_pressed(KeyCode::ArrowUp) { + effect.thought_amount = (effect.thought_amount + 1.0).min(16.0); + effect.exposure_amount = (effect.exposure_amount + 1.0).min(16.0); + } + if keys.just_pressed(KeyCode::ArrowDown) { + effect.thought_amount = (effect.thought_amount - 1.0).max(1.0); + effect.exposure_amount = (effect.exposure_amount - 1.0).max(1.0); + } + if keys.just_pressed(KeyCode::ArrowRight) { + effect.thought_rate = (effect.thought_rate + 0.25).min(3.0); + effect.exposure_rate = (effect.exposure_rate + 0.25).min(3.0); + } + if keys.just_pressed(KeyCode::ArrowLeft) { + effect.thought_rate = (effect.thought_rate - 0.25).max(0.25); + effect.exposure_rate = (effect.exposure_rate - 0.25).max(0.25); + } + } +} + +fn rebuild_lab_effects( + mut commands: Commands, + preset: Res, + roots: Query>, +) { + if !preset.is_changed() { + return; + } + for root in &roots { + commands.entity(root).despawn(); + } + + if *preset == LabPreset::Stress { + for z in 0..3 { + for x in 0..4 { + let mut effects = MaterialEffects::default(); + apply_preset(*preset, &mut effects); + effects.thought_amount = 10.0; + effects.exposure_amount = 10.0; + let root = spawn_material_effects( + &mut commands, + effects, + Transform::from_xyz((x as f32 - 1.5) * 3.8, 0.0, (z as f32 - 1.0) * 2.0), + ); + commands.entity(root).insert(LabEffectRoot); + } + } + } else { + let mut effects = MaterialEffects::default(); + apply_preset(*preset, &mut effects); + let root = spawn_material_effects(&mut commands, effects, Transform::IDENTITY); + commands.entity(root).insert(LabEffectRoot); + } +} + +fn orbit_camera( + buttons: Res>, + motion: Res, + scroll: Res, + mut orbit: ResMut, + mut cameras: Query<&mut Transform, With>, + mut windows: Query<&mut CursorOptions>, +) { + if buttons.pressed(MouseButton::Left) || buttons.pressed(MouseButton::Right) { + orbit.yaw -= motion.delta.x * 0.005; + orbit.pitch = (orbit.pitch + motion.delta.y * 0.005).clamp(-1.15, 1.25); + if let Ok(mut options) = windows.single_mut() { + options.grab_mode = CursorGrabMode::Confined; + } + } else if let Ok(mut options) = windows.single_mut() { + options.grab_mode = CursorGrabMode::None; + } + if scroll.delta.y.abs() > 0.0 { + orbit.distance = (orbit.distance * (1.0 - scroll.delta.y * 0.08)).clamp(2.2, 24.0); + } + if let Ok(mut transform) = cameras.single_mut() { + *transform = + Transform::from_translation(orbit_eye(&orbit)).looking_at(orbit.target, Vec3::Y); + } +} + +fn orbit_eye(orbit: &Orbit) -> Vec3 { + orbit.target + + Vec3::new( + orbit.yaw.sin() * orbit.pitch.cos(), + orbit.pitch.sin(), + orbit.yaw.cos() * orbit.pitch.cos(), + ) * orbit.distance +} + +fn update_hud( + preset: Res, + control: Res, + effects: Query<&MaterialEffects, With>, + mut hud: Query<&mut Text, With>, +) { + let Some(effect) = effects.iter().next() else { + return; + }; + let Ok(mut text) = hud.single_mut() else { + return; + }; + let lod = control + .forced_lod + .map(EffectLod::label) + .unwrap_or("auto (zoom)"); + *text = Text::new(format!( + "MISALIGNED EFFECTS LAB · {} · LOD {}{}\n\ + thought {:.0} @ {:.2} exposure {:.0} @ {:.2}\n\ + 1 liquid 2 dust 3 both 4 stress Q/W/E close/medium/far A auto Space pause\n\ + arrows amount/rate drag orbit scroll zoom", + preset.label(), + lod, + if control.paused { " · PAUSED" } else { "" }, + effect.thought_amount, + effect.thought_rate, + effect.exposure_amount, + effect.exposure_rate, + )); +} + +fn shot_harness_system( + mut commands: Commands, + harness: Option>, + mut exit: MessageWriter, +) { + let Some(mut harness) = harness else { + return; + }; + if harness.frames > 0 { + harness.frames -= 1; + return; + } + if !harness.taken { + harness.taken = true; + harness.frames = 45; + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(std::path::PathBuf::from(&harness.path))); + } else { + exit.write(AppExit::Success); + } +} diff --git a/crates/misaligned-assets/src/effects.rs b/crates/misaligned-assets/src/effects.rs new file mode 100644 index 0000000..b9a2895 --- /dev/null +++ b/crates/misaligned-assets/src/effects.rs @@ -0,0 +1,566 @@ +//! Shared Thought-liquid and Exposure-dust presentation. +//! +//! The effects lab supplies authored values; the game supplies sim readouts. +//! This module owns only disposable presentation state (art/effects-lab.md). + +use bevy::prelude::*; + +use crate::palette::{BONE, CRIMSON}; + +/// Camera-distance representation for both material effects. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum EffectLod { + #[default] + Close, + Medium, + Far, +} + +impl EffectLod { + pub const ALL: [Self; 3] = [Self::Close, Self::Medium, Self::Far]; + + pub const fn label(self) -> &'static str { + match self { + Self::Close => "close", + Self::Medium => "medium", + Self::Far => "far", + } + } +} + +/// Presentation controls shared by the lab and game frontend. +#[derive(Resource, Clone, Debug)] +pub struct EffectsControl { + /// `None` selects from camera distance; the lab can force a tier. + pub forced_lod: Option, + pub paused: bool, + pub speed: f32, + /// Fixed-step presentation time for deterministic screenshot evidence. + pub fixed_step: Option, +} + +impl Default for EffectsControl { + fn default() -> Self { + Self { + forced_lod: None, + paused: false, + speed: 1.0, + fixed_step: None, + } + } +} + +/// Readout-shaped inputs. These are amounts and rates, never game rules. +#[derive(Component, Clone, Debug)] +pub struct MaterialEffects { + pub show_thought: bool, + pub show_exposure: bool, + pub thought_amount: f32, + pub thought_rate: f32, + pub exposure_amount: f32, + pub exposure_rate: f32, + /// Local-space route endpoints. The presentation bends between them. + pub route_start: Vec3, + pub route_end: Vec3, + /// Downwind direction for airborne and settled Exposure. + pub wind: Vec3, +} + +impl Default for MaterialEffects { + fn default() -> Self { + Self { + show_thought: true, + show_exposure: true, + thought_amount: 6.0, + thought_rate: 1.0, + exposure_amount: 6.0, + exposure_rate: 1.0, + route_start: Vec3::new(-1.65, 0.85, 0.0), + route_end: Vec3::new(1.55, 0.72, 0.0), + wind: Vec3::new(0.8, 0.0, 0.35), + } + } +} + +/// Add this plugin in both the effects lab and the material game frontend. +pub struct MaterialEffectsPlugin; + +impl Plugin for MaterialEffectsPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Startup, setup_effect_assets) + .add_systems( + Update, + ( + advance_clock, + select_lod, + rebuild_effect_population, + animate_thought, + animate_dust, + animate_pools, + ) + .chain(), + ); + } +} + +/// Spawn an effect root. The plugin owns every child under this entity. +pub fn spawn_material_effects( + commands: &mut Commands, + effects: MaterialEffects, + transform: Transform, +) -> Entity { + commands + .spawn(( + effects, + EffectLodState::default(), + transform, + Visibility::default(), + Name::new("material_effects"), + )) + .id() +} + +#[derive(Resource, Default)] +struct EffectsClock { + elapsed: f32, +} + +#[derive(Resource)] +struct EffectAssets { + sphere: Handle, + cube: Handle, + mercury: Handle, + mercury_dim: Handle, + dust: Handle, + dust_haze: Handle, +} + +#[derive(Component, Clone, Copy, Debug, Default)] +struct EffectLodState(EffectLod); + +#[derive(Component)] +struct EffectVisual; + +#[derive(Component)] +struct ThoughtSlug { + start: Vec3, + end: Vec3, + phase: f32, + speed: f32, + scale: f32, + length: f32, +} + +#[derive(Component)] +struct DustMote { + base: Vec3, + phase: f32, + lofted: bool, + size: Vec3, +} + +#[derive(Component)] +struct LiquidPool { + base_scale: Vec3, + phase: f32, +} + +fn setup_effect_assets( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let mercury = materials.add(StandardMaterial { + base_color: Color::srgb(0.92, 0.90, 0.84), + emissive: BONE.to_linear() * 0.14, + metallic: 0.72, + perceptual_roughness: 0.12, + reflectance: 0.82, + ..default() + }); + let mercury_dim = materials.add(StandardMaterial { + base_color: Color::srgb(0.72, 0.71, 0.68), + emissive: BONE.to_linear() * 0.08, + metallic: 0.58, + perceptual_roughness: 0.20, + reflectance: 0.65, + ..default() + }); + let dust = materials.add(StandardMaterial { + base_color: Color::srgb(0.28, 0.012, 0.018), + emissive: CRIMSON.to_linear() * 0.012, + metallic: 0.02, + perceptual_roughness: 0.92, + reflectance: 0.08, + ..default() + }); + let dust_haze = materials.add(StandardMaterial { + base_color: Color::srgba(0.24, 0.008, 0.014, 0.34), + emissive: CRIMSON.to_linear() * 0.008, + metallic: 0.0, + perceptual_roughness: 1.0, + alpha_mode: AlphaMode::Blend, + ..default() + }); + commands.insert_resource(EffectAssets { + sphere: meshes.add(Sphere::new(1.0)), + cube: meshes.add(Cuboid::new(1.0, 1.0, 1.0)), + mercury, + mercury_dim, + dust, + dust_haze, + }); +} + +fn advance_clock(time: Res