diff --git a/README.md b/README.md index ca6ac597..d79991a7 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ Double-click **PLAY.bat** to launch. Use an XInput-compatible controller; **Esc** exits. Startup selects the supplied **Easy** profile. +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). + 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. diff --git a/crates/skate-core/src/input/anticipation_intentions.rs b/crates/skate-core/src/input/anticipation_intentions.rs new file mode 100644 index 00000000..171f4d19 --- /dev/null +++ b/crates/skate-core/src/input/anticipation_intentions.rs @@ -0,0 +1,48 @@ +//! Right-stick anticipation publication, Fill825999F0: +//! angle/magnitude 82599AD0..9C4C, emissions 8259AD0C..AD40. +use super::{ + angle::left_stick_angle, + controller::{DerivedControllerInput, magnitude}, + riding_intentions::RidingIntent, +}; + +pub fn produce(controller: &DerivedControllerInput) -> Vec { + let words = controller.words(); + let x = f32::from_bits(words[9]); + let y = f32::from_bits(words[10]); + if x == 0.0 && y == 0.0 { + return Vec::new(); + } + // Unlike steering, these two records have no actor1908 bit gate. + vec![ + RidingIntent { + name: "AnticMag", + value: magnitude(x.mul_add(x, y * y)), + }, + RidingIntent { + name: "AnticAngle", + value: left_stick_angle(x, y), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn neutral_removes_anticipation_and_tail_hold_selects_ollie_range() { + assert!(produce(&DerivedControllerInput::from_words([0; 26])).is_empty()); + let mut words = [0; 26]; + words[10] = (-1.0f32).to_bits(); + let intents = produce(&DerivedControllerInput::from_words(words)); + assert!((intents[0].value - 1.0).abs() < 0.000001); + assert!(intents[1].value.abs() <= 0.52); + words[10] = 1.0f32.to_bits(); + assert!( + produce(&DerivedControllerInput::from_words(words))[1] + .value + .abs() + >= 2.62 + ); + } +} diff --git a/crates/skate-core/src/input/gesture.rs b/crates/skate-core/src/input/gesture.rs new file mode 100644 index 00000000..9e92fe8b --- /dev/null +++ b/crates/skate-core/src/input/gesture.rs @@ -0,0 +1,248 @@ +//! TU3 pattern recognition, 82697168/826972B8/826974A8. +//! Points are supplied in PAT authoring order. Native 82696B18 pushes them +//! to the front of a ring buffer, then the matcher walks that buffer backwards. + +#[derive(Clone, Debug, PartialEq)] +pub struct Pattern { + pub name: String, + pub points: Vec<[f32; 2]>, + pub tolerance_squared: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Recognizer config byte 33. Supplied by the owner, never inferred here. + pub maximum_misses: u8, + pub difficulty: u32, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Node { + active: bool, + complete: bool, + next: usize, + elapsed: u16, + misses: u8, + distance: f32, +} + +impl Node { + fn tick(&mut self, pattern: &Pattern, sample: [f32; 2], maximum_misses: u8) { + if !self.active { + let distance = distance_squared(pattern.points[0], sample); + if distance <= pattern.tolerance_squared { + *self = Self { + active: true, + next: 1, + elapsed: 1, + distance, + ..Self::default() + }; + } + } else if !self.complete { + let distance = distance_squared(pattern.points[self.next], sample); + if distance <= pattern.tolerance_squared { + self.distance += distance; + self.elapsed = (self.elapsed + 1) & 0x3ff; + if self.next + 1 == pattern.points.len() { + self.complete = true; + } else { + self.next += 1; + self.misses = 0; + } + } else { + // Holding the first point does not age a pending gesture. + if self.next != 1 + || distance_squared(pattern.points[0], sample) > pattern.tolerance_squared + { + self.elapsed = (self.elapsed + 1) & 0x3ff; + self.misses = (self.misses + 1) & 0x3f; + } + if self.misses > maximum_misses { + *self = Self::default(); + } + } + } + } + + fn score(self, count: usize) -> f32 { + let count = count as f32; + // The unusual two-sided 0.15 operation is present in 826972B8. + let mean = (self.distance.max(0.15) / count).min(0.15); + (count * count * count * count) / (mean * self.elapsed as f32) + } +} + +fn distance_squared(a: [f32; 2], b: [f32; 2]) -> f32 { + let x = a[0] - b[0]; + let y = a[1] - b[1]; + // VMX multiplies then adds; do not contract to FMA. + x * x + y * y +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Recognition { + pub pattern: usize, + pub strength: f32, + pub distance: f32, + pub elapsed: f32, +} + +#[derive(Clone, Debug)] +pub struct Recognizer { + patterns: Vec, + nodes: Vec, + has_previous_sample: bool, + refractory: bool, + held: Option, +} + +impl Recognizer { + pub fn new(patterns: Vec) -> Result { + for pattern in &patterns { + if !(2..=15).contains(&pattern.points.len()) + || !pattern.tolerance_squared.is_finite() + || pattern.tolerance_squared < 0.0 + || pattern.points.iter().flatten().any(|v| !v.is_finite()) + { + return Err(format!("invalid native gesture pattern {}", pattern.name)); + } + } + Ok(Self { + nodes: vec![Node::default(); patterns.len()], + patterns, + has_previous_sample: false, + refractory: false, + held: None, + }) + } + + pub fn patterns(&self) -> &[Pattern] { + &self.patterns + } + + /// Held messages are checked before recognition in 826962D8. Native point + /// zero is the final authored coordinate (82699738 push-front). + pub fn held(&mut self, sample: [f32; 2]) -> Option { + if let Some(index) = self.held { + let pattern = &self.patterns[index]; + if distance_squared(*pattern.points.last().unwrap(), sample) > pattern.tolerance_squared + { + self.held = None; + } + } + self.held + } + + pub fn sample(&mut self, sample: [f32; 2], settings: Settings) -> Option { + if self.refractory { + self.nodes.fill(Node::default()); + self.refractory = false; + return None; + } + if !self.has_previous_sample { + self.has_previous_sample = true; + return None; + } + let mut best: Option = None; + for (node, pattern) in self.nodes.iter_mut().zip(&self.patterns) { + node.tick(pattern, sample, settings.maximum_misses); + } + for (index, node) in self.nodes.iter().enumerate() { + if node.complete + && best.is_none_or(|old| { + node.score(self.patterns[index].points.len()) + > self.nodes[old].score(self.patterns[old].points.len()) + }) + { + best = Some(index); + } + } + let pattern = best?; + let node = self.nodes[pattern]; + let ratio = node.elapsed as f32 / self.patterns[pattern].points.len() as f32; + // 822249B4/82063B08 and 821E63E8/821E63EC, checked against TU3. + let (low, high) = if settings.difficulty == 2 { + (1.5, 3.0) + } else { + (1.75, 4.4) + }; + let strength = if ratio <= low { + 1.0 + } else if ratio >= high { + 0.0 + } else { + let slope = 1.0 / (low - high); + slope.mul_add(ratio, -(slope * high)) + }; + self.refractory = true; + self.held = Some(pattern); + Some(Recognition { + pattern, + strength, + distance: node.distance, + elapsed: node.elapsed as f32, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn recognizer() -> Recognizer { + Recognizer::new(vec![Pattern { + name: "360Flip".into(), + points: vec![ + [-0.965714, 0.268571], + [-0.497143, 0.84], + [0.211429, 0.988571], + [0.908571, -0.405714], + ], + tolerance_squared: 0.4 * 0.4, + }]) + .unwrap() + } + const SETTINGS: Settings = Settings { + maximum_misses: 3, + difficulty: 0, + }; + + #[test] + fn anticipation_hold_does_not_weaken_authored_scoop() { + let mut r = recognizer(); + let points = r.patterns[0].points.clone(); + r.sample([0.0; 2], SETTINGS); + for _ in 0..120 { + assert!(r.sample(points[0], SETTINGS).is_none()); + } + assert!(r.sample(points[1], SETTINGS).is_none()); + assert!(r.sample(points[2], SETTINGS).is_none()); + let result = r.sample(points[3], SETTINGS).unwrap(); + assert_eq!( + (result.pattern, result.strength, result.elapsed), + (0, 1.0, 4.0) + ); + assert_eq!(r.held(points[3]), Some(0)); + assert!(r.sample(points[3], SETTINGS).is_none()); + assert_eq!(r.held([0.0; 2]), None); + } + + #[test] + fn disconnected_scoop_and_reverse_path_do_not_trigger() { + let mut r = recognizer(); + let points = r.patterns[0].points.clone(); + r.sample([0.0; 2], SETTINGS); + r.sample(points[0], SETTINGS); + for _ in 0..4 { + assert!(r.sample([0.0; 2], SETTINGS).is_none()); + } + for &point in &points[1..] { + assert!(r.sample(point, SETTINGS).is_none()); + } + let mut r = recognizer(); + r.sample([0.0; 2], SETTINGS); + for &point in points.iter().rev() { + assert!(r.sample(point, SETTINGS).is_none()); + } + } +} diff --git a/crates/skate-core/src/input/mod.rs b/crates/skate-core/src/input/mod.rs index 536a093b..2fced8f0 100644 --- a/crates/skate-core/src/input/mod.rs +++ b/crates/skate-core/src/input/mod.rs @@ -1,5 +1,7 @@ //! Native input publication. Controller/graph producers remain separate stages. pub mod angle; +pub mod gesture; +pub mod anticipation_intentions; pub mod animation_packet; pub mod controller; pub mod gameplay_map; diff --git a/crates/skate-data/src/gesture_patterns.rs b/crates/skate-data/src/gesture_patterns.rs new file mode 100644 index 00000000..a42b4fff --- /dev/null +++ b/crates/skate-data/src/gesture_patterns.rs @@ -0,0 +1,83 @@ +//! Authored joystick PAT data consumed by TU3 82696B18. +use skate_core::input::gesture::Pattern; +use std::path::Path; + +pub fn load(path: &Path) -> Result, String> { + parse(&std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?) + .map_err(|e| format!("{}: {e}", path.display())) +} + +pub fn parse(source: &str) -> Result, String> { + let mut patterns: Vec = Vec::new(); + let mut tolerance = None; + for (line, source) in source.lines().enumerate() { + let words: Vec<_> = source + .split("//") + .next() + .unwrap_or("") + .split_whitespace() + .collect(); + if words.is_empty() { + continue; + } + let fail = || format!("invalid PAT directive at line {}: {source}", line + 1); + let number = |index: usize| -> Result { + let value: f32 = words + .get(index) + .ok_or_else(fail)? + .parse() + .map_err(|_| fail())?; + if !value.is_finite() { + return Err(fail()); + } + Ok(value) + }; + match words[0] { + "global_tolerance_dist" if words.len() == 2 => { + let v = number(1)?; + tolerance = Some(v * v); + } + // The native loader reads but does not store these values. + "global_tolerance_time" + | "global_tolerance_speed" + | "global_anticipation_delay" + | "tolerance_time" + if words.len() == 2 => + { + number(1)?; + } + "pattern" if words.len() == 2 => patterns.push(Pattern { + name: words[1].into(), + points: Vec::new(), + tolerance_squared: tolerance + .ok_or_else(|| "PAT lacks global_tolerance_dist".to_owned())?, + }), + "tolerance_dist" if words.len() == 2 => { + let v = number(1)?; + patterns.last_mut().ok_or_else(fail)?.tolerance_squared = v * v; + } + "coord" if words.len() == 3 => { + let point = [number(1)?, number(2)?]; + patterns.last_mut().ok_or_else(fail)?.points.push(point); + } + // Fail explicitly on polar or modified directives until their + // transcendental implementation is provided. Stock skater.pat uses coord. + _ => return Err(fail()), + } + } + skate_core::input::gesture::Recognizer::new(patterns.clone())?; + Ok(patterns) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn authored_order_and_per_pattern_tolerances_are_preserved() { + let p = parse("global_tolerance_dist 0.4\nglobal_anticipation_delay 15\npattern Ollie\ntolerance_dist 0.55\ncoord 0 1\ncoord 0 -1\npattern Test\ncoord 1 0\ncoord -1 0").unwrap(); + assert_eq!(p[0].points, vec![[0.0, 1.0], [0.0, -1.0]]); + assert_eq!(p[0].tolerance_squared, 0.55 * 0.55); + assert_eq!(p[1].tolerance_squared, 0.4 * 0.4); + assert!(parse("global_tolerance_dist 0.4\npattern Broken\ncoord 0 1").is_err()); + } +} diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index 3e8ab2cc..6f812395 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -8,6 +8,7 @@ pub mod animation_frames; pub mod animation_metadata; pub mod collections; pub mod input_config; +pub mod gesture_patterns; pub mod physics_skeleton; mod sha256; pub mod state_graph; diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs index ec91cc67..b9376872 100644 --- a/crates/skate-game/src/graph_host/action.rs +++ b/crates/skate-game/src/graph_host/action.rs @@ -45,6 +45,7 @@ pub struct ActionHost { /// JuiceHook instance+8 starts with the native null intent name. Its /// update removes that pending key and resets it,82BA39A8. juice_pending: Vec, + trick_handlers: Vec, } impl ActionHost { @@ -109,6 +110,7 @@ impl ActionHost { body_flip: vec![body_flip_signal::State::default(); count], body_flip_settings: None, juice_pending: vec![String::new(); count], + trick_handlers: vec![crate::input::gesture_mapping::State::default(); count], } } @@ -194,6 +196,7 @@ impl Host for ActionHost { self.time_handlers[behavior] = TimeMgIntent::default(); self.body_flip[behavior] = body_flip_signal::State::default(); self.juice_pending[behavior].clear(); + self.trick_handlers[behavior] = crate::input::gesture_mapping::State::default(); let result = self.next_instance; self.next_instance = self.next_instance.wrapping_add(1).max(1); result @@ -202,6 +205,14 @@ impl Host for ActionHost { let Some(instance) = self.operation(behavior).cloned() else { return; }; + if let ActionOperation::CreateTrickIntentFromGesture { group, override_name } = &instance.operation { + if let Some((_, mirrored)) = self.stance { + self.trick_handlers[behavior].begin(*group, override_name.as_deref(), &self.action_intents, &mut self.motion_intents, mirrored); + } else { + self.errors.push("CreateTrickIntentFromGesture requires published skater stance".into()); + } + return; + } if instance.unsupported().is_some() { self.unsupported(behavior, &instance); return; @@ -250,6 +261,10 @@ impl Host for ActionHost { let Some(instance) = self.operation(behavior).cloned() else { return; }; + if matches!(instance.operation, ActionOperation::CreateTrickIntentFromGesture { .. }) { + self.trick_handlers[behavior].update(&mut self.motion_intents); + return; + } if instance.unsupported().is_some() { self.unsupported(behavior, &instance); return; @@ -320,6 +335,10 @@ impl Host for ActionHost { let Some(instance) = self.operation(behavior).cloned() else { return; }; + if matches!(instance.operation, ActionOperation::CreateTrickIntentFromGesture { .. }) { + self.trick_handlers[behavior].end(&mut self.motion_intents); + return; + } if let Some(name) = instance.config.mg_intent.as_deref() { let mutation = match instance.operation { ActionOperation::CreateConstMgIntent => self.const_handlers[behavior].end(), diff --git a/crates/skate-game/src/graph_host/action_nodes.rs b/crates/skate-game/src/graph_host/action_nodes.rs index 36d8085b..982ea9df 100644 --- a/crates/skate-game/src/graph_host/action_nodes.rs +++ b/crates/skate-game/src/graph_host/action_nodes.rs @@ -73,7 +73,7 @@ pub enum ActionOperation { CreateMgTimeIntent, CreateConstMgIntent, BoardAdjust, - CreateTrickIntentFromGesture, + CreateTrickIntentFromGesture { group: crate::input::gesture_catalog::Group, override_name: Option }, JuiceHook, BodyFlippingSignal, /// Diagnostic presentation is owned by this host and has no intent effect. @@ -120,9 +120,6 @@ impl ActionFactory { ActionOperation::CreateConstMgIntent } (OperationKind::Behavior, "BoardAdjust") => ActionOperation::BoardAdjust, - (OperationKind::Behavior, "CreateTrickIntentFromGesture") => { - ActionOperation::CreateTrickIntentFromGesture - } (OperationKind::Behavior, "JuiceHook") => ActionOperation::JuiceHook, (OperationKind::Behavior, "BodyFlippingSignal") => ActionOperation::BodyFlippingSignal, _ => ActionOperation::Unsupported { @@ -160,7 +157,12 @@ impl OperationFactory for ActionFactory { .unwrap_or_else(|| Self::operation(kind, name)) } } else { - if kind == OperationKind::Behavior && name == "PrintText2D" { + if kind == OperationKind::Behavior && name == "CreateTrickIntentFromGesture" { + ActionOperation::CreateTrickIntentFromGesture { + group: crate::input::gesture_catalog::Group::parse(attributes.text("group").unwrap_or("Square")).map_err(ActionFactoryError)?, + override_name: attributes.text("override").map(str::to_owned), + } + } else if kind == OperationKind::Behavior && name == "PrintText2D" { ActionOperation::PrintText(attributes.text("text").unwrap_or("").to_owned()) } else { Self::operation(kind, name) @@ -200,10 +202,6 @@ impl ActionInstance { kind: OperationKind::Behavior, name: "BoardAdjust".into(), }), - ActionOperation::CreateTrickIntentFromGesture => Some(UnsupportedOperation { - kind: OperationKind::Behavior, - name: "CreateTrickIntentFromGesture".into(), - }), _ => None, } } diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs index d785cd05..8ecb9673 100644 --- a/crates/skate-game/src/graph_host/mod.rs +++ b/crates/skate-game/src/graph_host/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod motion_landing; mod motion_landing_execute; pub(crate) mod motion_native; mod motion_nodes; +mod motion_tricks; #[path = "motion_offboard/push_off.rs"] pub(crate) mod motion_push_off; #[path = "motion_offboard/reset.rs"] diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs index ef7aa3d4..16f66a20 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -44,6 +44,7 @@ pub struct MotionPhysical { pub foot_frame: Option, } enum Instance { + Trick(i32), Stateless, IntentFilter(skate_core::animation::intent_filter::State), Landing(super::motion_landing::State), @@ -69,6 +70,7 @@ enum Instance { impl Instance { fn new(operation: &MotionOperation) -> Self { match operation { + MotionOperation::Trick(_) => Self::Trick(0), MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()), MotionOperation::Landing(_) => Self::Landing(Default::default()), MotionOperation::Wipeout(_) => Self::Wipeout(Default::default()), @@ -134,6 +136,8 @@ pub struct MotionHost { ///SpecificCA4 bit26, getter8258F900/setter8258F910; reset clears it. pub is_power_sliding: bool, pub score_packet: super::motion_native::ScorePacket, + pub(super) trick_requests: super::motion_tricks::Requests, + pub(super) trick_height_settings: (bool, bool), pub action_intents: IntentMap, pub time_tags: Option>, pub physical: Option, @@ -223,6 +227,8 @@ impl MotionHost { slide_latch: set_turning::SlideLatch::default(), is_power_sliding: false, score_packet: super::motion_native::ScorePacket::default(), + trick_requests: Default::default(), + trick_height_settings: (data.boolean("anim_motion", "jumping", "use_gesture_speed")?, data.boolean("anim_motion", "jumping", "clamp_gesture_to_antic")?), action_intents: IntentMap::new(), time_tags: None, physical: None, @@ -372,3 +378,7 @@ mod slide_tests; #[cfg(test)] #[path = "tests/motion_bump.rs"] mod bump_tests; + +#[cfg(test)] +#[path = "tests/motion_tricks.rs"] +mod trick_tests; diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs index 763a84ee..f6d3a93f 100644 --- a/crates/skate-game/src/graph_host/motion_execute.rs +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -15,6 +15,14 @@ impl MotionHost { .get(behavior) .ok_or("Unbound MotionGraph behavior")?; let operation = self.operations[id].clone(); + if let MotionOperation::Trick(operation) = operation { + let Some(&Instance::Trick(mut updates)) = self.instances.get(behavior) else { + return Err("Trick operation/instance mismatch".into()); + }; + let result = super::super::motion_tricks::execute(self, operation, &mut updates, phase); + self.instances[behavior] = Instance::Trick(updates); + return result; + } if let MotionOperation::Slide(operation) = operation { return self.execute_slide(behavior, operation, frame, phase); } diff --git a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs index f61b2ef9..7ded9a97 100644 --- a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs +++ b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs @@ -40,6 +40,8 @@ pub enum GameplayCondition { CanEnterSlide { right: bool }, GrabbingObject, Dark, + UnderflipRequested, + DarkCatchRequested, HandPlanting { state: u8, direction: u8 }, EnteringSkitch, Skitching, @@ -60,6 +62,8 @@ impl GameplayCondition { | "CanEnterSlide" | "IsGrabbingObject" | "IsDark" + | "IsUnderflipRequested" + | "IsDarkCatchRequested" | "IsHandPlanting" | "IsEnteringSkitch" | "IsSkitching" @@ -81,6 +85,8 @@ impl GameplayCondition { }, "IsGrabbingObject" => Self::GrabbingObject, "IsDark" => Self::Dark, + "IsUnderflipRequested" => Self::UnderflipRequested, + "IsDarkCatchRequested" => Self::DarkCatchRequested, "IsEnteringSkitch" => Self::EnteringSkitch, "IsSkitching" => Self::Skitching, "IsHandPlanting" => Self::HandPlanting { @@ -132,7 +138,7 @@ impl GameplayCondition { _ => false, } } - Self::DroppingBoard | Self::Dark | Self::CanEnterSlide { .. } => return None, + Self::DroppingBoard | Self::Dark | Self::UnderflipRequested | Self::DarkCatchRequested | Self::CanEnterSlide { .. } => return None, }) } @@ -149,6 +155,8 @@ impl GameplayCondition { Self::DroppingBoard => p.dropping_board || host.animation.channels.has("RetrieveBoard"), //82BA79A0 calls the specific MotionGraph getter8258FB68. Self::Dark => host.riding.dark, + Self::UnderflipRequested => host.trick_requests.underflip, + Self::DarkCatchRequested => host.trick_requests.dark_catch, Self::CanEnterSlide { right } => { //82BA6FE0 rejects RevertGround102, reverses the side for fakie, //then reads its start bit in the retained C84 slide packet. diff --git a/crates/skate-game/src/graph_host/motion_native.rs b/crates/skate-game/src/graph_host/motion_native.rs index 2c485f23..1580d70c 100644 --- a/crates/skate-game/src/graph_host/motion_native.rs +++ b/crates/skate-game/src/graph_host/motion_native.rs @@ -79,6 +79,7 @@ impl Operation { /// this game.82595D08 sets a name and ORs a bit in the graph's score packet. #[derive(Default)] pub struct ScorePacket { + pub trick: Option, pub name: Option, pub flags: u32, } diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs index 3ddebfec..874c7a7c 100644 --- a/crates/skate-game/src/graph_host/motion_nodes.rs +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -14,6 +14,7 @@ use skate_data::state_graph::{ #[derive(Clone, Debug, PartialEq)] pub enum MotionOperation { + Trick(super::motion_tricks::Operation), Play(PlayAnimation), ///TU3 vtable82309664: Begin/Update/End all point to the empty82B61BB8. PrintText2D, @@ -123,6 +124,7 @@ impl OperationFactory for MotionFactory { super::motion_air_leg::Operation::parse(a), )), "ClearTrickAttr" => Some(MotionOperation::ClearTrickAttr), + "SetTrickHeight" | "SetTrickAttr" | "ScoringTrick" | "MonitorUnderflip" | "SetDark" | "UpdateIsWeightOnNose" => super::motion_tricks::Operation::parse(a).map(MotionOperation::Trick), "CharacterGesture" => Some(MotionOperation::CharacterGesture), "DisallowPumping" => Some(MotionOperation::DisallowPumping), "FakieHeadChannel" => Some(MotionOperation::FakieHeadChannel), diff --git a/crates/skate-game/src/graph_host/motion_tricks.rs b/crates/skate-game/src/graph_host/motion_tricks.rs new file mode 100644 index 00000000..c986f716 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_tricks.rs @@ -0,0 +1,188 @@ +//! Original trick leaves. Registration strings, not community symbol guesses, +//! identify SetTrickAttr82BC7848 and ScoringTrick82BCC030. +use super::motion::MotionHost; +use skate_core::animation::{ + output::attributes::AttributeName, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + Height { + from: AttributeName, + rename: AttributeName, + value: Option, + manual: bool, + grind: bool, + }, + Attribute(AttributeName), + Scoring(AttributeName), + MonitorUnderflip, + Dark, + WeightOnNose, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "SetTrickHeight" => Self::Height { + from: encode(a.text("from").unwrap_or("").as_bytes()), + rename: encode(a.text("rename").unwrap_or("").as_bytes()), + value: a.get("setToValue").map(|v| f32::from_bits(v.float_bits)), + manual: a.get("trickFromManual").is_some(), + grind: a.get("trickFromGrind").is_some(), + }, + "SetTrickAttr" => Self::Attribute(encode(a.text("trick").unwrap_or("").as_bytes())), + "ScoringTrick" => Self::Scoring(encode(a.text("trick").unwrap_or("").as_bytes())), + "MonitorUnderflip" => Self::MonitorUnderflip, + "SetDark" => Self::Dark, + "UpdateIsWeightOnNose" => Self::WeightOnNose, + _ => return None, + }) + } +} + +#[derive(Default)] +pub struct Requests { + pub underflip: bool, + pub dark_catch: bool, +} + +pub fn execute( + host: &mut MotionHost, + operation: Operation, + updates: &mut i32, + phase: u8, +) -> Result<(), String> { + match operation { + Operation::Height { + from, + rename, + value, + manual, + grind, + } if phase == 0 => { + // 82BAEE90: graph intents override animation only on the authored paths. + let gesture = host + .animation + .motion_intents + .get("GestureSpeed") + .copied() + .unwrap_or(0.0); + let height = if let Some(value) = value { + value + } else if let Some(value) = host.animation.motion_intents.get("TrickHeight") { + *value + } else { + let mut height = host + .animation + .last_attribute(from)? + .and_then(|a| a.payload.0[0]) + .map(f32::from_bits) + .unwrap_or(0.0); + if host.trick_height_settings.0 { + height = if host.trick_height_settings.1 { + height.min(gesture.max(0.0)) + } else { + gesture + }; + } + height + }; + // Independent packet output carries gesture speed, not selected height. + host.animation.emit_packet( + encode(b"JumpHeightOverride"), + gesture + * if manual { + 0.27 + } else if grind { + 1.0 + } else { + 0.2 + }, + ); + host.animation.set_attribute(SettableAttribute { + name: rename, + value: height, + normalized: true, + sequence_id: -1, + }); + } + Operation::Attribute(value) => { + let name = encode(b"Trick"); + if phase == 0 { + if let Some(entry) = host + .animation + .construction_values + .iter_mut() + .find(|e| e.0 == name) + { + entry.1 = value; + } else { + host.animation.construction_values.push((name, value)); + } + } else if phase == 2 + && host + .animation + .last_attribute(encode(b"defaultcyc"))? + .is_some() + { + host.animation.construction_values.retain(|e| e.0 != name); + } + } + Operation::Scoring(name) if phase == 1 => { + // ISkaterMotionGraph236 ->8258FA20 stores both equal names and bit24. + host.score_packet.trick = Some(name); + host.score_packet.flags |= 0x0100_0000; + } + Operation::MonitorUnderflip => { + if phase == 0 { + *updates = 0; + } else if phase == 2 { + host.trick_requests = Requests::default(); + } else { + *updates = updates.wrapping_add(1); + if *updates > 1 { + let intents = &host.animation.motion_intents; + if [ + "U_L_F_Kickflip", + "U_L_F_Heelflip", + "U_L_B_Kickflip", + "U_L_B_Heelflip", + "U_Nollie", + "U_Fingerflip", + "U_Kickflip", + "U_Heelflip", + "U_N_Kickflip", + "U_N_Heelflip", + ] + .iter() + .any(|n| intents.contains_key(n)) + { + host.trick_requests.underflip = true; + } else if intents.contains_key("DarkCatch") { + host.trick_requests.dark_catch = true; + } + } + } + } + Operation::Dark => { + host.riding.dark = phase != 2; + if phase == 1 { + host.animation.emit_packet(encode(b"IsDark"), 1.0); + } + } + Operation::WeightOnNose if phase != 1 => { + //82BB2670/26D0 -> ISkaterAnim20 ->82B97110, bit28. + let flags = host + .animation + .skater_animation_flags + .as_mut() + .ok_or("UpdateIsWeightOnNose requires live ISkaterAnim")?; + *flags = (*flags & !0x1000_0000) | if phase == 0 { 0x1000_0000 } else { 0 }; + } + _ => {} + } + Ok(()) +} diff --git a/crates/skate-game/src/graph_host/tests/motion_tricks.rs b/crates/skate-game/src/graph_host/tests/motion_tricks.rs new file mode 100644 index 00000000..4f6bb33a --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_tricks.rs @@ -0,0 +1,194 @@ +use super::*; +use skate_core::animation::playback_tree::Evaluation; +#[test] +#[ignore = "requires private stock graphs and animation banks; no gameplay simulation"] +fn stock_360_crouch_takeoff_and_air_animation_pipeline() { + use crate::{animation_pose::PoseEvaluator, graph_runtime::CompiledGraph}; + use skate_data::{ + animation_banks::AnimationBanks, + state_graph::{StateGraph, binding::Binding}, + }; + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let banks = AnimationBanks::load(&root).unwrap(); + let evaluator = PoseEvaluator::from_banks(&banks).unwrap(); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + let graph = LoadedGraph { + source, + binding, + runtime, + }; + let collections = Collections::load(&root).unwrap(); + let mut host = MotionHost::from_graph( + &graph, + &collections, + banks.metadata().unwrap(), + PlaybackContext { + is_switch: Some(false), + is_mirrored: Some(false), + board_available: Some(true), + pro_skater: encode(b""), + transition_override: None, + }, + ) + .unwrap(); + host.animation + .set_hierarchy( + &evaluator.frames.bone_names, + &evaluator.frames.mirror_indices, + ) + .unwrap(); + + let frame = Frame { + dt: 1.0 / 60.0, + current: None, + last: None, + state_times: Vec::new(), + }; + let evaluation = Evaluation { + cull_threshold: 0.01, + update_history: true, + }; + let find = |suffix: &str, operation: &str| -> usize { + use skate_data::state_graph::binding::Node; + graph + .binding + .operations + .iter() + .enumerate() + .find_map(|(id, source)| { + let Node::State(mut state) = source.parent else { + return None; + }; + let mut names = Vec::new(); + loop { + names.push(graph.binding.states[state].name.clone()); + let Some(parent) = graph.binding.states[state].parent else { + break; + }; + state = parent; + } + names.reverse(); + if !names.join(".").ends_with(suffix) { + return None; + } + let runtime = host.remap.behaviors.iter().position(|&op| op == id)?; + let matches = match (&host.operations[id], operation) { + (MotionOperation::Play(_), "PlayAnimation") => true, + ( + MotionOperation::Trick(super::super::motion_tricks::Operation::Height { + .. + }), + "SetTrickHeight", + ) => true, + ( + MotionOperation::Trick(super::super::motion_tricks::Operation::Attribute( + _, + )), + "SetTrickAttr", + ) => true, + ( + MotionOperation::Trick(super::super::motion_tricks::Operation::Scoring(_)), + "ScoringTrick", + ) => true, + ( + MotionOperation::Trick( + super::super::motion_tricks::Operation::MonitorUnderflip, + ), + "MonitorUnderflip", + ) => true, + _ => false, + }; + matches.then_some(runtime) + }) + .unwrap_or_else(|| panic!("Missing {suffix}/{operation}")) + }; + let crouch = find("Anticipation.AnticInto.AnticTail", "PlayAnimation"); + let scoop = find( + "Anticipation.AnticCyc.360PopShuvitAnticCycle", + "PlayAnimation", + ); + let ground = find("TailTrick.360Flip.Takeoff.FromAntic", "PlayAnimation"); + let height = find("TailTrick.360Flip.Takeoff.FromAntic", "SetTrickHeight"); + let air = find("TailTrick.360Flip.LeftGround", "PlayAnimation"); + let attr = find("TailTrick.360Flip", "SetTrickAttr"); + let score = find("TailTrick.360Flip", "ScoringTrick"); + let monitor = find("TailTrick.360Flip", "MonitorUnderflip"); + for mirrored in [false, true] { + host.animation.reset_from_stock(); + host.animation.skater_animation_flags = Some(if mirrored { 0x40000000 } else { 0 }); + host.playback_context.is_mirrored = Some(mirrored); + host.animation.filtered_intents.insert("Compression", 1.0); + for (behavior, duration) in [(crouch, 0.25), (scoop, 0.5)] { + host.execute(behavior, &frame, 0).unwrap(); + host.animation.apply_parameters().unwrap(); + host.animation.advance(duration, 0.0); + host.animation.refresh_tree_attributes().unwrap(); + let pose = evaluator + .evaluate(&host.animation.evaluate_pose(evaluation).unwrap()) + .unwrap(); + assert!( + pose.iter() + .flat_map(|b| b.rotation.iter().chain(&b.translation)) + .all(|v| v.is_finite()) + ); + } + let antic = host + .animation + .last_attribute(encode(b"AnticStrength")) + .unwrap() + .expect("stock crouch must supply AnticStrength"); + assert!(f32::from_bits(antic.payload.0[0].unwrap()) > 0.0); + host.animation.motion_intents.insert("GestureSpeed", 1.0); + for behavior in [attr, score, monitor, ground, height] { + host.execute(behavior, &frame, 0).unwrap(); + } + host.execute(score, &frame, 1).unwrap(); + assert_eq!(host.score_packet.trick, Some(encode(b"360Flip"))); + assert!( + host.animation + .construction_values + .contains(&(encode(b"Trick"), encode(b"360Flip"))) + ); + host.animation.apply_parameters().unwrap(); + host.animation.advance(0.1, 0.0); + host.animation.refresh_tree_attributes().unwrap(); + assert!( + host.animation + .last_attribute(encode(b"TrickHeight")) + .unwrap() + .is_some() + ); + let pose = evaluator + .evaluate(&host.animation.evaluate_pose(evaluation).unwrap()) + .unwrap(); + assert!( + pose.iter() + .flat_map(|b| b.rotation.iter().chain(&b.translation)) + .all(|v| v.is_finite()) + ); + host.execute(air, &frame, 0).unwrap(); + host.animation.apply_parameters().unwrap(); + host.animation.advance(0.1, 0.0); + host.animation.refresh_tree_attributes().unwrap(); + let pose = evaluator + .evaluate(&host.animation.evaluate_pose(evaluation).unwrap()) + .unwrap(); + assert!( + pose.iter() + .flat_map(|b| b.rotation.iter().chain(&b.translation)) + .all(|v| v.is_finite()) + ); + host.execute(monitor, &frame, 1).unwrap(); + assert!(!host.trick_requests.dark_catch); + host.execute(monitor, &frame, 2).unwrap(); + } + println!( + "Stock crouch, scoop, B_360FLIP_G -> B_360FLIP_A, attributes and finite poses checked in both stances" + ); +} diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 3de683b0..91087583 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -4,6 +4,9 @@ use bevy::prelude::*; mod controllers; pub(crate) mod gesture_catalog; +mod gesture_mapping_data; +pub(crate) mod gesture_mapping; +pub(crate) mod gesture_input; mod platform; pub(crate) use controllers::{ControllerInput, ControllerStatus}; diff --git a/crates/skate-game/src/input/gesture_input.rs b/crates/skate-game/src/input/gesture_input.rs new file mode 100644 index 00000000..69db9677 --- /dev/null +++ b/crates/skate-game/src/input/gesture_input.rs @@ -0,0 +1,203 @@ +//! GameInputManager82696030/826962D8 -> listener8259B878/8259B9D0 -> +//! Fill8259B1F0..B7D8. Runs on the existing 60 Hz gameplay input boundary. +use skate_core::{ + graph::intents::IntentMap, + input::gesture::{Recognizer, Settings}, +}; +use skate_data::collections::Collections; +use std::path::Path; + +pub(crate) struct GestureInput { + recognizers: Vec<(usize, Recognizer)>, + maximum_misses: [u8; 2], + held_pattern: Option, +} +impl GestureInput { + pub fn load(root: &Path) -> Result { + let data = Collections::load(root)?; + let misses = |key| -> Result { + let field = data.field("recognizer", key, "NumTicksPatternNotInRangeBeforeCulling")?; + if field.type_name != "EA::Reflection::UInt8" { + return Err("Recognizer culling field must be UInt8".into()); + } + u8::from_str_radix(&field.data, 16).map_err(|e| e.to_string()) + }; + let mut recognizers = Vec::new(); + // Original constructor82695A68 insertion order; each file competes + // internally, and all winners are delivered to the listener in order. + for (stick, file) in [ + (1, "skater.pat"), + (1, "skater90.pat"), + (1, "skaterN90.pat"), + (1, "skater_air.pat"), + (1, "skater_fingerflip.pat"), + (0, "skaterls.pat"), + (0, "skaterstep.pat"), + ] { + recognizers.push(( + stick, + Recognizer::new(skate_data::gesture_patterns::load( + &root.join("private/stock/data/joystick").join(file), + )?)?, + )); + } + Ok(Self { + recognizers, + maximum_misses: [misses("left_stick")?, misses("right_stick")?], + held_pattern: None, + }) + } + + pub fn publish( + &mut self, + axes: [[f32; 2]; 2], + difficulty: u32, + flags: u32, + physical_state: u32, + ag: &mut IntentMap, + ) { + // Native manager negates mapped Y. Component deadzone0.1 is initialized + // by82F75F60 from820641A8, separately from cInputMap's own deadzones. + let samples = axes.map(|[x, y]| [x, -y].map(|v| if v.abs() < 0.1 { 0.0 } else { v })); + let mut events = Vec::new(); + let mut held = false; + for stick in [1, 0] { + // Held checks precede *all* matches on this stick. + for (_, recognizer) in self.recognizers.iter_mut().filter(|r| r.0 == stick) { + if let Some(index) = recognizer.held(samples[stick]) { + if self.held_pattern.as_deref().is_some_and(|name| { + name.eq_ignore_ascii_case(&recognizer.patterns()[index].name) + }) { + held = true; + } + } + } + for (_, recognizer) in self.recognizers.iter_mut().filter(|r| r.0 == stick) { + if let Some(result) = recognizer.sample( + samples[stick], + Settings { + maximum_misses: self.maximum_misses[stick], + difficulty, + }, + ) { + let name = recognizer.patterns()[result.pattern].name.clone(); + // Listener retains only these four hold-capable events. + if ["Kickflip", "Heelflip", "N_Kickflip", "N_Heelflip"].contains(&name.as_str()) + { + self.held_pattern = Some(name.clone()); + } + events.push((name, result.strength)); + } + } + } + if held { + ag.insert("HoldPattern", 1.0); + } + for (name, strength) in events { + if permitted(&name, flags, physical_state, ag) { + ag.insert("Trick", 1.0); + ag.insert(&name, 1.0); + ag.insert("GestureSpeed", strength); + } + } + } +} + +fn permitted(name: &str, flags: u32, state: u32, ag: &IntentMap) -> bool { + let bit = |n: u32| (flags & (1u32 << n)) != 0u32; + let member = |names: &[&str]| names.iter().any(|n| n.eq_ignore_ascii_case(name)); + if bit(5) && member(&["FingerFlip", "FS_Varial", "BS_Varial"]) { + return false; + } + if bit(4) + && (matches!(state, 200 | 201) + || member(&[ + "L_F_Kickflip", + "L_B_Kickflip", + "L_F_Heelflip", + "L_B_Heelflip", + "L_FS_Shuvit", + "L_BS_Shuvit", + ])) + { + return false; + } + if member(&["FrontFlip", "BackFlip"]) + && (!(ag.contains_key("RightAirGrab") || ag.contains_key("LeftAirGrab")) + || ag.contains_key("LeftPush") + || ag.contains_key("RightPush") + || bit(3)) + { + return false; + } + if bit(8) && member(&["SlideFs180", "SlideBs180"]) { + return false; + } + if bit(12) + && !member(&[ + "Ollie", + "Kickflip", + "Heelflip", + "Hardflip", + "InwardHeelflip", + "VarialKickflip", + "VarialHeelflip", + "PopShuvit", + "FSPopShuvit", + "360PopShuvit", + "FS360PopShuvit", + ]) + { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "requires private stock joystick and collection data"] + fn stock_360_gesture_reaches_native_square_mapping() { + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"), + ); + let mut input = GestureInput::load(&root).unwrap(); + let mut ag = IntentMap::new(); + let points = input.recognizers[0] + .1 + .patterns() + .iter() + .find(|p| p.name == "360Flip") + .unwrap() + .points + .clone(); + input.publish([[0.0; 2]; 2], 0, 0, 100, &mut ag); + for _ in 0..30 { + ag.clear(); + input.publish( + [[0.0; 2], [points[0][0], -points[0][1]]], + 0, + 0, + 100, + &mut ag, + ); + assert!(!ag.contains_key("Trick")); + } + for point in &points[1..] { + ag.clear(); + input.publish([[0.0; 2], [point[0], -point[1]]], 0, 0, 100, &mut ag); + } + assert!(ag.contains_key("360Flip"), "{ag:?}"); + assert_eq!( + super::super::gesture_mapping::select( + super::super::gesture_catalog::Group::Square, + &ag, + false + ), + Some("360Flip") + ); + assert_eq!(ag.get("GestureSpeed"), Some(&1.0)); + println!("Authored scoop through all seven PAT recognizers: {ag:?}"); + } +} diff --git a/crates/skate-game/src/input/gesture_mapping.rs b/crates/skate-game/src/input/gesture_mapping.rs new file mode 100644 index 00000000..07220159 --- /dev/null +++ b/crates/skate-game/src/input/gesture_mapping.rs @@ -0,0 +1,121 @@ +//! GestureTrickMapping82BA07F0 and CreateTrickIntentFromGesture82BA1C30..2228. +use super::{gesture_catalog::Group, gesture_mapping_data::TABLES}; +use skate_core::{animation::playback_parameters::intent_key, graph::intents::IntentMap}; + +pub(crate) fn select(group: Group, intents: &IntentMap, mirrored: bool) -> Option<&'static str> { + let rows = TABLES[group as usize]; + // 82BC0D90 uses greatest prime <=46, then maximum load10000. + // 82BC1BE8 prepends entries; 82BA07F0 visits ascending buckets. + let mut selected = None; + let mut bucket = usize::MAX; + for row in rows.iter().rev() { + let current = intent_key(row.0) + .iter() + .fold(0u32, |sum, word| sum.wrapping_add(*word)) as usize + % 43; + if current < bucket && intents.contains_key(row.0) { + selected = Some(row); + bucket = current; + } + } + let row = selected?; + let mirrored = mirrored && !intents.contains_key("DontMirrorTrick"); + Some(if intents.contains_key("DarkCatch") && row.3 != 0 { + match (row.3, mirrored) { + (2, false) | (1, true) => "Heelflip", + (3, true) | (4, false) => "N_Heelflip", + (3, false) | (4, true) => "N_Kickflip", + _ => "Kickflip", + } + } else if mirrored { + row.2 + } else { + row.1 + }) +} + +#[derive(Clone, Debug)] +pub(crate) struct State { + first_update: bool, + selected: Option, + successor: Option<&'static str>, +} +impl Default for State { + fn default() -> Self { + Self { + first_update: true, + selected: None, + successor: None, + } + } +} +impl State { + pub fn begin( + &mut self, + group: Group, + override_name: Option<&str>, + ag: &IntentMap, + mg: &mut IntentMap, + mirrored: bool, + ) { + let Some(name) = select(group, ag, mirrored) else { + return; + }; + let name = override_name.unwrap_or(name); + self.selected = Some(name.to_owned()); + self.successor = match name { + "Kickflip" => Some("KickflipHold"), + "Heelflip" => Some("HeelflipHold"), + "N_Kickflip" => Some("N_KickflipHold"), + "N_Heelflip" => Some("N_HeelflipHold"), + _ => None, + }; + mg.insert(name, 0.0); + } + pub fn update(&mut self, mg: &mut IntentMap) { + if !self.first_update { + if let (Some(name), Some(successor)) = (&self.selected, self.successor) { + mg.remove(name); + mg.insert(successor, 0.0); + } + } + self.first_update = false; + } + pub fn end(&mut self, mg: &mut IntentMap) { + if let Some(name) = &self.selected { + if let Some(successor) = self.successor { + mg.remove(successor); + } + mg.remove(name); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn tre_flip_uses_original_mirror_mapping_and_presence_not_strength() { + let mut ag = IntentMap::new(); + ag.insert("360Flip", 0.0); + assert_eq!(select(Group::Square, &ag, false), Some("360Flip")); + assert_eq!(select(Group::Square, &ag, true), Some("Laserflip")); + ag.insert("DontMirrorTrick", 0.0); + assert_eq!(select(Group::Square, &ag, true), Some("360Flip")); + } + #[test] + fn first_update_retains_launch_intent_then_successor_is_cleaned_on_exit() { + let mut ag = IntentMap::new(); + ag.insert("Kickflip", 1.0); + let mut mg = IntentMap::new(); + let mut state = State::default(); + state.begin(Group::Square, None, &ag, &mut mg, false); + state.update(&mut mg); + assert!(mg.contains_key("Kickflip")); + state.update(&mut mg); + assert!(!mg.contains_key("Kickflip")); + assert!(mg.contains_key("KickflipHold")); + state.end(&mut mg); + assert!(mg.is_empty()); + } +} diff --git a/crates/skate-game/src/input/gesture_mapping_data.rs b/crates/skate-game/src/input/gesture_mapping_data.rs new file mode 100644 index 00000000..2faa3889 --- /dev/null +++ b/crates/skate-game/src/input/gesture_mapping_data.rs @@ -0,0 +1,413 @@ +//! TU3 82B98F70 records, re-extracted from original bytes and all 270 map inserts. +// Each tuple is (gesture key, normal intent, mirrored intent, dark-catch category). +pub(super) const TABLES: [&[(&str, &str, &str, u32)]; 7] = [ + &[ + ("Ollie", "Ollie", "Ollie", 1), + ("PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1), + ("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2), + ("Hardflip", "Hardflip", "InwardHeelflip", 1), + ("InwardHeelflip", "InwardHeelflip", "Hardflip", 2), + ("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1), + ("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2), + ("360Flip", "360Flip", "Laserflip", 0), + ("Laserflip", "Laserflip", "360Flip", 0), + ("360Hardflip", "360Hardflip", "360InwardHeelflip", 0), + ("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0), + ("Kickflip", "Kickflip", "Heelflip", 0), + ("Heelflip", "Heelflip", "Kickflip", 0), + ("Nollie", "Nollie", "Nollie", 3), + ("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ( + "N_VarialKickflip", + "N_VarialKickflip", + "N_VarialHeelflip", + 3, + ), + ( + "N_VarialHeelflip", + "N_VarialHeelflip", + "N_VarialKickflip", + 4, + ), + ("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3), + ("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4), + ("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3), + ("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4), + ("N_360Flip", "N_360Flip", "N_Laserflip", 0), + ("N_Laserflip", "N_Laserflip", "N_360Flip", 0), + ("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0), + ( + "N_360InwardHeelflip", + "N_360InwardHeelflip", + "N_360Hardflip", + 0, + ), + ("N_Kickflip", "N_Kickflip", "N_Heelflip", 0), + ("N_Heelflip", "N_Heelflip", "N_Kickflip", 0), + ], + &[ + ("Nollie", "Nollie", "Nollie", 3), + ("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ( + "N_VarialKickflip", + "N_VarialKickflip", + "N_VarialHeelflip", + 3, + ), + ( + "N_VarialHeelflip", + "N_VarialHeelflip", + "N_VarialKickflip", + 4, + ), + ("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3), + ("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4), + ("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3), + ("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4), + ("N_360Flip", "N_360Flip", "N_Laserflip", 0), + ("N_Laserflip", "N_Laserflip", "N_360Flip", 0), + ("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0), + ( + "N_360InwardHeelflip", + "N_360InwardHeelflip", + "N_360Hardflip", + 0, + ), + ("N_Kickflip", "N_Kickflip", "N_Heelflip", 0), + ("N_Heelflip", "N_Heelflip", "N_Kickflip", 0), + ("Ollie", "Nollie", "Nollie", 3), + ("PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ("VarialKickflip", "Nollie", "Nollie", 3), + ("VarialHeelflip", "Nollie", "Nollie", 4), + ("Hardflip", "Nollie", "Nollie", 3), + ("InwardHeelflip", "Nollie", "Nollie", 4), + ("360PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("FS360PopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ("360Flip", "Nollie", "Nollie", 3), + ("Laserflip", "Nollie", "Nollie", 4), + ("360Hardflip", "Nollie", "Nollie", 3), + ("360InwardHeelflip", "Nollie", "Nollie", 4), + ("Kickflip", "Nollie", "Nollie", 3), + ("Heelflip", "Nollie", "Nollie", 4), + ], + &[ + ("Ollie", "Ollie", "Ollie", 1), + ("PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1), + ("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2), + ("Hardflip", "Hardflip", "InwardHeelflip", 1), + ("InwardHeelflip", "InwardHeelflip", "Hardflip", 2), + ("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1), + ("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2), + ("360Flip", "360Flip", "Laserflip", 0), + ("Laserflip", "Laserflip", "360Flip", 0), + ("360Hardflip", "360Hardflip", "360InwardHeelflip", 0), + ("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0), + ("Kickflip", "Kickflip", "Heelflip", 0), + ("Heelflip", "Heelflip", "Kickflip", 0), + ("Nollie", "Ollie", "Ollie", 1), + ("N_PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N_VarialKickflip", "Ollie", "Ollie", 1), + ("N_VarialHeelflip", "Ollie", "Ollie", 2), + ("N_Hardflip", "Ollie", "Ollie", 1), + ("N_InwardHeelflip", "Ollie", "Ollie", 2), + ("N_360PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N_FS360PopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N_360Flip", "Ollie", "Ollie", 1), + ("N_Laserflip", "Ollie", "Ollie", 2), + ("N_360Hardflip", "Ollie", "Ollie", 1), + ("N_360InwardHeelflip", "Ollie", "Ollie", 2), + ("N_Kickflip", "Ollie", "Ollie", 1), + ("N_Heelflip", "Ollie", "Ollie", 2), + ], + &[ + ("90_Nollie", "Nollie", "Nollie", 3), + ("90_N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("90_N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ( + "90_N_VarialKickflip", + "N_VarialKickflip", + "N_VarialHeelflip", + 3, + ), + ( + "90_N_VarialHeelflip", + "N_VarialHeelflip", + "N_VarialKickflip", + 4, + ), + ("90_N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3), + ("90_N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4), + ("90_N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3), + ( + "90_N_FS360PopShuvit", + "N_FS360PopShuvit", + "N_360PopShuvit", + 4, + ), + ("90_N_360Flip", "N_360Flip", "N_Laserflip", 0), + ("90_N_Laserflip", "N_Laserflip", "N_360Flip", 0), + ( + "90_N_360Hardflip", + "N_360Hardflip", + "N_360InwardHeelflip", + 0, + ), + ( + "90_N_360InwardHeelflip", + "N_360InwardHeelflip", + "N_360Hardflip", + 0, + ), + ("90_N_Kickflip", "N_Kickflip", "N_Heelflip", 0), + ("90_N_Heelflip", "N_Heelflip", "N_Kickflip", 0), + ("Nollie", "Nollie", "Nollie", 3), + ("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ( + "N_VarialKickflip", + "N_VarialKickflip", + "N_VarialHeelflip", + 3, + ), + ( + "N_VarialHeelflip", + "N_VarialHeelflip", + "N_VarialKickflip", + 4, + ), + ("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3), + ("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4), + ("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3), + ("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4), + ("N_360Flip", "N_360Flip", "N_Laserflip", 0), + ("N_Laserflip", "N_Laserflip", "N_360Flip", 0), + ("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0), + ( + "N_360InwardHeelflip", + "N_360InwardHeelflip", + "N_360Hardflip", + 0, + ), + ("N_Kickflip", "N_Kickflip", "N_Heelflip", 0), + ("N_Heelflip", "N_Heelflip", "N_Kickflip", 0), + ("Ollie", "Nollie", "Nollie", 3), + ("PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ("VarialKickflip", "Nollie", "Nollie", 3), + ("VarialHeelflip", "Nollie", "Nollie", 4), + ("Hardflip", "Nollie", "Nollie", 3), + ("InwardHeelflip", "Nollie", "Nollie", 4), + ("360PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("FS360PopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ("360Flip", "Nollie", "Nollie", 3), + ("Laserflip", "Nollie", "Nollie", 4), + ("360Hardflip", "Nollie", "Nollie", 3), + ("360InwardHeelflip", "Nollie", "Nollie", 4), + ("Kickflip", "Nollie", "Nollie", 3), + ("Heelflip", "Nollie", "Nollie", 4), + ], + &[ + ("90_Ollie", "Ollie", "Ollie", 1), + ("90_PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("90_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("90_VarialKickflip", "VarialKickflip", "VarialHeelflip", 1), + ("90_VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2), + ("90_Hardflip", "Hardflip", "InwardHeelflip", 1), + ("90_InwardHeelflip", "InwardHeelflip", "Hardflip", 2), + ("90_360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1), + ("90_FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2), + ("90_360Flip", "360Flip", "Laserflip", 0), + ("90_Laserflip", "Laserflip", "360Flip", 0), + ("90_360Hardflip", "360Hardflip", "360InwardHeelflip", 0), + ( + "90_360InwardHeelflip", + "360InwardHeelflip", + "360Hardflip", + 0, + ), + ("90_Kickflip", "Kickflip", "Heelflip", 0), + ("90_Heelflip", "Heelflip", "Kickflip", 0), + ("Ollie", "Ollie", "Ollie", 1), + ("PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1), + ("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2), + ("Hardflip", "Hardflip", "InwardHeelflip", 1), + ("InwardHeelflip", "InwardHeelflip", "Hardflip", 2), + ("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1), + ("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2), + ("360Flip", "360Flip", "Laserflip", 0), + ("Laserflip", "Laserflip", "360Flip", 0), + ("360Hardflip", "360Hardflip", "360InwardHeelflip", 0), + ("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0), + ("Kickflip", "Kickflip", "Heelflip", 0), + ("Heelflip", "Heelflip", "Kickflip", 0), + ("Nollie", "Ollie", "Ollie", 1), + ("N_PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N_VarialKickflip", "Ollie", "Ollie", 1), + ("N_VarialHeelflip", "Ollie", "Ollie", 2), + ("N_Hardflip", "Ollie", "Ollie", 1), + ("N_InwardHeelflip", "Ollie", "Ollie", 2), + ("N_360PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N_FS360PopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N_360Flip", "Ollie", "Ollie", 1), + ("N_Laserflip", "Ollie", "Ollie", 2), + ("N_360Hardflip", "Ollie", "Ollie", 1), + ("N_360InwardHeelflip", "Ollie", "Ollie", 2), + ("N_Kickflip", "Ollie", "Ollie", 1), + ("N_Heelflip", "Ollie", "Ollie", 2), + ], + &[ + ("N90_Nollie", "Nollie", "Nollie", 3), + ("N90_N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("N90_N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ( + "N90_N_VarialKickflip", + "N_VarialKickflip", + "N_VarialHeelflip", + 3, + ), + ( + "N90_N_VarialHeelflip", + "N_VarialHeelflip", + "N_VarialKickflip", + 4, + ), + ("N90_N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3), + ("N90_N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4), + ( + "N90_N_360PopShuvit", + "N_360PopShuvit", + "N_FS360PopShuvit", + 3, + ), + ( + "N90_N_FS360PopShuvit", + "N_FS360PopShuvit", + "N_360PopShuvit", + 4, + ), + ("N90_N_360Flip", "N_360Flip", "N_Laserflip", 0), + ("N90_N_Laserflip", "N_Laserflip", "N_360Flip", 0), + ( + "N90_N_360Hardflip", + "N_360Hardflip", + "N_360InwardHeelflip", + 0, + ), + ( + "N90_N_360InwardHeelflip", + "N_360InwardHeelflip", + "N_360Hardflip", + 0, + ), + ("N90_N_Kickflip", "N_Kickflip", "N_Heelflip", 0), + ("N90_N_Heelflip", "N_Heelflip", "N_Kickflip", 0), + ("Nollie", "Nollie", "Nollie", 3), + ("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ( + "N_VarialKickflip", + "N_VarialKickflip", + "N_VarialHeelflip", + 3, + ), + ( + "N_VarialHeelflip", + "N_VarialHeelflip", + "N_VarialKickflip", + 4, + ), + ("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3), + ("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4), + ("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3), + ("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4), + ("N_360Flip", "N_360Flip", "N_Laserflip", 0), + ("N_Laserflip", "N_Laserflip", "N_360Flip", 0), + ("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0), + ( + "N_360InwardHeelflip", + "N_360InwardHeelflip", + "N_360Hardflip", + 0, + ), + ("N_Kickflip", "N_Kickflip", "N_Heelflip", 0), + ("N_Heelflip", "N_Heelflip", "N_Kickflip", 0), + ("Ollie", "Nollie", "Nollie", 3), + ("PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ("VarialKickflip", "Nollie", "Nollie", 3), + ("VarialHeelflip", "Nollie", "Nollie", 4), + ("Hardflip", "Nollie", "Nollie", 3), + ("InwardHeelflip", "Nollie", "Nollie", 4), + ("360PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3), + ("FS360PopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4), + ("360Flip", "Nollie", "Nollie", 3), + ("Laserflip", "Nollie", "Nollie", 4), + ("360Hardflip", "Nollie", "Nollie", 3), + ("360InwardHeelflip", "Nollie", "Nollie", 4), + ("Kickflip", "Nollie", "Nollie", 3), + ("Heelflip", "Nollie", "Nollie", 4), + ], + &[ + ("N90_Ollie", "Ollie", "Ollie", 1), + ("N90_PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N90_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N90_VarialKickflip", "VarialKickflip", "VarialHeelflip", 1), + ("N90_VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2), + ("N90_Hardflip", "Hardflip", "InwardHeelflip", 1), + ("N90_InwardHeelflip", "InwardHeelflip", "Hardflip", 2), + ("N90_360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1), + ("N90_FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2), + ("N90_360Flip", "360Flip", "Laserflip", 0), + ("N90_Laserflip", "Laserflip", "360Flip", 0), + ("N90_360Hardflip", "360Hardflip", "360InwardHeelflip", 0), + ( + "N90_360InwardHeelflip", + "360InwardHeelflip", + "360Hardflip", + 0, + ), + ("N90_Kickflip", "Kickflip", "Heelflip", 0), + ("N90_Heelflip", "Heelflip", "Kickflip", 0), + ("Ollie", "Ollie", "Ollie", 1), + ("PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1), + ("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2), + ("Hardflip", "Hardflip", "InwardHeelflip", 1), + ("InwardHeelflip", "InwardHeelflip", "Hardflip", 2), + ("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1), + ("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2), + ("360Flip", "360Flip", "Laserflip", 0), + ("Laserflip", "Laserflip", "360Flip", 0), + ("360Hardflip", "360Hardflip", "360InwardHeelflip", 0), + ("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0), + ("Kickflip", "Kickflip", "Heelflip", 0), + ("Heelflip", "Heelflip", "Kickflip", 0), + ("Nollie", "Ollie", "Ollie", 1), + ("N_PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N_VarialKickflip", "Ollie", "Ollie", 1), + ("N_VarialHeelflip", "Ollie", "Ollie", 2), + ("N_Hardflip", "Ollie", "Ollie", 1), + ("N_InwardHeelflip", "Ollie", "Ollie", 2), + ("N_360PopShuvit", "PopShuvit", "FSPopShuvit", 1), + ("N_FS360PopShuvit", "FSPopShuvit", "PopShuvit", 2), + ("N_360Flip", "Ollie", "Ollie", 1), + ("N_Laserflip", "Ollie", "Ollie", 2), + ("N_360Hardflip", "Ollie", "Ollie", 1), + ("N_360InwardHeelflip", "Ollie", "Ollie", 2), + ("N_Kickflip", "Ollie", "Ollie", 1), + ("N_Heelflip", "Ollie", "Ollie", 2), + ], +]; diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 20c1e1c7..5923cd48 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -59,7 +59,13 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } }; - app::build(config, manifest, graphs, physics, skater).run() + let controls = match physics::PlayerControls::load(&config.asset_root) { + Ok(controls) => controls, + Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); } + }; + let mut app = app::build(config, manifest, graphs, physics, skater); + app.insert_resource(controls); + app.run() } #[cfg(test)] diff --git a/crates/skate-game/src/physics/controls.rs b/crates/skate-game/src/physics/controls.rs index 6c4701ae..53c51f7f 100644 --- a/crates/skate-game/src/physics/controls.rs +++ b/crates/skate-game/src/physics/controls.rs @@ -19,6 +19,7 @@ pub(crate) struct PlayerControls { pub bumper_state_502: bool, pub bumper_state_104: bool, pub preferences: PushPreferences, + gestures: Option, } impl Default for PlayerControls { fn default() -> Self { @@ -33,6 +34,7 @@ impl Default for PlayerControls { bumper_state_502: false, bumper_state_104: false, preferences: PushPreferences::default(), + gestures: None, } } } @@ -50,9 +52,22 @@ pub(super) fn sample( physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204, ); + player.publish_gestures(physics.animation_profile.physics_mode, skater.player_input.physical.state.state_16); } impl PlayerControls { + pub fn load(root: &std::path::Path) -> Result { + Ok(Self { gestures: Some(crate::input::gesture_input::GestureInput::load(root)?), ..Self::default() }) + } + + pub fn publish_gestures(&mut self, difficulty: u32, physical_state: u32) { + if let Some(gestures) = &mut self.gestures { + let words = self.controller.words(); + let axes = [[words[7],words[8]], [words[9],words[10]]].map(|p|p.map(f32::from_bits)); + gestures.publish(axes,difficulty,self.actor_flags,physical_state,&mut self.action_intents); + } + } + pub fn update(&mut self, map: &mut impl ActionMap, dt: f32, magnitude_threshold: f32, physical_capabilities: u32) { self.controller.update( map, @@ -70,6 +85,7 @@ impl PlayerControls { self.intents.extend(wipeout_intentions::produce( &self.controller, self.actor_flags, physical_capabilities, )); + self.intents.extend(skate_core::input::anticipation_intentions::produce(&self.controller)); //GenerateActionGraphIntents82594310 clears the AG map through82BC1B68 //before Listener::Fill. MG lifecycle intents use a different persistent map. self.action_intents.clear(); diff --git a/crates/skate-game/src/tests/action_host.rs b/crates/skate-game/src/tests/action_host.rs index a2b4b4ce..32fcf1c2 100644 --- a/crates/skate-game/src/tests/action_host.rs +++ b/crates/skate-game/src/tests/action_host.rs @@ -19,6 +19,35 @@ fn parameter(mg_intent: &str, value: f32) -> Parameter { } } +#[test] +fn gesture_behavior_publishes_and_releases_the_stock_tre_flip_intent() { + let mut config = parameter("", 0.0); + config.mg_intent = None; + let instance = ActionInstance { + operation: ActionOperation::CreateTrickIntentFromGesture { + group: crate::input::gesture_catalog::Group::Square, + override_name: None, + }, + config, + parameters: Vec::new(), + }; + let mut host = ActionHost::new(ActionInstances::new(vec![instance]), OperationRemap { + behaviors: vec![0], conditions: Vec::new(), hooks: Vec::new(), + }); + let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: Vec::new() }; + for (mirrored, expected) in [(false, "360Flip"), (true, "Laserflip")] { + host.stance = Some((false, mirrored)); + host.action_intents.insert("360Flip", 1.0); + Host::allocate(&mut host, 0, &frame); + Host::begin(&mut host, 0, [0; 6], &frame); + Host::update(&mut host, 0, [0; 6], &frame); + assert_eq!(host.motion_intents.get(expected), Some(&0.0)); + Host::end(&mut host, 0, [0; 6], &frame); + assert!(host.motion_intents.is_empty()); + } + assert!(host.errors.is_empty(), "{:?}", host.errors); +} + #[test] fn const_callbacks_publish_then_remove_motion_intent() { let instance = ActionInstance { diff --git a/docs/360-FLIP.md b/docs/360-FLIP.md new file mode 100644 index 00000000..42349a78 --- /dev/null +++ b/docs/360-FLIP.md @@ -0,0 +1,81 @@ +# 360 flip through the stock graphs + +The ordinary on-board 360-flip route is wired for user visual testing. +PLAY.bat and PLAY-MAP.bat use the staged build. The supplied graphs and +animation banks remain the source of animation selection, transitions, +postures, timing, and physical animation attributes. + +## Input and playback + +- Right-stick magnitude and angle publish `AnticMag` and `AnticAngle`. + The existing anticipation states choose and blend the crouch/scoop poses. +- The seven supplied joystick `.pat` files now run through the recovered + recognizer. There is no trick hotkey, synthetic launch impulse, or new clip + selected outside the graphs. +- Recognized events pass through the original listener gates, then publish + the gesture name, `Trick`, and `GestureSpeed` into the ActionGraph. +- `CreateTrickIntentFromGesture` uses all 270 recovered mapping records, + including native bucket order, mirrored mappings, overrides, dark-catch + categories, and the four hold successors. A non-mirrored `360Flip` gesture + maps to `360Flip`; when mirrored, that gesture maps to `Laserflip`, and the + opposite gesture maps to `360Flip`, as in the original mapping. +- The stock ground path selects `B_360FLIP_G`, then sequences into + `B_360FLIP_A`. `SetTrickHeight` consumes the preceding animation's + `AnticStrength`, gesture speed, and the stock jumping settings. Its + `JumpHeightOverride` packet enters the existing skeleton-input handler. +- Trick construction identity, scoring metadata, underflip/dark-catch + requests, and nose-weight anticipation now have their original handlers. + +The stock scoop coordinates for the non-mirrored 360-flip gesture correspond +to left/slightly down, down-left, down/slightly right, then an up-right flick +on the physical right stick. The recognizer permits holding the initial +point without weakening the eventual gesture. Direction changes with the +stock mirrored mapping; this is not an extra control setting. + +## Evidence + +TU3 instruction bytes were checked in a disposable IDA database against +the recompiled PPC instruction listings. Relevant boundaries: + +| Behavior | Native source | +| --- | --- | +| PAT loading and push-front coordinate storage | 82696B18, 82699738 | +| Recognition, competition, strength, refractory frame | 82697168, 826972B8, 826974A8 | +| Axis sign and component deadzone | 82696030, 826962D8, 82F75F60 | +| Anticipation inputs | 82599AD0–82599C4C, 8259AD0C–8259AD40 | +| Pattern/held listeners and intent publication | 8259B878, 8259B9D0, 8259B1F0–8259B7D8 | +| Gesture mappings and graph lifecycle | 82B98F70, 82BA07F0, 82BA1C30–82BA2228 | +| Trick height | 82BAED78, 82BAEE90 | +| Trick construction identity | 82BB5C20, 82BB5CA0 | +| Scoring metadata | 82BBFA88, 8258FA20 | +| Underflip requests | 82BBE098, 82BBE0A8, 82BBE358 | +| Nose-weight anticipation | 82BB2670, 82BB26D0, 82B97110 | + +Factory names were verified from registration strings. In particular, +the community label on 82BC7848 incorrectly calls it `ScoringTrick`; the +actual registration is `SetTrickAttr`. Scoring's factory is 82BCC030. +Local research outputs and the re-extracted mapping records are under +`logs/`; original private binaries and animation data are not committed. + +## Verification and user checks + +Nonvisual checks pass for 36 core input tests, the PAT parser, the gesture +mapping/ActionHost lifecycle, and two private-stock fixtures. Those fixtures +recognize the authored scoop through all seven files and exercise the +stock crouch, scoop, takeoff, and air-animation handlers in both stances, +including finite decoded poses and required attributes. These are data/code +checks, not a claim of visually verified gameplay parity. No gameplay or +visual test was automated. The existing game instance was closed to stage +the new build; restarting it for the user is separate from these checks. + +Please check: + +1. Hold the right stick to crouch while stationary and while rolling. +2. Perform a 360 flip; check preparation, pop, board flip/spin, and foot motion. +3. Land and push away, then repeat in the opposite stance. + +This work validates the ordinary ground route, not every trick in the newly +connected catalog. Advanced manual/grind-out branches still contain the +known unsupported `JumpInto` behavior, and the existing separate +`AddRunoutAttribs` gap remains. Those handlers were not bypassed. The current +test does not certify dark catches or the rest of the trick catalog visually.