diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 2d69fc86..40e3b4d7 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -29,6 +29,7 @@ fn bind( skins: Query<(Entity, &SkinnedMesh)>, nodes: Query<(&Name, &Transform)>, parents: Query<&ChildOf>, + visibility: Query<&Visibility>, roots: Query>, mut exit: MessageWriter, ) { @@ -37,12 +38,16 @@ fn bind( } let names = &skater.animation.evaluator.frames.bone_names; let result = (|| -> Result>, String> { + let mut bindings = Vec::new(); + let mut seen = std::collections::HashSet::new(); for (entity, skin) in &skins { if !parents.iter_ancestors(entity).any(|e| roots.contains(e)) { continue; } + if parents.iter_ancestors(entity).any(|e| visibility.get(e).is_ok_and(|v| *v == Visibility::Hidden)) { continue; } let mut binding = Vec::with_capacity(skin.joints.len()); for &joint in &skin.joints { + if !seen.insert(joint) { continue; } let (name, _) = nodes .get(joint) .map_err(|_| "Skater skin joint is missing its name or transform")?; @@ -80,13 +85,10 @@ fn bind( parent_bone, }); } - // All ten imported mesh primitives share these joint entities and - // their authored inverse-bind matrices. Bind the joints once and - // retain that mesh reference: the stock initialization pose is an - // animation pose, not a replacement skin bind pose. - return Ok(Some(binding)); + // Bind each visible modular rig, retaining its authored inverse binds. + bindings.extend(binding); } - Ok(None) + Ok(if bindings.is_empty() { None } else { Some(bindings) }) })(); match result { Ok(Some(binding)) => { diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index b1707d55..f3f02bc4 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -66,12 +66,13 @@ fn present(mut runtime: ResMut, windows: Query<&Window>, if let Projection::Perspective(p) = &mut *projection { p.fov = previous.fov + (current.fov - previous.fov) * alpha; } - if customiser.as_ref().is_some_and(|c| c.open) { - let center = current.root.translation + Vec3::Y * 0.95; - let offset = current.root.rotation * Vec3::new(0.0, 0.25, 3.2); + if let Some(customiser) = customiser.as_ref().filter(|c| c.open) { + let (height,distance,yaw)=customiser.preview_camera(); + let center = current.root.translation + Vec3::Y * height; + let offset = current.root.rotation * Quat::from_rotation_y(yaw) * Vec3::new(0.0, 0.25, distance); let eye = center + offset; let right = offset.normalize().cross(Vec3::Y).normalize(); - *transform = Transform::from_translation(eye).looking_at(center + right * 0.85, Vec3::Y); + *transform = Transform::from_translation(eye).looking_at(center + right * (distance * 0.265625), Vec3::Y); if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); } } camera.is_active = true; diff --git a/crates/skate-game/src/customiser.rs b/crates/skate-game/src/customiser.rs index 5fd0e95a..5d7f467b 100644 --- a/crates/skate-game/src/customiser.rs +++ b/crates/skate-game/src/customiser.rs @@ -1,63 +1,57 @@ -//! Draft/commit character editing over the existing retail GLB and animation rig. -use bevy::{asset::LoadState, gltf::Gltf, prelude::*}; +//! Compact character menus over resident retail parts. +use crate::customiser_parts::{Library, Parts}; +use bevy::{input::mouse::MouseWheel, prelude::*}; use serde::Deserialize; use serde_json::{Value, json}; -use std::{ - io::Write, - path::PathBuf, - process::{Command, Stdio}, - sync::{Mutex, mpsc}, -}; +use std::path::PathBuf; #[derive(Clone, Default, Deserialize)] struct Entry { label: String, #[serde(default)] children: Vec, + #[serde(default)] + options: Vec, patch: Option, scalar: Option, note: Option, + gender: Option, minimum: Option, maximum: Option, initial: Option, step: Option, } -#[derive(Clone, Deserialize)] -struct Worker { - python: PathBuf, - working_directory: PathBuf, -} #[derive(Resource, Default)] pub(crate) struct Navigation { pub pressed: u16, previous: u16, + held_for: f32, + repeat_at: f32, } #[derive(Resource)] pub(crate) struct Customiser { pub open: bool, + pub enabled: bool, just_opened: bool, index: Entry, path: Vec, selected: usize, - draft: Value, - committed: Value, - scene: String, - committed_scene: String, - config: PathBuf, + page_size: usize, + search: String, + pub draft: Value, settings: PathBuf, - worker: Option, - receiver: Option>>>, - loading: Option<(Handle, String, Value)>, - status: String, - redraw: bool, + pub status: String, + pub redraw: bool, } impl Customiser { pub(crate) fn begin(&mut self) { self.open = true; + self.enabled = true; self.just_opened = true; self.path.clear(); + self.search.clear(); self.selected = 0; - self.status = "Choose a category. Changes are previews until you Save.".into(); + self.status.clear(); self.redraw = true; } fn page(&self) -> &Entry { @@ -67,165 +61,644 @@ impl Customiser { } page } - fn busy(&self) -> bool { - self.receiver.is_some() || self.loading.is_some() + fn visible(&self) -> Vec { + let gender = self.draft["gender"].as_str().unwrap_or("male"); + self.page() + .children + .iter() + .enumerate() + .filter(|(_, e)| { + e.gender + .as_deref() + .is_none_or(|g| g == gender || g == "unisex") + }) + .filter(|(_, e)| { + self.search.is_empty() + || e.label.to_lowercase().contains(&self.search.to_lowercase()) + }) + .map(|(i, _)| i) + .collect() } - fn request(&mut self, profile: Value) { - if self.busy() { - self.status = "Please wait for the current preview.".into(); - return; + pub fn preview_camera(&self) -> (f32, f32, f32) { + let mut page = &self.index; + let mut labels = vec![]; + for &i in &self.path { + page = &page.children[i]; + labels.push(page.label.as_str()); } - let Some(worker) = self.worker.clone() else { - self.status = "Private customisation worker is not configured.".into(); - return; - }; - let config = self.config.clone(); - let (tx, rx) = mpsc::channel(); - self.receiver = Some(Mutex::new(rx)); - self.status = "Preparing retail meshes and textures…".into(); - self.redraw = true; - std::thread::spawn(move || { - let run = || -> Result { - let mut command = Command::new(&worker.python); - command - .current_dir(&worker.working_directory) - .args([ - "-m", - "tools.asset_pipeline.customisation_worker", - "--config", - ]) - .arg(config) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x08000000); - } - let mut child = command - .spawn() - .map_err(|e| format!("Cannot start asset worker: {e}"))?; - child - .stdin - .take() - .ok_or("Worker stdin missing")? - .write_all(&serde_json::to_vec(&profile).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?; - let output = child.wait_with_output().map_err(|e| e.to_string())?; - let response: Value = serde_json::from_slice(&output.stdout).map_err(|e| { - format!( - "Worker response: {e}; {}", - String::from_utf8_lossy(&output.stderr) - .chars() - .take(400) - .collect::() - ) - })?; - if let Some(e) = response.get("error").and_then(Value::as_str) { - return Err(e.into()); - } - if !output.status.success() { - return Err("Asset worker failed".into()); - } - Ok(response) + if labels.contains(&"Upper body") || labels.contains(&"Lower body") { + let upper = labels.contains(&"Upper body"); + let side = self.draft["tattoos"][if upper { "Arm" } else { "Leg" }]["side"] + .as_u64() + .unwrap_or(0); + let yaw = match side { + 0 => -0.65, + 1 => 0.65, + 3 => std::f32::consts::PI, + _ => 0., }; - let _ = tx.send(run()); - }); + return (if upper { 1.15 } else { 0.5 }, 2.2, yaw); + } + if labels.iter().any(|s| { + [ + "Face", + "Face presets", + "Hair", + "Hair colour", + "Facial hair", + "Skin tone", + ] + .contains(s) + }) { + return (1.48, 1.65, 0.); + } + if labels.contains(&"Board") { + return (0.25, 2.0, 0.6); + } + (0.95, 3.2, 0.) + } + pub fn preview(&self, parts: &Parts) -> Value { + let mut profile = self.draft.clone(); + if !self.open { + return profile; + } + let mut page = &self.index; + let mut zone = None; + let mut tattoos = false; + for &i in &self.path { + page = &page.children[i]; + if page.label == "Tattoos" { + tattoos = true; + } + if tattoos && page.label == "Upper body" { + zone = Some("OuterTorso"); + } + if tattoos && page.label == "Lower body" { + zone = Some("Pants"); + } + } + if let Some(slot) = zone { + let gender = profile["gender"].as_str().unwrap_or("male"); + let candidate = parts + .library + .models + .iter() + .filter(|(_, p)| { + p.slot == slot + && p.flag("Gender") == gender + && (if slot == "OuterTorso" { + p.flag("TopType").is_empty() + } else { + p.flag("IsTattooViewingChoice").eq_ignore_ascii_case("true") + }) + }) + .min_by_key(|(id, _)| *id); + if let Some((id, p)) = candidate { + profile["selections"][slot] = json!({"asset_id":id,"material_id":p.materials[0]}); + if let Ok(resolved) = parts.resolve(&profile) { + return resolved; + } + } + } + profile + } + fn preload_entries(&self) -> Vec<&Entry> { + if !self.open { + return vec![]; + } + let visible = self.visible(); + let start = self.selected / self.page_size * self.page_size; + visible + .iter() + .skip(start) + .take(self.page_size) + .flat_map(|&i| { + let e = &self.page().children[i]; + let current = option_index(e, &self.draft).unwrap_or(0); + e.options + .iter() + .enumerate() + .filter(move |(i, _)| *i < 4 || i.abs_diff(current) <= 2) + .map(|(_, v)| v) + .chain(std::iter::once(e)) + }) + .collect() + } + pub fn preload_outfits(&self, parts: &Parts) -> Vec { + self.preload_entries() + .into_iter() + .filter_map(|e| e.patch.as_ref()) + .filter_map(|patch| { + let mut profile = self.draft.clone(); + if let Some(gender) = patch["gender"].as_str() { + profile = parts.library.defaults.get(gender)?.clone(); + } else { + merge(&mut profile, patch); + if let Some(hair) = patch["selections"].get("Hair") { + profile["hair_choice"] = hair.clone(); + } + } + parts.resolve(&profile).ok() + }) + .collect() } } #[derive(Component)] struct Root; #[derive(Component)] struct Row(usize); +#[derive(Component)] +struct Adjust(usize, i32); +const BACK: usize = usize::MAX; +const NEXT: usize = usize::MAX - 1; +const PREV: usize = usize::MAX - 2; +const RESET: usize = usize::MAX - 3; pub(crate) struct CustomiserPlugin; impl Plugin for CustomiserPlugin { fn build(&self, app: &mut App) { + crate::customiser_material::register(app); app.init_resource::() + .add_systems(Startup, crate::customiser_parts::setup) .add_systems(PreUpdate, navigation) .add_systems(PostStartup, setup) .add_systems( Update, - (interact, complete, draw) + (interact, crate::customiser_parts::update, preferences, draw) .chain() .after(crate::graphics_menu::interact) .before(crate::app::FrameSet::Animation), ); } } -fn navigation(mut nav: ResMut) { - let current = (0..4) +fn navigation(mut nav: ResMut, time: Res>, keys: Res>) { + let mut current = (0..4) .find_map(|i| crate::input::platform::poll(i).ok()) - .map_or(0, |p| p.state.buttons); + .map_or(0, |p| { + p.state.buttons + | if p.state.left[1] > 16000 { + 1 + } else if p.state.left[1] < -16000 { + 2 + } else { + 0 + } + | if p.state.left[0] > 16000 { + 8 + } else if p.state.left[0] < -16000 { + 4 + } else { + 0 + } + }); + for (key, bit) in [ + (KeyCode::ArrowUp, 1), + (KeyCode::ArrowDown, 2), + (KeyCode::ArrowLeft, 4), + (KeyCode::ArrowRight, 8), + ] { + if keys.pressed(key) { + current |= bit; + } + } nav.pressed = current & !nav.previous; + if current & 15 != 0 && current & 15 == nav.previous & 15 { + nav.held_for += time.delta_secs(); + if nav.held_for >= nav.repeat_at { + nav.pressed |= current & 15; + nav.repeat_at += 0.09; + } + } else { + nav.held_for = 0.; + nav.repeat_at = 0.35; + } nav.previous = current; } -fn setup( - mut commands: Commands, - config: Res, - manifest: Res, -) { - let root = config.asset_root.join("private/customisation"); - let read = |name: &str| { - std::fs::read(root.join(name)) - .ok() - .and_then(|b| serde_json::from_slice::(&b).ok()) +fn page(label: impl Into, children: Vec) -> Entry { + Entry { + label: label.into(), + children, + ..default() + } +} +fn choice(label: impl Into, patch: Value) -> Entry { + Entry { + label: label.into(), + patch: Some(patch), + ..default() + } +} +fn tidy(s: &str) -> String { + s.split_whitespace() + .map(|w| { + let mut c = w.chars(); + c.next() + .map(|x| x.to_uppercase().collect::() + c.as_str()) + .unwrap_or_default() + }) + .collect::>() + .join(" ") +} +fn menu(lib: &Library, extras: Vec) -> Entry { + let model_page = |title: &str, slot: &str, types: &[&str]| { + let mut entries = vec![]; + if [ + "Hat", + "Hair", + "Sock", + "Glasses", + "Jewellery", + "WristItem", + "Accessory", + ] + .contains(&slot) + { + entries.push(choice("None", json!({"selections":{slot:Value::Null}}))); + } + if title == "T-shirts" { + for (id, p) in &lib.models { + if p.slot == "OuterTorso" && p.flag("TopType").is_empty() { + entries.push(Entry { gender:Some(p.flag("Gender").into()), + ..choice("No top",json!({"selections":{"OuterTorso":{"asset_id":id,"material_id":p.materials[0]}}})) }); + } + } + } + let mut models: Vec<_> = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == slot + && (slot != "Hair" || p.flag("HairModelType") == "full") + && (types.is_empty() || types.contains(&p.flag("TopType"))) + }) + .collect(); + models.sort_by(|a, b| a.1.name.cmp(&b.1.name).then(a.0.cmp(b.0))); + for (id, p) in models { + let options: Vec<_> = p + .materials + .iter() + .flat_map(|mid| { + let Some(m)=lib.materials.get(mid) else {return vec![];}; + let mut options=vec![choice(&m.name,json!({"selections":{slot:{"asset_id":id,"material_id":mid}},"colours":{slot:Value::Null}}))]; + if m.flag("IsColourizable").eq_ignore_ascii_case("true") { + options.extend(lib.colours.iter().map(|c| choice(&c.name, + json!({"selections":{slot:{"asset_id":id,"material_id":mid}},"colours":{slot:c.rgb}})))); + } + options + }) + .collect(); + if options.is_empty() { + continue; + } + let gender = p.flag("Gender"); + entries.push(Entry { + label: if slot == "Hair" { + tidy(p.flag("HairStyle").replace("moosed", "moussed").as_str()) + } else { + p.name.clone() + }, + options, + gender: if gender.is_empty() { + None + } else { + Some(gender.into()) + }, + ..default() + }); + } + if ["SkateBoard", "SkateTruck", "SkateWheel"].contains(&slot) { + let mut groups: std::collections::BTreeMap> = default(); + for e in entries { + for mut option in e.options { + let label = option.label.trim_end_matches(" (Worn)").to_owned(); + option.label = if option.label.ends_with(" (Worn)") { + "Worn" + } else { + "New" + } + .into(); + groups.entry(label).or_default().push(option); + } + } + entries = groups + .into_iter() + .map(|(label, options)| Entry { + label, + options, + ..default() + }) + .collect(); + } + page(title, entries) }; - let index = read("menu.json") - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or(Entry { - label: "Character customiser".into(), - note: Some("Prepare owned customisation assets for this installation.".into()), - ..default() - }); - let defaults = read("default.json") - .unwrap_or(json!({"selections":{},"morphs":{},"truck":0.7,"wheel":0.7,"posture":0})); + let morph = |title: &str, name: &str| Entry { + label: title.into(), + scalar: Some(format!("morphs.{name}")), + minimum: Some(0.), + maximum: Some(0.5), + initial: Some(if name == "fat" || name == "thin" { + 0. + } else { + 0.25 + }), + step: Some(0.025), + ..default() + }; + let mut body = page( + "Body", + vec![ + page( + "Gender", + vec![ + choice("Male", json!({"gender":"male"})), + choice("Female", json!({"gender":"female"})), + ], + ), + page( + "Skin tone", + ["light", "dark"] + .map(|s| choice(tidy(s), json!({"skin":s}))) + .to_vec(), + ), + model_page("Hair", "Hair", &[]), + page( + "Build", + vec![morph("Weight", "fat"), morph("Definition", "thin")], + ), + page( + "Face", + vec![ + page( + "Brows", + vec![ + morph("Depth", "local_brows_depth"), + morph("Height", "local_brows_height"), + morph("Angle", "local_brows_rotation"), + ], + ), + page( + "Eyes", + vec![ + morph("Height", "local_eye_height"), + morph("Angle", "local_eye_rotation"), + morph("Width", "local_eye_width"), + ], + ), + page( + "Nose", + vec![ + morph("Curve", "local_nose_curve"), + morph("Height", "local_nose_height"), + morph("Length", "local_nose_length"), + morph("Width", "local_nose_width"), + ], + ), + page( + "Mouth", + vec![ + morph("Corners", "local_mouth_corner"), + morph("Height", "local_mouth_height"), + morph("Fullness", "local_mouth_lipsize"), + morph("Width", "local_mouth_width"), + ], + ), + page( + "Jaw & chin", + vec![ + morph("Definition", "local_jaw_chiseled"), + morph("Depth", "local_jaw_depth"), + morph("Chin length", "local_chin_length"), + ], + ), + ], + ), + page("Facial hair", { + let mut names: Vec<_> = lib + .materials + .values() + .map(|m| m.flag("FacialHairStyle")) + .filter(|s| !s.is_empty()) + .collect(); + names.sort(); + names.dedup(); + names + .into_iter() + .map(|s| Entry { + gender: Some("male".into()), + ..choice( + match s { + "none" => "Clean shaven".into(), + "billygoat" => "Long goatee".into(), + "soulpatch" => "Soul patch".into(), + "magnum" => "Thick moustache".into(), + _ => tidy(s), + }, + json!({"beard":s}), + ) + }) + .collect() + }), + ], + ); + let mut tattoos: Vec<_> = lib.tattoos.iter().collect(); + tattoos.sort_by(|a, b| a.1.name.cmp(&b.1.name).then(a.0.cmp(b.0))); + body.children.push(page( + "Tattoos", + [("Upper body", "Arm"), ("Lower body", "Leg")] + .into_iter() + .map(|(name, slot)| { + let mut designs = + vec![choice("None", json!({"tattoos":{slot:{"id":Value::Null}}}))]; + designs.extend( + tattoos + .iter() + .map(|(id, t)| choice(&t.name, json!({"tattoos":{slot:{"id":id}}}))), + ); + page( + name, + vec![ + page("Design", designs), + page("Placement", { + let mut positions = vec![ + choice( + if slot == "Arm" { + "Left arm" + } else { + "Left leg" + }, + json!({"tattoos":{slot:{"side":0}}}), + ), + choice( + if slot == "Arm" { + "Right arm" + } else { + "Right leg" + }, + json!({"tattoos":{slot:{"side":1}}}), + ), + ]; + if slot == "Arm" { + positions.extend([ + choice("Chest", json!({"tattoos":{slot:{"side":2}}})), + choice("Back", json!({"tattoos":{slot:{"side":3}}})), + ]); + } + positions + }), + ], + ) + }) + .collect(), + )); + let clothes = page( + "Clothes", + vec![ + model_page("Hats", "Hat", &[]), + model_page( + "T-shirts", + "OuterTorso", + &["tshirt", "ls_tshirt", "tanktop"], + ), + model_page("Shirts", "OuterTorso", &["buttonshirt"]), + model_page("Hoodies", "OuterTorso", &["hoody"]), + model_page("Jackets", "OuterTorso", &["jacket"]), + model_page("Sweaters", "OuterTorso", &["sweater"]), + model_page("Pants & shorts", "Pants", &[]), + model_page("Shoes", "Feet", &[]), + model_page("Socks", "Sock", &[]), + page( + "Accessories", + vec![ + model_page("Glasses", "Glasses", &[]), + model_page("Necklaces", "Jewellery", &[]), + model_page("Wristwear", "WristItem", &[]), + model_page("Other", "Accessory", &[]), + ], + ), + ], + ); + let board = page( + "Board", + vec![ + model_page("Deck", "SkateBoard", &[]), + model_page("Trucks", "SkateTruck", &[]), + model_page("Wheels", "SkateWheel", &[]), + Entry { + label: "Truck tightness".into(), + scalar: Some("truck".into()), + minimum: Some(0.), + maximum: Some(1.), + initial: Some(0.7), + step: Some(0.1), + ..default() + }, + Entry { + label: "Wheel hardness".into(), + scalar: Some("wheel".into()), + minimum: Some(0.), + maximum: Some(1.), + initial: Some(0.7), + step: Some(0.1), + ..default() + }, + ], + ); + let mut root = page( + "Your skater", + vec![ + body, + clothes, + board, + page( + "Style", + vec![page( + "Posture", + ["Default", "Stiff", "Slouch", "Buff"] + .iter() + .enumerate() + .map(|(i, s)| choice(*s, json!({"posture":i}))) + .collect(), + )], + ), + ], + ); + for e in extras { + if e.label == "Style" { + root.children[3].children.extend(e.children); + } else if let Some(i) = root.children[0] + .children + .iter() + .position(|p| p.label == e.label) + { + root.children[0].children[i] = e; + } else { + root.children[0].children.push(e); + } + } + root +} +fn setup(mut commands: Commands, config: Res, parts: Res) { let settings = config .asset_root .parent() .unwrap_or(&config.asset_root) .join("settings/character.json"); - let saved = std::fs::read(&settings) - .ok() - .and_then(|b| serde_json::from_slice::(&b).ok()); - let worker = read("worker.json").and_then(|v| serde_json::from_value(v).ok()); - let mut state = Customiser { + let mut draft = parts + .library + .defaults + .get("male") + .cloned() + .unwrap_or(json!({"selections":{},"morphs":{}})); + let mut enabled = false; + let status = String::new(); + if let Ok(b) = std::fs::read(&settings) { + if let Ok(saved) = serde_json::from_slice::(&b) { + if let Ok(p) = parts.resolve(&saved) { + draft = p; + enabled = true; + } + } + } + let extras = std::fs::read( + config + .asset_root + .join("private/customisation/extra-menu.json"), + ) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + commands.insert_resource(Customiser { open: false, + enabled, just_opened: false, - index, + index: menu(&parts.library, extras), path: vec![], selected: 0, - draft: defaults.clone(), - committed: defaults, - scene: manifest.0.character_scene.clone(), - committed_scene: manifest.0.character_scene.clone(), - config: root.join("worker.json"), + page_size: 7, + search: String::new(), + draft, settings, - worker, - receiver: None, - loading: None, - status: String::new(), + status, redraw: true, - }; - if let Some(profile) = saved { - state.committed = profile.clone(); - state.request(profile); - } - commands.insert_resource(state); + }); commands.spawn(( Root, GlobalZIndex(20), Node { display: Display::None, position_type: PositionType::Absolute, - width: percent(42), - height: percent(100), - padding: UiRect::all(px(20)), + left: px(24), + top: percent(4), + width: percent(38), + min_width: px(370), + max_width: px(520), + height: percent(92), + padding: UiRect::all(px(22)), flex_direction: FlexDirection::Column, - row_gap: px(6), + row_gap: px(10), + border_radius: BorderRadius::all(px(18)), ..default() }, - BackgroundColor(Color::srgba(0.025, 0.04, 0.06, 0.98)), + BackgroundColor(Color::srgba(0.025, 0.035, 0.048, 0.98)), )); } fn merge(target: &mut Value, patch: &Value) { @@ -238,114 +711,199 @@ fn merge(target: &mut Value, patch: &Value) { } } fn scalar(profile: &Value, key: &str, initial: f64) -> f64 { - let pointer = format!("/{}", key.replace('.', "/")); profile - .pointer(&pointer) + .pointer(&format!("/{}", key.replace('.', "/"))) .and_then(Value::as_f64) .unwrap_or(initial) } +fn option_index(entry: &Entry, profile: &Value) -> Option { + entry.options.iter().position(|o| { + o.patch.as_ref().is_some_and(|p| { + p.as_object().is_some_and(|fields| { + fields.iter().all(|(key, value)| { + if key == "selections" { + value.as_object().is_some_and(|selections| { + selections.iter().all(|(slot, v)| { + (if slot == "Hair" { + profile + .get("hair_choice") + .unwrap_or(&profile["selections"][slot]) + } else { + &profile["selections"][slot] + }) == v + }) + }) + } else { + matches_patch(&profile[key], value) + } + }) + }) + }) + }) +} + fn interact( keys: Res>, + mouse: Res>, nav: Res, + mut wheel: MessageReader, + mut typing: MessageReader, mut state: ResMut, - mut menu: ResMut, - server: Res, - buttons: Query<(&Interaction, &Row), Changed>, + parts: Res, + mut pause: ResMut, + buttons: Query<(&Interaction, &Row)>, + adjustments: Query<(&Interaction, &Adjust)>, ) { if !state.open { + wheel.clear(); + typing.clear(); return; } if state.just_opened { state.just_opened = false; + wheel.clear(); + typing.clear(); return; } - let mut action = None; - let count = state.page().children.len(); - if count > 0 { - if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { - state.selected = (state.selected + count - 1) % count; + if state.page().children.iter().all(|e| e.children.is_empty()) { + for event in typing.read() { + if event.state == bevy::input::ButtonState::Pressed + && !keys.pressed(KeyCode::ControlLeft) + && !keys.pressed(KeyCode::ControlRight) + { + if let Some(text) = &event.text { + for c in text + .chars() + .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-') + { + if state.search.chars().count() < 32 { + state.search.push(c); + state.selected = 0; + state.redraw = true; + } + } + } + } + } + } else { + typing.clear(); + } + if !state.search.is_empty() { + if keys.just_pressed(KeyCode::Backspace) { + state.search.pop(); + state.selected = 0; state.redraw = true; + return; } - if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { - state.selected = (state.selected + 1) % count; + if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x2000 != 0 { + state.search.clear(); + state.selected = 0; state.redraw = true; - } - if keys.just_pressed(KeyCode::Enter) - || keys.just_pressed(KeyCode::ArrowRight) - || nav.pressed & (0x1000 | 8) != 0 - { - action = Some((state.selected, 1)); - } - if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { - action = Some((state.selected, -1)); + return; } } - let mut save = keys.just_pressed(KeyCode::F5) || nav.pressed & 0x4000 != 0; + let visible = state.visible(); + let count = visible.len(); + let mut action = None; let mut back = keys.just_pressed(KeyCode::Escape) || keys.just_pressed(KeyCode::Backspace) || nav.pressed & 0x2000 != 0; - let mut reset = false; - for (interaction, row) in &buttons { - if *interaction == Interaction::Pressed { - match row.0 { - usize::MAX => save = true, - x if x == usize::MAX - 1 => back = true, - x if x == usize::MAX - 2 => reset = true, - i => { - state.selected = i; - action = Some((i, 1)); + let mut movement = 0; + if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { + movement -= 1; + } + if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { + movement += 1; + } + for scroll in wheel.read() { + if scroll.y > 0. { + movement -= 1; + } else if scroll.y < 0. { + movement += 1; + } + } + if keys.just_pressed(KeyCode::PageDown) { + movement += state.page_size as i32; + } + if keys.just_pressed(KeyCode::PageUp) { + movement -= state.page_size as i32; + } + if count > 0 { + if movement != 0 { + state.selected = (state.selected as i32 + movement).rem_euclid(count as i32) as usize; + state.redraw = true; + } + if keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0 { + action = Some((visible[state.selected], 0)); + } + if keys.just_pressed(KeyCode::ArrowRight) || nav.pressed & 8 != 0 { + action = Some((visible[state.selected], 1)); + } + if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { + action = Some((visible[state.selected], -1)); + } + } + if mouse.just_pressed(MouseButton::Left) { + for (interaction, row) in &buttons { + if *interaction == Interaction::Pressed { + match row.0 { + BACK => back = true, + NEXT => { + if count > 0 { + state.selected = (state.selected + state.page_size).min(count - 1); + state.redraw = true; + } + } + PREV => { + state.selected = state.selected.saturating_sub(state.page_size); + state.redraw = true; + } + RESET => { + let g = state.draft["gender"].as_str().unwrap_or("male"); + if let Some(p) = parts.library.defaults.get(g) { + state.draft = p.clone(); + state.redraw = true; + } + } + i => { + state.selected = visible.iter().position(|v| *v == i).unwrap_or(0); + action = Some((i, 0)); + } } } } } - if save { - if state.busy() { - state.status = "Wait for the preview before saving.".into(); - } else { - let result = (|| -> Result<(), String> { - std::fs::create_dir_all(state.settings.parent().unwrap()) - .map_err(|e| e.to_string())?; - let temp = state.settings.with_extension("pending.json"); - std::fs::write( - &temp, - serde_json::to_vec_pretty(&state.draft).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - // Same directory replacement preserves the previous committed profile on write failure. - std::fs::rename(&temp, &state.settings).map_err(|e| e.to_string()) - })(); - match result { - Ok(()) => { - state.committed = state.draft.clone(); - state.committed_scene = state.scene.clone(); - state.status = "Saved. Back returns to skating with this character.".into(); - } - Err(e) => state.status = format!("Could not save: {e}"), + if mouse.just_pressed(MouseButton::Left) { + for (interaction, adjust) in &adjustments { + if *interaction == Interaction::Pressed { + state.selected = visible.iter().position(|i| *i == adjust.0).unwrap_or(0); + action = Some((adjust.0, adjust.1)); } } - state.redraw = true; - } - if reset && !state.busy() { - let profile = std::fs::read(state.config.with_file_name("default.json")) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()); - if let Some(profile) = profile { - state.request(profile); - } } if back { - if !state.path.is_empty() { - state.selected = state.path.pop().unwrap(); - } else if state.busy() { - state.status = "Wait for the preview before leaving.".into(); + state.search.clear(); + if let Some(i) = state.path.pop() { + state.selected = state.visible().iter().position(|v| *v == i).unwrap_or(0); } else { - if state.draft != state.committed { - let scene = state.committed_scene.clone(); - let profile = state.committed.clone(); - state.loading = Some((server.load(scene.clone()), scene, profile)); + let save = (|| -> Result<(), String> { + std::fs::create_dir_all(state.settings.parent().unwrap()) + .map_err(|e| e.to_string())?; + let tmp = state.settings.with_extension("pending.json"); + std::fs::write( + &tmp, + serde_json::to_vec_pretty(&state.draft).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + std::fs::rename(tmp, &state.settings).map_err(|e| e.to_string()) + })(); + match save { + Ok(()) => { + state.open = false; + pause.open = false; + } + Err(e) => state.status = format!("Could not save: {e}"), } - state.open = false; - menu.open = false; } state.redraw = true; return; @@ -355,118 +913,94 @@ fn interact( return; }; if !entry.children.is_empty() { + state.search.clear(); state.path.push(i); state.selected = 0; + state.status.clear(); } else if let Some(note) = entry.note { state.status = note; - } else if !state.busy() { + } else { let mut profile = state.draft.clone(); if let Some(key) = entry.scalar { - let current = scalar(&profile, &key, entry.initial.unwrap_or(0.)); - let value = (current + direction as f64 * entry.step.unwrap_or(0.1)) - .clamp(entry.minimum.unwrap_or(0.), entry.maximum.unwrap_or(1.)); - if let Some((parent, child)) = key.split_once('.') { - profile[parent][child] = json!(value); + let amount = if direction < 0 { -1. } else { 1. }; + let val = (scalar(&profile, &key, entry.initial.unwrap_or(0.)) + + amount * entry.step.unwrap_or(0.1)) + .clamp(entry.minimum.unwrap_or(0.), entry.maximum.unwrap_or(1.)); + if let Some((p, c)) = key.split_once('.') { + profile[p][c] = json!(val); } else { - profile[&key] = json!(value); + profile[&key] = json!(val); } - // Equipment controls are consumed directly by existing native physics. - if key == "truck" || key == "wheel" { - state.draft = profile; + } else { + let patch = if entry.options.is_empty() { + entry.patch } else { - state.request(profile); - } - } else if let Some(patch) = entry.patch { - merge(&mut profile, &patch); - if patch.get("posture").is_some() { - state.draft = profile; - state.status="Posture is selected. The native motion tree applies it when skating resumes.".into(); - } else { - state.request(profile); + let current = option_index(&entry, &profile); + let index = match current { + Some(i) => (i as i32 + if direction == 0 { 1 } else { direction }) + .rem_euclid(entry.options.len() as i32) + as usize, + None => 0, + }; + entry.options[index].patch.clone() + }; + if let Some(patch) = patch { + if let Some(gender) = patch["gender"].as_str() { + if let Some(default) = parts.library.defaults.get(gender) { + profile = default.clone(); + } + } else { + merge(&mut profile, &patch); + if let Some(hair) = patch["selections"].get("Hair") { + profile["hair_choice"] = hair.clone(); + } + } } } - } else { - state.status = "Please wait for the current preview.".into(); + match parts.resolve(&profile) { + Ok(p) => { + state.draft = p; + state.status.clear(); + } + Err(e) => state.status = e, + } } state.redraw = true; } } -fn complete( - mut state: ResMut, - server: Res, - mut commands: Commands, - root: Query>, - scenes: Query<(Entity, &ChildOf), With>, - mut animation: ResMut, +fn preferences( + state: Res, mut physics: ResMut, mut skater: ResMut, ) { - let response = state - .receiver - .as_ref() - .and_then(|rx| rx.lock().ok()?.try_recv().ok()); - if let Some(response) = response { - state.receiver = None; - state.redraw = true; - match response { - Ok(value) => { - if let (Some(scene), Some(profile)) = ( - value.get("scene").and_then(Value::as_str), - value.get("profile"), - ) { - state.loading = - Some((server.load(scene.to_owned()), scene.into(), profile.clone())); - state.status = "Loading the assembled character…".into(); - } else { - state.status = "Asset worker returned an incomplete character.".into(); - } - } - Err(e) => state.status = format!("Preview unchanged: {e}"), - } - } - if let Some((handle, _, _)) = &state.loading { - if let Some(LoadState::Failed(error)) = server.get_load_state(handle.id()) { - state.status = format!("Preview unchanged: {error}"); - state.loading = None; - state.redraw = true; - } else if server.is_loaded_with_dependencies(handle.id()) { - if let Ok(root) = root.single() { - let (_, scene, profile) = state.loading.take().unwrap(); - for (entity, parent) in &scenes { - if parent.parent() == root { - commands.entity(entity).despawn(); - } - } - commands.entity(root).with_children(|p| { - p.spawn(SceneRoot( - server.load(GltfAssetLabel::Scene(0).from_asset(scene.clone())), - )); - }); - *animation = default(); - if profile == state.committed { - state.committed_scene = scene.clone(); - } - state.scene = scene; - state.draft = profile; - state.status = "Preview ready. Save to keep, or Back at the top to discard.".into(); - state.redraw = true; - } - } - } physics.set_equipment_preferences( - scalar(&state.draft, "truck", 0.7).clamp(0., 1.) as f32, - scalar(&state.draft, "wheel", 0.7).clamp(0., 1.) as f32, + scalar(&state.draft, "truck", 0.7) as f32, + scalar(&state.draft, "wheel", 0.7) as f32, + ); + physics.set_gesture_preferences(state.draft["gestures"].as_object().map(|g| { + // ResetGestureSet824FA730 marks all 37 entries available and selects + // the first four in table order for Up,Down,Left,Right. + std::array::from_fn(|i| { + g.get(&i.to_string()) + .and_then(Value::as_u64) + .unwrap_or(i as u64) as u32 + }) + })); + skater.animation.set_customisation( + scalar(&state.draft, "stance", 1.) as u32, + scalar(&state.draft, "style", 0.) as u32, ); skater .animation .motion .animation .posture - .set_profile(scalar(&state.draft, "posture", 0.).clamp(0., 3.) as u32); + .set_profile(scalar(&state.draft, "posture", 0.) as u32); } fn draw( mut commands: Commands, mut state: ResMut, + window: Single<&Window>, mut root: Single<(Entity, &mut Node), With>, ) { root.1.display = if state.open { @@ -474,128 +1008,385 @@ fn draw( } else { Display::None }; - if !state.open || !state.redraw { + if !state.open { + return; + } + let size = (((window.height() * 0.92 - 255.) / 90.).floor() as usize).clamp(1, 10); + if size != state.page_size { + state.page_size = size; + state.redraw = true; + } + if !state.redraw { return; } state.redraw = false; commands.entity(root.0).despawn_children(); let page = state.page(); - let start = state.selected / 10 * 10; + let visible = state.visible(); + let start = state.selected / state.page_size * state.page_size; + let subtitle = if !state.search.is_empty() { + format!("Search: {}", state.search) + } else if state.path.is_empty() { + "Make it yours".to_owned() + } else { + let mut p = &state.index; + let mut crumbs = vec![]; + for &i in &state.path { + crumbs.push(p.label.clone()); + p = &p.children[i]; + } + crumbs.join(" / ") + }; commands.entity(root.0).with_children(|p| { + p.spawn(( + Text::new(subtitle), + TextFont { + font_size: 13., + ..default() + }, + TextColor(Color::srgb(0.48, 0.62, 0.68)), + )); p.spawn(( Text::new(&page.label), TextFont { - font_size: 25., + font_size: 29., ..default() }, TextColor(Color::WHITE), )); - p.spawn(( - Text::new("RETAIL CHARACTER • TEST BUILD"), - TextFont { - font_size: 13., - ..default() - }, - TextColor(Color::srgb(0.4, 0.85, 0.8)), - )); - for (i, entry) in page.children.iter().enumerate().skip(start).take(10) { - let value = entry - .scalar - .as_ref() - .map(|key| { - format!( - " {:.3}", - scalar(&state.draft, key, entry.initial.unwrap_or(0.)) - ) - }) - .unwrap_or_default(); - let suffix = if !entry.children.is_empty() { - " >" - } else { - "" - }; - p.spawn(( - Button, - Row(i), - Node { - width: percent(100), - min_height: px(32), - padding: UiRect::all(px(7)), - ..default() - }, - BackgroundColor(if state.selected == i { - Color::srgb(0.08, 0.32, 0.33) - } else { - Color::srgb(0.06, 0.10, 0.14) - }), - )) - .with_children(|r| { - r.spawn(( - Text::new(format!("{}{value}{suffix}", entry.label)), + p.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: px(6), + flex_grow: 1., + min_height: px(0), + ..default() + }) + .with_children(|list| { + if visible.is_empty() { + list.spawn(( + Text::new("No matches. Esc clears your search."), TextFont { font_size: 16., ..default() }, TextColor(Color::WHITE), )); - }); - } - if page.children.len() > 10 { + } + for (pos, &i) in visible.iter().enumerate().skip(start).take(state.page_size) { + let e = &page.children[i]; + let active = option_index(e, &state.draft); + let detail = if let Some(key) = &e.scalar { + let v = scalar(&state.draft, key, e.initial.unwrap_or(0.)); + format!( + "{}% − / +", + ((v - e.minimum.unwrap_or(0.)) + / (e.maximum.unwrap_or(1.) - e.minimum.unwrap_or(0.)) + * 100.) + .round() as i32 + ) + } else if !e.options.is_empty() { + let index = active.unwrap_or(0); + format!( + "{} · {}/{}", + e.options[index].label, + index + 1, + e.options.len() + ) + } else if !e.children.is_empty() { + String::new() + } else if e.patch.is_some() { + if e.patch + .as_ref() + .is_some_and(|p| matches_patch(&state.draft, p)) + { + "Selected".into() + } else { + "Select".into() + } + } else { + String::new() + }; + let adjustable = e.scalar.is_some() || e.options.len() > 1; + let width = (window.width() * 0.38).clamp(370., 520.) + - 44. + - 24. + - if adjustable { 68. } else { 0. }; + let label = format!( + "{}{}", + e.label, + if !e.children.is_empty() { " ›" } else { "" } + ); + let label_size = (width / (label.chars().count().max(1) as f32 * 0.64)).clamp(16., 18.); + let detail_size = (width / (detail.chars().count().max(1) as f32 * 0.64)).clamp(10., 12.); + list.spawn(( + Node { + width: percent(100), + height: px(84), + flex_shrink: 0., + align_items: AlignItems::Stretch, + border_radius: BorderRadius::all(px(9)), + ..default() + }, + BackgroundColor(if pos == state.selected { + Color::srgb(0.08, 0.27, 0.29) + } else { + Color::srgb(0.045, 0.065, 0.085) + }), + )) + .with_children(|r| { + if adjustable { + adjust_button(r, i, -1, "−"); + } + r.spawn(( + Button, + Row(i), + Node { + flex_grow: 1., + min_width: px(0), + padding: UiRect::axes(px(12), px(7)), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + ..default() + }, + )) + .with_children(|text| { + text.spawn(( + Text::new(label), + TextFont { + font_size: label_size, + ..default() + }, + TextColor(Color::WHITE), + )); + if !detail.is_empty() { + text.spawn(( + Text::new(detail), + TextFont { + font_size: detail_size, + ..default() + }, + TextColor(Color::srgb(0.60, 0.76, 0.78)), + )); + } + }); + if adjustable { + adjust_button(r, i, 1, "+"); + } + }); + } + }); + if !state.status.is_empty() { p.spawn(( - Text::new(format!( - "{}–{} / {} • Up/Down to browse", - start + 1, - (start + 10).min(page.children.len()), - page.children.len() - )), + Text::new(&state.status), TextFont { font_size: 13., ..default() }, + TextColor(Color::srgb(1., 0.72, 0.4)), )); } - for (id, title) in [ - (usize::MAX, "Save [F5 / X]"), - (usize::MAX - 1, "Back [Esc / B]"), - (usize::MAX - 2, "Restore original character"), - ] { - p.spawn(( - Button, - Row(id), - Node { - padding: UiRect::all(px(7)), - ..default() - }, - BackgroundColor(Color::srgb(0.10, 0.17, 0.21)), - )) - .with_children(|r| { - r.spawn(( - Text::new(title), + p.spawn(Node { + width: percent(100), + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + ..default() + }) + .with_children(|footer| { + for (id, title) in [ + ( + BACK, + if state.path.is_empty() { + "Done" + } else { + "‹ Back" + }, + ), + (PREV, "‹"), + (NEXT, "›"), + ] { + if id != BACK && visible.len() <= state.page_size { + continue; + } + footer + .spawn(( + Button, + Row(id), + Node { + padding: UiRect::axes(px(14), px(8)), + border_radius: BorderRadius::all(px(8)), + ..default() + }, + BackgroundColor(if id == BACK { + Color::srgb(0.12, 0.39, 0.38) + } else { + Color::srgb(0.07, 0.10, 0.13) + }), + )) + .with_children(|r| { + r.spawn(( + Text::new(title), + TextFont { + font_size: 17., + ..default() + }, + TextColor(Color::WHITE), + )); + }); + } + if visible.len() > state.page_size { + footer.spawn(( + Text::new(format!( + "{} / {}", + start / state.page_size + 1, + visible.len().div_ceil(state.page_size) + )), TextFont { - font_size: 16., + font_size: 13., ..default() }, - TextColor(Color::WHITE), + TextColor(Color::srgb(0.6, 0.7, 0.75)), )); - }); - } + } + }); p.spawn(( - Text::new(&state.status), + Text::new(if state.path.is_empty() { + "Done saves your changes and returns to skating." + } else { + "↑↓ Browse ←→ Change Type to search" + }), TextFont { - font_size: 14., + font_size: 12., ..default() }, - TextColor(Color::srgb(0.8, 0.85, 0.9)), - )); - p.spawn(( - Text::new( - "Enter / A: choose • Left/Right: adjust\nBack at the top discards unsaved changes.", - ), - TextFont { - font_size: 13., - ..default() - }, - TextColor(Color::srgb(0.55, 0.65, 0.7)), + TextColor(Color::srgb(0.48, 0.60, 0.66)), )); }); } + +fn adjust_button(parent: &mut ChildSpawnerCommands, index: usize, direction: i32, label: &str) { + parent + .spawn(( + Button, + Adjust(index, direction), + Node { + width: px(34), + flex_shrink: 0., + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }, + )) + .with_children(|p| { + p.spawn(( + Text::new(label), + TextFont { + font_size: 22., + ..default() + }, + TextColor(Color::srgb(0.7, 0.9, 0.9)), + )); + }); +} +fn matches_patch(profile: &Value, patch: &Value) -> bool { + if let Some(object) = patch.as_object() { + object.iter().all(|(k, v)| matches_patch(&profile[k], v)) + } else { + profile == patch + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn customiser_colour_choice_is_distinct_from_the_original() { + let entry = Entry { + options: vec![ + choice( + "Original", + json!({"selections":{"Feet":{"asset_id":"shoe","material_id":"new"}},"colours":{"Feet":null}}), + ), + choice( + "Red", + json!({"selections":{"Feet":{"asset_id":"shoe","material_id":"new"}},"colours":{"Feet":[1.,0.,0.]}}), + ), + ], + ..default() + }; + let mut p = json!({"selections":{"Feet":{"asset_id":"shoe","material_id":"new"}}}); + assert_eq!(option_index(&entry, &p), Some(0)); + merge(&mut p, entry.options[1].patch.as_ref().unwrap()); + assert_eq!(option_index(&entry, &p), Some(1)); + } + #[test] + fn customiser_owned_menu_presets_tattoos_and_search() { + let Ok(path) = std::env::var("SKATE_CAC_TEST_LIBRARY") else { + return; + }; + let lib: Library = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let extras: Vec = serde_json::from_slice( + &std::fs::read(std::path::Path::new(&path).with_file_name("extra-menu.json")).unwrap(), + ) + .unwrap(); + let parts = Parts::for_test(lib); + for gender in ["male", "female"] { + let mut state = Customiser { + open: true, + enabled: true, + just_opened: false, + index: menu(&parts.library, extras.clone()), + path: vec![], + selected: 0, + page_size: 6, + search: String::new(), + draft: parts.library.defaults[gender].clone(), + settings: PathBuf::new(), + status: String::new(), + redraw: true, + }; + assert_eq!(state.visible().len(), 4); + let body = 0; + let tattoos = state.index.children[body] + .children + .iter() + .position(|e| e.label == "Tattoos") + .unwrap(); + let original = state.draft.clone(); + for (i, slot) in [(0, "OuterTorso"), (1, "Pants")] { + state.path = vec![body, tattoos, i]; + let preview = state.preview(&parts); + assert_ne!(preview["selections"][slot], original["selections"][slot]); + assert_eq!(state.draft, original); + } + state.open = false; + assert_eq!(state.preview(&parts), original); + state.open = true; + let faces = state.index.children[body] + .children + .iter() + .position(|e| e.label == "Face presets") + .unwrap(); + state.path = vec![body, faces]; + assert_eq!(state.visible().len(), 10); + for i in state.visible() { + let mut profile = state.draft.clone(); + merge( + &mut profile, + state.page().children[i].patch.as_ref().unwrap(), + ); + assert!(parts.resolve(&profile).is_ok()); + } + state.search = "Face 10".into(); + assert_eq!(state.visible().len(), 1); + state.search = "No such item".into(); + assert_eq!(state.visible().len(), 0); + // Selecting a new face/body profile must survive JSON save/reload. + let encoded = serde_json::to_vec(&state.draft).unwrap(); + let restored: Value = serde_json::from_slice(&encoded).unwrap(); + assert_eq!( + parts.resolve(&restored).unwrap(), + parts.resolve(&state.draft).unwrap() + ); + } + } +} diff --git a/crates/skate-game/src/customiser_material.rs b/crates/skate-game/src/customiser_material.rs new file mode 100644 index 00000000..0a337107 --- /dev/null +++ b/crates/skate-game/src/customiser_material.rs @@ -0,0 +1,145 @@ +//! Skin stamps modify albedo before the unchanged StandardMaterial lighting. +use bevy::{ + asset::embedded_asset, + pbr::{ExtendedMaterial, MaterialExtension}, + prelude::*, + render::render_resource::AsBindGroup, + shader::ShaderRef, +}; + +pub(crate) type SkaterMaterial = ExtendedMaterial; + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] +pub(crate) struct SkinStamp { + #[texture(100)] + #[sampler(101)] + pub texture: Option>, + /// Scale.xy, translation.zw in the authored secondary UV stream. + #[uniform(102)] + pub transform: Vec4, + /// min.xy, max.zw of the selected arm/leg's stampable rectangle. + #[uniform(102)] + pub rectangle: Vec4, + #[uniform(102)] + pub enabled: Vec4, +} +impl MaterialExtension for SkinStamp { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/customiser_stamp.wgsl".into() + } + fn deferred_fragment_shader() -> ShaderRef { + Self::fragment_shader() + } +} +pub(crate) fn register(app: &mut App) { + embedded_asset!(app, "customiser_stamp.wgsl"); + app.add_plugins(MaterialPlugin::::default()); +} + +pub(crate) fn placement(target: [f32; 4], art: [f32; 4]) -> (Vec4, Vec4) { + // Retail rectangles are [top, bottom, left, right], with V increasing + // upwards. Texture pixels and glTF use downward V. Artwork pixel bounds + // and both limb meshes corroborate this conversion. + let target_min = Vec2::new(target[2], 1. - target[0]); + let target_max = Vec2::new(target[3], 1. - target[1]); + let art_min = Vec2::new(art[2], 1. - art[0]); + let art_max = Vec2::new(art[3], 1. - art[1]); + let scale = (art_max - art_min) / (target_max - target_min); + let offset = art_min - target_min * scale; + ( + Vec4::new(scale.x, scale.y, offset.x, offset.y), + Vec4::new(target_min.x, target_min.y, target_max.x, target_max.y), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn customiser_stamp_maps_authored_borders() { + let (t, r) = placement([0.5, 0.0, 0.1, 0.4], [0.9, 0.1, 0.2, 0.8]); + assert!((Vec2::new(r.x, r.y) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.2, 0.1), 1e-6)); + assert!((Vec2::new(r.z, r.w) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.8, 0.9), 1e-6)); + assert_eq!( + bevy::asset::embedded_path!("customiser_stamp.wgsl"), + std::path::PathBuf::from("skate3rust/customiser_stamp.wgsl") + ); + } + #[test] + fn customiser_shader_compiles_offline() { + use bevy::render::render_resource::{DownlevelFlags, WgpuFeatures}; + use bevy::shader::{ShaderCache, ShaderDefVal}; + let Ok(path) = std::env::var("SKATE_CAC_TEST_BEVY_SOURCES") else { + return; + }; + fn walk(path: &std::path::Path, result: &mut Vec) { + for entry in std::fs::read_dir(path).unwrap().flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, result); + } else if p.extension().is_some_and(|e| e == "wgsl") { + result.push(p); + } + } + } + let mut files = vec![]; + for entry in std::fs::read_dir(path).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("bevy_") && name.ends_with("-0.18.1") { + walk(&entry.path().join("src"), &mut files); + } + } + let mut assets = Assets::::default(); + let mut cache = + ShaderCache::<(), ()>::new(WgpuFeatures::empty(), DownlevelFlags::all(), |_, _, _| { + Ok(()) + }); + let defs: Vec<_> = [ + ("MAX_DIRECTIONAL_LIGHTS", 10), + ("MAX_CASCADES_PER_LIGHT", 4), + ("MAX_VIEW_LIGHT_PROBES", 8), + ("PER_OBJECT_BUFFER_BATCH_SIZE", 64), + ("MATERIAL_BIND_GROUP", 3), + ("AVAILABLE_STORAGE_BUFFER_BINDINGS", 8), + ] + .into_iter() + .map(|(n, v)| ShaderDefVal::UInt(n.into(), v)) + .collect(); + for path in files { + let source = std::fs::read_to_string(&path).unwrap(); + if !source.contains("#define_import_path") { + continue; + } + let shader = Shader::from_wgsl_with_defs( + source, + path.to_string_lossy().to_string(), + defs.clone(), + ); + let h = assets.add(shader.clone()); + cache.set_shader(h.id(), shader); + } + let shader = Shader::from_wgsl( + include_str!("customiser_stamp.wgsl"), + "customiser_stamp.wgsl", + ); + let h = assets.add(shader.clone()); + cache.set_shader(h.id(), shader); + for secondary_uv in [false, true] { + let mut d = defs.clone(); + for name in [ + "VERTEX_UVS_A", + "VERTEX_OUTPUT_INSTANCE_INDEX", + "VERTEX_NORMALS", + "MESH_PIPELINE", + "STANDARD_MATERIAL", + "STANDARD_MATERIAL_BASE_COLOR_TEXTURE", + ] { + d.push(ShaderDefVal::Bool(name.into(), true)); + } + if secondary_uv { + d.push(ShaderDefVal::Bool("VERTEX_UVS_B".into(), true)); + } + cache.get(&(), 0, h.id(), &d).unwrap(); + } + } +} diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs new file mode 100644 index 00000000..2038595f --- /dev/null +++ b/crates/skate-game/src/customiser_parts.rs @@ -0,0 +1,772 @@ +//! Resident retail parts: selection changes visibility/materials; morphs stay on GPU. +use crate::customiser_material::{SkaterMaterial, SkinStamp}; +use bevy::{gltf::Gltf, mesh::morph::MorphWeights, prelude::*}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Deserialize)] +pub(crate) struct Part { + pub slot: String, + pub name: String, + pub flags: HashMap, + pub materials: Vec, + pub scene: String, +} +impl Part { + pub fn flag(&self, key: &str) -> &str { + self.flags.get(key).map(String::as_str).unwrap_or("") + } +} +#[derive(Clone, Deserialize)] +pub(crate) struct Material { + pub name: String, + pub flags: HashMap, + diffuse: String, + normal: Option, + rough: Option, + alpha: bool, + tint: [f32; 3], + metallic: f32, + roughness: f32, +} +impl Material { + pub fn flag(&self, key: &str) -> &str { + self.flags.get(key).map(String::as_str).unwrap_or("") + } +} +#[derive(Clone, Deserialize)] +pub(crate) struct Colour { + pub name: String, + pub rgb: [f32; 3], +} +#[derive(Clone, Deserialize)] +pub(crate) struct Tattoo { + pub name: String, + pub texture: String, + pub bounds: [f32; 4], +} +#[derive(Default, Deserialize)] +pub(crate) struct Library { + pub models: HashMap, + pub materials: HashMap, + pub defaults: HashMap, + pub morphs: Vec, + #[serde(default)] + pub tattoos: HashMap, + #[serde(default)] + pub colours: Vec, +} +#[derive(Resource, Default)] +pub(crate) struct Parts { + pub library: Library, + geometry: HashMap>, + materials: HashMap, Vec>)>, + instances: HashMap, + tattoos: HashMap>, + pub applied: Value, +} +#[derive(Component)] +pub(crate) struct PartRoot(pub String); +impl Parts { + #[cfg(test)] + pub fn for_test(library: Library) -> Self { + Self { + library, + ..default() + } + } + pub fn resolve(&self, profile: &Value) -> Result { + let lib = &self.library; + if let Some(gender) = profile["gender"].as_str() { + if !["male", "female"].contains(&gender) { + return Err("Invalid body".into()); + } + } + for (name, max) in [ + ("truck", 1.), + ("wheel", 1.), + ("stance", 1.), + ("style", 3.), + ("posture", 3.), + ] { + if let Some(value) = profile.get(name) { + if value.as_f64().is_none_or(|v| !(0.0..=max).contains(&v)) { + return Err(format!("Invalid {name}")); + } + } + } + for key in ["skin_tint", "hair_tint"] { + if let Some(value) = profile.get(key) { + let v = serde_json::from_value::<[f32; 3]>(value.clone()) + .map_err(|_| "Invalid colour")?; + if v.iter().any(|v| !(0.0..=1.0).contains(v)) { + return Err("Invalid colour".into()); + } + } + } + if let Some(weights) = profile["morphs"].as_object() { + if weights.iter().any(|(key, v)| { + !lib.morphs.contains(key) || v.as_f64().is_none_or(|v| !(0.0..=0.5).contains(&v)) + }) { + return Err("Invalid face or body value".into()); + } + } + if let Some(gestures) = profile["gestures"].as_object() { + if gestures.iter().any(|(key, v)| { + !["0", "1", "2", "3"].contains(&key.as_str()) || v.as_u64().is_none_or(|v| v >= 37) + }) { + return Err("Invalid gesture".into()); + } + } + if let Some(zones) = profile["tattoos"].as_object() { + for (zone, v) in zones { + if !["Arm", "Leg"].contains(&zone.as_str()) + || v["id"] + .as_str() + .is_some_and(|id| !lib.tattoos.contains_key(id)) + || v["side"] + .as_u64() + .is_some_and(|v| v > if zone == "Arm" { 3 } else { 1 }) + { + return Err("Invalid tattoo".into()); + } + } + } + + let mut result = profile.clone(); + let gender = profile["gender"].as_str().unwrap_or("male"); + if let Some(hair) = profile.get("hair_choice") { + result["selections"]["Hair"] = hair.clone(); + } + let selections = result["selections"] + .as_object_mut() + .ok_or("No outfit selected")?; + selections.retain(|_, v| !v.is_null()); + for (slot, v) in selections.iter() { + let id = v["asset_id"].as_str().ok_or("Invalid item")?; + let part = lib.models.get(id).ok_or("Item unavailable")?; + if part.slot != *slot || !["", gender, "unisex"].contains(&part.flag("Gender")) { + return Err("Choose an item for this body".into()); + } + let mid = v["material_id"].as_str().ok_or("Invalid colour")?; + if !part.materials.iter().any(|s| s == mid) || !lib.materials.contains_key(mid) { + return Err("Colour unavailable".into()); + } + } + let model = |s: &serde_json::Map, slot: &str| { + s.get(slot) + .and_then(|v| v["asset_id"].as_str()) + .and_then(|id| lib.models.get(id)) + }; + let choose = |s: &mut serde_json::Map, + slot: &str, + key: &str, + value: &str| + -> Result<(), String> { + if value.is_empty() || value == "none" { + s.remove(slot); + return Ok(()); + } + if model(s, slot).is_some_and(|p| p.flag(key) == value) { + return Ok(()); + } + let mut candidates: Vec<_> = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == slot + && ["", gender, "unisex"].contains(&p.flag("Gender")) + && p.flag(key) == value + }) + .collect(); + candidates.sort_by_key(|(id, _)| *id); + let (id, p) = candidates + .first() + .ok_or_else(|| format!("No matching {}", slot))?; + let mid = p + .materials + .iter() + .find(|id| { + lib.materials + .get(*id) + .is_some_and(|m| m.flag("IsDefault") == "yes") + }) + .or_else(|| { + p.materials + .iter() + .find(|id| lib.materials.contains_key(*id)) + }) + .ok_or("No matching material")?; + s.insert( + slot.into(), + serde_json::json!({"asset_id":id,"material_id":mid}), + ); + Ok(()) + }; + if let Some(outer) = model(selections, "OuterTorso") { + choose( + selections, + "Arm", + "ArmModelType", + outer.flag("ArmModelRequired"), + )?; + choose( + selections, + "InnerTorso", + "InnerCutType", + outer.flag("InnerCutTypeRequired"), + )?; + if outer.flag("SupportsWristItem") == "no" { + selections.remove("WristItem"); + } + if outer.flag("IsNecklaceRemoved") == "yes" { + selections.remove("Jewellery"); + } + } + if let Some(pants) = model(selections, "Pants") { + choose( + selections, + "Leg", + "LegModelType", + pants.flag("LegModelRequired"), + )?; + } + if let Some(leg) = model(selections, "Leg") { + let sock_style = selections + .get("Sock") + .and_then(|v| v["material_id"].as_str()) + .and_then(|id| lib.materials.get(id)) + .map(|m| m.flag("SockStyle")) + .unwrap_or("none"); + let desired = if sock_style == "none" { "" } else { sock_style }; + if leg.flag("RequiresSockStyle") != desired + && !(desired.is_empty() && leg.flag("RequiresSockStyle") == "none") + { + let mut matches: Vec<_> = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == "Leg" + && ["", gender, "unisex"].contains(&p.flag("Gender")) + && p.flag("LegModelType") == leg.flag("LegModelType") + && (p.flag("RequiresSockStyle") == desired + || (desired.is_empty() && p.flag("RequiresSockStyle") == "none")) + }) + .collect(); + matches.sort_by_key(|(id, _)| *id); + let (id, p) = matches.first().ok_or("Choose another sock length")?; + selections.insert( + "Leg".into(), + serde_json::json!({"asset_id":id,"material_id":p.materials[0]}), + ); + } + } + if !selections.contains_key("Hat") { + if let Some(hair) = model(selections, "Hair") { + if hair.flag("HairModelType") != "full" { + let candidate = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == "Hair" + && p.flag("HairStyle") == hair.flag("HairStyle") + && p.flag("Gender") == hair.flag("Gender") + && p.flag("HairModelType") == "full" + }) + .min_by_key(|(id, _)| *id); + if let Some((id, p)) = candidate { + selections.insert( + "Hair".into(), + serde_json::json!({"asset_id":id,"material_id":p.materials[0]}), + ); + } + } + } + } + if let Some(hat) = model(selections, "Hat") { + let required = hat.flag("RequiresHairModelType"); + if required == "none" { + selections.remove("Hair"); + } else if let Some(hair) = model(selections, "Hair") { + if !required.is_empty() && hair.flag("HairModelType") != required { + let matching = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == "Hair" + && p.flag("HairStyle") == hair.flag("HairStyle") + && p.flag("HairModelType") == required + && ["", gender, "unisex"].contains(&p.flag("Gender")) + }) + .min_by_key(|(id, _)| *id); + if let Some((id, p)) = matching { + selections.insert( + "Hair".into(), + serde_json::json!({"asset_id":id,"material_id":p.materials[0]}), + ); + } else { + selections.remove("Hair"); + } + } + } + } + let removals: Vec = selections + .keys() + .flat_map(|s| { + model(selections, s).into_iter().flat_map(|p| { + p.flag("RequiresRemovalOfComponents") + .split(',') + .map(|s| s.trim().to_owned()) + }) + }) + .filter(|s| !s.is_empty()) + .collect(); + for s in removals { + selections.remove(&s); + } + let shadow = selections + .get("Hair") + .and_then(|v| v["asset_id"].as_str()) + .and_then(|id| lib.models.get(id)) + .map(|p| { + if p.flag("RequiresHairlineShadowStyle") == "on" { + "on" + } else { + "" + } + }) + .unwrap_or(""); + let head = selections + .get("Rostral") + .and_then(|v| v["material_id"].as_str()) + .and_then(|id| lib.materials.get(id)) + .ok_or("Head unavailable")?; + let tone = profile["skin"].as_str().unwrap_or(head.flag("SkinTone")); + let beard = if gender == "female" { + "" + } else { + profile["beard"] + .as_str() + .unwrap_or(head.flag("FacialHairStyle")) + }; + for (slot, value) in selections.iter_mut() { + let old_id = value["material_id"].as_str().unwrap(); + let old = &lib.materials[old_id]; + if old.flag("SkinTone").is_empty() { + continue; + } + let part = &lib.models[value["asset_id"].as_str().unwrap()]; + let best = part + .materials + .iter() + .filter_map(|id| lib.materials.get(id).map(|m| (id, m))) + .filter(|(_, m)| { + m.flag("SkinTone") == tone + && (slot != "Rostral" || m.flag("FacialHairStyle") == beard) + }) + .min_by_key(|(id, m)| { + let shadow_penalty = + if slot == "Rostral" && m.flag("HairlineShadowStyle") != shadow { + 100 + } else { + 0 + }; + shadow_penalty + + if *id == old_id { + 0 + } else { + 1 + old + .flags + .iter() + .filter(|(k, v)| { + k.as_str() != "SkinTone" + && k.as_str() != "FacialHairStyle" + && m.flags.get(*k) != Some(*v) + }) + .count() + } + }); + let (id, _) = best.ok_or("This skin and facial hair combination is unavailable")?; + value["material_id"] = Value::String(id.clone()); + } + Ok(result) + } + pub fn warm(&mut self, id: &str, server: &AssetServer, materials: &mut Assets) { + if self.materials.contains_key(id) { + return; + } + let Some(m) = self.library.materials.get(id) else { + return; + }; + let mut images = vec![]; + let mut load = |path: &String, linear: bool| { + let handle = server.load_with_settings( + path.clone(), + move |s: &mut bevy::image::ImageLoaderSettings| { + s.is_srgb = !linear; + }, + ); + images.push(handle.clone()); + handle + }; + let diffuse = load(&m.diffuse, false); + let normal = m.normal.as_ref().map(|p| load(p, true)); + let rough = m.rough.as_ref().map(|p| load(p, true)); + let material = materials.add(SkaterMaterial { + base: StandardMaterial { + base_color: Color::srgb(m.tint[0], m.tint[1], m.tint[2]), + base_color_texture: Some(diffuse), + normal_map_texture: normal, + metallic_roughness_texture: rough, + metallic: m.metallic, + perceptual_roughness: if m.rough.is_some() { 1. } else { m.roughness }, + alpha_mode: if m.alpha { + AlphaMode::Mask(0.5) + } else { + AlphaMode::Opaque + }, + ..default() + }, + extension: SkinStamp::default(), + }); + self.materials.insert(id.into(), (material, images)); + } +} +pub(crate) fn setup( + mut commands: Commands, + config: Res, + server: Res, +) { + let library = std::fs::read(config.asset_root.join("private/customisation/library.json")) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default(); + let geometry = library + .models + .iter() + .map(|(id, p)| (id.clone(), server.load(p.scene.clone()))) + .collect(); + let tattoos = library + .tattoos + .iter() + .map(|(id, t)| { + ( + id.clone(), + server.load_with_settings( + t.texture.clone(), + |s: &mut bevy::image::ImageLoaderSettings| { + s.is_srgb = false; + }, + ), + ) + }) + .collect(); + commands.insert_resource(Parts { + tattoos, + library, + geometry, + ..default() + }); +} +pub(crate) fn update( + mut commands: Commands, + server: Res, + mut parts: ResMut, + mut state: ResMut, + mut materials: ResMut>, + root: Query>, + parents: Query<&ChildOf>, + mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), With>, + mut mesh_materials: Query<(Entity, Option<&mut MeshMaterial3d>), With>, + mut morphs: Query<(Entity, &mut MorphWeights)>, + mut animation: ResMut, +) { + if !state.enabled { + return; + } + let Ok(root) = root.single() else { + return; + }; + let preview = state.preview(&parts); + let Some(selections) = preview["selections"].as_object() else { + return; + }; + let desired: Vec<(String, String)> = selections + .values() + .filter_map(|v| { + Some(( + v["asset_id"].as_str()?.into(), + v["material_id"].as_str()?.into(), + )) + }) + .collect(); + if parts.applied == preview && !state.open { + return; + } + // Resolve the visible choices ahead of selection, including skin, head, + // sleeves, and sock dependencies. No conversion process runs in the game. + let precache: HashSet<(String, String)> = state + .preload_outfits(&parts) + .iter() + .flat_map(|p| { + p["selections"] + .as_object() + .into_iter() + .flat_map(|s| s.values()) + }) + .filter_map(|v| { + Some(( + v["asset_id"].as_str()?.to_owned(), + v["material_id"].as_str()?.to_owned(), + )) + }) + .chain(desired.iter().cloned()) + .collect(); + for (_, mid) in &precache { + parts.warm(mid, &server, &mut materials); + } + let precache: HashSet = precache.into_iter().map(|(id, _)| id).collect(); + for id in precache { + if parts.instances.contains_key(&id) { + continue; + } + if let Some(handle) = parts.geometry.get(&id) { + if server.is_loaded_with_dependencies(handle.id()) { + let scene = parts.library.models[&id].scene.clone(); + let entity = commands + .spawn(( + PartRoot(id.clone()), + Visibility::Hidden, + SceneRoot(server.load(GltfAssetLabel::Scene(0).from_asset(scene))), + )) + .id(); + commands.entity(root).add_child(entity); + parts.instances.insert(id, entity); + } + } + } + if parts.applied == preview { + return; + } + let mut ready = true; + for (id, mid) in &desired { + parts.warm(mid, &server, &mut materials); + if !parts.instances.contains_key(id) { + ready = false; + } + let part = &parts.library.models[id]; + let slot = if part.slot == "OuterTorso" && part.flag("TopType").is_empty() { + "Arm" + } else { + &part.slot + }; + if let Some(tattoo) = state.draft["tattoos"][slot]["id"].as_str() { + if parts + .tattoos + .get(tattoo) + .is_none_or(|h| !server.is_loaded_with_dependencies(h.id())) + { + ready = false; + } + } + if !parts.materials.get(mid).is_some_and(|(_, images)| { + images + .iter() + .all(|h| server.is_loaded_with_dependencies(h.id())) + }) { + ready = false; + } + if let Some(&entity) = parts.instances.get(id) { + if !mesh_materials + .iter() + .any(|(e, _)| parents.iter_ancestors(e).any(|p| p == entity)) + { + ready = false; + } + } + } + if !ready { + return; + } + let wanted: HashSet<_> = desired.iter().map(|(id, _)| id.as_str()).collect(); + let mut rebind = false; + for (entity, part, mut visible) in &mut scenes { + if !parents.iter_ancestors(entity).any(|p| p == root) { + continue; + } + let target = if part.is_some_and(|p| wanted.contains(p.0.as_str())) { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *visible != target { + *visible = target; + rebind = true; + } + } + for (id, mid) in &desired { + let entity = parts.instances[id]; + let handle = parts.materials[mid].0.clone(); + let part = &parts.library.models[id]; + let source = &parts.library.materials[mid]; + let tint = if !source.flag("SkinTone").is_empty() { + state.draft.get("skin_tint") + } else if part.slot == "Hair" { + state.draft.get("hair_tint") + } else { + state.draft["colours"].get(&part.slot) + }; + let rgb = tint + .and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()) + .unwrap_or(source.tint); + let color = Color::srgb(rgb[0], rgb[1], rgb[2]); + if materials + .get(&handle) + .is_some_and(|m| m.base.base_color != color) + { + if let Some(m) = materials.get_mut(&handle) { + m.base.base_color = color; + } + } + let slot = if part.slot == "OuterTorso" && part.flag("TopType").is_empty() { + "Arm" + } else { + &part.slot + }; + let stamp = &state.draft["tattoos"][slot]; + let selected = stamp["id"] + .as_str() + .and_then(|id| parts.library.tattoos.get(id).map(|t| (id, t))); + let mut extension = SkinStamp::default(); + if let Some((tid, t)) = selected { + let key = match stamp["side"].as_u64().unwrap_or(0) { + 1 => "StampUVConstraintQ4", + 2 => "StampUVConstraintQ1", + 3 => "StampUVConstraintQ2", + _ => "StampUVConstraintQ3", + }; + let bounds: Vec = part + .flag(key) + .split(',') + .filter_map(|n| n.parse().ok()) + .collect(); + if let Ok(bounds) = <[f32; 4]>::try_from(bounds) { + let (transform, rectangle) = + crate::customiser_material::placement(bounds, t.bounds); + extension = SkinStamp { + texture: parts.tattoos.get(tid).cloned(), + transform, + rectangle, + enabled: Vec4::X, + }; + } + } + let changed = materials.get(&handle).is_some_and(|m| { + m.extension.texture != extension.texture + || m.extension.transform != extension.transform + || m.extension.enabled != extension.enabled + }); + if changed { + if let Some(m) = materials.get_mut(&handle) { + m.extension = extension; + } + } + for (e, material) in &mut mesh_materials { + if !parents.iter_ancestors(e).any(|p| p == entity) { + continue; + } + if let Some(mut material) = material { + if material.0 != handle { + material.0 = handle.clone(); + } + } else { + commands + .entity(e) + .remove::>() + .insert(MeshMaterial3d(handle.clone())); + } + } + } + let weights: Vec = parts + .library + .morphs + .iter() + .enumerate() + .map(|(i, n)| { + state.draft["morphs"][n] + .as_f64() + .unwrap_or(if (2..19).contains(&i) { 0.25 } else { 0. }) + .clamp(0., 0.5) as f32 + }) + .collect(); + for (entity, mut morph) in &mut morphs { + if parents + .iter_ancestors(entity) + .any(|p| parts.instances.values().any(|e| *e == p)) + && morph.weights().len() == weights.len() + && morph.weights() != weights + { + morph.weights_mut().copy_from_slice(&weights); + } + } + if rebind { + *animation = default(); + } + if parts.applied != preview { + parts.applied = preview; + state.status.clear(); + state.redraw = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn owned_library_choices_resolve_without_conversion() { + let Ok(path) = std::env::var("SKATE_CAC_TEST_LIBRARY") else { + return; + }; + let library: Library = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let parts = Parts { + library, + ..default() + }; + let mut failures = vec![]; + for gender in ["male", "female"] { + let base = &parts.library.defaults[gender]; + assert!( + parts.resolve(base).is_ok(), + "{gender}: {:?}", + parts.resolve(base) + ); + for (id, part) in &parts.library.models { + if !["", gender, "unisex"].contains(&part.flag("Gender")) { + continue; + } + if ["Arm", "Leg", "Organ", "Rostral", "InnerTorso"].contains(&part.slot.as_str()) { + continue; + } + for mid in &part.materials { + let mut p = base.clone(); + p["selections"][&part.slot] = + serde_json::json!({"asset_id":id,"material_id":mid}); + if let Err(e) = parts.resolve(&p) { + failures.push(format!("{gender} {} {}: {e}", part.slot, part.name)); + } + } + } + for tone in ["light", "dark"] { + let mut p = base.clone(); + p["skin"] = serde_json::json!(tone); + if let Err(e) = parts.resolve(&p) { + failures.push(format!("{gender} {tone}: {e}")); + } + } + } + failures.sort(); + failures.dedup(); + assert!(failures.is_empty(), "{}", failures.join("\n")); + } +} diff --git a/crates/skate-game/src/customiser_stamp.wgsl b/crates/skate-game/src/customiser_stamp.wgsl new file mode 100644 index 00000000..793bc01b --- /dev/null +++ b/crates/skate-game/src/customiser_stamp.wgsl @@ -0,0 +1,129 @@ +// StandardMaterial entry point; the added block only composes skin-stamp albedo. +// Bevy 0.18.1 pbr.wgsl, MIT/Apache-2.0, copyright Bevy contributors. +struct SkinStamp { + transform: vec4, + rectangle: vec4, + enabled: vec4, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(100) var stamp_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(101) var stamp_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var stamp: SkinStamp; +#import bevy_pbr::{ + pbr_types, + pbr_functions::alpha_discard, + pbr_fragment::pbr_input_from_standard_material, + decal::clustered::apply_decals, +} + +#ifdef PREPASS_PIPELINE +#import bevy_pbr::{ + prepass_io::{VertexOutput, FragmentOutput}, + pbr_deferred_functions::deferred_output, +} +#else +#import bevy_pbr::{ + forward_io::{VertexOutput, FragmentOutput}, + pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, + pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT, +} +#endif + +#ifdef VISIBILITY_RANGE_DITHER +#import bevy_pbr::pbr_functions::visibility_range_dither; +#endif + +#ifdef MESHLET_MESH_MATERIAL_PASS +#import bevy_pbr::meshlet_visibility_buffer_resolve::resolve_vertex_output +#endif + +#ifdef OIT_ENABLED +#import bevy_core_pipeline::oit::oit_draw +#endif // OIT_ENABLED + +#ifdef FORWARD_DECAL +#import bevy_pbr::decal::forward::get_forward_decal_info +#endif + +@fragment +fn fragment( +#ifdef MESHLET_MESH_MATERIAL_PASS + @builtin(position) frag_coord: vec4, +#else + vertex_output: VertexOutput, + @builtin(front_facing) is_front: bool, +#endif +) -> FragmentOutput { +#ifdef MESHLET_MESH_MATERIAL_PASS + let vertex_output = resolve_vertex_output(frag_coord); + let is_front = true; +#endif + + var in = vertex_output; + + // If we're in the crossfade section of a visibility range, conditionally + // discard the fragment according to the visibility pattern. +#ifdef VISIBILITY_RANGE_DITHER + visibility_range_dither(in.position, in.visibility_range_dither); +#endif + +#ifdef FORWARD_DECAL + let forward_decal_info = get_forward_decal_info(in); + in.world_position = forward_decal_info.world_position; + in.uv = forward_decal_info.uv; +#endif + + // generate a PbrInput struct from the StandardMaterial bindings + var pbr_input = pbr_input_from_standard_material(in, is_front); + + +#ifdef VERTEX_UVS_B + // cacstamp_skin_defaultPS: skin * (1 + (decal.rgb - 1) * decal.a). + // Its VS transforms TEXCOORD1 by the two i_customGraphic rows. + let stamp_uv = in.uv_b * stamp.transform.xy + stamp.transform.zw; + let decal = textureSample(stamp_texture, stamp_sampler, stamp_uv); + let inside = all(in.uv_b >= stamp.rectangle.xy) && all(in.uv_b <= stamp.rectangle.zw); + let opacity = decal.a * stamp.enabled.x * select(0.0, 1.0, inside); + pbr_input.material.base_color = vec4( + pbr_input.material.base_color.rgb * mix(vec3(1.0), decal.rgb, opacity), + pbr_input.material.base_color.a); +#endif + + // alpha discard + pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + + // clustered decals + apply_decals(&pbr_input); + +#ifdef PREPASS_PIPELINE + // write the gbuffer, lighting pass id, and optionally normal and motion_vector textures + let out = deferred_output(in, pbr_input); +#else + // in forward mode, we calculate the lit color immediately, and then apply some post-lighting effects here. + // in deferred mode the lit color and these effects will be calculated in the deferred lighting shader + var out: FragmentOutput; + if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + out.color = apply_pbr_lighting(pbr_input); + } else { + out.color = pbr_input.material.base_color; + } + + // apply in-shader post processing (fog, alpha-premultiply, and also tonemapping, debanding if the camera is non-hdr) + // note this does not include fullscreen postprocessing effects like bloom. + out.color = main_pass_post_lighting_processing(pbr_input, out.color); +#endif + +#ifdef OIT_ENABLED + let alpha_mode = pbr_input.material.flags & pbr_types::STANDARD_MATERIAL_FLAGS_ALPHA_MODE_RESERVED_BITS; + if alpha_mode != pbr_types::STANDARD_MATERIAL_FLAGS_ALPHA_MODE_OPAQUE { + // The fragments will only be drawn during the oit resolve pass. + oit_draw(in.position, out.color); + discard; + } +#endif // OIT_ENABLED + +#ifdef FORWARD_DECAL + out.color.a = min(forward_decal_info.alpha, out.color.a); +#endif + + return out; +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index f6b90970..48be6716 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -467,7 +467,7 @@ mod tests { .insert_resource(images) .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), - difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), + difficulty: Difficulty::Easy, maps: vec![], selected_map: 0, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), }) .add_systems(Update, apply); app.world_mut().spawn((Window::default(), PrimaryWindow)); diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index dce393e1..4d87f74b 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -16,6 +16,8 @@ mod verification; mod performance; mod graphics_menu; mod customiser; +mod customiser_parts; +mod customiser_material; mod render_capacity; mod retail_render; mod presentation; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index d653b5fb..832cbd42 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -169,6 +169,9 @@ mod exchange_tests { } impl GamePhysics { + pub(crate) fn set_gesture_preferences(&mut self, gestures: Option<[u32; 4]>) { + self.animation_profile.gesture_selections = gestures.filter(|g| g.iter().all(|v| *v < 37)); + } pub(crate) fn set_equipment_preferences(&mut self, truck: f32, wheel: f32) { if truck.is_finite() && wheel.is_finite() { self.animation_profile.truck_tightness = truck.clamp(0.0, 1.0); diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index ee1903d2..0dbe1ce2 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -859,6 +859,7 @@ mod tests { &mut Commands::new(&mut queue, &world), &mut meshes, &mut materials, + &mut Assets::::default(), &mut images, ); queue.apply(&mut world); diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index e07127d3..f33216b2 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -62,6 +62,18 @@ pub(crate) struct SkaterAnimation { } impl SkaterAnimation { + /// Native Initialize82B97E38 selects both orientation/mirror bits for + /// regular stance. A profile edit changes the natural basis while retaining + /// the current relative stance and trick state. + pub(crate) fn set_customisation(&mut self, natural: u32, style: u32) { + if natural <= 1 && self.state.publication.natural_stance != natural as i32 { + self.state.publication.natural_stance = natural as i32; + self.state.flags ^= 0xC000_0000; + } + // GetCACSettings82590D20..D84: 0=null,1=Loose,2=Gonzo,3=Aggressive. + let name: &[u8] = match style { 1 => b"Loose", 2 => b"Gonzo", 3 => b"Aggressive", _ => b"" }; + self.motion.playback_context.pro_skater = encode(name); + } pub fn stance(&self) -> (bool, bool) { (self.state.fakie(), self.state.mirrored()) } diff --git a/docs/character-customisation.md b/docs/character-customisation.md index eef91f78..9fa3622f 100644 --- a/docs/character-customisation.md +++ b/docs/character-customisation.md @@ -1,124 +1,115 @@ -# Character customisation test build +# Character customiser V2 -A dedicated local executable and `Play Character Customiser.bat` are now prepared. -The game was **not launched**: validation here is compilation, synthetic unit tests, -and offline retail asset assembly. User playtesting is still required. +The dedicated local test build uses `Play Character Customiser V2.bat` and +`skate3rust-customiser-v2.exe`. Open Escape/Start, then Character customiser. +The game and launcher have not been executed for validation; the build is ready +for user playtesting. No existing game executable is replaced. -The Escape/Start menu opens a left-side character menu with the actual animated -skater framed on the right. Supported male model/material selections are assembled -on demand through the existing RX2/ABIN-to-GLB pipeline. The current character -stays visible during conversion or a failed request. Completed replacements reset -skin bindings before the animation system runs. Save commits the profile; -Back at the top discards unsaved changes. F5/controller X saves; Escape/controller -B goes back; arrows/D-pad select and adjust; Enter/controller A chooses. +## Menu and controls -Working paths in this test include hair, hats, authored skin/facial-hair material -variants, clothing and accessories, boards, all 19 individual body/face morph -weights, native truck/wheel ranges, and posture selection. Equipment retains -its 0.7 defaults. Posture takes effect through the next eligible native motion -construction when skating resumes. The original character loads unchanged when -there is no saved custom profile. +Four main sections: **Body, Clothes, Board, Style**. Item lists are alphabetical, +fit the available panel height, and can be filtered by typing. Backspace edits a +search; Escape/B clears it before going back. Arrows/D-pad/left stick browse, +left/right or visible minus/plus buttons adjust, and Enter/A chooses. Mouse wheel +and page buttons move through lists. Back returns one level. **Done**, present +only at the root, writes the profile and resumes skating. There are no repeated +Save buttons or material submenus. -**This is not a complete retail CAC implementation.** Gender switching, facial -presets, tattoos, gestures, stance and style remain explicit informational pages. -The existing StandardMaterial shading is retained. Native stamp compositing, -colour-zone controls, unlock filtering, full material parity and all outfit -compatibility cases are unfinished. Authored asset names are shown where localized -retail display names have not been resolved. Do not describe this test as full -retail customiser parity. +Names remove exporter terms and distinguish new/worn finishes. Board graphics, +trucks and wheels are individually named entries instead of hundreds of hidden +variants behind one model. The camera frames the selected area, and turns around +for back tattoos. Tattoo previews temporarily use the authored viewing outfit; +this does not change the saved clothes. -`tools/asset_pipeline/customisation_worker.py` provides the JSON worker and menu -index. A private `worker.json` holds local Python, source and asset-root paths; -retail data stays outside Git. The private test overlay links the existing stock, -skies and maps and owns its character cache and settings. It does not overwrite -an existing game executable or require another installation/conversion. +## Implemented controls -Validation: 18 synthetic Python tests pass. Offline retail assemblies cover the -baseline, body/face changes, skin, beard, hair, hat, hoodie, pants, shoes, deck and -glasses. Packed 11/11/10 signed normal deltas accompany weighted body morphs; -normalization occurs after accumulating deltas. These checks are not a rendered -or interactive gameplay test. +- Both bodies use their authored fallback outfits and retail rigs. +- Seven native skin colours, native hair colours, hairstyles and facial hair. +- Body weight/definition and all 17 individual facial controls, with native + `0..0.5` ranges and unchanged defaults. +- Ten native face presets per body, decoded by target hash and normalized range. +- Upper/lower tattoos: 170 owned designs; left/right limbs plus chest/back + placement for upper-body stamps. Tattoos can be removed and are saved. +- Hats, T-shirts, shirts, hoodies, jackets, sweaters, pants/shorts, shoes, socks, + accessories, decks, trucks and wheels. Authored variants and primary clothing + colours are selectable. Hat/hair, sleeves, inner tops, legs/socks and accessory + removal dependencies are resolved together. +- All 37 gestures in each of the four direction slots, regular/goofy stance, + Standard/Loose/Gonzo/Aggressive skating styles, and native posture options. +- Truck tightness and wheel hardness retain `0..1`, step `0.1`, default `0.7`. -## Implemented, private-data-only tooling +Physical/animation preferences apply to the running actor. Posture and skating +style are consumed by the appropriate native motion construction when skating +resumes; selecting them does not fabricate a separate preview animation. -`tools/asset_pipeline/customisation_catalog.py` reads the user's existing -`createacharacter.big` and `db.big` directly. It indexes the authored XML, -exports CAC BIN/VLT data with complete arrays, and optionally extracts only -explicitly selected models and their texture dependencies. It does not require -an ISO dialog, map conversion, downloads, a running game, or Blender. +## Immediate appearance updates -```powershell -python -m tools.asset_pipeline.customisation_catalog ` - --game-root --output -``` +The Python converter is no longer invoked by the game. Preparation creates 480 +resident part GLBs and shared textures. Visible choices also prefetch their +resolved sleeve, leg, hair and skin dependencies. Selection changes visibility +and material handles as one transaction after dependencies are ready. Morph +weights update directly on the GPU. Existing parts stay visible during initial +asset loading. First-time GPU/texture loading can still take frames; no measured +zero-latency or frame-rate claim is made without gameplay testing. -Outputs are private `catalog.json`, `native.json`, `extraction-report.json`, -the source XML, and five small database inputs. Do not commit, bundle, or -publish these files. The output directory must be separate from the owned -source. Existing staged resources are reused only when their bytes match; -different content is never overwritten. Publication of a resource is atomic. +Each visible rig is bound to the existing stock pose. Shared joint entities are +bound once. The untouched original character remains in use until the customiser +is opened or a valid saved profile is found. The profile lives at +`settings/character.json` beside the test assets and is written through a temporary +file and rename. Legacy profiles without a gender field are interpreted as male. +Map restarts reuse the same asset/settings root and reload the saved profile. -`--selection ` accepts an array like the following, with IDs -copied from that user's catalog rather than invented: +## Asset preparation and validation + +Retail payloads, catalogues, converted parts and machine paths remain private. +The source archive and installed stock assets are reused; no downloads, Blender, +ISO prompts, or map reconversion are needed. The non-geometric Misc model is the +stamp catalogue, not a missing renderable part. The female face's extra XML +material group is retained in metadata; its high-LOD source has one drawable mesh. + +Preparation commands, using a private configuration with the existing source and +asset roots: ```text -[{"slot": , "asset_id": , "lod": 0, - "material_ids": []}] +python -m tools.asset_pipeline.customisation_library +python -m tools.asset_pipeline.customisation_profiles ``` -This validates **extraction membership**, not outfit compatibility. A successful -extraction must not be presented as a renderable or gameplay-ready character. -The report explicitly sets `runtime_ready` to false. The existing installer -still prepares only the fallback skater; this tool is an opt-in preparation -boundary until the runtime dependencies are implemented. +The old worker remains an offline compatibility tool. It is not the V2 runtime. -The catalog keeps original shader names, every shader parameter and texture -channel in order, authoring flags including empty values, model names and -IDs, both LODs, and ordered material instances. Unknown scalar/sampler/UV -attributes are retained in `authored`; missing runtime constants are never -filled with guessed values. The original XML bytes are also preserved. +Validation: 21 Python tests and five Rust customiser tests pass. Owned-data checks +cover all 480 GLBs, indices, skin weights, 22 GPU target slots, texture references, +both default outfits, all selectable model/material combinations against those +outfits, presets, search, colour selection, JSON round trips, and temporary tattoo +outfits. Shader composition is parsed and validated offline with Bevy's shader +cache, with and without secondary UVs; this creates no window or GPU device. +Release compilation also passes. Interactive layout, controller feel and visual +appearance still require the user's game test. -The native export preserves reflected fields and inheritance links, including -complete `MorphPreset`/`ColourPreset` arrays and relocated text-array strings. -It resolves class/collection names using the archive's own summary report; -it does not require an IDA dump or a personal hash-name cache. Its `morphs` -index preserves target order and UI-zone index separately. The two native -gender-branch parameter triples remain labelled A/B pending verification of -the caller's boolean convention; both triples match in the inspected bank. +This is a sandbox catalogue, with owned items available irrespective of career +unlock state. The existing StandardMaterial lighting remains in use. Full retail +lighting/multipass material parity is outside this UI/profile update; primary +clothing tint is not a claim of every native colour-zone effect. -## Owned-data validation +## Additional native evidence for V2 -The inspected archive contains **19 component slots, 481 model variants, -962 LOD records and 3,328 material definitions**. Every referenced model and -texture exists in the same archive. These are authored counts, not counts of -unlocked or compatible menu choices. - -The female Rostral has two material instances at each LOD. The extractor -requires both; it does not silently keep the first. The high-LOD mesh has -1,432 vertices and 7,362 indices. How the two native material loops contribute -to that mesh remains part of the compositor investigation. - -The ten fallback components resolve to the exact model IDs, arena IDs, -material IDs, texture bindings and resource SHA-256s in -`tools/default_skater_retail_manifest.json`. Forty-four resources including -the catalog XML were staged; a second run reused all forty-four unchanged. -A separate female-head request staged both material-loop dependency sets. - -The native export contains 267 CAC-related collections, 19 morph mappings, -and 21 complete morph-preset arrays with 19 elements each (the schema default -plus ten male and ten female presets). This is raw authored data; parent-row -inheritance and preset application still need the native consumer. - -Synthetic verification, requiring no game content: - -```text -python -m unittest tools.asset_pipeline.test_customisation_catalog tools.asset_pipeline.test_vlt -``` - -Thirteen tests cover VLT array bounds/alignment/text relocation, material-loop -ordering, broken references, missing resources, staging isolation, no-clobber -reuse, separate UI/target indices and malformed morph ranges. No game, -recomp, screenshot process, or `--check-assets` run was launched. +- `GetCACSettings`, `0x82590B50`, including its complete tail: regular/goofy, + four gesture indices, posture, and style selectors Standard/Loose/Gonzo/Aggressive. +- `ResetGestureSet`, `0x824FA730`: marks all 37 gesture entries available and + initializes the first four in table order. +- `InitializeSkaterAnim`, `0x82B97E38`: stance basis and high flag bits. Changing + natural stance preserves the actor's relative stance. +- `cac_presets`: 32-byte MorphPreset records, target hash at byte 8, normalized + float at byte 24; invert `0x8253CD18` normalization using native target ranges. +- `cacstamp_skin_defaultVS`: transforms TEXCOORD1 with the two i_customGraphic + rows. `cacstamp_skin_defaultPS`: multiplies skin albedo by + `1 + (decal.rgb - 1) * decal.a`, before lighting. The V2 extension adds this + composition and retains Bevy's existing PBR/post-lighting entry point. +- RX2 format `0x002C23A5` is Xenos format 37, FLOAT2, not SHORT4N. The secondary + UV decoder now reads two big-endian float32 values. Retail rectangle order is + top/bottom/left/right; conversion to downward texture V is corroborated by + artwork pixel bounds and exposed-limb UV regions. Left limb positions also + agree with the authored LeftArm bind positions. ## Native evidence @@ -167,38 +158,3 @@ Do not equate low-level constructors with gameplay defaults. while the current Rust actor profile uses `0.7`, following its documented actor settings path. That current behavior is unchanged here. Saved/default CAC profile initialization must be traced before changing it. - -## Remaining implementation gates - -1. **CAC material composition.** The current character converter exports - `StandardMaterial`; its fallback image preparation explicitly skips - `decal/decal2` because they are shader inputs, not unconditional overlays. - It handles diffuse, hair alpha and normal reconstruction for that fallback. - The native catalog includes skin/face stamps, cloth stamps, colour shifts, - alpha and multi-instance cases. Recover the native compositor's input - constants, masks, UV transforms, stamp ordering and tint math, then connect - them to the renderer's character-material adapter. The renderer task - confirmed this dependency is not implemented. Do not multiply arbitrary - colours or paste tattoo textures into diffuse images as a substitute. -2. **Assembly and rigs.** Finish the native correction sequence and validate - both genders against authored component skinning/bind matrices. Preserve - hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer - tops, removed necklaces/wrist items and multiple material instances. The - test worker now supports male single-material mesh groups and applies the - explicit garment/hair/removal flags, but full native sequence parity remains. -3. **Complete profile/menu semantics.** Apply parent-row inheritance and - native face presets, colour zones, available-item/unlock filters, tattoos, - gestures, stance, style and posture. Keep physical board adjustments - separate. Use actual saved defaults rather than constructor placeholders. -4. **Runtime/UI validation.** The test implementation now includes navigation, - preview/save/back, persistence and in-process replacement. It still requires - user gameplay testing, particularly replacement while paused, reopening saved - outfits and controller navigation. No game-based validation was run. - -The requested hierarchy is recorded verbatim in `HIERARCHY`. It is a scope -contract, not a declaration that those pages work. The map-switch task's -proposed interface leaves a separate customiser resource and character -entities alive, emits `WorldChanged` after `MapTransitionSet`, and reconstructs -the skater using shared immutable animation data. Reapply saved profile values -after that transition without moving selection ownership into transient -physics state. This interface still needs integration against its final commit. diff --git a/tools/asset_pipeline/character_glb.py b/tools/asset_pipeline/character_glb.py index 5966dd70..d1943350 100644 --- a/tools/asset_pipeline/character_glb.py +++ b/tools/asset_pipeline/character_glb.py @@ -32,9 +32,12 @@ class Glb: path.write_bytes(struct.pack('4f',bytes.fromhex(row['fields']['Hash_7827ED970A88B70C']['data']))[:3])) + colours.sort(key=lambda c:c['name']) + library=dict(version=3,colours=colours,tattoos=tattoos,models=model_data,materials=mat_data,defaults={'male':male,'female':female}, + morphs=recipe_base['morph_assembly']['live_targets'],errors=errors) + (cache/'library.json').write_text(json.dumps(library,separators=(',',':'))) + print('LIBRARY_READY',len(model_data),len(mat_data),'errors',errors,flush=True) + return library + +if __name__=='__main__':prepare(json.loads(Path(sys.argv[1]).read_text())) diff --git a/tools/asset_pipeline/customisation_profiles.py b/tools/asset_pipeline/customisation_profiles.py new file mode 100644 index 00000000..907fc00e --- /dev/null +++ b/tools/asset_pipeline/customisation_profiles.py @@ -0,0 +1,58 @@ +"""Map owned native CAC profiles into named, directly applicable menu choices.""" +import json,struct,re +from pathlib import Path +from tools.asset_pipeline.customisation_native import key_hash +from tools.asset_pipeline.customisation_worker import ROOT + +def generate(native): + def page(name,children):return dict(label=name,children=children) + def choice(name,patch,**extra):return dict(label=name,patch=patch,**extra) + targets={key_hash(m['key']):m for m in native['morphs']} + presets=[] + for row in sorted(native['collections'],key=lambda r:r['key']): + if row['class']!='cac_presets' or not re.fullmatch(r'(male|female)_\d+',row['key']):continue + values={} + for field in row['fields'].values(): + if field['type']!='Sk8::CAC::MorphPreset':continue + for item in field['array']['items']: + raw=bytes.fromhex(item);target=targets[struct.unpack_from('>Q',raw,8)[0]] + normalized=struct.unpack_from('>f',raw,24)[0] + if not 0<=normalized<=1:raise ValueError('Preset is outside normalized range') + bounds=target['branch_a'] + # Inverse of CASMorph normalization8253CD18, preserving target + # hashes rather than assuming VLT array order matches UI order. + values[target['target']]=bounds['min']+normalized*(bounds['max']-bounds['min']) + if len(values)!=19:raise ValueError('Incomplete native face preset') + gender,number=row['key'].split('_') + presets.append(choice('Face '+str(int(number)),{'morphs':values},gender=gender)) + source=(ROOT/'crates/skate-game/src/graph_host/motion_character_gesture.rs').read_text() + names=re.findall(r'"B_GSTR_([A-Z_]+)"',source) + if len(names)!=37:raise ValueError('Native gesture table changed') + labels=['Air guitar','Airplane','Boxing','Bruce Lee','Check the time','Devil horns','Finger guns','Dunno', + 'Finger wag','Fists','Flex','Flip the table','The Fonz','Freedom','Raised fist','Get away','Get outta here', + 'Handcuffs','High fist pump','Low fist pump','Wind up','Peace','Point','Raise the roof','Shaka','Shrug', + 'Point to the sky','Snap','Soul arch','Spock','Surf\'s up','Swing high','Swing low','Throw your arms', + 'Thumbs down','Wings','Yard sale'] + gestures=page('Gestures',[page(direction,[choice(label,{'gestures':{str(i):j}}) for j,label in enumerate(labels)]) + for i,direction in enumerate(['Up','Down','Left','Right'])]) + skin_labels={'skin_light_3':'Porcelain','skin_light_2':'Fair','skin_light_4':'Warm', + 'skin_light_6':'Tan','skin_dark_1':'Brown','skin_dark_3':'Deep brown','skin_dark_2':'Dark'} + skin=[];hair=[] + for row in native['collections']: + if row['class']!='cac_colour':continue + fields=row['fields'];key=row['key'] + if key in skin_labels: + rgb=struct.unpack('>4f',bytes.fromhex(fields['Hash_7827ED970A88B70C']['data']))[:3] + skin.append(choice(skin_labels[key],{'skin':fields['Hash_AB0E65FA2F485ED8']['data'],'skin_tint':rgb})) + elif key.endswith('_hair') and not key.startswith('default') and not key.endswith('_facial_hair'): + rgb=struct.unpack('>4f',bytes.fromhex(fields['Hash_97462E6ABC78A423']['data']))[:3] + hair.append(choice(key[:-5].replace('_',' ').title(),{'hair_tint':rgb})) + skin.sort(key=lambda e: -sum(e['patch']['skin_tint'])) + hair.sort(key=lambda e:e['label']) + return [page('Skin tone',skin),page('Hair colour',hair),page('Face presets',presets),page('Style',[gestures, + page('Stance',[choice('Regular',{'stance':0}),choice('Goofy',{'stance':1})]), + page('Skating style',[choice(label,{'style':i}) for i,label in enumerate(['Standard','Loose','Gonzo','Aggressive'])])])] + +if __name__=='__main__': + import sys + root=Path(sys.argv[1]);(root/'extra-menu.json').write_text(json.dumps(generate(json.loads((root/'native.json').read_text())),indent=2)) diff --git a/tools/asset_pipeline/test_customisation_library.py b/tools/asset_pipeline/test_customisation_library.py new file mode 100644 index 00000000..215f806d --- /dev/null +++ b/tools/asset_pipeline/test_customisation_library.py @@ -0,0 +1,78 @@ +"""Offline CAC preparation checks; no window, game process, or renderer.""" +import json +import os +import struct +import unittest +from pathlib import Path + +import numpy as np + +from tools.asset_pipeline.customisation_library import friendly, variant_label +from tools.asset_pipeline.retail_character import RX2 + + +class LibraryTests(unittest.TestCase): + def test_secondary_uv_is_float2_not_four_normalized_shorts(self): + data = struct.pack('>4f', .125, .75, .875, .625) + description = {'stride': 8, 'elements': [{'usage_name': 'TEXCOORD', + 'usage_index': 1, 'format_name': RX2.VTX_FORMATS[0x002C23A5][0], 'offset': 0}]} + self.assertEqual(RX2._decode_vertices(data, 0, 2, description)['uvs2'], + [(.125, .75), (.875, .625)]) + + def test_menu_names_remove_exporter_words_and_explain_wear(self): + self.assertEqual(friendly('male_longsleeve_tshirt_stampable_sleeves_up'), 'Rolled-Sleeve Tee') + self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material_d'), 'Worn') + self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material'), 'New') + self.assertEqual(friendly('tattoo_biglizard_mat'), 'Big Lizard') + + @unittest.skipUnless(os.environ.get('SKATE_CAC_TEST_LIBRARY'), 'owned library is opt-in') + def test_owned_geometry_and_dependencies(self): + path = Path(os.environ['SKATE_CAC_TEST_LIBRARY']) + library = json.loads(path.read_text(encoding='utf-8')) + assets = path.parents[2] + self.assertEqual(library['errors'], []) + self.assertEqual(len(library['models']), 480) + self.assertEqual(len(library['tattoos']), 170) + for part in library['models'].values(): + data = (assets / part['scene']).read_bytes() + magic, version, size, text_size = struct.unpack_from('<4I', data) + self.assertEqual((magic, version, size), (0x46546C67, 2, len(data))) + doc = json.loads(data[20:20+text_size]) + blob = data[28+text_size:] + + def array(index): + accessor = doc['accessors'][index] + view = doc['bufferViews'][accessor['bufferView']] + width = {'SCALAR':1, 'VEC2':2, 'VEC3':3, 'VEC4':4, 'MAT4':16}[accessor['type']] + result = np.frombuffer(blob, dtype={5126:'=left)&(uv[:,0]<=right)&(uv[:,1]>=1-top)&(uv[:,1]<=1-bottom) + self.assertTrue(inside.any(),part['name']+' '+side) + for material in library['materials'].values(): + for channel in ['diffuse','normal','rough']: + if material[channel]: self.assertTrue((assets/material[channel]).is_file()) + for tattoo in library['tattoos'].values(): + self.assertTrue((assets/tattoo['texture']).is_file()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/launch_customiser_test.bat b/tools/launch_customiser_test.bat index dcfdf1bc..9812edef 100644 --- a/tools/launch_customiser_test.bat +++ b/tools/launch_customiser_test.bat @@ -1,21 +1,26 @@ @echo off setlocal cd /d "%~dp0" -if not exist "%~dp0skate3rust-customiser.exe" ( - echo The dedicated customiser executable is missing. +if not exist "%~dp0skate3rust-customiser-v2.exe" ( + echo The V2 customiser executable is missing. pause exit /b 1 ) -echo Skate 3 Character Customiser Test -echo Open Escape / Start, then choose Character customiser. -echo Save: F5 / controller X. Back: Escape / controller B. -echo First-time clothing changes can take a few seconds. -echo Diagnostic output is written to customiser-session.log. -"%~dp0skate3rust-customiser.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-session.log" 2>&1 +if not exist "%~dp0assets\private\customisation\library.json" ( + echo The prepared character library is missing. + pause + exit /b 1 +) +echo Skate 3 Character Customiser V2 +echo Open Escape / Start, then Character customiser. +echo Browse with arrows / D-pad. Change with left-right or the minus-plus buttons. +echo Type to search an item list. Escape / B goes back. Done saves and resumes. +echo Diagnostic output: customiser-v2-session.log +"%~dp0skate3rust-customiser-v2.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-v2-session.log" 2>&1 set "skate_customiser_exit=%errorlevel%" if not "%skate_customiser_exit%"=="0" ( echo The test exited with code %skate_customiser_exit%. - echo See %~dp0customiser-session.log + echo See %~dp0customiser-v2-session.log pause ) exit /b %skate_customiser_exit% diff --git a/tools/vendor/skate3_anim/PROVENANCE.md b/tools/vendor/skate3_anim/PROVENANCE.md index 5db56627..61171203 100644 --- a/tools/vendor/skate3_anim/PROVENANCE.md +++ b/tools/vendor/skate3_anim/PROVENANCE.md @@ -12,3 +12,8 @@ OffBoard clips to the same armature and action bank. They decode animation and skeleton files supplied locally by the user. No retail payload is included. + +Local CAC correction: vertex format `0x002C23A5` now decodes as two big-endian +float32 values. Its Xenos surface-format field is 37 (32_32_FLOAT); the previous +SHORT4N interpretation corrupted secondary tattoo UVs. Source declarations, +owned-mesh UV bounds and synthetic decoding tests corroborate the change. diff --git a/tools/vendor/skate3_anim/rx2_skeleton.py b/tools/vendor/skate3_anim/rx2_skeleton.py index 6362112c..e0c3dd6c 100644 --- a/tools/vendor/skate3_anim/rx2_skeleton.py +++ b/tools/vendor/skate3_anim/rx2_skeleton.py @@ -398,7 +398,7 @@ VTX_FORMATS: Dict[int, Tuple[str, int]] = { 0x001A215A: ("SHORT4", 8), # raw int16[4] -- position 0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights 0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord - 0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair + 0x002C23A5: ("FLOAT2", 8), # Xenos format37: two big-endian float32 UVs 0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal } @@ -514,6 +514,9 @@ def _decode_vertices(d: bytes, vb: int, count: int, vdesc: dict) -> dict: for v in range(count): u, w = struct.unpack_from(">2h", d, vb + v * stride + off) vals.append((u / 32768.0, w / 32768.0)) + elif fmt == "FLOAT2": + for v in range(count): + vals.append(struct.unpack_from(">2f", d, vb + v * stride + off)) elif fmt == "SHORT4N": for v in range(count): q = struct.unpack_from(">4h", d, vb + v * stride + off)