mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add playable customiser test build with transactional retail character swaps
This commit is contained in:
1 parent
9ca5ee076f
commit
a2ead83471
14 files changed
+1035
-36
No files matched your search
@@ -101,6 +101,7 @@ pub(crate) fn build(
|
|||||||
crate::grind_world::GrindGeometryPlugin,
|
crate::grind_world::GrindGeometryPlugin,
|
||||||
camera::CameraPlugin,
|
camera::CameraPlugin,
|
||||||
crate::graphics_menu::GraphicsMenuPlugin,
|
crate::graphics_menu::GraphicsMenuPlugin,
|
||||||
|
crate::customiser::CustomiserPlugin,
|
||||||
crate::render_capacity::RenderCapacityPlugin,
|
crate::render_capacity::RenderCapacityPlugin,
|
||||||
verification::VerificationPlugin,
|
verification::VerificationPlugin,
|
||||||
crate::performance::PerformancePlugin,
|
crate::performance::PerformancePlugin,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ fn spawn(mut commands: Commands, config: Res<Config>, retail: Res<crate::retail_
|
|||||||
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
||||||
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
|
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
|
||||||
replay: Res<crate::replay::Replay>,
|
replay: Res<crate::replay::Replay>,
|
||||||
|
customiser: Option<Res<crate::customiser::Customiser>>,
|
||||||
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
|
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
|
||||||
if let Ok(window) = windows.single() {
|
if let Ok(window) = windows.single() {
|
||||||
runtime.set_aspect_ratio(window.width() / window.height());
|
runtime.set_aspect_ratio(window.width() / window.height());
|
||||||
@@ -65,6 +66,14 @@ fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
|||||||
if let Projection::Perspective(p) = &mut *projection {
|
if let Projection::Perspective(p) = &mut *projection {
|
||||||
p.fov = previous.fov + (current.fov - previous.fov) * alpha;
|
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);
|
||||||
|
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);
|
||||||
|
if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); }
|
||||||
|
}
|
||||||
camera.is_active = true;
|
camera.is_active = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
//! Draft/commit character editing over the existing retail GLB and animation rig.
|
||||||
|
use bevy::{asset::LoadState, gltf::Gltf, prelude::*};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
io::Write,
|
||||||
|
path::PathBuf,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
sync::{Mutex, mpsc},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize)]
|
||||||
|
struct Entry {
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
children: Vec<Entry>,
|
||||||
|
patch: Option<Value>,
|
||||||
|
scalar: Option<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
minimum: Option<f64>,
|
||||||
|
maximum: Option<f64>,
|
||||||
|
initial: Option<f64>,
|
||||||
|
step: Option<f64>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
struct Worker {
|
||||||
|
python: PathBuf,
|
||||||
|
working_directory: PathBuf,
|
||||||
|
}
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub(crate) struct Navigation {
|
||||||
|
pub pressed: u16,
|
||||||
|
previous: u16,
|
||||||
|
}
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub(crate) struct Customiser {
|
||||||
|
pub open: bool,
|
||||||
|
just_opened: bool,
|
||||||
|
index: Entry,
|
||||||
|
path: Vec<usize>,
|
||||||
|
selected: usize,
|
||||||
|
draft: Value,
|
||||||
|
committed: Value,
|
||||||
|
scene: String,
|
||||||
|
committed_scene: String,
|
||||||
|
config: PathBuf,
|
||||||
|
settings: PathBuf,
|
||||||
|
worker: Option<Worker>,
|
||||||
|
receiver: Option<Mutex<mpsc::Receiver<Result<Value, String>>>>,
|
||||||
|
loading: Option<(Handle<Gltf>, String, Value)>,
|
||||||
|
status: String,
|
||||||
|
redraw: bool,
|
||||||
|
}
|
||||||
|
impl Customiser {
|
||||||
|
pub(crate) fn begin(&mut self) {
|
||||||
|
self.open = true;
|
||||||
|
self.just_opened = true;
|
||||||
|
self.path.clear();
|
||||||
|
self.selected = 0;
|
||||||
|
self.status = "Choose a category. Changes are previews until you Save.".into();
|
||||||
|
self.redraw = true;
|
||||||
|
}
|
||||||
|
fn page(&self) -> &Entry {
|
||||||
|
let mut page = &self.index;
|
||||||
|
for &i in &self.path {
|
||||||
|
page = &page.children[i];
|
||||||
|
}
|
||||||
|
page
|
||||||
|
}
|
||||||
|
fn busy(&self) -> bool {
|
||||||
|
self.receiver.is_some() || self.loading.is_some()
|
||||||
|
}
|
||||||
|
fn request(&mut self, profile: Value) {
|
||||||
|
if self.busy() {
|
||||||
|
self.status = "Please wait for the current preview.".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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<Value, String> {
|
||||||
|
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::<String>()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
let _ = tx.send(run());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Component)]
|
||||||
|
struct Root;
|
||||||
|
#[derive(Component)]
|
||||||
|
struct Row(usize);
|
||||||
|
pub(crate) struct CustomiserPlugin;
|
||||||
|
impl Plugin for CustomiserPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<Navigation>()
|
||||||
|
.add_systems(PreUpdate, navigation)
|
||||||
|
.add_systems(PostStartup, setup)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(interact, complete, draw)
|
||||||
|
.chain()
|
||||||
|
.after(crate::graphics_menu::interact)
|
||||||
|
.before(crate::app::FrameSet::Animation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn navigation(mut nav: ResMut<Navigation>) {
|
||||||
|
let current = (0..4)
|
||||||
|
.find_map(|i| crate::input::platform::poll(i).ok())
|
||||||
|
.map_or(0, |p| p.state.buttons);
|
||||||
|
nav.pressed = current & !nav.previous;
|
||||||
|
nav.previous = current;
|
||||||
|
}
|
||||||
|
fn setup(
|
||||||
|
mut commands: Commands,
|
||||||
|
config: Res<crate::config::Config>,
|
||||||
|
manifest: Res<crate::assets::AssetManifest>,
|
||||||
|
) {
|
||||||
|
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::<Value>(&b).ok())
|
||||||
|
};
|
||||||
|
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 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::<Value>(&b).ok());
|
||||||
|
let worker = read("worker.json").and_then(|v| serde_json::from_value(v).ok());
|
||||||
|
let mut state = Customiser {
|
||||||
|
open: false,
|
||||||
|
just_opened: false,
|
||||||
|
index,
|
||||||
|
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"),
|
||||||
|
settings,
|
||||||
|
worker,
|
||||||
|
receiver: None,
|
||||||
|
loading: None,
|
||||||
|
status: String::new(),
|
||||||
|
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)),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: px(6),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.025, 0.04, 0.06, 0.98)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fn merge(target: &mut Value, patch: &Value) {
|
||||||
|
if let (Some(a), Some(b)) = (target.as_object_mut(), patch.as_object()) {
|
||||||
|
for (k, v) in b {
|
||||||
|
merge(a.entry(k.clone()).or_insert(Value::Null), v);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*target = patch.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn scalar(profile: &Value, key: &str, initial: f64) -> f64 {
|
||||||
|
let pointer = format!("/{}", key.replace('.', "/"));
|
||||||
|
profile
|
||||||
|
.pointer(&pointer)
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.unwrap_or(initial)
|
||||||
|
}
|
||||||
|
fn interact(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
nav: Res<Navigation>,
|
||||||
|
mut state: ResMut<Customiser>,
|
||||||
|
mut menu: ResMut<crate::graphics_menu::Menu>,
|
||||||
|
server: Res<AssetServer>,
|
||||||
|
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
|
||||||
|
) {
|
||||||
|
if !state.open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if state.just_opened {
|
||||||
|
state.just_opened = false;
|
||||||
|
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;
|
||||||
|
state.redraw = true;
|
||||||
|
}
|
||||||
|
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
|
||||||
|
state.selected = (state.selected + 1) % count;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut save = keys.just_pressed(KeyCode::F5) || nav.pressed & 0x4000 != 0;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
state.open = false;
|
||||||
|
menu.open = false;
|
||||||
|
}
|
||||||
|
state.redraw = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((i, direction)) = action {
|
||||||
|
let Some(entry) = state.page().children.get(i).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !entry.children.is_empty() {
|
||||||
|
state.path.push(i);
|
||||||
|
state.selected = 0;
|
||||||
|
} else if let Some(note) = entry.note {
|
||||||
|
state.status = note;
|
||||||
|
} else if !state.busy() {
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
profile[&key] = json!(value);
|
||||||
|
}
|
||||||
|
// Equipment controls are consumed directly by existing native physics.
|
||||||
|
if key == "truck" || key == "wheel" {
|
||||||
|
state.draft = profile;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.status = "Please wait for the current preview.".into();
|
||||||
|
}
|
||||||
|
state.redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn complete(
|
||||||
|
mut state: ResMut<Customiser>,
|
||||||
|
server: Res<AssetServer>,
|
||||||
|
mut commands: Commands,
|
||||||
|
root: Query<Entity, With<crate::world::PlayerRoot>>,
|
||||||
|
scenes: Query<(Entity, &ChildOf), With<SceneRoot>>,
|
||||||
|
mut animation: ResMut<crate::animation::AnimationStatus>,
|
||||||
|
mut physics: ResMut<crate::physics::GamePhysics>,
|
||||||
|
mut skater: ResMut<crate::physics::SkaterRuntime>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
skater
|
||||||
|
.animation
|
||||||
|
.motion
|
||||||
|
.animation
|
||||||
|
.posture
|
||||||
|
.set_profile(scalar(&state.draft, "posture", 0.).clamp(0., 3.) as u32);
|
||||||
|
}
|
||||||
|
fn draw(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut state: ResMut<Customiser>,
|
||||||
|
mut root: Single<(Entity, &mut Node), With<Root>>,
|
||||||
|
) {
|
||||||
|
root.1.display = if state.open {
|
||||||
|
Display::Flex
|
||||||
|
} else {
|
||||||
|
Display::None
|
||||||
|
};
|
||||||
|
if !state.open || !state.redraw {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.redraw = false;
|
||||||
|
commands.entity(root.0).despawn_children();
|
||||||
|
let page = state.page();
|
||||||
|
let start = state.selected / 10 * 10;
|
||||||
|
commands.entity(root.0).with_children(|p| {
|
||||||
|
p.spawn((
|
||||||
|
Text::new(&page.label),
|
||||||
|
TextFont {
|
||||||
|
font_size: 25.,
|
||||||
|
..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)),
|
||||||
|
TextFont {
|
||||||
|
font_size: 16.,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if page.children.len() > 10 {
|
||||||
|
p.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"{}–{} / {} • Up/Down to browse",
|
||||||
|
start + 1,
|
||||||
|
(start + 10).min(page.children.len()),
|
||||||
|
page.children.len()
|
||||||
|
)),
|
||||||
|
TextFont {
|
||||||
|
font_size: 13.,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
TextFont {
|
||||||
|
font_size: 16.,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
p.spawn((
|
||||||
|
Text::new(&state.status),
|
||||||
|
TextFont {
|
||||||
|
font_size: 14.,
|
||||||
|
..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)),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ impl GraphicsSettings {
|
|||||||
}
|
}
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
pub(crate) struct Menu {
|
pub(crate) struct Menu {
|
||||||
open: bool,
|
pub(crate) open: bool,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
settings: GraphicsSettings,
|
settings: GraphicsSettings,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -95,7 +95,7 @@ struct FramePacer(Instant);
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct MenuRoot;
|
struct MenuRoot;
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct MenuRow(usize);
|
pub(crate) struct MenuRow(usize);
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct MenuLabel(usize);
|
struct MenuLabel(usize);
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
@@ -198,7 +198,7 @@ fn setup(
|
|||||||
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
|
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
|
||||||
panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
|
panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
|
||||||
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
|
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
|
||||||
for i in 0..10 {
|
for i in 0..11 {
|
||||||
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
|
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
|
||||||
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
|
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
|
||||||
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
|
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
|
||||||
@@ -235,8 +235,10 @@ fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
|
|||||||
let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32;
|
let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32;
|
||||||
values[(index + direction).rem_euclid(values.len() as i32) as usize]
|
values[(index + direction).rem_euclid(values.len() as i32) as usize]
|
||||||
}
|
}
|
||||||
fn interact(
|
pub(crate) fn interact(
|
||||||
config: Res<crate::config::Config>,
|
config: Res<crate::config::Config>,
|
||||||
|
mut customiser: ResMut<crate::customiser::Customiser>,
|
||||||
|
nav: Res<crate::customiser::Navigation>,
|
||||||
mut physics: ResMut<crate::physics::GamePhysics>,
|
mut physics: ResMut<crate::physics::GamePhysics>,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut menu: ResMut<Menu>,
|
mut menu: ResMut<Menu>,
|
||||||
@@ -244,21 +246,22 @@ fn interact(
|
|||||||
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
|
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
|
||||||
mut exit: MessageWriter<AppExit>,
|
mut exit: MessageWriter<AppExit>,
|
||||||
) {
|
) {
|
||||||
if keys.just_pressed(KeyCode::Escape) {
|
if customiser.open { return; }
|
||||||
|
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
|
||||||
menu.open = !menu.open;
|
menu.open = !menu.open;
|
||||||
}
|
}
|
||||||
let mut action = None;
|
let mut action = None;
|
||||||
if menu.open {
|
if menu.open {
|
||||||
if keys.just_pressed(KeyCode::ArrowUp) {
|
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
|
||||||
menu.selected = (menu.selected + 9) % 10;
|
menu.selected = (menu.selected + 10) % 11;
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::ArrowDown) {
|
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
|
||||||
menu.selected = (menu.selected + 1) % 10;
|
menu.selected = (menu.selected + 1) % 11;
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::ArrowLeft) {
|
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
|
||||||
action = Some((menu.selected, -1));
|
action = Some((menu.selected, -1));
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) {
|
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 {
|
||||||
action = Some((menu.selected, 1));
|
action = Some((menu.selected, 1));
|
||||||
}
|
}
|
||||||
for (interaction, row) in &buttons {
|
for (interaction, row) in &buttons {
|
||||||
@@ -308,6 +311,7 @@ fn interact(
|
|||||||
9 => {
|
9 => {
|
||||||
exit.write(AppExit::Success);
|
exit.write(AppExit::Success);
|
||||||
}
|
}
|
||||||
|
10 => customiser.begin(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if row < 5 {
|
if row < 5 {
|
||||||
@@ -380,13 +384,14 @@ fn apply(
|
|||||||
}
|
}
|
||||||
fn labels(
|
fn labels(
|
||||||
menu: Res<Menu>,
|
menu: Res<Menu>,
|
||||||
|
customiser: Res<crate::customiser::Customiser>,
|
||||||
window: Single<&Window, With<PrimaryWindow>>,
|
window: Single<&Window, With<PrimaryWindow>>,
|
||||||
mut root: Single<&mut Node, With<MenuRoot>>,
|
mut root: Single<&mut Node, With<MenuRoot>>,
|
||||||
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
|
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
|
||||||
mut status: Single<&mut Text, With<StatusLabel>>,
|
mut status: Single<&mut Text, With<StatusLabel>>,
|
||||||
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
|
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
|
||||||
) {
|
) {
|
||||||
root.display = if menu.open {
|
root.display = if menu.open && !customiser.open {
|
||||||
Display::Flex
|
Display::Flex
|
||||||
} else {
|
} else {
|
||||||
Display::None
|
Display::None
|
||||||
@@ -424,7 +429,8 @@ fn labels(
|
|||||||
6 => format!("Map {}", menu.maps[menu.selected_map].label),
|
6 => format!("Map {}", menu.maps[menu.selected_map].label),
|
||||||
7 => "Load map (restarts session)".into(),
|
7 => "Load map (restarts session)".into(),
|
||||||
8 => "Resume".into(),
|
8 => "Resume".into(),
|
||||||
_ => "Quit game".into(),
|
9 => "Quit game".into(),
|
||||||
|
_ => "Character customiser".into(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
***status = menu.status.clone();
|
***status = menu.status.clone();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub(crate) mod gesture_catalog;
|
|||||||
mod gesture_mapping_data;
|
mod gesture_mapping_data;
|
||||||
pub(crate) mod gesture_mapping;
|
pub(crate) mod gesture_mapping;
|
||||||
pub(crate) mod gesture_input;
|
pub(crate) mod gesture_input;
|
||||||
mod platform;
|
pub(crate) mod platform;
|
||||||
pub(crate) use controllers::{ControllerInput, ControllerStatus};
|
pub(crate) use controllers::{ControllerInput, ControllerStatus};
|
||||||
use skate_core::input::tick::TickInput;
|
use skate_core::input::tick::TickInput;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction.
|
//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction.
|
||||||
use skate_core::input::xbox::XboxState;
|
use skate_core::input::xbox::XboxState;
|
||||||
|
|
||||||
pub(super) struct DevicePacket {
|
pub(crate) struct DevicePacket {
|
||||||
pub number: u32,
|
pub number: u32,
|
||||||
pub state: XboxState,
|
pub state: XboxState,
|
||||||
pub subtype: u8,
|
pub subtype: u8,
|
||||||
@@ -95,7 +95,7 @@ mod windows {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
|
pub(crate) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
|
||||||
assert!(index < 4);
|
assert!(index < 4);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
return windows::poll(index as u32);
|
return windows::poll(index as u32);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod skater_animation;
|
|||||||
mod verification;
|
mod verification;
|
||||||
mod performance;
|
mod performance;
|
||||||
mod graphics_menu;
|
mod graphics_menu;
|
||||||
|
mod customiser;
|
||||||
mod render_capacity;
|
mod render_capacity;
|
||||||
mod retail_render;
|
mod retail_render;
|
||||||
mod presentation;
|
mod presentation;
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ mod exchange_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GamePhysics {
|
impl GamePhysics {
|
||||||
|
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);
|
||||||
|
self.animation_profile.wheel_hardness = wheel.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) {
|
pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) {
|
||||||
// Actor publication carries this selector into the next physical packet.
|
// Actor publication carries this selector into the next physical packet.
|
||||||
// Keep the board, active trick, equipment preferences and controller history.
|
// Keep the board, active trick, equipment preferences and controller history.
|
||||||
|
|||||||
@@ -1,10 +1,43 @@
|
|||||||
# Character customisation: extraction boundary and native blockers
|
# Character customisation test build
|
||||||
|
|
||||||
Status: **not a playable customiser**. The runtime, Escape menu, character
|
A dedicated local executable and `Play Character Customiser.bat` are now prepared.
|
||||||
loader, physics defaults, and renderer are unchanged by this work. No dummy
|
The game was **not launched**: validation here is compilation, synthetic unit tests,
|
||||||
controls or replacement models were added. A faithful implementation is
|
and offline retail asset assembly. User playtesting is still required.
|
||||||
blocked on the CAC material/compositor contract and the remaining native
|
|
||||||
assembly/profile application described below.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Implemented, private-data-only tooling
|
## Implemented, private-data-only tooling
|
||||||
|
|
||||||
@@ -151,16 +184,16 @@ CAC profile initialization must be traced before changing it.
|
|||||||
both genders against authored component skinning/bind matrices. Preserve
|
both genders against authored component skinning/bind matrices. Preserve
|
||||||
hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer
|
hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer
|
||||||
tops, removed necklaces/wrist items and multiple material instances. The
|
tops, removed necklaces/wrist items and multiple material instances. The
|
||||||
existing GLB pipeline accepts only the fixed fallback component manifest;
|
test worker now supports male single-material mesh groups and applies the
|
||||||
extend it after the assembly contract is established.
|
explicit garment/hair/removal flags, but full native sequence parity remains.
|
||||||
3. **Complete profile/menu semantics.** Apply parent-row inheritance and
|
3. **Complete profile/menu semantics.** Apply parent-row inheritance and
|
||||||
native face presets, colour zones, available-item/unlock filters, tattoos,
|
native face presets, colour zones, available-item/unlock filters, tattoos,
|
||||||
gestures, stance, style and posture. Keep physical board adjustments
|
gestures, stance, style and posture. Keep physical board adjustments
|
||||||
separate. Use actual saved defaults rather than constructor placeholders.
|
separate. Use actual saved defaults rather than constructor placeholders.
|
||||||
4. **Runtime/UI.** Implement the requested hierarchy, left controls and right
|
4. **Runtime/UI validation.** The test implementation now includes navigation,
|
||||||
preview only for working native paths. Add transactional preview/apply/back,
|
preview/save/back, persistence and in-process replacement. It still requires
|
||||||
coherent persistence, controller navigation and in-process character
|
user gameplay testing, particularly replacement while paused, reopening saved
|
||||||
replacement. No runtime or menu integration is present in this commit.
|
outfits and controller navigation. No game-based validation was run.
|
||||||
|
|
||||||
The requested hierarchy is recorded verbatim in `HIERARCHY`. It is a scope
|
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
|
contract, not a declaration that those pages work. The map-switch task's
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class Glb:
|
|||||||
path.write_bytes(struct.pack('<III',0x46546c67,2,28+len(text)+len(self.data))+
|
path.write_bytes(struct.pack('<III',0x46546c67,2,28+len(text)+len(self.data))+
|
||||||
struct.pack('<I4s',len(text),b'JSON')+text+struct.pack('<I4s',len(self.data),b'BIN\0')+self.data)
|
struct.pack('<I4s',len(text),b'JSON')+text+struct.pack('<I4s',len(self.data),b'BIN\0')+self.data)
|
||||||
|
|
||||||
def convert(models,private,recipe):
|
def convert(models,private,recipe,*,stock=None,output=None,materials=None):
|
||||||
source=AnimSource(private/'stock/data/anim/OnBoard.abin')
|
source=AnimSource((stock or private/'stock')/'data/anim/OnBoard.abin')
|
||||||
skeleton=SkeletonSet(str(models))
|
skeleton=SkeletonSet(str(models))
|
||||||
if skeleton.errors:raise ValueError(str(skeleton.errors))
|
if skeleton.errors:raise ValueError(str(skeleton.errors))
|
||||||
mapping=skeleton.index_map(ABIN.BONE_NAMES,source.parents)
|
mapping=skeleton.index_map(ABIN.BONE_NAMES,source.parents)
|
||||||
@@ -76,11 +76,13 @@ def convert(models,private,recipe):
|
|||||||
raw=raw[0];positions=np.asarray(mesh['pos'],dtype=np.float64)
|
raw=raw[0];positions=np.asarray(mesh['pos'],dtype=np.float64)
|
||||||
morphs=decode_dense_morphs(path,parsed,len(positions),RX2)
|
morphs=decode_dense_morphs(path,parsed,len(positions),RX2)
|
||||||
if [m['name'] for m in morphs]!=recipe['morph_assembly']['expected_targets'][slot]:raise ValueError('Unexpected morph set '+slot)
|
if [m['name'] for m in morphs]!=recipe['morph_assembly']['expected_targets'][slot]:raise ValueError('Unexpected morph set '+slot)
|
||||||
|
normals=np.asarray(raw['normals'],dtype=np.float64)
|
||||||
for morph in morphs:
|
for morph in morphs:
|
||||||
weight=morph_weight(morph['name'],recipe)
|
weight=morph_weight(morph['name'],recipe)
|
||||||
if weight and morph['nonzero_normal_deltas']:raise ValueError('Unsupported morph normals '+slot)
|
|
||||||
positions+=np.asarray(morph['deltas'])*weight
|
positions+=np.asarray(morph['deltas'])*weight
|
||||||
normals=np.asarray(raw['normals'],dtype=np.float64);normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
|
if weight:
|
||||||
|
normals+=np.asarray(morph['normal_deltas'])*weight
|
||||||
|
normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
|
||||||
joints=np.zeros((len(positions),4),dtype=np.uint16);weights=np.zeros((len(positions),4),dtype=np.float32)
|
joints=np.zeros((len(positions),4),dtype=np.uint16);weights=np.zeros((len(positions),4),dtype=np.float32)
|
||||||
if not mesh['skin']:raise ValueError('Missing skin '+slot)
|
if not mesh['skin']:raise ValueError('Missing skin '+slot)
|
||||||
for i,influences in enumerate(mesh['skin']):
|
for i,influences in enumerate(mesh['skin']):
|
||||||
@@ -89,7 +91,7 @@ def convert(models,private,recipe):
|
|||||||
if len(combined)>4 or not combined:raise ValueError('Invalid skin influences '+slot)
|
if len(combined)>4 or not combined:raise ValueError('Invalid skin influences '+slot)
|
||||||
for j,(bone,w) in enumerate(combined.items()):joints[i,j]=joint_index[bone];weights[i,j]=w
|
for j,(bone,w) in enumerate(combined.items()):joints[i,j]=joint_index[bone];weights[i,j]=w
|
||||||
weights/=weights.sum(axis=1,keepdims=True)
|
weights/=weights.sum(axis=1,keepdims=True)
|
||||||
folder=private/'default_skater/textures/materials'
|
folder=materials or private/'default_skater/textures/materials'
|
||||||
pbr={'baseColorFactor':[*component['tint'],1.0],
|
pbr={'baseColorFactor':[*component['tint'],1.0],
|
||||||
'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')),
|
'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')),
|
||||||
'metallicFactor':0.65 if slot=='SkateTruck' else 0.0,
|
'metallicFactor':0.65 if slot=='SkateTruck' else 0.0,
|
||||||
@@ -113,7 +115,7 @@ def convert(models,private,recipe):
|
|||||||
root=len(nodes);nodes.append({'name':'Skate3_RX2_Rig','children':root_children+[mesh_node]})
|
root=len(nodes);nodes.append({'name':'Skate3_RX2_Rig','children':root_children+[mesh_node]})
|
||||||
glb.doc.update(nodes=nodes,meshes=[{'primitives':primitives}],skins=[{'joints':list(range(len(indices))),
|
glb.doc.update(nodes=nodes,meshes=[{'primitives':primitives}],skins=[{'joints':list(range(len(indices))),
|
||||||
'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0)
|
'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0)
|
||||||
glb.save(private/'skater.glb')
|
glb.save(output or private/'skater.glb')
|
||||||
|
|
||||||
if __name__=='__main__':
|
if __name__=='__main__':
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Private, on-demand retail CAC assembly. Never starts a game or an editor.
|
||||||
|
|
||||||
|
The protocol is JSON on stdin/stdout. Paths belong to a local worker.json,
|
||||||
|
not the saved outfit. Failed assemblies never replace an existing GLB.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, copy, hashlib, json, math, os, shutil, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tools.asset_pipeline.customisation_catalog import write_private, selected_paths
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def flag(item, key):
|
||||||
|
return item['flags'].get('cas.' + key, '')
|
||||||
|
|
||||||
|
|
||||||
|
def label(name):
|
||||||
|
return name.replace('_', ' ').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def default_profile():
|
||||||
|
recipe = json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())
|
||||||
|
return {'selections': {c['slot']: {'asset_id': c['asset_id'], 'material_id': c['material_id']}
|
||||||
|
for c in recipe['components']}, 'morphs': {},
|
||||||
|
'truck': 0.7, 'wheel': 0.7, 'posture': 0}
|
||||||
|
|
||||||
|
|
||||||
|
def menu_index(catalog, native):
|
||||||
|
"""Use authored names and IDs; no generated merchandise or synthetic presets."""
|
||||||
|
mats = catalog['materials']
|
||||||
|
slots = {c['slot']: c['models'] for c in catalog['components']}
|
||||||
|
def page(name, children=None, **extra):
|
||||||
|
return dict(label=name, children=children or [], **extra)
|
||||||
|
def unavailable(name, reason):
|
||||||
|
return page(name, note=reason)
|
||||||
|
def models(name, slot, predicate=lambda m: True):
|
||||||
|
children = []
|
||||||
|
if slot in {'Hat', 'Hair', 'Glasses', 'Jewellery', 'WristItem', 'Sock', 'Accessory'}:
|
||||||
|
children.append(page('None', patch={'selections': {slot: None}}))
|
||||||
|
for m in slots[slot]:
|
||||||
|
if flag(m, 'Gender') not in {'', 'male', 'unisex'} or not predicate(m):
|
||||||
|
continue
|
||||||
|
lod = next(l for l in m['lods'] if l['index'] == 0)
|
||||||
|
if len(lod['material_instances']) != 1:
|
||||||
|
continue
|
||||||
|
variants = []
|
||||||
|
for v in lod['material_instances'][0]:
|
||||||
|
if not any(t['channel'] == 'diffuse' for t in mats[v['id']]['textures']):
|
||||||
|
continue
|
||||||
|
variants.append(page(label(v['name']), patch={'selections': {slot: {
|
||||||
|
'asset_id': m['id'], 'material_id': v['id']}}}))
|
||||||
|
if variants:
|
||||||
|
children.append(page(label(m['name']), variants))
|
||||||
|
return page(name, children)
|
||||||
|
morphs = []
|
||||||
|
for m in native['morphs']:
|
||||||
|
r = m['branch_a']
|
||||||
|
morphs.append(page(label(m['key']), scalar='morphs.'+m['target'],
|
||||||
|
minimum=r['min'], maximum=r['max'], initial=r['default'], step=0.025))
|
||||||
|
skin = sorted({flag(m, 'SkinTone') for m in mats.values()} - {''})
|
||||||
|
beard = sorted({flag(m, 'FacialHairStyle') for m in mats.values()} - {''})
|
||||||
|
body = page('Body mods', [
|
||||||
|
unavailable('Gender', 'Male is supported in this test. Female material groups are still being decoded.'),
|
||||||
|
page('Skin', [page(x, patch={'skin': x}) for x in skin]), models('Hair', 'Hair'),
|
||||||
|
page('Body shape', morphs[:2]),
|
||||||
|
unavailable('Facial presets', 'Native preset arrays are indexed; preset application is not yet connected.'),
|
||||||
|
page('Face modification', morphs[2:]),
|
||||||
|
page('Facial hair', [page(x, patch={'beard': x}) for x in beard]),
|
||||||
|
unavailable('Upper body tattoo', 'Tattoo stamp compositing is not yet implemented.'),
|
||||||
|
unavailable('Lower body tattoo', 'Tattoo stamp compositing is not yet implemented.')])
|
||||||
|
merch = [models('Hats', 'Hat')]
|
||||||
|
for title, kinds in [('Tshirts', {'tshirt','ls_tshirt','tanktop'}), ('Button shirts', {'buttonshirt'}),
|
||||||
|
('Hoodies', {'hoody'}), ('Jackets', {'jacket'}), ('Sweaters', {'sweater'})]:
|
||||||
|
merch.append(models(title, 'OuterTorso', lambda m, k=kinds: flag(m,'TopType') in k))
|
||||||
|
merch += [models('Pants','Pants'), models('Shoes','Feet'), models('Socks','Sock'),
|
||||||
|
page('Accessories', [models(s, s) for s in ['Glasses','Jewellery','WristItem','Accessory']]),
|
||||||
|
page('Skateboards', [models('Deck','SkateBoard'),models('Trucks','SkateTruck'),models('Wheels','SkateWheel')])]
|
||||||
|
style = page('Edit style', [unavailable('Gestures', 'Gesture selection is not yet connected.'),
|
||||||
|
unavailable('Stance', 'Changing the native stance publication is not yet connected.'),
|
||||||
|
unavailable('Style', 'Native style-to-animation selection is still being decoded.'),
|
||||||
|
page('Posture', [page(x, patch={'posture':i}) for i,x in enumerate(['Default','Stiff','Slouch','Buff'])])])
|
||||||
|
return page('Character customiser', [body, page('Merchandise', merch), style,
|
||||||
|
page('Trucks adjustment',scalar='truck',minimum=0.,maximum=1.,initial=.7,step=.1),
|
||||||
|
page('Wheels adjustment',scalar='wheel',minimum=0.,maximum=1.,initial=.7,step=.1)])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(catalog, profile):
|
||||||
|
profile = copy.deepcopy(profile)
|
||||||
|
models = {m['id']: (c['slot'],m) for c in catalog['components'] for m in c['models']}
|
||||||
|
materials = catalog['materials']
|
||||||
|
selections = profile['selections']
|
||||||
|
# Validate even entries which would subsequently be removed by a dependency.
|
||||||
|
for slot, selection in list(selections.items()):
|
||||||
|
if selection is None:
|
||||||
|
del selections[slot]
|
||||||
|
continue
|
||||||
|
if selection['asset_id'] not in models or models[selection['asset_id']][0] != slot:
|
||||||
|
raise ValueError('Unknown model in '+slot)
|
||||||
|
m = models[selection['asset_id']][1]
|
||||||
|
if flag(m,'Gender') not in {'','male','unisex'}:
|
||||||
|
raise ValueError('Female assembly is not supported by this test build')
|
||||||
|
groups = next(l for l in m['lods'] if l['index']==0)['material_instances']
|
||||||
|
if len(groups)!=1 or selection['material_id'] not in {v['id'] for v in groups[0]}:
|
||||||
|
raise ValueError('Invalid material binding for '+slot)
|
||||||
|
def model(slot):
|
||||||
|
return models[selections[slot]['asset_id']][1] if slot in selections else None
|
||||||
|
def choose(slot, key, value):
|
||||||
|
if not value or value == 'none':
|
||||||
|
selections.pop(slot, None)
|
||||||
|
return
|
||||||
|
current=model(slot)
|
||||||
|
if current and flag(current,key)==value:
|
||||||
|
return
|
||||||
|
candidates=[m for s,m in models.values() if s==slot and flag(m,key)==value
|
||||||
|
and flag(m,'Gender') in {'','male','unisex'}]
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError(f'No compatible {slot}: {key}={value}')
|
||||||
|
m=candidates[0]
|
||||||
|
variants=next(l for l in m['lods'] if l['index']==0)['material_instances'][0]
|
||||||
|
v=next((v for v in variants if flag(materials[v['id']],'IsDefault').lower()=='yes'), variants[0])
|
||||||
|
selections[slot]={'asset_id':m['id'],'material_id':v['id']}
|
||||||
|
outer=model('OuterTorso')
|
||||||
|
if outer:
|
||||||
|
choose('Arm','ArmModelType',flag(outer,'ArmModelRequired'))
|
||||||
|
inner=flag(outer,'InnerCutTypeRequired')
|
||||||
|
choose('InnerTorso','InnerCutType',inner)
|
||||||
|
if flag(outer,'SupportsWristItem')=='no':selections.pop('WristItem',None)
|
||||||
|
if flag(outer,'IsNecklaceRemoved').lower()=='yes':selections.pop('Jewellery',None)
|
||||||
|
pants=model('Pants')
|
||||||
|
if pants:choose('Leg','LegModelType',flag(pants,'LegModelRequired'))
|
||||||
|
leg=model('Leg')
|
||||||
|
if leg:
|
||||||
|
sock=selections.get('Sock')
|
||||||
|
sock_style=flag(materials[sock['material_id']],'SockStyle') if sock else 'none'
|
||||||
|
candidates=[m for s,m in models.values() if s=='Leg' and flag(m,'Gender') in {'male','unisex',''}
|
||||||
|
and flag(m,'LegModelType')==flag(leg,'LegModelType')
|
||||||
|
and (flag(m,'RequiresSockStyle') or 'none')==sock_style]
|
||||||
|
if not candidates:raise ValueError('These socks do not fit the selected pants; choose another sock length')
|
||||||
|
if leg not in candidates:
|
||||||
|
m=candidates[0];variants=m['lods'][0]['material_instances'][0]
|
||||||
|
previous=selections['Leg']['material_id']
|
||||||
|
v=next((v for v in variants if v['id']==previous),variants[0])
|
||||||
|
selections['Leg']={'asset_id':m['id'],'material_id':v['id']}
|
||||||
|
hat=model('Hat')
|
||||||
|
if hat:
|
||||||
|
required=flag(hat,'RequiresHairModelType')
|
||||||
|
hair=model('Hair')
|
||||||
|
if required=='none':selections.pop('Hair',None)
|
||||||
|
elif required and hair and flag(hair,'HairModelType')!=required:
|
||||||
|
matches=[m for s,m in models.values() if s=='Hair' and flag(m,'Gender') in {'male','unisex',''}
|
||||||
|
and flag(m,'HairStyle')==flag(hair,'HairStyle') and flag(m,'HairModelType')==required]
|
||||||
|
if not matches:raise ValueError('This hat needs another hair style; choose None under Hair first')
|
||||||
|
m=matches[0];v=m['lods'][0]['material_instances'][0][0]
|
||||||
|
selections['Hair']={'asset_id':m['id'],'material_id':v['id']}
|
||||||
|
for slot in list(selections):
|
||||||
|
m=model(slot)
|
||||||
|
if m:
|
||||||
|
for removed in flag(m,'RequiresRemovalOfComponents').split(','):
|
||||||
|
selections.pop(removed.strip(),None)
|
||||||
|
# Keep exposed body material variants on the selected head's authored tone.
|
||||||
|
head=selections['Rostral']
|
||||||
|
tone=profile.get('skin',flag(materials[head['material_id']],'SkinTone'))
|
||||||
|
beard=profile.get('beard',flag(materials[head['material_id']],'FacialHairStyle'))
|
||||||
|
for slot,sel in selections.items():
|
||||||
|
old=materials[sel['material_id']]
|
||||||
|
if not flag(old,'SkinTone'):continue
|
||||||
|
variants=model(slot)['lods'][0]['material_instances'][0]
|
||||||
|
candidates=[v for v in variants if flag(materials[v['id']],'SkinTone')==tone
|
||||||
|
and (slot!='Rostral' or flag(materials[v['id']],'FacialHairStyle')==beard)]
|
||||||
|
if not candidates:raise ValueError(f'No authored {slot} material for skin {tone} / facial hair {beard}')
|
||||||
|
# Preserve unrelated eyebrow, hairline and skin-normal flags where possible.
|
||||||
|
def distance(v):
|
||||||
|
f=materials[v['id']]['flags']
|
||||||
|
return sum(f.get(k)!=val for k,val in old['flags'].items() if k not in {'cas.SkinTone','cas.FacialHairStyle'})
|
||||||
|
if not any(v['id']==sel['material_id'] for v in candidates):
|
||||||
|
sel['material_id']=min(candidates,key=distance)['id']
|
||||||
|
allowed={'fat','thin'}|set(json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())['morph_assembly']['face_targets'])
|
||||||
|
for key,value in profile.get('morphs',{}).items():
|
||||||
|
if key not in allowed:raise ValueError('Unknown morph target: '+key)
|
||||||
|
if not isinstance(value,(int,float)) or not math.isfinite(value) or not 0<=value<=.5:
|
||||||
|
raise ValueError('Morph value outside the native range: '+key)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def build(config, requested):
|
||||||
|
from tools.owned_game.big import BigArchive
|
||||||
|
from tools.extract_default_skater import import_rx2_parser, decode_texture, build_composite_images
|
||||||
|
from tools.asset_pipeline.retail_character import RX2, decode_dense_morphs
|
||||||
|
from tools.asset_pipeline.character_glb import convert
|
||||||
|
assets=Path(config['assets']); cache=assets/'private/customisation'
|
||||||
|
catalog=json.loads((cache/'catalog.json').read_text())
|
||||||
|
profile=resolve(catalog,requested)
|
||||||
|
key=hashlib.sha256(json.dumps(profile,sort_keys=True).encode()).hexdigest()[:24]
|
||||||
|
work=cache/'builds'/key; output=work/'character.glb'
|
||||||
|
if output.is_file():return {'profile':profile,'scene':output.relative_to(assets).as_posix()}
|
||||||
|
work.mkdir(parents=True,exist_ok=True)
|
||||||
|
indexed={m['id']:m for c in catalog['components'] for m in c['models']}
|
||||||
|
selections=[dict(slot=s,asset_id=v['asset_id'],lod=0,material_ids=[v['material_id']])
|
||||||
|
for s,v in profile['selections'].items()]
|
||||||
|
paths=selected_paths(catalog,selections)
|
||||||
|
archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big')
|
||||||
|
entries={e.path.lower():e for e in archive.entries}
|
||||||
|
for path in paths:
|
||||||
|
dest=cache/'source'/path
|
||||||
|
if not dest.is_file():write_private(dest,archive.read(entries[path.lower()]))
|
||||||
|
parser=import_rx2_parser(ROOT/'tools/vendor/utt')
|
||||||
|
decoded=cache/'decoded'; decoded.mkdir(exist_ok=True)
|
||||||
|
recipe=json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())
|
||||||
|
original={c['slot']:c for c in recipe['components']}
|
||||||
|
recipe['components']=[];recipe['morph_assembly']['expected_targets']={}
|
||||||
|
for slot,sel in profile['selections'].items():
|
||||||
|
m=indexed[sel['asset_id']];lod=next(l for l in m['lods'] if l['index']==0)
|
||||||
|
mat=catalog['materials'][sel['material_id']]
|
||||||
|
textures={t['channel']:t['id'] for t in mat['textures']}
|
||||||
|
if 'diffuse' not in textures:raise ValueError('No diffuse map for '+slot)
|
||||||
|
for t in mat['textures']:
|
||||||
|
dest=decoded/(t['id']+'.png')
|
||||||
|
if not dest.is_file():decode_texture(parser,cache/'source'/t['path'],dest)
|
||||||
|
target=work/'models'/slot/Path(lod['path']).name
|
||||||
|
target.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
if not target.exists():shutil.copyfile(cache/'source'/lod['path'],target)
|
||||||
|
parsed=RX2.parse_rx2(str(target));meshes=[m for m in parsed['meshes'] if m.get('positions') and m.get('indices')]
|
||||||
|
if len(meshes)!=1:raise ValueError('Unsupported mesh groups in '+slot)
|
||||||
|
morphs=decode_dense_morphs(target,parsed,len(meshes[0]['positions']),RX2)
|
||||||
|
recipe['morph_assembly']['expected_targets'][slot]=[m['name'] for m in morphs]
|
||||||
|
base=original.get(slot,{})
|
||||||
|
# Preserve the existing renderer and its baseline material constants.
|
||||||
|
tint=base.get('tint',[1.,1.,1.]) if base.get('material_id')==sel['material_id'] else [1.,1.,1.]
|
||||||
|
recipe['components'].append(dict(slot=slot,textures=textures,tint=tint,
|
||||||
|
alpha_mode='MASK' if 'alpha' in textures else 'OPAQUE'))
|
||||||
|
body=recipe['preset']['body_mods'];values=profile.get('morphs',{})
|
||||||
|
body.update(fatness=values.get('fat',0.),skinniness=values.get('thin',0.),targets=values)
|
||||||
|
material_root=work/'materials'
|
||||||
|
build_composite_images(recipe,decoded,material_root)
|
||||||
|
temporary=work/'character.pending.glb'
|
||||||
|
convert(work/'models',assets/'private',recipe,output=temporary,materials=material_root)
|
||||||
|
os.replace(temporary,output)
|
||||||
|
(work/'recipe.json').write_text(json.dumps(recipe,indent=2))
|
||||||
|
return {'profile':profile,'scene':output.relative_to(assets).as_posix()}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--config',type=Path,required=True)
|
||||||
|
args=p.parse_args();config=json.loads(args.config.read_text())
|
||||||
|
request=json.load(sys.stdin)
|
||||||
|
try:
|
||||||
|
import contextlib
|
||||||
|
with contextlib.redirect_stdout(sys.stderr):
|
||||||
|
result=build(config,request)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc(file=sys.stderr)
|
||||||
|
result={'error':str(e)}
|
||||||
|
print(json.dumps(result))
|
||||||
|
return 1 if 'error' in result else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':sys.exit(main())
|
||||||
@@ -333,7 +333,7 @@ def morph_weight(name: str, manifest: dict) -> float:
|
|||||||
if name == "thin" or name.startswith("thin_"):
|
if name == "thin" or name.startswith("thin_"):
|
||||||
return float(body["skinniness"])
|
return float(body["skinniness"])
|
||||||
if name in manifest["morph_assembly"]["face_targets"]:
|
if name in manifest["morph_assembly"]["face_targets"]:
|
||||||
return float(body["face_fields"])
|
return float(body.get("targets", {}).get(name, body["face_fields"]))
|
||||||
raise RuntimeError(f"Retail morph target has no recipe mapping: {name}")
|
raise RuntimeError(f"Retail morph target has no recipe mapping: {name}")
|
||||||
|
|
||||||
def decode_dense_morphs(
|
def decode_dense_morphs(
|
||||||
@@ -424,6 +424,7 @@ def decode_dense_morphs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
deltas = []
|
deltas = []
|
||||||
|
normal_deltas = []
|
||||||
nonzero_vertices = 0
|
nonzero_vertices = 0
|
||||||
nonzero_normal_deltas = 0
|
nonzero_normal_deltas = 0
|
||||||
max_delta = 0.0
|
max_delta = 0.0
|
||||||
@@ -434,7 +435,9 @@ def decode_dense_morphs(
|
|||||||
x, y, z, _remap = struct.unpack_from(
|
x, y, z, _remap = struct.unpack_from(
|
||||||
">4h", data, vertex_offset
|
">4h", data, vertex_offset
|
||||||
)
|
)
|
||||||
if be_u32(data, vertex_offset + 8) != 0:
|
packed_normal = be_u32(data, vertex_offset + 8)
|
||||||
|
normal_deltas.append(rx2_skeleton._dec_11_11_10(packed_normal))
|
||||||
|
if packed_normal != 0:
|
||||||
nonzero_normal_deltas += 1
|
nonzero_normal_deltas += 1
|
||||||
if be_u32(data, vertex_offset + 12) != 0x3F800000:
|
if be_u32(data, vertex_offset + 12) != 0x3F800000:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -456,6 +459,7 @@ def decode_dense_morphs(
|
|||||||
"hash": f"{target_hash:016x}",
|
"hash": f"{target_hash:016x}",
|
||||||
"descriptor_index": descriptor_index,
|
"descriptor_index": descriptor_index,
|
||||||
"deltas": deltas,
|
"deltas": deltas,
|
||||||
|
"normal_deltas": normal_deltas,
|
||||||
"nonzero_vertices": nonzero_vertices,
|
"nonzero_vertices": nonzero_vertices,
|
||||||
"nonzero_normal_deltas": nonzero_normal_deltas,
|
"nonzero_normal_deltas": nonzero_normal_deltas,
|
||||||
"max_source_delta": max_delta,
|
"max_source_delta": max_delta,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Non-game protocol and dependency tests, with synthetic catalog data."""
|
||||||
|
import copy
|
||||||
|
import unittest
|
||||||
|
from tools.asset_pipeline.customisation_worker import resolve
|
||||||
|
from tools.asset_pipeline.retail_character import morph_weight
|
||||||
|
|
||||||
|
|
||||||
|
def fixture():
|
||||||
|
def mat(tone):return {'flags':{'cas.SkinTone':tone,'cas.FacialHairStyle':'none'}}
|
||||||
|
def model(id, gender='male', **flags):
|
||||||
|
return {'id':id,'flags':{'cas.Gender':gender,**{'cas.'+k:v for k,v in flags.items()}},
|
||||||
|
'lods':[{'index':0,'material_instances':[[{'id':'light'},{'id':'dark'}]]}]}
|
||||||
|
catalog={'materials':{'light':mat('light'),'dark':mat('dark')},'components':[
|
||||||
|
{'slot':'Rostral','models':[model('head'),model('female','female')]},
|
||||||
|
{'slot':'OuterTorso','models':[model('top',ArmModelRequired='wrist')]},
|
||||||
|
{'slot':'Arm','models':[model('long_arm',ArmModelType='shoulder'),model('wrist',ArmModelType='wrist')]}]}
|
||||||
|
profile={'selections':{'Rostral':{'asset_id':'head','material_id':'light'},
|
||||||
|
'OuterTorso':{'asset_id':'top','material_id':'light'},
|
||||||
|
'Arm':{'asset_id':'long_arm','material_id':'light'}},'morphs':{}}
|
||||||
|
return catalog,profile
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerTests(unittest.TestCase):
|
||||||
|
def test_dependency_replacement_does_not_mutate_request(self):
|
||||||
|
c,p=fixture();before=copy.deepcopy(p);out=resolve(c,p)
|
||||||
|
self.assertEqual(p,before)
|
||||||
|
self.assertEqual(out['selections']['Arm']['asset_id'],'wrist')
|
||||||
|
|
||||||
|
def test_tone_propagates_to_exposed_skin(self):
|
||||||
|
c,p=fixture();p['skin']='dark';out=resolve(c,p)
|
||||||
|
self.assertTrue(all(v['material_id']=='dark' for v in out['selections'].values()))
|
||||||
|
|
||||||
|
def test_rejects_cross_model_material_and_unsupported_gender(self):
|
||||||
|
c,p=fixture();p['selections']['Rostral']['material_id']='missing'
|
||||||
|
with self.assertRaisesRegex(ValueError,'Invalid material'):resolve(c,p)
|
||||||
|
p['selections']['Rostral']={'asset_id':'female','material_id':'light'}
|
||||||
|
with self.assertRaisesRegex(ValueError,'Female'):resolve(c,p)
|
||||||
|
|
||||||
|
def test_rejects_out_of_range_and_unmapped_morphs(self):
|
||||||
|
c,p=fixture()
|
||||||
|
for values in [{'fat':.6},{'thin':float('nan')},{'invented':.25}]:
|
||||||
|
p['morphs']=values
|
||||||
|
with self.assertRaises(ValueError):resolve(c,p)
|
||||||
|
|
||||||
|
def test_per_target_face_values_preserve_other_defaults(self):
|
||||||
|
recipe={'preset':{'body_mods':{'fatness':.4,'skinniness':.1,'face_fields':.25,
|
||||||
|
'targets':{'local_nose_length':.5}}},
|
||||||
|
'morph_assembly':{'face_targets':['local_nose_height','local_nose_length']}}
|
||||||
|
self.assertEqual(morph_weight('local_nose_length',recipe),.5)
|
||||||
|
self.assertEqual(morph_weight('local_nose_height',recipe),.25)
|
||||||
|
self.assertEqual(morph_weight('fat_arms',recipe),.4)
|
||||||
|
self.assertEqual(morph_weight('thin_arms',recipe),.1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if not exist "%~dp0skate3rust-customiser.exe" (
|
||||||
|
echo The dedicated 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
|
||||||
|
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
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
exit /b %skate_customiser_exit%
|
||||||
Reference in new issue
Block a user