diff --git a/.gitignore b/.gitignore index 158ad119..c53ee6d4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,25 @@ /maps/private/ /maps/*.skate !/maps/format-demo.skate + +# Local extraction, animation authoring, archives and credentials. +*.zip +*.7z +*.rar +*.xex +*.iso +*.i64 +*.idb +*.glb +*.gltf +*.fbx +*.blend +*.abin +*.vlt +*.stategraph +*.rwcmset +!/crates/skate-data/tests/fixtures/retail-collision.rwcmset +.env +.env.* +*.pem +*.key diff --git a/README.md b/README.md index ba4175d7..ff83b867 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,29 @@ -# Imported skater project +

+ Rust crab riding a skateboard +

-Double-click **PLAY.bat** to launch. Use an XInput-compatible controller; -**Esc** opens the pause/settings menu. Select **Easy**, **Normal**, or **Hardcore** -there; the selection saves for the next launch. The initial default is Easy. -Difficulty uses the extracted stock `physics_mode` tables and native selectors. -See [difficulty implementation and remaining parity gaps](docs/DIFFICULTY.md). +# Skate 3 Imported -The menu supports window resolution (720p through 4K), internal render scale -(25–100%), supported MSAA levels (Off/2×/4×/8×), an FPS cap (30–240 or -Unlimited), and GPU occlusion culling (On/Off). Click a row to cycle it, or use arrow keys and Enter. Changes apply -immediately and save to `settings/graphics.json`. Escape or Resume returns to -gameplay; Quit game exits. The menu stays at the window's native resolution. -Occlusion culling defaults to On and skips meshes hidden behind other geometry. -It needs no changes to `.skate` files. Its benefit depends on the view; compare -On/Off where you see dips. It does not reduce visible mesh detail or collision. +A Rust and Bevy skating project built from Skate 3 reverse-engineering research. +Includes skating, tricks, grinds, offboard movement, difficulty settings and +`.skate` map support. Gameplay parity is still a work in progress. -The standing/rolling **360 flip** now uses the supplied gesture files and -ActionGraph/MotionGraph route, including right-stick crouch preparation. -See [360-flip integration and visual checks](docs/360-FLIP.md). +## Build and run -Double-click **PLAY-MAP.bat** to choose a `.skate` map, or drag a map onto -either launcher. `maps/private/` contains local copies of the transition park -and parity grid. `maps/format-demo.skate` is a small original textured floor. -No-map PLAY.bat still loads the supplied test environment. +Requires Windows, Rust with the MSVC toolchain, and LLVM installed in its default +location. Run `BUILD.bat` to build, then `PLAY.bat` to launch the test world. +Use `PLAY-MAP.bat` to select a map. An XInput controller is required for gameplay; +Escape opens difficulty and graphics settings. -New San Vanelona now loads its embedded `RWCM` collision archive, including -native edge flags and cluster query filtering. The staged build was checked -with the actual map and rendered the skater on its streets. No separate -collision sidecar is needed. See [map support notes](docs/SKATE-MAPS.md) for -verification and remaining sky/grind limitations. +**Skate 3 assets are not included.** Running the game requires a separately +prepared local asset set in `assets/private/`, including its `game.json` +manifest, character, animation banks, graphs and gameplay data. This repository +does not include an asset extraction pipeline. A source checkout alone cannot +run the game. -This folder is a **local Git repository**, on `lol/skate-map-support`, with no -GitHub remote. Private assets, private maps, binaries, logs and build outputs -are excluded. See [the map support notes](docs/SKATE-MAPS.md) for supported -versions and the explicit runtime limitations. +Custom animations and climbing support remain available, but no custom clips +are shipped. The included format-demo map is original procedural content. -This folder is separate from the earlier clone. It contains the contents of -`crates (1).zip` and `assets.zip`, a reconstructed Cargo workspace, and launch -scripts. The supplied stock animation banks, decoded frames, graphs, skater -mesh and textures are used together. No previous-clone animation code was -needed for setup. - -The supplied scene contains a small test environment. Gameplay quality and -Skate 3 parity remain for the user to assess; compiling and starting the -project do not establish those claims. - -## Observed runtime issue - -The later bump crash (tick 4159, behaviors 3623/3624) is repaired: the host now -supports the stock `B_BUMP` blend space and `SetBumpCoefficients`. See -[the fix and verification notes](docs/BUMP-CRASH-FIX.md). Visual confirmation -remains with the user; the separate runout issue below is still open. - -The build launches and reports `GAME_CHARACTER_READY bones=35`. All 35 skin -bones match the supplied stock rig; the decoded frame data contains 487 clips. -However, the imported motion-graph host exits when it encounters -`AddRunoutAttribs` (behavior 86). Both startup sessions recorded that exact -unsupported-behavior error. This is an implementation gap, not a missing -animation-file error. It has not been bypassed or replaced with guessed behavior. -See `logs/game-20260906-033940.stderr.log` for the character-ready message -and subsequent failure. No automated gameplay or screenshot test was run. - -## Files - -- `crates/`: imported Rust source. The two startup profile selections were - changed from `normal` to `easy`. -- `assets/private/`: supplied assets; keep these private. -- `bin/`: staged executable and its exact Rust/Bevy runtime DLL dependencies. -- `logs/`: per-launch output and error logs. -- `Cargo.lock`: resolved dependency versions for reproducible rebuilds. -- `SETUP-PROVENANCE.json`: original ZIP hashes and setup changes. - -**BUILD.bat** rebuilds and stages the executable. It requires Cargo/Rust and -LLVM `llvm-readobj` at the installed Windows location. The initial build -reused the earlier project's compilation cache; PLAY.bat uses only this -folder's staged binary, libraries and assets. Subsequent default builds use -this folder's own `target/` directory. - -If startup fails, the launcher keeps the console open and shows the error -log path. Do not substitute animation files to silence a missing-data error; -record the exact error first. +Implementation notes are in [`docs/`](docs/). Patched Bevy dependencies and +their licenses are in [`vendor/`](vendor/). This is an unofficial project, +not affiliated with EA. diff --git a/crates/skate-core/src/player/offboard/air_launch.rs b/crates/skate-core/src/player/offboard/air_launch.rs index 9f2dbcb2..ce2bbecd 100644 --- a/crates/skate-core/src/player/offboard/air_launch.rs +++ b/crates/skate-core/src/player/offboard/air_launch.rs @@ -158,7 +158,11 @@ fn jump(out: &mut Launch, p: &Input, j: &JumpInput<'_>) { redirected = scale(unit_unchecked(projected), sqrt(speed2)); } else if angle < 90. * f32::from_bits(0x3c8efa35) { redirected = scale( - limit_angle(unit_unchecked(projected), initial, 45.), + limit_angle( + unit_unchecked(projected), + initial, + 45. * f32::from_bits(0x3c8efa35), + ), sqrt(speed2), ); } diff --git a/crates/skate-core/src/player/offboard/landing_deck.rs b/crates/skate-core/src/player/offboard/landing_deck.rs new file mode 100644 index 00000000..3fe6aee7 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_deck.rs @@ -0,0 +1,74 @@ +//! LandingOnDeckManager trajectory preparation,82D78B28/82D78EE8/82D78D30. +//! The board frame and velocity are observations of the canonical live board. +use super::air_launch::{V,dot,length,madd,scale,sub}; +use crate::air::trajectory::Trajectory; +const ZERO:V=[0.;4]; +const GRAVITY:V=[0.,-9.8,0.,0.]; +#[derive(Clone,Copy,Debug)] +pub struct Settings {pub deck_min_uprightness:f32,pub approximate_com_height:f32} +#[derive(Clone,Copy,Debug)] +pub struct Input { + pub board_position:V,pub board_velocity:V,pub board_up:V,pub up:V, + pub board_contact_count:i32,pub flags_2480:u32, +} +#[derive(Clone,Debug)] +pub struct State { + pub trajectory:Trajectory,pub proposed:Trajectory,pub elapsed:f32,pub trajectory_valid:bool, + pub horizontal_correction:V,pub remaining:f32,pub proposed_remaining:f32, + pub contact_height:f32,pub query_count:i32,pub can_land:bool,pub force:bool, + pub blocked:bool,pub first_query:bool,pub hippy_hurdling:bool,pub collision_override:bool, + pub collision_normal:V,pub query_pending:bool, +} +impl Default for State { + fn default()->Self { + let trajectory=Trajectory{position:ZERO,velocity:ZERO,acceleration:ZERO,duration:-1.}; + Self{trajectory,proposed:trajectory,elapsed:0.,trajectory_valid:false,horizontal_correction:ZERO, + remaining:0.,proposed_remaining:0.,contact_height:0.,query_count:0,can_land:false,force:false, + blocked:false,first_query:false,hippy_hurdling:false,collision_override:false,collision_normal:ZERO,query_pending:false} + } +} +impl State { + ///82D78EE8: solve the descending crossing of the deck's COM-height plane, + ///then prepare the velocity matching trajectory. No scene hit is invented. + pub fn probe(&mut self,input:Input,settings:Settings,position:V,velocity:V)->bool { + let mut board_velocity=input.board_velocity; + let acceleration=if input.board_contact_count==0 {board_velocity[1]=0.;scale(GRAVITY,0.5)} else {GRAVITY}; + let relative=sub(velocity,board_velocity); + let plane=madd(input.up,settings.approximate_com_height,input.board_position); + let a=dot(scale(acceleration,0.5),input.up); + let b=dot(relative,input.up);let c=dot(sub(position,plane),input.up); + let discriminant=b*b-4.*a*c; + if discriminant<0. {self.can_land=false;return false;} + //82D60B98 picks the larger root of82D60C80; the caller requires t>0. + let root=discriminant.sqrt(); + let time=((-b+root)/(2.*a)).max((-b-root)/(2.*a)); + if !(time>0.) {self.can_land=false;return false;} + let distance=length(sub(position,plane)); + let duration=(2.*distance/9.8).sqrt().max(time*0.75).min(time*1.3); + self.proposed=Trajectory{position, + velocity:madd(input.board_velocity,1.,sub(scale(sub(plane,position),1./duration),scale(GRAVITY,0.5*duration))), + acceleration:GRAVITY,duration:-1.}; + let relative_arc=Trajectory{position,velocity:relative,acceleration,duration:-1.}; + self.horizontal_correction=sub(plane,relative_arc.position_at(time));self.horizontal_correction[1]=0.; + self.remaining=time;self.proposed_remaining=duration; + true + } + ///82D78D30, before the world's obstruction query82D79948. Some is the + ///actual duration to submit to that query; None means no submission this tick. + pub fn update_air(&mut self,input:Input,settings:Settings,position:V,velocity:V,maximum_velocity_difference:f32)->Option { + self.query_count=0;self.first_query=false; + if (!self.force&&input.flags_2480&0x8000!=0)||!(input.board_up[1]>settings.deck_min_uprightness) {return None;} + self.probe(input,settings,position,velocity); + if self.remaining>0.4 { + self.trajectory=self.proposed;self.trajectory_valid=true;self.remaining=self.proposed_remaining; + self.can_land=length(sub(velocity,self.proposed.velocity))Trajectory { + let mut target=madd(input.board_velocity,duration,input.board_position);target[1]+=f32::from_bits(0x3e4cccce); + self.blocked=false;self.query_pending=true;self.contact_height=target[1]-0.1; + Trajectory{position,velocity:sub(scale(sub(target,position),1./duration),scale(GRAVITY,duration*0.5)),acceleration:GRAVITY,duration} + } +} diff --git a/crates/skate-core/src/player/offboard/mod.rs b/crates/skate-core/src/player/offboard/mod.rs index babfa55a..eb07418f 100644 --- a/crates/skate-core/src/player/offboard/mod.rs +++ b/crates/skate-core/src/player/offboard/mod.rs @@ -42,3 +42,4 @@ pub mod air_prediction; pub mod air_queries; pub mod air_state; pub mod feet; +pub mod landing_deck; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index c5b43608..38f053cf 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -5,6 +5,7 @@ mod air_phase; mod air_trajectory; pub(crate) mod camera_output; mod clock; +mod climbing; mod colliders; mod controls; mod foot_ik; diff --git a/crates/skate-game/src/physics/climbing/approach.rs b/crates/skate-game/src/physics/climbing/approach.rs new file mode 100644 index 00000000..30229269 --- /dev/null +++ b/crates/skate-game/src/physics/climbing/approach.rs @@ -0,0 +1,208 @@ +//! Airborne reach is a presentation overlay. Stock graphs, gravity and collisions +//! retain ownership until both arm targets are physically within reach. +use super::*; +pub(super) struct Approach { + ledge: ledge::Ledge, + pub(super) weight: f32, +} +pub(crate) fn advance( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + controls: &PlayerControls, +) { + let mut runtime = std::mem::take(&mut skater.climbing); + update(&mut runtime, physics, skater, controls); + skater.climbing = runtime; +} +fn update( + r: &mut Runtime, + physics: &GamePhysics, + skater: &mut SkaterRuntime, + controls: &PlayerControls, +) { + let Some(clips) = &r.clips else { + return; + }; + let c = &clips.reach; + let root = matrix(skater.animated_skeleton.roots.animation_to_world); + let globals: Vec<_> = r + .indices + .iter() + .map(|&i| matrix(skater.render_pose[i])) + .collect(); + let mut locals: Vec<_> = globals + .iter() + .enumerate() + .map(|(i, &g)| { + Transform::from_matrix(if c.parents[i] < 0 { + g + } else { + globals[c.parents[i] as usize].inverse() * g + }) + }) + .collect(); + let state = skater.player_state.current(); + let ground_speed = Vec3::from_slice( + &skater + .player_input + .physical + .reckoning + .vector_16 + .map(f32::from_bits)[..3], + ) + .length(); + if state == PhysicalStateId::BipedGround && (r.ground_entry.is_empty() || ground_speed < 0.3) { + r.ground_entry = locals.clone(); + // Native walking roots sit on the support plane. Normalize the saved + // return pose so a lifted foot in the run cycle cannot lower that root. + let height = c.feet(&globals).y; + for (i, local) in r.ground_entry.iter_mut().enumerate() { + if c.parents[i] < 0 { + local.translation.y -= height; + } + } + } + let airborne = state == PhysicalStateId::BipedAir; + if (!airborne && state != PhysicalStateId::BipedGround) || r.cooldown > 0. { + r.approach = None; + return; + } + let feet = root.transform_point3(c.feet(&globals)); + let velocity = Vec3::from_slice( + &skater + .player_input + .physical + .reckoning + .vector_16 + .map(f32::from_bits)[..3], + ); + let direction = controls + .offboard_direction + .map(|v| Vec3::new(v[0], 0., v[2])) + .filter(|v| v.length_squared() > 0.04) + .unwrap_or_else(|| { + root.z_axis.truncate() + * if skater.player_input.processed.flags_2476 & 4 != 0 { + -1. + } else { + 1. + } + }); + let moving = airborne + || ground_speed > 0.15 + || controls + .offboard_direction + .is_some_and(|v| Vec2::new(v[0], v[2]).length_squared() > 0.04); + let found = moving + .then(|| ledge::find_air(&physics.world, feet, direction)) + .flatten(); + // Follow the approach while running. Freeze the two contacts on takeoff, + // once the player has committed to this edge. + if !airborne { + if let (Some(a), Some(ledge)) = (&mut r.approach, found) { + if a.ledge.anchor.distance(ledge.anchor) < 0.5 { + a.ledge = ledge; + } + } + } + if r.approach.is_none() { + if let Some(ledge) = found { + if velocity.dot(ledge.forward) >= -0.1 { + r.approach = Some(Approach { ledge, weight: 0. }); + } + } + } + let Some(a) = &mut r.approach else { + return; + }; + let separation = a.ledge.anchor - feet; + let distance = Vec2::new(separation.x, separation.z).length(); + let valid = moving + && separation.y > 0.35 + && separation.y < 2.75 + && distance < 2.7 + && separation.dot(a.ledge.forward) > 0. + && direction.normalize_or_zero().dot(a.ledge.forward) > 0.65 + && velocity.dot(a.ledge.forward) >= -0.3 + && ledge::clear(&physics.world, a.ledge); + let dt = physics.settings.step.simulation.time_step; + // Continuous distance gain, starting during the run. Ground anticipation + // is gentle; the jump smoothly builds from it into full hand contact. + let desired = if valid { + reach_gain(distance, airborne) + } else { + 0. + }; + a.weight += (desired - a.weight) * (1. - (-dt / 0.10).exp()); + if a.weight < 0.001 && !valid { + r.approach = None; + return; + } + let weight = a.weight; + // Only shoulders and arms borrow the authored reach. The stock jump keeps + // its hips, legs, trajectory and timing, even during a failed attempt. + let authored = c.sample(c.duration()); + for side in ["LEFT", "RIGHT"] { + for suffix in ["SHOULDER", "ARM", "FOREARM", "HAND"] { + let i = c.index(&format!("{side}{suffix}")); + locals[i].rotation = locals[i] + .rotation + .slerp(authored[i].rotation, weight * 0.75); + } + } + let before_ik = c.globals(&locals); + let reachable = ["LEFT", "RIGHT"].into_iter().enumerate().all(|(i, side)| { + let shoulder = + root.transform_point3(before_ik[c.index(&format!("{side}ARM"))].w_axis.truncate()); + let elbow = root.transform_point3( + before_ik[c.index(&format!("{side}FOREARM"))] + .w_axis + .truncate(), + ); + let hand = + root.transform_point3(before_ik[c.index(&format!("{side}HAND"))].w_axis.truncate()); + shoulder.distance(contacts::wrist(a.ledge, i).0) + <= shoulder.distance(elbow) + elbow.distance(hand) - 0.015 + }); + contacts::hands(c, &mut locals, root, a.ledge, weight); + let output = c.globals(&locals); + for (&i, &g) in r.indices.iter().zip(&output) { + skater.render_pose[i] = native(g); + } + if airborne && valid && reachable && a.weight >= 0.95 && !r.ground_entry.is_empty() { + let deck = matrix(super::super::solve::deck_frame(&physics.board)); + r.active = Some(Attached { + phase: Phase::Catch, + time: 0., + ledge: a.ledge, + start_root: Transform::from_matrix(root), + entry: locals, + fallback: skater.render_pose.clone(), + board_world: root * output[c.index("SKATEBOARD_ROOT")], + physical_board_world: deck, + carry_board: skater.skateboard_controller.fields.state_448 == 1, + }); + r.approach = None; + skater.offboard.air_prediction = None; + skater.offboard.feet.reset(); + skater.foot_ik.state.enable_feet(false); + bevy::log::info!("Climbing: caught ledge from stock jump"); + } +} + +fn reach_gain(distance: f32, airborne: bool) -> f32 { + let proximity = smooth((2.6 - distance) / 1.8); + proximity * if airborne { 1. } else { 0.45 } +} +#[test] +fn reach_builds_continuously_before_takeoff() { + assert_eq!(reach_gain(2.6, false), 0.); + assert!(reach_gain(2., false) > 0.); + assert!(reach_gain(1.5, false) > reach_gain(2., false)); + assert!(reach_gain(0.8, false) <= 0.45); + assert_eq!(reach_gain(0.8, true), 1.); + for i in 1..260 { + let x = i as f32 * 0.01; + assert!((reach_gain(x, true) - reach_gain(x + 0.01, true)).abs() < 0.009); + } +} diff --git a/crates/skate-game/src/physics/climbing/clip.rs b/crates/skate-game/src/physics/climbing/clip.rs new file mode 100644 index 00000000..0724d35b --- /dev/null +++ b/crates/skate-game/src/physics/climbing/clip.rs @@ -0,0 +1,160 @@ +//! Optional authored clips, separate from the SHA-bound stock animation banks. +use bevy::prelude::*; +use serde::Deserialize; +use std::path::Path; + +#[derive(Deserialize)] +struct File { + version: u32, + clips: Vec, +} + +#[derive(Deserialize)] +pub(super) struct Clip { + pub name: String, + pub fps: f32, + pub names: Vec, + pub parents: Vec, + pub frames: Vec>, +} + +pub(super) struct Clips { + pub reach: Clip, + pub mantle: Clip, +} +impl Clips { + pub fn load(root: &Path) -> Result, String> { + let path = root.join("private/custom/climbing.json"); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + let mut file: File = + serde_json::from_slice(&bytes).map_err(|e| format!("Climbing clips: {e}"))?; + if file.version != 1 { + return Err("Unsupported climbing clip version".into()); + } + for c in &file.clips { + c.validate()?; + } + let mut take = |name| { + file.clips + .iter() + .position(|c| c.name == name) + .map(|i| file.clips.remove(i)) + .ok_or_else(|| format!("Missing climbing clip {name}")) + }; + let reach = take("reach")?; + let mantle = take("mantle")?; + if reach.names != mantle.names || reach.parents != mantle.parents { + return Err("Climbing clips must share a skeleton".into()); + } + Ok(Some(Self { reach, mantle })) + } +} +impl Clip { + fn validate(&self) -> Result<(), String> { + let valid = self.fps.is_finite() + && self.fps > 0. + && self.fps <= 240. + && self.frames.len() >= 2 + && !self.names.is_empty() + && self.parents.len() == self.names.len() + && self + .parents + .iter() + .enumerate() + .all(|(i, &p)| p >= -1 && p < i as i32) + && self + .names + .iter() + .enumerate() + .all(|(i, n)| !self.names[..i].contains(n)) + && self.frames.iter().all(|f| { + f.len() == self.names.len() + && f.iter().all(|s| { + s.iter().all(|v| v.is_finite()) + && s[..3].iter().all(|v| *v > 0.0001) + && (Quat::from_array([s[3], s[4], s[5], s[6]]).length() - 1.).abs() + < 0.01 + }) + }); + if !valid { + return Err(format!("Invalid climbing clip {}", self.name)); + } + for name in [ + "HIPS", + "SPINE3", + "LEFTSHOULDER", + "RIGHTSHOULDER", + "LEFTARM", + "RIGHTARM", + "LEFTFOREARM", + "RIGHTFOREARM", + "LEFTHAND", + "RIGHTHAND", + "LEFTFOOT", + "RIGHTFOOT", + "SKATEBOARD_ROOT", + ] { + if !self.names.iter().any(|n| n == name) { + return Err(format!("Missing climbing bone {name}")); + } + } + Ok(()) + } + pub fn duration(&self) -> f32 { + (self.frames.len() - 1) as f32 / self.fps + } + pub fn sample(&self, time: f32) -> Vec { + let frame = (time * self.fps).clamp(0., (self.frames.len() - 1) as f32); + let i = frame.floor() as usize; + let j = (i + 1).min(self.frames.len() - 1); + self.frames[i] + .iter() + .zip(&self.frames[j]) + .map(|(a, b)| blend(decode(a), decode(b), frame.fract())) + .collect() + } + pub fn globals(&self, locals: &[Transform]) -> Vec { + let mut result = Vec::with_capacity(locals.len()); + for (i, t) in locals.iter().enumerate() { + let m = t.to_matrix(); + result.push(if self.parents[i] < 0 { + m + } else { + result[self.parents[i] as usize] * m + }); + } + result + } + pub fn index(&self, name: &str) -> usize { + self.names.iter().position(|n| n == name).unwrap() + } + pub fn hands(&self, globals: &[Mat4]) -> Vec3 { + (globals[self.index("LEFTHAND")].w_axis.truncate() + + globals[self.index("RIGHTHAND")].w_axis.truncate()) + * 0.5 + } + pub fn feet(&self, globals: &[Mat4]) -> Vec3 { + (globals[self.index("LEFTFOOT")].w_axis.truncate() + + globals[self.index("RIGHTFOOT")].w_axis.truncate()) + * 0.5 + - Vec3::Y * 0.11 + } +} +fn decode(s: &[f32; 10]) -> Transform { + Transform { + scale: Vec3::new(s[0], s[1], s[2]), + rotation: Quat::from_xyzw(s[3], s[4], s[5], s[6]).normalize(), + translation: Vec3::new(s[7], s[8], s[9]), + } +} +pub(super) fn blend(a: Transform, b: Transform, t: f32) -> Transform { + Transform { + translation: a.translation.lerp(b.translation, t), + rotation: a.rotation.slerp(b.rotation, t), + scale: a.scale.lerp(b.scale, t), + } +} diff --git a/crates/skate-game/src/physics/climbing/contacts.rs b/crates/skate-game/src/physics/climbing/contacts.rs new file mode 100644 index 00000000..08330b17 --- /dev/null +++ b/crates/skate-game/src/physics/climbing/contacts.rs @@ -0,0 +1,114 @@ +//! Two-bone IK over the authored pose. Preserve limb lengths and +//! align both palms to fixed surface contacts with independent wrist rotation. +use super::clip::Clip; +use bevy::prelude::*; + +pub(super) fn clearance(clip: &Clip, globals: &[Mat4]) -> Vec3 { + let hands = clip.hands(globals); + let hips = globals[clip.index("HIPS")].w_axis.truncate(); + // The Mixamo freehang arches its hips past its hands. Bring the torso + // outside a vertical wall and raise it enough to keep the edge in reach. + let back = (hips.z - hands.z + 0.24).max(0.); + Vec3::new(0., back * 0.53, -back) +} + +pub(super) const PALM: Vec3 = Vec3::new(0.075, 0.052, 0.); +pub(super) fn wrist(ledge: super::ledge::Ledge, i: usize) -> (Vec3, Quat) { + let normal = ledge.normals[i]; + let forward = (ledge.forward - normal * ledge.forward.dot(normal)).normalize(); + let rotation = Quat::from_mat3(&Mat3::from_cols(forward, -normal, forward.cross(-normal))); + (ledge.palms[i] - rotation * PALM, rotation) +} +pub(super) fn hands( + clip: &Clip, + locals: &mut [Transform], + root: Mat4, + ledge: super::ledge::Ledge, + weight: f32, +) { + let globals = clip.globals(locals); + let inverse = root.inverse(); + let root_rotation = Transform::from_matrix(root).rotation; + for (i, side) in ["LEFT", "RIGHT"].into_iter().enumerate() { + let upper = clip.index(&format!("{side}ARM")); + let lower = clip.index(&format!("{side}FOREARM")); + let hand = clip.index(&format!("{side}HAND")); + let (position, rotation) = wrist(ledge, i); + let target = globals[hand] + .w_axis + .truncate() + .lerp(inverse.transform_point3(position), weight); + let rotation = Transform::from_matrix(globals[hand]) + .rotation + .slerp(root_rotation.inverse() * rotation, weight); + solve(clip, locals, upper, lower, hand, target, rotation); + } +} +fn solve( + clip: &Clip, + locals: &mut [Transform], + upper: usize, + lower: usize, + hand: usize, + target: Vec3, + wrist: Quat, +) { + let g = clip.globals(locals); + let a = g[upper].w_axis.truncate(); + let b = g[lower].w_axis.truncate(); + let c = g[hand].w_axis.truncate(); + let l1 = a.distance(b); + let l2 = b.distance(c); + let Some(dir) = (target - a).try_normalize() else { + return; + }; + let distance = a + .distance(target) + .clamp((l1 - l2).abs() + 0.0001, l1 + l2 - 0.0001); + let along = (l1 * l1 - l2 * l2 + distance * distance) / (2. * distance); + let bend = (b - a) - dir * (b - a).dot(dir); + let pole = bend + .try_normalize() + .unwrap_or_else(|| dir.any_orthonormal_vector()); + let elbow = a + dir * along + pole * (l1 * l1 - along * along).max(0.).sqrt(); + rotate(clip, locals, &g, upper, b - a, elbow - a); + let g = clip.globals(locals); + let b = g[lower].w_axis.truncate(); + rotate( + clip, + locals, + &g, + lower, + g[hand].w_axis.truncate() - b, + a + dir * distance - b, + ); + let g = clip.globals(locals); + let relative = Transform::from_matrix(g[lower]).rotation.inverse() * wrist; + // Pronation belongs in the forearm, not a 100+ degree wrist twist. + // Roll about the actual elbow-to-wrist axis so the contact cannot move. + let axis = locals[hand].translation.normalize(); + let projected = axis * Vec3::new(relative.x, relative.y, relative.z).dot(axis); + let raw_twist = Quat::from_xyzw(projected.x, projected.y, projected.z, relative.w); + let twist = if raw_twist.length_squared() > 1e-8 { + raw_twist.normalize() + } else { + Quat::IDENTITY + }; + locals[lower].rotation = (locals[lower].rotation * twist).normalize(); + locals[hand].rotation = (twist.inverse() * relative).normalize(); +} +fn rotate( + clip: &Clip, + locals: &mut [Transform], + globals: &[Mat4], + bone: usize, + from: Vec3, + to: Vec3, +) { + let delta = Quat::from_rotation_arc(from.normalize(), to.normalize()); + let parent = clip.parents[bone] as usize; + locals[bone].rotation = (Transform::from_matrix(globals[parent]).rotation.inverse() + * delta + * Transform::from_matrix(globals[bone]).rotation) + .normalize(); +} diff --git a/crates/skate-game/src/physics/climbing/ledge.rs b/crates/skate-game/src/physics/climbing/ledge.rs new file mode 100644 index 00000000..fb72710a --- /dev/null +++ b/crates/skate-game/src/physics/climbing/ledge.rs @@ -0,0 +1,123 @@ +//! Static collision probes for the first authored jump/hang/mantle set. +use bevy::prelude::*; +use skate_core::{math::Vector3, physics::board_world::BoardWorld}; + +#[derive(Clone, Copy, Debug)] +pub(super) struct Ledge { + pub anchor: Vec3, + pub landing: Vec3, + pub forward: Vec3, + pub palms: [Vec3; 2], + pub normals: [Vec3; 2], +} +fn v(p: Vec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) +} +fn hit(world: &BoardWorld, a: Vec3, b: Vec3, radius: f32) -> Option<(Vec3, Vec3)> { + let h = world.query_swept_line(v(a), v(b), radius).ok()??.geometry; + Some(( + Vec3::new(h.position.x, h.position.y, h.position.z), + Vec3::new(h.normal.x, h.normal.y, h.normal.z), + )) +} +pub(super) fn find(world: &BoardWorld, feet: Vec3, facing: Vec3) -> Option { + find_range(world, feet, facing, 1.90, 0.95) +} +pub(super) fn find_air(world: &BoardWorld, feet: Vec3, facing: Vec3) -> Option { + find_range(world, feet, facing, 1.25, 2.6) +} +fn find_range( + world: &BoardWorld, + feet: Vec3, + facing: Vec3, + minimum: f32, + distance: f32, +) -> Option { + let facing = Vec3::new(facing.x, 0., facing.z).try_normalize()?; + let chest = feet + Vec3::Y * 1.15; + let (wall, normal) = hit(world, chest, chest + facing * distance, 0.)?; + if normal.y.abs() > 0.2 || normal.dot(facing) > -0.65 { + return None; + } + let forward = -Vec3::new(normal.x, 0., normal.z).normalize(); + let right = Vec3::Y.cross(forward); + let probe = wall + forward * 0.12; + let (top, up) = hit( + world, + Vec3::new(probe.x, feet.y + 2.75, probe.z), + Vec3::new(probe.x, feet.y + minimum, probe.z), + 0., + )?; + if up.y < 0.95 { + return None; + } + let anchor = Vec3::new(wall.x, top.y + 0.025, wall.z) + forward * 0.055; + // Both hands need the same ledge, not the tip of a thin pole or a corner. + let mut palms = [Vec3::ZERO; 2]; + let mut normals = [Vec3::Y; 2]; + for (i, side) in [0.30, -0.30].into_iter().enumerate() { + let p = wall + forward * 0.08 + right * side; + let (p, n) = hit( + world, + Vec3::new(p.x, top.y + 0.15, p.z), + Vec3::new(p.x, top.y - 0.15, p.z), + 0., + )?; + palms[i] = p + n * 0.005; + normals[i] = n; + if (p.y - top.y).abs() > 0.06 || n.y < 0.95 { + return None; + } + } + let ledge = Ledge { + anchor, + landing: Vec3::new(wall.x, top.y + 0.015, wall.z) + forward * 0.62, + forward, + palms, + normals, + }; + if !clear(world, ledge) { + return None; + } + // Space for the torso outside the face, and for the head as it rises. + let outside = anchor - forward * 0.32; + if hit( + world, + outside - Vec3::Y * 1.25, + outside + Vec3::Y * 0.55, + 0.20, + ) + .is_some() + { + return None; + } + Some(ledge) +} +pub(super) fn clear(world: &BoardWorld, ledge: Ledge) -> bool { + let right = Vec3::Y.cross(ledge.forward); + for offset in [ + Vec3::ZERO, + right * 0.25, + -right * 0.25, + ledge.forward * 0.25, + -ledge.forward * 0.25, + ] { + let p = ledge.landing + offset; + let Some((ground, n)) = hit(world, p + Vec3::Y * 0.15, p - Vec3::Y * 0.15, 0.) else { + return false; + }; + if (ground.y - ledge.landing.y).abs() > 0.06 || n.y < 0.95 { + return false; + } + if hit(world, p + Vec3::Y * 0.35, p + Vec3::Y * 1.55, 0.28).is_some() { + return false; + } + } + hit( + world, + ledge.anchor + Vec3::Y * 0.65 - ledge.forward * 0.32, + ledge.landing + Vec3::Y * 0.65, + 0.22, + ) + .is_none() +} diff --git a/crates/skate-game/src/physics/climbing/mod.rs b/crates/skate-game/src/physics/climbing/mod.rs new file mode 100644 index 00000000..8eea6593 --- /dev/null +++ b/crates/skate-game/src/physics/climbing/mod.rs @@ -0,0 +1,426 @@ +//! Hybrid ledge climbing. This extension owns movement while attached; the +//! stock action/motion graphs resume on release. It does not forge stock states +//! or animation-bank records, and uses the same skeleton and render snapshots. +pub(super) mod approach; +mod clip; +mod contacts; +mod ledge; +#[cfg(test)] +mod tests; +use super::{GamePhysics, PlayerControls, SkaterRuntime}; +use bevy::prelude::*; +use clip::{Clips, blend}; +use skate_core::{ + animation::output::NativeMatrix, + math::{Basis3, Vector3}, + physics::{ + drive_frames::RetailAffineTransform, skeleton_animation_record::map_animation_parts, + }, + player::state::PhysicalStateId, +}; + +#[derive(Default)] +pub(crate) struct Runtime { + clips: Option, + indices: Vec, + active: Option, + cooldown: f32, + approach: Option, + ground_entry: Vec, +} +#[derive(Clone, Copy, Debug, PartialEq)] +enum Phase { + Catch, + Hang, + Mantle, + Settle, +} +struct Attached { + phase: Phase, + time: f32, + ledge: ledge::Ledge, + start_root: Transform, + entry: Vec, + fallback: Vec, + board_world: Mat4, + physical_board_world: Mat4, + carry_board: bool, +} +impl Runtime { + pub fn load(root: &std::path::Path, names: &[String]) -> Result { + let clips = Clips::load(root)?; + let indices = match &clips { + Some(clips) => clips + .reach + .names + .iter() + .map(|name| { + names + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Climbing rig bone {name} missing from skater")) + }) + .collect::, _>>()?, + None => Vec::new(), + }; + Ok(Self { + clips, + indices, + ..Default::default() + }) + } +} +fn matrix(m: NativeMatrix) -> Mat4 { + crate::animation::native_matrix(m) +} +fn native(m: Mat4) -> NativeMatrix { + let mut result = m.to_cols_array_2d(); + // Native physical frames use vector padding, not a homogeneous position. + // A one in this lane becomes a false 60 m/s root derivative on handoff. + result[3][3] = 0.; + result +} +fn point(p: Vec3) -> [f32; 4] { + [p.x, p.y, p.z, 0.] +} +fn smooth(t: f32) -> f32 { + let t = t.clamp(0., 1.); + t * t * (3. - 2. * t) +} +fn pressed(controls: &PlayerControls, bit: u32) -> bool { + let w = controls.controller.words(); + w[13] & (1 << bit) != 0 && w[6] & (1 << bit) == 0 +} + +/// Called before native animation/input/solve. Attached motion has a single +/// owner; no gravity, foot IK, or stock root motion competes with hand contact. +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + controls: &PlayerControls, + camera: &mut crate::camera::CameraRuntime, +) -> Result { + let mut runtime = std::mem::take(&mut skater.climbing); + let result = runtime.advance(physics, skater, controls, camera); + skater.climbing = runtime; + result +} +impl Runtime { + fn advance( + &mut self, + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + controls: &PlayerControls, + camera: &mut crate::camera::CameraRuntime, + ) -> Result { + let dt = physics.settings.step.simulation.time_step; + if skater.player_input.pending_teleport().is_some() { + self.active = None; + self.approach = None; + self.cooldown = 0.3; + return Ok(false); + } + self.cooldown = (self.cooldown - dt).max(0.); + let Some(clips) = &self.clips else { + return Ok(false); + }; + let c = &clips.reach; + if self.active.is_none() { + return Ok(false); + } + let a = self.active.as_mut().unwrap(); + // A press during catch settling is not buffered into Mantle. Holding takeoff's + // X therefore never skips the user's explicit hanging step. + if a.phase == Phase::Hang && pressed(controls, 23) && ledge::clear(&physics.world, a.ledge) + { + a.phase = Phase::Mantle; + a.time = 0.; + bevy::log::info!("Climbing: pull up"); + } + a.time += dt; + let yaw = Quat::from_rotation_y(a.ledge.forward.x.atan2(a.ledge.forward.z)); + let hang = c.sample(c.duration()); + let hang_globals = c.globals(&hang); + let hang_root = a.ledge.anchor - yaw * c.hands(&hang_globals) + + yaw * contacts::clearance(c, &hang_globals); + let mantle = &clips.mantle; + let mantle_end = mantle.sample(mantle.duration()); + let mut complete = false; + let sample_phase = a.phase; + let sample_time = a.time; + let (mut locals, root) = match a.phase { + Phase::Catch => { + // Give a high catch time to lower the body beneath its hands. + // A fixed short blend made a tucked stock jump drop too fast. + let duration = + (0.22 + a.start_root.translation.distance(hang_root) * 0.18).min(0.5); + let t = (a.time / duration).min(1.); + let mut locals = hang.clone(); + let weight = smooth(t); + for (p, from) in locals.iter_mut().zip(&a.entry) { + *p = blend(*from, *p, weight); + } + let root = Transform { + translation: a.start_root.translation.lerp(hang_root, weight), + rotation: a.start_root.rotation.slerp(yaw, weight), + ..default() + }; + if t >= 1. { + a.phase = Phase::Hang; + a.time = 0.; + bevy::log::info!("Climbing: hanging; press X to climb"); + } + (locals, root) + } + Phase::Hang => ( + hang.clone(), + Transform::from_translation(hang_root).with_rotation(yaw), + ), + Phase::Mantle => { + let mut locals = mantle.sample(a.time); + for (p, from) in locals.iter_mut().zip(&hang) { + *p = blend(*from, *p, smooth(a.time / 0.22)); + } + let g = c.globals(&locals); + // Lock the hands through the weight-bearing half, then release + // into the authored vault and warp feet onto the clear top. + let attached = + a.ledge.anchor - yaw * c.hands(&g) + yaw * contacts::clearance(c, &g); + let end_attached = a.ledge.anchor + - yaw * c.hands(&c.globals(&mantle.sample(mantle.duration() * 0.55))); + let release = smooth((a.time / mantle.duration() - 0.55) / 0.45); + let final_root = a.ledge.landing - yaw * c.feet(&c.globals(&mantle_end)); + let translation = if release > 0. { + end_attached.lerp(final_root, release) + } else { + attached + }; + if a.time >= mantle.duration() { + a.phase = Phase::Settle; + a.time = 0.; + } + ( + locals, + Transform::from_translation(translation).with_rotation(yaw), + ) + } + Phase::Settle => { + let weight = smooth(a.time / 0.25); + let locals: Vec<_> = mantle_end + .iter() + .zip(&self.ground_entry) + .map(|(&from, &to)| blend(from, to, weight)) + .collect(); + let translation = a.ledge.landing - yaw * c.feet(&c.globals(&locals)); + complete = a.time >= 0.25; + ( + locals, + Transform::from_translation(translation).with_rotation(yaw), + ) + } + }; + // Store and publish the actual bones into the existing physical body. + // Board helpers are never driven by the Mixamo board-at-origin track. + let root_matrix = root.to_matrix(); + let contact_weight = match sample_phase { + Phase::Catch => 1., + Phase::Hang => 1., + Phase::Mantle => 1. - smooth((sample_time / mantle.duration() - 0.50) / 0.05), + Phase::Settle => 0., + }; + if contact_weight > 0. { + contacts::hands(c, &mut locals, root_matrix, a.ledge, contact_weight); + } + let mut globals = c.globals(&locals); + let board_index = c.index("SKATEBOARD_ROOT"); + let board_world = if a.carry_board { + let hips = root_matrix.transform_point3(globals[c.index("HIPS")].w_axis.truncate()); + let chest = root_matrix.transform_point3(globals[c.index("SPINE3")].w_axis.truncate()); + let up = (chest - hips).normalize(); + let shoulders = root_matrix.transform_vector3( + globals[c.index("LEFTARM")].w_axis.truncate() + - globals[c.index("RIGHTARM")].w_axis.truncate(), + ); + let forward = shoulders.cross(up).normalize(); + let right = up.cross(forward).normalize(); + let stowed = Transform::from_translation((hips + chest) * 0.5 - forward * 0.20) + .with_rotation(Quat::from_mat3(&Mat3::from_cols(right, -forward, up))); + let mut board = blend( + Transform::from_matrix(a.board_world), + stowed, + smooth(a.time / 0.2).max(if a.phase != Phase::Catch { 1. } else { 0. }), + ); + if sample_phase == Phase::Settle { + let hand_carry = root_matrix * c.globals(&self.ground_entry)[board_index]; + board = blend( + board, + Transform::from_matrix(hand_carry), + smooth(sample_time / 0.25), + ); + } + board.to_matrix() + } else { + // A dropped board remains a physical object while the skater is + // attached. Continue its gravity/collisions independently. + physics.board.clear_forces(); + physics + .riding + .start_wheel_queries(&physics.board, &physics.world)?; + physics.riding.finish_wheel_queries()?; + let volumes = super::colliders::world_volumes(&physics.board, &physics.settings); + let collisions = physics + .world + .query_primitives(&volumes, physics.query, physics.retention) + .to_vec(); + physics.contact_count = collisions.len(); + physics + .board + .advance(&collisions, [0.; 2], physics.settings.step); + physics.riding.finish_post_physics( + &mut physics.board, + physics.board_wiping_out, + physics.processed_flags_2468, + dt, + )?; + matrix(super::solve::deck_frame(&physics.board)) + * a.physical_board_world.inverse() + * a.board_world + }; + let board_delta = root_matrix.inverse() * board_world * globals[board_index].inverse(); + for i in 0..globals.len() { + let mut parent = i as i32; + while parent >= 0 && parent != board_index as i32 { + parent = c.parents[parent as usize]; + } + if parent == board_index as i32 { + globals[i] = board_delta * globals[i]; + } + } + let mut pose = a.fallback.clone(); + for (&index, &g) in self.indices.iter().zip(&globals) { + pose[index] = native(g); + } + let actual_board = board_world * a.board_world.inverse() * a.physical_board_world; + if a.carry_board { + physics.board.set_transform(RetailAffineTransform { + basis: Basis3 { + columns: [ + actual_board.x_axis.truncate().to_array(), + actual_board.y_axis.truncate().to_array(), + actual_board.z_axis.truncate().to_array(), + ], + }, + translation: Vector3::new( + actual_board.w_axis.x, + actual_board.w_axis.y, + actual_board.w_axis.z, + ), + }); + for b in physics.board.bodies_mut() { + zero_rates(&mut b.rates); + } + } + publish_pose(physics, skater, root_matrix, pose)?; + // Camera still uses its normal graph and collision avoidance. Read live + // right stick while the stock animation graph is temporarily paused. + let w = controls.controller.words(); + skater.animation_input.extra.look_x = f32::from_bits(w[9]); + skater.animation_input.extra.look_y = f32::from_bits(w[10]); + let feedback = skater.physical_feedback; + super::camera_output::advance(physics, skater, &feedback, camera)?; + physics.ticks += 1; + physics.clock.finish_tick(); + if complete { + // Re-seed the real ground controller at the landing. Old ground + // query/velocity history must not pull the skater back to takeoff. + super::player_state::resume_after_climb(physics, skater)?; + self.active = None; + self.cooldown = 0.3; + bevy::log::info!("Climbing: returned to walking"); + } + Ok(true) + } +} +fn zero_rates(r: &mut skate_core::physics::rigid_body::RetailBodyRates) { + r.linear_velocity = Vector3::ZERO; + r.angular_velocity = Vector3::ZERO; + r.force_acceleration = Vector3::ZERO; + r.torque_acceleration = Vector3::ZERO; +} +fn publish_pose( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + root: Mat4, + pose: Vec, +) -> Result<(), String> { + let s = &mut skater.animated_skeleton; + let parts = + map_animation_parts(&pose, &s.bone_indices, &s.physics_frames).map_err(str::to_owned)?; + s.roots.animation_to_world = native(root); + s.roots.world_to_animation = native(root.inverse()); + s.record + .update(&parts, &s.roots.animation_to_board, &s.masses); + s.animation_hips = parts[23]; + s.animation_board = parts[0]; + let com = root.transform_point3(Vec3::from_slice(&s.record.centre_of_mass[..3])); + s.board_frames + .update_com_lift(&native(root), point(com), 0.24); + s.board_frames.centre_of_mass = point(com); + s.board_frames.previous_centre_of_mass = point(com); + s.board_frames.com_velocity = [0.; 4]; + for (i, p) in parts.iter().enumerate() { + skater + .skeleton + .set_part_transform(i, native(root * matrix(*p))); + } + // The two auxiliary COM bodies participate in the same constraints as the + // visible bones. Leaving them at takeoff causes a false collision impulse. + skater + .skeleton + .set_part_transform(24, s.board_frames.lifted_com_frame); + skater + .skeleton + .set_part_transform(25, s.board_frames.com_frame); + skater.skeleton_drives.targets.reset( + native(root * matrix(parts[23])), + native(root * matrix(parts[0])), + ); + let targets = skater.skeleton_drives.targets.update_extra_targets( + &mut skater.skeleton, + &s.board_frames.com_frame, + &s.board_frames.lifted_com_frame, + ); + skater.skeleton_input.extra_target_positions = + [targets.com, targets.lifted_com, targets.following_com]; + skater.pose_errors.set_targets(targets); + skater.skeleton_input.drive_frames = parts; + for b in skater.skeleton.bodies_mut() { + zero_rates(&mut b.rates); + } + skater.skeleton.animation_to_world = native(root); + skater.skeleton_input.previous_root_position = point(root.w_axis.truncate()); + skater.skeleton_input.root_velocity = [0.; 4]; + skater.skeleton_output.correction = Default::default(); + skater + .skeleton + .publish_physical_record(native(root * matrix(parts[0]))); + let p = &mut skater.player_input.processed; + p.effective_anim_transform_192 = native(root).map(|v| v.map(f32::to_bits)); + p.vectors_544_560_592_608[2] = point(com).map(f32::to_bits); + p.vectors_544_560_592_608[3] = [0; 4]; + p.flags_2472 &= !(1 << 10); + let physical = &mut skater.player_input.physical; + physical.skeleton.anim_to_world_11920 = native(root).map(|v| v.map(f32::to_bits)); + physical.reckoning.vector_64 = point(com).map(f32::to_bits); + physical.reckoning.vector_16 = [0; 4]; + physical.reckoning.vector_96 = [0., 1., 0., 0.].map(f32::to_bits); + physical.air.flag_441 = 0; + physical.air.use_air_reckoning_452 = 0; + skater.centre_of_mass_output = skater.centre_of_mass_filter.update(point(com), [0.; 4]); + skater.render_pose = pose; + skater.pose_generation = skater.pose_generation.wrapping_add(1); + skater.player_input.player.update_count_1316 = + skater.player_input.player.update_count_1316.wrapping_add(1); + let _ = physics; + Ok(()) +} diff --git a/crates/skate-game/src/physics/climbing/tests.rs b/crates/skate-game/src/physics/climbing/tests.rs new file mode 100644 index 00000000..d9193e4e --- /dev/null +++ b/crates/skate-game/src/physics/climbing/tests.rs @@ -0,0 +1,430 @@ +use super::*; +use skate_core::physics::{ + board_world::{ + BoardWorld, WorldTriangle, + query_metadata::{Bounds, QueryMesh, QueryMetadata, QueryPool}, + }, + collision::TriangleFeature, + contact::RetailContactMaterial, +}; + +fn world(height: f32, width: f32, depth: f32, ceiling: bool, top: bool) -> BoardWorld { + let mut triangles = Vec::new(); + let mut quad = |p: [[f32; 3]; 4]| { + for ids in [[0, 1, 2], [0, 2, 3]] { + triangles.push( + WorldTriangle::from_vertices( + ids.map(|i| Vector3::new(p[i][0], p[i][1], p[i][2])), + RetailContactMaterial { + static_friction: 0.8, + dynamic_friction: 0.6, + restitution: 0., + }, + 0, + TriangleFeature::ONE_SIDED, + [1.; 3], + 0., + ) + .unwrap(), + ); + } + }; + quad([ + [-20., 0., -20.], + [-20., 0., 20.], + [20., 0., 20.], + [20., 0., -20.], + ]); + quad([ + [-width, 0., 0.6], + [-width, height, 0.6], + [width, height, 0.6], + [width, 0., 0.6], + ]); + if top { + quad([ + [-width, height, 0.6], + [-width, height, 0.6 + depth], + [width, height, 0.6 + depth], + [width, height, 0.6], + ]); + } + if ceiling { + quad([ + [-width, height + 1.2, 0.3], + [width, height + 1.2, 0.3], + [width, height + 1.2, 3.], + [-width, height + 1.2, 3.], + ]); + } + let count = triangles.len(); + BoardWorld::with_query_metadata( + triangles, + QueryMetadata { + packed_surfaces: vec![0; count], + static_edges: vec![], + island_flags: 0, + meshes: vec![QueryMesh { + triangle_range: 0..count, + pool: QueryPool::Ground, + matching_group: 0, + local_bounds: Bounds { + min: Vector3::new(-20., -1., -20.), + max: Vector3::new(20., height + 2., 20.), + }, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + }], + }, + ) + .unwrap() +} +#[test] +fn reachable_ledge_needs_width_support_and_headroom() { + let probe = |w| ledge::find(&w, Vec3::ZERO, Vec3::Z); + let good = probe(world(2.2, 2., 3., false, true)).expect("reachable ledge"); + assert!((good.anchor.y - 2.225).abs() < 0.002); + assert!(probe(world(4., 2., 3., false, true)).is_none(), "too high"); + assert!(probe(world(2.2, 2., 3., false, false)).is_none(), "no top"); + assert!( + probe(world(2.2, 0.2, 3., false, true)).is_none(), + "too narrow for hands" + ); + assert!( + probe(world(2.2, 2., 0.4, false, true)).is_none(), + "no standing support" + ); + assert!(probe(world(2.2, 2., 3., true, true)).is_none(), "ceiling"); + assert!(ledge::find(&world(2.2, 2., 3., false, true), Vec3::ZERO, -Vec3::Z).is_none()); +} + +#[test] +fn default_course_has_a_reachable_test_block() { + let world = super::super::ground::world(RetailContactMaterial { + static_friction: 0.8, + dynamic_friction: 0.6, + restitution: 0., + }); + assert!( + ledge::find( + &world, + Vec3::new(-4.4, super::super::ground::FLOOR_HEIGHT, 0.), + Vec3::NEG_X + ) + .is_some() + ); +} + +#[test] +#[ignore = "requires private stock graphs and authored climbing clips"] +fn hybrid_climb_holds_for_second_press_and_resumes_walking() { + run_climb(false, false); +} + +#[test] +#[ignore = "requires private stock graphs and authored climbing clips"] +fn climbing_with_a_dropped_board_preserves_free_board_physics() { + run_climb(true, false); +} + +#[test] +#[ignore = "requires private stock graphs and authored climbing clips"] +fn turning_away_during_reach_fades_out_and_lands_normally() { + run_climb(false, true); +} + +fn run_climb(drop_board: bool, miss: bool) { + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let assets = skate_data::GameAssets::load(&root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(&root, &assets).unwrap(); + let mut physics = + GamePhysics::load_with_difficulty(&root, None, crate::difficulty::Difficulty::Normal) + .unwrap(); + let mut skater = SkaterRuntime::load(&root, &graphs, &physics, "normal").unwrap(); + let mut camera = crate::camera::CameraRuntime::load(&root).unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + // Dismount through the real stock controller and graphs before testing. + for tick in 0..360 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick == 120 { 0x8000 } else { 0 }, + triggers: if drop_board && tick == 180 { + [255, 0] + } else { + [0; 2] + }, + left: [0; 2], + right: [0; 2], + }); + controls.update( + &mut input.player_actions(), + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + super::super::frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + true, + &mut camera, + ) + .unwrap(); + } + assert_eq!(skater.player_state.current(), PhysicalStateId::BipedGround); + assert_eq!( + skater.skateboard_controller.fields.state_448 == 1, + !drop_board + ); + physics.world = world(2.2, 4., 8., false, true); + // Put the real skeleton's feet at the fixture origin before entering Biped. + let c = &skater.climbing.clips.as_ref().unwrap().reach; + let g: Vec<_> = skater + .climbing + .indices + .iter() + .map(|&i| matrix(skater.render_pose[i])) + .collect(); + let root_matrix = Mat4::from_translation(-c.feet(&g) - Vec3::Z * 2.); + let pose = skater.render_pose.clone(); + publish_pose(&physics, &mut skater, root_matrix, pose).unwrap(); + super::super::player_state::resume_after_climb(&mut physics, &mut skater).unwrap(); + controls.offboard_direction = Some([0., 0., 1., 0.]); + let button = |controls: &mut PlayerControls, down: bool, previous: bool| { + let mut words = *controls.controller.words(); + words[13] = u32::from(down) << 23; + words[6] = u32::from(previous) << 23; + controls.controller = + skate_core::input::controller::DerivedControllerInput::from_words(words); + }; + approach::advance(&physics, &mut skater, &controls); + let mut saw_air = false; + let mut saw_reach = false; + let mut saw_ground_reach = false; + for tick in 0..180 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick >= 33 && tick < 120 { 0x4000 } else { 0 }, + triggers: [0; 2], + left: [0, 24000], + right: [0; 2], + }); + controls.update( + &mut input.player_actions(), + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + controls.offboard_direction = Some([0., 0., if miss && tick >= 45 { -1. } else { 1. }, 0.]); + super::super::frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + true, + &mut camera, + ) + .unwrap(); + saw_air |= skater.player_state.current() == PhysicalStateId::BipedAir; + saw_reach |= skater.climbing.approach.is_some(); + saw_ground_reach |= skater.player_state.current() == PhysicalStateId::BipedGround + && skater + .climbing + .approach + .as_ref() + .is_some_and(|a| a.weight > 0.02); + if !miss && [15, 25, 30, 35, 40, 45, 50, 55].contains(&tick) { + capture(&skater, &format!("00_jump_{tick}")); + } + if skater + .climbing + .active + .as_ref() + .is_some_and(|a| a.phase == Phase::Hang) + { + break; + } + } + assert!(saw_air, "must use stock airborne jump before catching"); + assert!(saw_ground_reach, "arms must start reaching during the run"); + if miss { + assert!(saw_reach, "miss scenario must begin a reach first"); + assert!(skater.climbing.active.is_none()); + assert!(skater.climbing.approach.is_none()); + assert_eq!(skater.player_state.current(), PhysicalStateId::BipedGround); + return; + } + assert!( + skater.climbing.active.is_some(), + "stock jump failed to catch" + ); + assert_eq!(skater.climbing.active.as_ref().unwrap().phase, Phase::Hang); + let c = &skater.climbing.clips.as_ref().unwrap().reach; + let g: Vec<_> = skater + .climbing + .indices + .iter() + .map(|&i| matrix(skater.render_pose[i])) + .collect(); + let ledge = skater.climbing.active.as_ref().unwrap().ledge; + let root = matrix(skater.animated_skeleton.roots.animation_to_world); + for (i, name) in ["LEFTHAND", "RIGHTHAND"].into_iter().enumerate() { + let hand = root * g[c.index(name)]; + let palm = hand.transform_point3(contacts::PALM); + assert!( + palm.distance(ledge.palms[i]) < 0.015, + "{name}: palm={palm:?} target={:?}", + ledge.palms[i] + ); + assert!(hand.y_axis.truncate().dot(-ledge.normals[i]) > 0.99); + let lower = g[c.index(if i == 0 { + "LEFTFOREARM" + } else { + "RIGHTFOREARM" + })]; + let relative = Transform::from_matrix(lower.inverse() * g[c.index(name)]); + let axis = relative.translation.normalize(); + let q = relative.rotation; + assert!( + Vec3::new(q.x, q.y, q.z).dot(axis).abs() < 0.01, + "forearm roll must not invert the wrist: {name} {q:?}" + ); + } + let hips = matrix(skater.animated_skeleton.roots.animation_to_world) + .transform_point3(g[c.index("HIPS")].w_axis.truncate()); + assert!( + hips.z < 0.45, + "hanging hips must stay outside the wall: {hips:?}" + ); + capture(&skater, "02_hanging"); + button(&mut controls, false, true); + advance(&mut physics, &mut skater, &controls, &mut camera).unwrap(); + button(&mut controls, true, false); + advance(&mut physics, &mut skater, &controls, &mut camera).unwrap(); + assert_eq!( + skater.climbing.active.as_ref().unwrap().phase, + Phase::Mantle + ); + button(&mut controls, false, true); + for tick in 0..260 { + if !advance(&mut physics, &mut skater, &controls, &mut camera).unwrap() { + break; + } + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()) + ); + if [30, 90, 150, 210].contains(&tick) { + capture(&skater, &format!("03_mantle_{tick}")); + } + } + assert!(skater.climbing.active.is_none()); + assert_eq!(skater.player_state.current(), PhysicalStateId::BipedGround); + let landing = matrix(skater.animated_skeleton.roots.animation_to_world) + .w_axis + .truncate(); + capture(&skater, "04_handoff"); + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0, + triggers: [0; 2], + left: [0; 2], + right: [0; 2], + }); + controls.offboard_direction = Some([0.; 4]); + for tick in 0..120 { + controls.update( + &mut input.player_actions(), + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + super::super::frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + false, + &mut camera, + ) + .unwrap(); + assert_eq!( + skater.player_state.current(), + PhysicalStateId::BipedGround, + "unexpected state after handoff at tick {tick}" + ); + } + let end = matrix(skater.animated_skeleton.roots.animation_to_world) + .w_axis + .truncate(); + assert!( + end.distance(landing) < 0.1, + "handoff drifted: {landing:?} -> {end:?}" + ); + assert_eq!(skater.player_state.current(), PhysicalStateId::BipedGround); + capture(&skater, "05_standing"); + for _ in 0..60 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0, + triggers: [0; 2], + left: [0, 16000], + right: [0; 2], + }); + controls.update( + &mut input.player_actions(), + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + controls.offboard_direction = Some([0., 0., 0.5, 0.]); + super::super::frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + true, + &mut camera, + ) + .unwrap(); + } + let walked = matrix(skater.animated_skeleton.roots.animation_to_world) + .w_axis + .truncate(); + assert!( + walked.z > end.z + 0.2, + "walking did not resume: {end:?} -> {walked:?}" + ); + assert!( + (walked.y - 2.2).abs() < 0.15, + "walking left the ledge: {walked:?}" + ); + assert_eq!(skater.player_state.current(), PhysicalStateId::BipedGround); +} + +fn capture(skater: &SkaterRuntime, name: &str) { + if skater.skateboard_controller.fields.state_448 != 1 { + return; + } + if let Some(path) = std::env::var_os("SKATE_CLIMB_CAPTURE") { + let path = std::path::PathBuf::from(path); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write( + path.join(format!("{name}.json")), + serde_json::to_vec(&serde_json::json!({ + "root":skater.animated_skeleton.roots.animation_to_world, + "names":skater.animation.evaluator.frames.bone_names, + "pose":skater.render_pose, + "board_state":skater.skateboard_controller.fields.state_448, + })) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index bfb99b42..2c9b97a1 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -30,6 +30,9 @@ pub(super) fn advance( //Publishing Ground here runs its ForcePhysics Begin before the initial //input reset82DB8998, which would immediately clear that mode again. } + if super::climbing::advance(physics, skater, controls, camera)? { + return Ok(()); + } if skater.trajectory.edges.is_empty() { skater.trajectory.edges.clone_from(&physics.grind.primitives); } physics.board.clear_forces(); let query_timer = crate::performance::Scope::new("wheel_and_foot_queries"); @@ -219,6 +222,7 @@ pub(super) fn advance( events, }); camera_output::advance(physics, skater, &feedback, camera)?; + super::climbing::approach::advance(physics, skater, controls); skater.animation_input.finish_output_publication(); skater.player_input.player.update_count_1316 = skater.player_input.player.update_count_1316.wrapping_add(1); diff --git a/crates/skate-game/src/physics/ground.rs b/crates/skate-game/src/physics/ground.rs index 85048257..8f815216 100644 --- a/crates/skate-game/src/physics/ground.rs +++ b/crates/skate-game/src/physics/ground.rs @@ -20,7 +20,7 @@ pub(crate) const FLOOR_HEIGHT: f32 = HEIGHT - 1.5; /// Shared quads for rendering and collision: landing floor, starting box, half pipe. /// Quads use the same winding as the original floor (0,2,1 and 0,3,2). -pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 3] { +pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 4] { let v = Vector3::new; let floor = vec![[ v(-50.0, FLOOR_HEIGHT, -50.0), @@ -104,7 +104,21 @@ pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 3] { let y = FLOOR_HEIGHT + radius; half_pipe.push([v(x0, y, -6.0), v(x1, y, -6.0), v(x1, y, 6.0), v(x0, y, 6.0)]); } - [floor, platform, half_pipe] + // A separate 2.2 m ledge on the left of the course makes the custom + // climbing flow directly testable without altering the riding routes. + let climb_top = [ + v(-9., FLOOR_HEIGHT + 2.2, -2.), + v(-5., FLOOR_HEIGHT + 2.2, -2.), + v(-5., FLOOR_HEIGHT + 2.2, 2.), + v(-9., FLOOR_HEIGHT + 2.2, 2.), + ]; + let mut climbing = vec![climb_top]; + for i in 0..4 { + let a = climb_top[i]; + let b = climb_top[(i + 1) % 4]; + climbing.push([b, a, v(a.x, FLOOR_HEIGHT, a.z), v(b.x, FLOOR_HEIGHT, b.z)]); + } + [floor, platform, half_pipe, climbing] } pub(super) fn world(material: RetailContactMaterial) -> BoardWorld { diff --git a/crates/skate-game/src/physics/offboard/air_trajectory.rs b/crates/skate-game/src/physics/offboard/air_trajectory.rs index 64c246c4..0039fd70 100644 --- a/crates/skate-game/src/physics/offboard/air_trajectory.rs +++ b/crates/skate-game/src/physics/offboard/air_trajectory.rs @@ -24,7 +24,7 @@ pub(crate) fn prepare(skater: &SkaterRuntime, current: bool) -> Result Result<(), String> { + super::offboard::ground_state::exit(physics, skater); + skater.offboard.air_prediction = None; + skater.collision_extra_displacements = [[0.; 4]; 2]; + skater.animated_skeleton.motion.velocity_world = [0.; 4]; + skater.player_input.processed.vectors_544_560_592_608[3] = [0; 4]; + if skater.player_state.current() == PhysicalStateId::BipedGround { + super::offboard::ground_state::enter(physics, skater)?; + } else { + transition::set(physics, skater, PhysicalStateId::BipedGround)?; + } + // Climbing bypassed the ground query pipeline. Complete real observations + // at the new position before the next native job can interpret no contact + // as a fall, or consume the old foot hits from below the ledge. + super::foot_ik_queries::query(&physics.world, &skater.skeleton)? + .publish(&mut skater.player_input.player); + let frame = skater.offboard.ground.frame_80; + let observation = super::offboard::contact_queries::submit_toolkit( + &physics.world, &skater.offboard.layout, + skate_core::player::offboard::contact_queries::Input { + position: frame[3], surface_right: frame[0], surface_up: frame[1], surface_forward: frame[2], + animation_right: frame[0], animation_up: frame[1], velocity: [0.;4], + }, skater.player_input.processed.actor_query_2948, + )?; + skater.offboard.contacts.submit(observation); + publish(physics, skater) +} + ///Original82DB6050 prefix; coordinator calls the selected state pre-update next. pub(crate) fn pre_state( physics: &mut GamePhysics, diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index a7e55846..532ac365 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -27,6 +27,7 @@ use std::path::Path; #[derive(Resource)] pub(crate) struct SkaterRuntime { + pub climbing: super::climbing::Runtime, /// Completed physical pose in native animation space, read by rendering. pub render_pose: Vec, pub pose_generation: u64, @@ -180,6 +181,7 @@ impl SkaterRuntime { &data, asset_root, &animation.evaluator.frames.source_sha256, )?; Ok(Self { + climbing: super::climbing::Runtime::load(asset_root, &animation.evaluator.frames.bone_names)?, offboard, render_pose: initial_hierarchy, pose_generation: 0, diff --git a/crates/skate-game/src/tests/wipeout_playback.rs b/crates/skate-game/src/tests/wipeout_playback.rs index 22b8d7d3..2acbf607 100644 --- a/crates/skate-game/src/tests/wipeout_playback.rs +++ b/crates/skate-game/src/tests/wipeout_playback.rs @@ -265,7 +265,8 @@ fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) { let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); for difficulty in crate::difficulty::Difficulty::ALL { - for walking in [false, true] { + for scenario in 0..3 { + let walking=scenario!=0; let mut physics = GamePhysics::load_with_difficulty(root, map, difficulty).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, difficulty.key()).unwrap(); @@ -287,7 +288,7 @@ fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) { } else { 0 }, - triggers: [0; 2], + triggers: if scenario==2&&(tick==180||tick==420) {[255,0]} else {[0; 2]}, left: if walking && tick > 200 { [0, 16000] } else { @@ -312,7 +313,7 @@ fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) { &mut camera, ) .unwrap_or_else(|error| { - panic!("Runout {difficulty:?} walking={walking} tick{tick}: {error}") + panic!("Offboard {difficulty:?} scenario={scenario} tick{tick}: {error}") }); assert_finite(&physics, &skater, tick); visited.insert(skater.player_state.current() as u32); diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index de768490..2a3b6d67 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -43,6 +43,7 @@ fn spawn( Color::srgb(0.16, 0.19, 0.21), Color::srgb(0.48, 0.35, 0.22), Color::srgb(0.30, 0.43, 0.48), + Color::srgb(0.24, 0.48, 0.31), ]; for (quads, color) in crate::physics::ground::surfaces().into_iter().zip(colors) { let positions: Vec<[f32; 3]> = quads.into_iter().flat_map(|vertices| { diff --git a/docs/climbing.md b/docs/climbing.md new file mode 100644 index 00000000..fd192a1b --- /dev/null +++ b/docs/climbing.md @@ -0,0 +1,44 @@ +# Hybrid ledge climbing + +Get off the board with **Y**, approach a reachable wall, and press **X** (the existing offboard jump button). The skater jumps, grabs the top, and stays hanging. Release X and press it again to climb up. Normal walking resumes on top. Holding the first press does not automatically climb. + +The default course has a **green, 2.2 metre practice block beside the starting platform, opposite the half-pipe**, on the lower floor. Approach it from that floor, facing its wall, Jump towards it with a little run-up; gentle arm anticipation starts up to 2.6 metres from a valid edge and grows with proximity. Jump before running into the wall so the stock jump retains its forward momentum. Collision triangles drive the detection, so imported maps can use the same mechanic without named climbable objects. + +## Current scope + +- Grounded takeoff onto static, near-vertical walls with a flat top roughly 1.9–2.75 metres above the feet. +- Room for both hands, about 0.9 metres of supported depth, and clear standing headroom are required. +- The held board blends onto the back during climbing and returns to the carry pose. A dropped board continues simulating independently. +- Hanging uses the final reach pose. There is no separate breathing idle, shimmy, or drop button yet. A requested teleport cancels climbing. +- The skater rig has no separate finger bones, so these clips cannot close individual fingers around the edge. + +## Animation and movement ownership + +`physics/climbing` is a separate extension, not an invented stock state or `.abin` record. Its phases are Reach → Hang → Mantle → Settle. While attached it owns character movement and pauses the stock animation/character solve. It writes the same real skeleton bodies, auxiliary COM bodies, physical outputs, and render pose used by the existing presentation interpolation and gameplay camera. + +Before catching, the normal action/motion graphs, gravity, collision solve and jump trajectory continue running. A presentation overlay starts during the run, with continuous distance-based gain capped at 45% on the ground. The jump builds towards full reach, with a 0.10-second exponential response smoothing changes in the target weight. Two-bone IK guides each wrist towards its own fixed palm contact; palm orientation follows the top surface. Axial roll is applied to the forearm instead of inverting the wrist; that rotation preserves the elbow-to-wrist line and the palm contact. If the player turns away, moves out of range or loses the approach, the overlay smoothly fades out without changing the airborne trajectory. The original rig has no finger joints, so this positions palms rather than curling fingers. + +Both wrists must be inside the actual arm reach before attachment. The saved ledge stays fixed as the skater rises past the original wall probe. Catch blends the current jump pose into hanging, with extra settling time for a high catch. Local translation/rotation/scale blending smooths the pull-up and return to walking. Hand IK stays active through the supporting half of the pull-up, then releases smoothly. Standing clearance is checked again before pulling up. + +`approach.rs` runs after native physical output and before presentation capture; it does not publish its arm overlay into the physics bodies. Once caught, the attached phase owns the full pose and body as before. The saved ground return pose is normalized to the support plane so a raised foot during the run cycle does not lower the walking root. + +On completion, the existing offboard controller is placed on the upper surface. Its collision queries are refreshed from that surface and its physical targets are updated before the native pipeline resumes. This prevents stale ground hits or COM targets from creating a fall or bail. + +## Assets and rebaking + +Optional asset: `assets/private/custom/climbing.json`. Missing assets leave stock gameplay available; malformed or mismatched clips report a load error. This private asset is ignored by Git, like the existing stock animation banks. + +The JSON contains version 1, two named clips (`reach`, `mantle`), FPS, joint names, parent indices, and local SQT samples. Sample order is scale XYZ, quaternion XYZW, translation XYZ. It uses native animation bone axes, not raw Blender matrix-basis channels. It does not modify the stock bank decoder or its SHA-bound cache format. + +The 60 FPS clips come from the reviewed Mixamo retargets: + +- `C:/Users/Daddy/Documents/skate3-climbing-retarget/stand-to-freehang/stand_to_freehang_skater.blend` +- `C:/Users/Daddy/Documents/skate3-climbing-retarget/climbing_skater.blend` + +Rebake using Blender's background mode and `C:/Users/Daddy/Documents/skate3-climbing-retarget/runtime-export/export_climbing.py`. This script reads the masters without saving changes and derives the exact bone conversion from `assets/private/skater.glb`. Extraction and Blender tooling remain outside the game project. + +## Verification + +Run `cargo test -p skate-game climbing` for the geometry checks. Set `SKATE3_ASSET_ROOT` to the absolute assets directory and add `-- --include-ignored` to run the real-asset playback tests too. Those tests dismount through the stock graph, grab, hold X without automatically climbing, press again, complete the climb, stand still, and walk on the upper surface; both held and dropped board cases are covered. A separate real-asset test turns away during the reach and verifies that it fades out, never attaches, and lands normally. + +Optional `SKATE_CLIMB_CAPTURE` names a folder for pose snapshots from the tests. `runtime-export/preview_runtime.py`, outside the project, renders those captured runtime bones through the original skater skin for visual inspection. diff --git a/docs/images/skating-crab.png b/docs/images/skating-crab.png new file mode 100644 index 00000000..abf64ba6 Binary files /dev/null and b/docs/images/skating-crab.png differ diff --git a/docs/repository-content.md b/docs/repository-content.md new file mode 100644 index 00000000..4d7e32d2 --- /dev/null +++ b/docs/repository-content.md @@ -0,0 +1,22 @@ +# Repository content + +Private runtime assets, extracted maps, custom animation clips, build output, +settings and logs stay local. They are excluded by `.gitignore`. + +The two project-owned binary fixtures are generated without game assets: + +- `maps/format-demo.skate`: `python tools/make_skate_demo.py` +- `crates/skate-data/tests/fixtures/retail-collision.rwcmset`: + `python tools/make_collision_fixture.py` + +Run these commands from the repository root. Both generators reproduced the +tracked fixtures byte for byte during publication preparation. + +The README artwork in `docs/images/skating-crab.png` was supplied by the project +owner for publication. Other binary resources belong to the vendored Bevy dependencies, whose license +files remain alongside their source. Do not add extracted game content to +these directories or force-add ignored private files. + +The pre-publication audit inspected all reachable historical blobs for private +paths, matches against local private-file hashes, unexpected binary files and +common credential patterns. No matches requiring removal were found. diff --git a/tools/make_collision_fixture.py b/tools/make_collision_fixture.py new file mode 100644 index 00000000..b69fe435 --- /dev/null +++ b/tools/make_collision_fixture.py @@ -0,0 +1,23 @@ +"""Generate three original test triangles, without reading any game assets. + +Run from the repository root. The fixture covers float, 16-bit offset and +32-bit integer vertex encodings; its IDs and edge flags are test values. +""" +import struct +from pathlib import Path +records=[] +for mode in range(3): + c=bytearray(96);struct.pack_into('>HHH',c,0,1,11,4);struct.pack_into('>H',c,8,96);c[10]=3;c[12]=mode + points=[(mode*40,0,0),(mode*40,0,4),(mode*40+4,0,0)] + if mode==0: + for i,p in enumerate(points):struct.pack_into('>4f',c,16+i*16,*(v*.25 for v in p),0) + elif mode==1: + struct.pack_into('>3i',c,16,mode*40,0,0) + for i,p in enumerate(points):struct.pack_into('>3h',c,28+i*6,p[0]-mode*40,p[1],p[2]) + else: + for i,p in enumerate(points):struct.pack_into('>3i',c,16+i*12,*p) + c[80:91]=bytes([0xe1,0,1,2,0x20,0x42,0x9a,0x34,0x12,0x21,0x43]) + m=bytearray(160);struct.pack_into('>3f',m,0,mode*10,0,0);struct.pack_into('>3f',m,16,mode*10+1,0,1) + struct.pack_into('>I',m,40,1);struct.pack_into('>II',m,48,96,144);struct.pack_into('>fHBBI',m,56,.25,0x10,2,2,1);struct.pack_into('>I',m,80,256);struct.pack_into('>I',m,144,160);m+=c + name=f'compression-{mode}'.encode();records.append(struct.pack('