use bevy::prelude::*;
use bevy_carnage::blood::dry::{
CRAQUELURE_ONSET, HALO_HUMIDITY, SRGB_HEMI, SRGB_MET, SRGB_OXY, appearance, dry_ticks,
};
use bevy_carnage::{BloodSettings, hash_f32};
const HZ: u32 = 60;
const AREAS: [f32; 2] = [1.0e-3, 4.0e-3];
const CENTRES: [f32; 2] = [-0.055, 0.075];
const NAMES: [&str; 2] = ["pool A", "pool B"];
const RIM_STEPS: u32 = 32;
const RIM_DEPTH: f32 = 0.55;
const HALO_OUTER: f32 = 1.38;
const SERUM_RGB: [f32; 3] = [0.88, 0.82, 0.58];
const Y_HALO: f32 = 0.0004;
const Y_DISC: f32 = 0.0008;
const Y_RIM: f32 = 0.0012;
const Y_CRACK: f32 = 0.0016;
const HUMIDITIES: [f32; 4] = [0.30, 0.45, 0.55, 0.85];
const PLAY_STEP: u32 = 4;
const SCRUB_PER_SEC: f32 = 700.0;
const MAX_CRACKS: u32 = 14;
#[derive(Resource)]
struct Scrub {
age: f32,
playing: bool,
stop: usize,
blood: BloodSettings,
span: u32,
}
impl Default for Scrub {
fn default() -> Self {
let stop = 1;
let humidity = HUMIDITIES[stop.min(HUMIDITIES.len() - 1)];
let blood = BloodSettings { humidity, ..Default::default() };
let span = AREAS.iter().map(|a| dry_ticks(*a, HZ)).max().unwrap_or(1).max(1);
Scrub { age: 0.0, playing: false, stop, blood, span }
}
}
impl Scrub {
fn age_ticks(&self) -> u32 {
self.age.max(0.0).round() as u32
}
}
#[derive(Resource)]
struct Rims(Vec<Handle<Mesh>>);
#[derive(Component)]
struct Disc(usize);
#[derive(Component)]
struct Rim(usize);
#[derive(Component)]
struct Halo(usize);
#[derive(Component)]
struct Panel(usize);
#[derive(Component)]
struct PoolText(usize);
#[derive(Component)]
struct Readout;
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "bevy_carnage - drying".into(),
canvas: Some("#carnage-canvas".into()),
..default()
}),
..default()
}))
.insert_resource(Time::<Fixed>::from_hz(HZ as f64))
.init_resource::<Scrub>()
.add_systems(Startup, setup)
.add_systems(Update, (keys, paint, panels, readout, cracks).chain())
.add_systems(FixedUpdate, play)
.run();
}
fn radius(area: f32) -> f32 {
(area / std::f32::consts::PI).sqrt()
}
fn centre(index: usize) -> Option<Vec3> {
CENTRES.get(index).map(|x| Vec3::new(*x, 0.0, 0.0))
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
scrub: Res<Scrub>,
) {
let look = Vec3::new(0.01, 0.0, 0.0);
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.01, 0.17, 0.18).looking_at(look, Vec3::Y),
));
commands.spawn((
DirectionalLight { illuminance: 13_000.0, shadow_maps_enabled: false, ..default() },
Transform::from_xyz(0.01, 0.69, -0.73).looking_at(look, Vec3::Y),
));
commands.spawn((
DirectionalLight { illuminance: 2_600.0, shadow_maps_enabled: false, ..default() },
Transform::from_xyz(0.30, 0.50, 0.55).looking_at(look, Vec3::Y),
));
commands.insert_resource(GlobalAmbientLight {
color: Color::srgb(0.58, 0.62, 0.74),
brightness: 260.0,
..default()
});
let floor = meshes.add(Mesh::from(Plane3d::default().mesh().size(2.0, 2.0)));
let floor_mat = materials.add(StandardMaterial {
base_color: Color::srgb(0.17, 0.17, 0.19),
perceptual_roughness: 0.90,
..default()
});
commands.spawn((Mesh3d(floor), MeshMaterial3d(floor_mat)));
let flat = Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2);
let rim_zero = meshes.add(Mesh::from(Annulus::new(0.999, 1.0)));
let mut rims: Vec<Handle<Mesh>> = vec![rim_zero.clone()];
rims.extend((1..=RIM_STEPS).map(|step| {
let inner = (1.0 - RIM_DEPTH * step as f32 / RIM_STEPS as f32).min(0.999);
meshes.add(Mesh::from(Annulus::new(inner, 1.0)))
}));
let halo_mesh = meshes.add(Mesh::from(Annulus::new(1.0, HALO_OUTER)));
for (i, area) in AREAS.iter().enumerate() {
let Some(c) = centre(i) else { continue };
let r = radius(*area);
let disc = meshes.add(Mesh::from(Circle::new(r)));
let fresh = Color::srgb(SRGB_OXY[0], SRGB_OXY[1], SRGB_OXY[2]);
let disc_mat = materials.add(StandardMaterial {
base_color: fresh,
perceptual_roughness: scrub.blood.wet_roughness,
..default()
});
commands.spawn((
Disc(i),
Mesh3d(disc),
MeshMaterial3d(disc_mat),
Transform::from_translation(c + Vec3::Y * Y_DISC).with_rotation(flat),
));
let rim_mat = materials.add(StandardMaterial {
base_color: fresh,
perceptual_roughness: scrub.blood.dry_roughness,
..default()
});
commands.spawn((
Rim(i),
Mesh3d(rim_zero.clone()),
MeshMaterial3d(rim_mat),
Transform::from_translation(c + Vec3::Y * Y_RIM).with_rotation(flat).with_scale(
Vec3::splat(r),
),
Visibility::Hidden,
));
let halo_mat = materials.add(StandardMaterial {
base_color: Color::srgba(SERUM_RGB[0], SERUM_RGB[1], SERUM_RGB[2], 0.0),
perceptual_roughness: 0.55,
alpha_mode: AlphaMode::Blend,
..default()
});
commands.spawn((
Halo(i),
Mesh3d(halo_mesh.clone()),
MeshMaterial3d(halo_mat),
Transform::from_translation(c + Vec3::Y * Y_HALO).with_rotation(flat).with_scale(
Vec3::splat(r),
),
Visibility::Hidden,
));
}
commands.insert_resource(Rims(rims));
build_ui(&mut commands);
}
fn build_ui(commands: &mut Commands) {
let font = TextFont { font_size: FontSize::Px(14.0), ..default() };
let pale = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.88));
commands.spawn((
Text::new(format!(
"bevy_carnage - DRYING: one scalar age, five channels\n\
\n\
left / right scrub the age\n\
H humidity\n\
Space play\n\
\n\
The colour walks three stops, not two -- which is why old blood is a brown\n\
rather than a dark red: oxyHb {:.2} {:.2} {:.2} -> metHb {:.2} {:.2} {:.2}\n\
-> hemichrome {:.2} {:.2} {:.2}\n\
The rim dries first: matte at the edge, still glossy in the middle.\n\
A serum halo appears OUTSIDE the pool only at or above {:.0}% humidity --\n\
below that there is none at all, at any age. Gloss, not hue, is the strongest\n\
cue, so wetness is a specular channel here. Craquelure cracks the crust past\n\
t = {:.2}. The two pools differ ONLY in area, and dry_ticks scales with it.",
SRGB_OXY[0], SRGB_OXY[1], SRGB_OXY[2],
SRGB_MET[0], SRGB_MET[1], SRGB_MET[2],
SRGB_HEMI[0], SRGB_HEMI[1], SRGB_HEMI[2],
HALO_HUMIDITY * 100.0,
CRAQUELURE_ONSET,
)),
font.clone(),
pale,
Node { position_type: PositionType::Absolute, top: px(12), left: px(14), ..default() },
));
commands.spawn((
Readout,
Text::new(""),
font.clone(),
TextColor(Color::srgba(1.0, 0.94, 0.72, 0.95)),
Node { position_type: PositionType::Absolute, top: px(12), right: px(14), ..default() },
));
for i in 0..AREAS.len() {
let mut root = Node {
position_type: PositionType::Absolute,
bottom: px(12),
padding: px(6).all(),
..default()
};
if i == 0 {
root.left = px(14);
} else {
root.right = px(14);
}
commands.spawn((Panel(i), root, BackgroundColor(Color::NONE))).with_children(|b| {
b.spawn((PoolText(i), Text::new(""), font.clone(), pale));
});
}
}
fn keys(keys: Res<ButtonInput<KeyCode>>, time: Res<Time>, mut s: ResMut<Scrub>) {
let dt = time.delta_secs();
if keys.pressed(KeyCode::ArrowRight) {
s.age += dt * SCRUB_PER_SEC;
}
if keys.pressed(KeyCode::ArrowLeft) {
s.age -= dt * SCRUB_PER_SEC;
}
let span = s.span as f32;
s.age = s.age.clamp(0.0, span);
if keys.just_pressed(KeyCode::KeyH) {
s.stop = (s.stop + 1) % HUMIDITIES.len();
if let Some(h) = HUMIDITIES.get(s.stop) {
s.blood.humidity = *h;
}
}
if keys.just_pressed(KeyCode::Space) {
s.playing = !s.playing;
}
}
fn play(mut s: ResMut<Scrub>) {
if !s.playing {
return;
}
let next = s.age_ticks().saturating_add(PLAY_STEP);
s.age = if next > s.span { 0.0 } else { next as f32 };
}
fn paint(
scrub: Res<Scrub>,
rims: Res<Rims>,
mut materials: ResMut<Assets<StandardMaterial>>,
discs: Query<(&Disc, &MeshMaterial3d<StandardMaterial>)>,
mut rings: Query<
(&Rim, &mut Mesh3d, &mut Visibility, &MeshMaterial3d<StandardMaterial>),
Without<Halo>,
>,
mut halos: Query<(&Halo, &mut Visibility, &MeshMaterial3d<StandardMaterial>), Without<Rim>>,
) {
let age = scrub.age_ticks();
for (disc, skin) in &discs {
let Some(area) = AREAS.get(disc.0) else { continue };
let a = appearance(age, HZ, *area, &scrub.blood);
if let Some(mut mat) = materials.get_mut(&skin.0) {
mat.base_color = Color::srgb(a.srgb[0], a.srgb[1], a.srgb[2]);
mat.perceptual_roughness = a.roughness;
}
}
for (rim, mut mesh, mut vis, skin) in &mut rings {
let Some(area) = AREAS.get(rim.0) else { continue };
let a = appearance(age, HZ, *area, &scrub.blood);
let step = (a.rim.clamp(0.0, 1.0) * RIM_STEPS as f32).round() as usize;
let want = if step == 0 { Visibility::Hidden } else { Visibility::Visible };
if *vis != want {
*vis = want;
}
if let Some(handle) = rims.0.get(step) {
if mesh.0 != *handle {
mesh.0 = handle.clone();
}
}
if let Some(mut mat) = materials.get_mut(&skin.0) {
mat.base_color = Color::srgb(a.srgb[0], a.srgb[1], a.srgb[2]);
mat.perceptual_roughness = scrub.blood.dry_roughness;
}
}
for (halo, mut vis, skin) in &mut halos {
let Some(area) = AREAS.get(halo.0) else { continue };
let a = appearance(age, HZ, *area, &scrub.blood);
let want = if a.halo > 0.0 { Visibility::Visible } else { Visibility::Hidden };
if *vis != want {
*vis = want;
}
if let Some(mut mat) = materials.get_mut(&skin.0) {
mat.base_color =
Color::srgba(SERUM_RGB[0], SERUM_RGB[1], SERUM_RGB[2], a.halo.clamp(0.0, 1.0) * 0.8);
}
}
}
fn cracks(mut gizmos: Gizmos, scrub: Res<Scrub>) {
let age = scrub.age_ticks();
for (i, area) in AREAS.iter().enumerate() {
let a = appearance(age, HZ, *area, &scrub.blood);
if a.craquelure <= 0.0 {
continue;
}
let Some(c) = centre(i) else { continue };
let r = radius(*area);
let n = (a.craquelure * MAX_CRACKS as f32).round() as u32;
let colour = Color::srgba(0.10, 0.04, 0.03, (0.30 + 0.70 * a.craquelure).min(1.0));
for k in 0..n {
let seed = ((i as u32) << 16) | k;
let mut heading = hash_f32(seed ^ 0x1234_5678) * std::f32::consts::TAU;
let mut p = c + Vec3::new(heading.cos(), 0.0, heading.sin()) * r * 0.10;
let mut pts = Vec::with_capacity(6);
pts.push(p + Vec3::Y * Y_CRACK);
for step in 1..6u32 {
heading += (hash_f32(seed ^ (step * 7919)) - 0.5) * 0.9;
p += Vec3::new(heading.cos(), 0.0, heading.sin()) * r * 0.2;
let out = p - c;
let far = out.length();
if far > r * 0.95 {
p = c + out / far.max(1.0e-6) * r * 0.95;
pts.push(p + Vec3::Y * Y_CRACK);
break;
}
pts.push(p + Vec3::Y * Y_CRACK);
}
gizmos.linestrip(pts, colour);
}
}
}
fn bar(v: f32) -> String {
let n = (v.clamp(0.0, 1.0) * 10.0).round() as usize;
(0..10).map(|i| if i < n { '#' } else { '.' }).collect()
}
fn stop_label(t: f32) -> String {
if t < 0.35 {
format!("oxyHb->metHb {:>3.0}%", (t / 0.35).clamp(0.0, 1.0) * 100.0)
} else {
format!("metHb->hemi {:>3.0}%", ((t - 0.35) / 0.65).clamp(0.0, 1.0) * 100.0)
}
}
fn panels(
scrub: Res<Scrub>,
mut texts: Query<(&PoolText, &mut Text)>,
mut tints: Query<(&Panel, &mut BackgroundColor)>,
) {
let age = scrub.age_ticks();
let (wet, dry) = (scrub.blood.wet_roughness, scrub.blood.dry_roughness);
for (panel, mut text) in &mut texts {
let Some(area) = AREAS.get(panel.0) else { continue };
let a = appearance(age, HZ, *area, &scrub.blood);
let span = dry_ticks(*area, HZ);
let t = (age as f32 / span as f32).clamp(0.0, 1.0);
let rough_f = if dry > wet { (a.roughness - wet) / (dry - wet) } else { 0.0 };
let name = NAMES.get(panel.0).copied().unwrap_or("pool");
let (cm2, secs, stop) = (area * 1.0e4, span as f32 / HZ as f32, stop_label(t));
let (r, g, b) = (a.srgb[0], a.srgb[1], a.srgb[2]);
let (rough, rim, halo, crq) = (a.roughness, a.rim, a.halo, a.craquelure);
let halo_bar = if halo > 0.0 { format!("[{}]", bar(halo)) } else { "absent".into() };
let next = format!(
"{name} {cm2:.0} cm2 dry span {span} t ({secs:.1} s) t = {t:.2}\n\
colour {r:.2} {g:.2} {b:.2} {stop}\n\
roughness {rough:.2} [{}]\n\
rim {rim:.2} [{}]\n\
halo {halo:.2} {halo_bar}\n\
craquelure {crq:.2} [{}]",
bar(rough_f),
bar(rim),
bar(crq),
);
if text.0 != next {
text.0 = next;
}
}
for (panel, mut tint) in &mut tints {
let Some(area) = AREAS.get(panel.0) else { continue };
let a = appearance(age, HZ, *area, &scrub.blood);
let want = Color::srgba(a.srgb[0], a.srgb[1], a.srgb[2], 0.55);
if tint.0 != want {
tint.0 = want;
}
}
}
fn readout(scrub: Res<Scrub>, mut out: Query<&mut Text, With<Readout>>) {
let age = scrub.age_ticks();
let (span, secs) = (scrub.span, age as f32 / HZ as f32);
let (rh, thr) = (scrub.blood.humidity, HALO_HUMIDITY);
let verdict = if rh >= thr { "halo forms" } else { "NO halo, at any age" };
let play = if scrub.playing {
format!("playing, {PLAY_STEP} ticks per tick")
} else {
"paused -- Space plays".to_string()
};
let text = format!(
"age {age:>4} t of {span} ({secs:>5.2} s)\n\
humidity {rh:.2} serum halo threshold {thr:.2} {verdict}\n\
{play}",
);
for mut t in &mut out {
if t.0 != text {
t.0 = text.clone();
}
}
}