mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Prepare source repository for GitHub with asset exclusions and README
This commit is contained in:
1 parent
30e318aa15
commit
c9c4d728ea
23 files changed
+1732
-82
No files matched your search
+22
@@ -6,3 +6,25 @@
|
|||||||
/maps/private/
|
/maps/private/
|
||||||
/maps/*.skate
|
/maps/*.skate
|
||||||
!/maps/format-demo.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
|
||||||
@@ -1,82 +1,29 @@
|
|||||||
# Imported skater project
|
<p align="center">
|
||||||
|
<img src="docs/images/skating-crab.png" alt="Rust crab riding a skateboard" width="480">
|
||||||
|
</p>
|
||||||
|
|
||||||
Double-click **PLAY.bat** to launch. Use an XInput-compatible controller;
|
# Skate 3 Imported
|
||||||
**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).
|
|
||||||
|
|
||||||
The menu supports window resolution (720p through 4K), internal render scale
|
A Rust and Bevy skating project built from Skate 3 reverse-engineering research.
|
||||||
(25–100%), supported MSAA levels (Off/2×/4×/8×), an FPS cap (30–240 or
|
Includes skating, tricks, grinds, offboard movement, difficulty settings and
|
||||||
Unlimited), and GPU occlusion culling (On/Off). Click a row to cycle it, or use arrow keys and Enter. Changes apply
|
`.skate` map support. Gameplay parity is still a work in progress.
|
||||||
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.
|
|
||||||
|
|
||||||
The standing/rolling **360 flip** now uses the supplied gesture files and
|
## Build and run
|
||||||
ActionGraph/MotionGraph route, including right-stick crouch preparation.
|
|
||||||
See [360-flip integration and visual checks](docs/360-FLIP.md).
|
|
||||||
|
|
||||||
Double-click **PLAY-MAP.bat** to choose a `.skate` map, or drag a map onto
|
Requires Windows, Rust with the MSVC toolchain, and LLVM installed in its default
|
||||||
either launcher. `maps/private/` contains local copies of the transition park
|
location. Run `BUILD.bat` to build, then `PLAY.bat` to launch the test world.
|
||||||
and parity grid. `maps/format-demo.skate` is a small original textured floor.
|
Use `PLAY-MAP.bat` to select a map. An XInput controller is required for gameplay;
|
||||||
No-map PLAY.bat still loads the supplied test environment.
|
Escape opens difficulty and graphics settings.
|
||||||
|
|
||||||
New San Vanelona now loads its embedded `RWCM` collision archive, including
|
**Skate 3 assets are not included.** Running the game requires a separately
|
||||||
native edge flags and cluster query filtering. The staged build was checked
|
prepared local asset set in `assets/private/`, including its `game.json`
|
||||||
with the actual map and rendered the skater on its streets. No separate
|
manifest, character, animation banks, graphs and gameplay data. This repository
|
||||||
collision sidecar is needed. See [map support notes](docs/SKATE-MAPS.md) for
|
does not include an asset extraction pipeline. A source checkout alone cannot
|
||||||
verification and remaining sky/grind limitations.
|
run the game.
|
||||||
|
|
||||||
This folder is a **local Git repository**, on `lol/skate-map-support`, with no
|
Custom animations and climbing support remain available, but no custom clips
|
||||||
GitHub remote. Private assets, private maps, binaries, logs and build outputs
|
are shipped. The included format-demo map is original procedural content.
|
||||||
are excluded. See [the map support notes](docs/SKATE-MAPS.md) for supported
|
|
||||||
versions and the explicit runtime limitations.
|
|
||||||
|
|
||||||
This folder is separate from the earlier clone. It contains the contents of
|
Implementation notes are in [`docs/`](docs/). Patched Bevy dependencies and
|
||||||
`crates (1).zip` and `assets.zip`, a reconstructed Cargo workspace, and launch
|
their licenses are in [`vendor/`](vendor/). This is an unofficial project,
|
||||||
scripts. The supplied stock animation banks, decoded frames, graphs, skater
|
not affiliated with EA.
|
||||||
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.
|
|
||||||
@@ -158,7 +158,11 @@ fn jump(out: &mut Launch, p: &Input, j: &JumpInput<'_>) {
|
|||||||
redirected = scale(unit_unchecked(projected), sqrt(speed2));
|
redirected = scale(unit_unchecked(projected), sqrt(speed2));
|
||||||
} else if angle < 90. * f32::from_bits(0x3c8efa35) {
|
} else if angle < 90. * f32::from_bits(0x3c8efa35) {
|
||||||
redirected = scale(
|
redirected = scale(
|
||||||
limit_angle(unit_unchecked(projected), initial, 45.),
|
limit_angle(
|
||||||
|
unit_unchecked(projected),
|
||||||
|
initial,
|
||||||
|
45. * f32::from_bits(0x3c8efa35),
|
||||||
|
),
|
||||||
sqrt(speed2),
|
sqrt(speed2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<f32> {
|
||||||
|
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))<maximum_velocity_difference;
|
||||||
|
}
|
||||||
|
if self.can_land&&!self.first_query {self.first_query=true;Some(self.proposed_remaining)} else {None}
|
||||||
|
}
|
||||||
|
///82D79948 uses a distinct obstruction trajectory to the moving deck +.2m.
|
||||||
|
pub fn obstruction_query(&mut self,input:Input,position:V,duration:f32)->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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,3 +42,4 @@ pub mod air_prediction;
|
|||||||
pub mod air_queries;
|
pub mod air_queries;
|
||||||
pub mod air_state;
|
pub mod air_state;
|
||||||
pub mod feet;
|
pub mod feet;
|
||||||
|
pub mod landing_deck;
|
||||||
@@ -5,6 +5,7 @@ mod air_phase;
|
|||||||
mod air_trajectory;
|
mod air_trajectory;
|
||||||
pub(crate) mod camera_output;
|
pub(crate) mod camera_output;
|
||||||
mod clock;
|
mod clock;
|
||||||
|
mod climbing;
|
||||||
mod colliders;
|
mod colliders;
|
||||||
mod controls;
|
mod controls;
|
||||||
mod foot_ik;
|
mod foot_ik;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Clip>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct Clip {
|
||||||
|
pub name: String,
|
||||||
|
pub fps: f32,
|
||||||
|
pub names: Vec<String>,
|
||||||
|
pub parents: Vec<i32>,
|
||||||
|
pub frames: Vec<Vec<[f32; 10]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Clips {
|
||||||
|
pub reach: Clip,
|
||||||
|
pub mantle: Clip,
|
||||||
|
}
|
||||||
|
impl Clips {
|
||||||
|
pub fn load(root: &Path) -> Result<Option<Self>, 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<Transform> {
|
||||||
|
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<Mat4> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<Ledge> {
|
||||||
|
find_range(world, feet, facing, 1.90, 0.95)
|
||||||
|
}
|
||||||
|
pub(super) fn find_air(world: &BoardWorld, feet: Vec3, facing: Vec3) -> Option<Ledge> {
|
||||||
|
find_range(world, feet, facing, 1.25, 2.6)
|
||||||
|
}
|
||||||
|
fn find_range(
|
||||||
|
world: &BoardWorld,
|
||||||
|
feet: Vec3,
|
||||||
|
facing: Vec3,
|
||||||
|
minimum: f32,
|
||||||
|
distance: f32,
|
||||||
|
) -> Option<Ledge> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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<Clips>,
|
||||||
|
indices: Vec<usize>,
|
||||||
|
active: Option<Attached>,
|
||||||
|
cooldown: f32,
|
||||||
|
approach: Option<approach::Approach>,
|
||||||
|
ground_entry: Vec<Transform>,
|
||||||
|
}
|
||||||
|
#[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<Transform>,
|
||||||
|
fallback: Vec<NativeMatrix>,
|
||||||
|
board_world: Mat4,
|
||||||
|
physical_board_world: Mat4,
|
||||||
|
carry_board: bool,
|
||||||
|
}
|
||||||
|
impl Runtime {
|
||||||
|
pub fn load(root: &std::path::Path, names: &[String]) -> Result<Self, String> {
|
||||||
|
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::<Result<Vec<_>, _>>()?,
|
||||||
|
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<bool, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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<NativeMatrix>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ pub(super) fn advance(
|
|||||||
//Publishing Ground here runs its ForcePhysics Begin before the initial
|
//Publishing Ground here runs its ForcePhysics Begin before the initial
|
||||||
//input reset82DB8998, which would immediately clear that mode again.
|
//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); }
|
if skater.trajectory.edges.is_empty() { skater.trajectory.edges.clone_from(&physics.grind.primitives); }
|
||||||
physics.board.clear_forces();
|
physics.board.clear_forces();
|
||||||
let query_timer = crate::performance::Scope::new("wheel_and_foot_queries");
|
let query_timer = crate::performance::Scope::new("wheel_and_foot_queries");
|
||||||
@@ -219,6 +222,7 @@ pub(super) fn advance(
|
|||||||
events,
|
events,
|
||||||
});
|
});
|
||||||
camera_output::advance(physics, skater, &feedback, camera)?;
|
camera_output::advance(physics, skater, &feedback, camera)?;
|
||||||
|
super::climbing::approach::advance(physics, skater, controls);
|
||||||
skater.animation_input.finish_output_publication();
|
skater.animation_input.finish_output_publication();
|
||||||
skater.player_input.player.update_count_1316 =
|
skater.player_input.player.update_count_1316 =
|
||||||
skater.player_input.player.update_count_1316.wrapping_add(1);
|
skater.player_input.player.update_count_1316.wrapping_add(1);
|
||||||
|
|||||||
@@ -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.
|
/// 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).
|
/// 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 v = Vector3::new;
|
||||||
let floor = vec![[
|
let floor = vec![[
|
||||||
v(-50.0, FLOOR_HEIGHT, -50.0),
|
v(-50.0, FLOOR_HEIGHT, -50.0),
|
||||||
@@ -104,7 +104,21 @@ pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 3] {
|
|||||||
let y = FLOOR_HEIGHT + radius;
|
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)]);
|
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 {
|
pub(super) fn world(material: RetailContactMaterial) -> BoardWorld {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub(crate) fn prepare(skater: &SkaterRuntime, current: bool) -> Result<air_launc
|
|||||||
up_544: p.vectors_544_560_592_608[0].map(f32::from_bits),
|
up_544: p.vectors_544_560_592_608[0].map(f32::from_bits),
|
||||||
velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits),
|
velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits),
|
||||||
position_592: p.vectors_544_560_592_608[2].map(f32::from_bits),
|
position_592: p.vectors_544_560_592_608[2].map(f32::from_bits),
|
||||||
target_112: toolkit.effective[3],
|
target_112: toolkit.deck[3],
|
||||||
velocity_912: p.vectors_880_896_912_928_944[2].map(f32::from_bits),
|
velocity_912: p.vectors_880_896_912_928_944[2].map(f32::from_bits),
|
||||||
//Retained live grind primitive, published before native state entry.
|
//Retained live grind primitive, published before native state entry.
|
||||||
grind_position_1120: p.grind_position_1120.map(f32::from_bits),
|
grind_position_1120: p.grind_position_1120.map(f32::from_bits),
|
||||||
|
|||||||
@@ -124,6 +124,35 @@ pub(crate) fn enter_after_teleport(
|
|||||||
transition::set(physics, skater, PhysicalStateId::PhysicsGround)
|
transition::set(physics, skater, PhysicalStateId::PhysicsGround)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom authored climbing completes at a validated standing surface.
|
||||||
|
pub(crate) fn resume_after_climb(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> 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.
|
///Original82DB6050 prefix; coordinator calls the selected state pre-update next.
|
||||||
pub(crate) fn pre_state(
|
pub(crate) fn pre_state(
|
||||||
physics: &mut GamePhysics,
|
physics: &mut GamePhysics,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
pub(crate) struct SkaterRuntime {
|
pub(crate) struct SkaterRuntime {
|
||||||
|
pub climbing: super::climbing::Runtime,
|
||||||
/// Completed physical pose in native animation space, read by rendering.
|
/// Completed physical pose in native animation space, read by rendering.
|
||||||
pub render_pose: Vec<skate_core::animation::output::NativeMatrix>,
|
pub render_pose: Vec<skate_core::animation::output::NativeMatrix>,
|
||||||
pub pose_generation: u64,
|
pub pose_generation: u64,
|
||||||
@@ -180,6 +181,7 @@ impl SkaterRuntime {
|
|||||||
&data, asset_root, &animation.evaluator.frames.source_sha256,
|
&data, asset_root, &animation.evaluator.frames.source_sha256,
|
||||||
)?;
|
)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
climbing: super::climbing::Runtime::load(asset_root, &animation.evaluator.frames.bone_names)?,
|
||||||
offboard,
|
offboard,
|
||||||
render_pose: initial_hierarchy,
|
render_pose: initial_hierarchy,
|
||||||
pose_generation: 0,
|
pose_generation: 0,
|
||||||
|
|||||||
@@ -265,7 +265,8 @@ fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) {
|
|||||||
let assets = skate_data::GameAssets::load(root).unwrap();
|
let assets = skate_data::GameAssets::load(root).unwrap();
|
||||||
let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap();
|
let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap();
|
||||||
for difficulty in crate::difficulty::Difficulty::ALL {
|
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 physics = GamePhysics::load_with_difficulty(root, map, difficulty).unwrap();
|
||||||
let mut skater =
|
let mut skater =
|
||||||
SkaterRuntime::load(root, &graphs, &physics, difficulty.key()).unwrap();
|
SkaterRuntime::load(root, &graphs, &physics, difficulty.key()).unwrap();
|
||||||
@@ -287,7 +288,7 @@ fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
triggers: [0; 2],
|
triggers: if scenario==2&&(tick==180||tick==420) {[255,0]} else {[0; 2]},
|
||||||
left: if walking && tick > 200 {
|
left: if walking && tick > 200 {
|
||||||
[0, 16000]
|
[0, 16000]
|
||||||
} else {
|
} else {
|
||||||
@@ -312,7 +313,7 @@ fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) {
|
|||||||
&mut camera,
|
&mut camera,
|
||||||
)
|
)
|
||||||
.unwrap_or_else(|error| {
|
.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);
|
assert_finite(&physics, &skater, tick);
|
||||||
visited.insert(skater.player_state.current() as u32);
|
visited.insert(skater.player_state.current() as u32);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ fn spawn(
|
|||||||
Color::srgb(0.16, 0.19, 0.21),
|
Color::srgb(0.16, 0.19, 0.21),
|
||||||
Color::srgb(0.48, 0.35, 0.22),
|
Color::srgb(0.48, 0.35, 0.22),
|
||||||
Color::srgb(0.30, 0.43, 0.48),
|
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) {
|
for (quads, color) in crate::physics::ground::surfaces().into_iter().zip(colors) {
|
||||||
let positions: Vec<[f32; 3]> = quads.into_iter().flat_map(|vertices| {
|
let positions: Vec<[f32; 3]> = quads.into_iter().flat_map(|vertices| {
|
||||||
|
|||||||
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 896 KiB |
@@ -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.
|
||||||
@@ -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('<I',len(name))+name+struct.pack('<I',len(m))+m)
|
||||||
|
p=Path('crates/skate-data/tests/fixtures/retail-collision.rwcmset');p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(b'RWCMSET1'+struct.pack('<I',3)+b''.join(records))
|
||||||
Reference in new issue
Block a user