One-shot emotes: LiftOff/Land in combat, no restarts, local play (#773)
Three ways the one-shot emote player differed from 1.12.1. - **LiftOff and Land were refused in combat.** `0x5fcd20` exempts AnimIDs 192 and 200 from its combat test only (`0x5fcd5f`-`0x5fcd92`), so a dragon's take-off and landing now play mid-fight. - **An emote could restart a clip already armed.** The player skips an id already armed on the key bone or bone 0 (`0x5fcd56`, `0x5fdb50`). It compares the requested id, which op4 stores at `+0xf8` (`0x71252f`), not the fallback that plays. - **Your own emote waited for the server.** `DoEmote` plays it locally while a direction key is held (`0x5fd680`, called at `0x5ef660`), so a moving `/wave` shows at once and its echo is skipped. Tests cover the exemption, the armed test (including on a rider), each local-play gate, and the skipped echo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
56d8011ab7
commit
4fd0c643a6
@@ -424,6 +424,10 @@ pub(crate) struct EmoteAnim {
|
||||
pub(crate) anim_id: u16,
|
||||
/// `PlaySeq` stamp; a kit anim inherits its `CastEvent`'s.
|
||||
pub(crate) seq: u64,
|
||||
/// Came through the shared one-shot emote player (`0x5fcd20`: `SMSG_EMOTE`, the gesture
|
||||
/// dispatcher), which skips an id already armed on the key bone or bone 0 (`0x5fcd56`). A kit
|
||||
/// anim and `DoEmote`'s local play call `0x5fe2f0` directly and take no such test.
|
||||
pub(crate) via_player: bool,
|
||||
}
|
||||
|
||||
/// A state kit's `AnimID`, compared and never played: both field-4 callers (`0x5ff4c6`,
|
||||
@@ -465,6 +469,7 @@ fn flourish_to_anim(
|
||||
entity: child.0,
|
||||
anim_id: select::MOUNT_SPECIAL,
|
||||
seq: play_seq.next(),
|
||||
via_player: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -511,6 +516,7 @@ mod emote_anim;
|
||||
/// The client-local gestures (chat, NPC interact), the reference's `0x60bb30`.
|
||||
mod gesture;
|
||||
use emote_anim::emote_to_anim;
|
||||
pub(crate) use emote_anim::{local_play_eligible, LocalPlay};
|
||||
pub(crate) use gesture::{select_gesture, Gesture, GestureQueue};
|
||||
|
||||
/// The Bevy systems that execute the state machine `select` picks.
|
||||
@@ -845,6 +851,8 @@ impl Plugin for CreatureAnimPlugin {
|
||||
// Same frame as the loot kneel: the reference force-plays Loot 50 in the
|
||||
// handler that arms the latch.
|
||||
.after(crate::ui_loot::resolve_loot_kneel)
|
||||
// `DoEmote`'s local play, written in the chat drain.
|
||||
.after(crate::ui_chat::ChatDrain)
|
||||
// After Input so a sheath request (the Z toggle) executes the same frame.
|
||||
.after(WorldStage::Input)
|
||||
// `VisualSheath` must land before `resolve_equipment` reads it, or a sheath
|
||||
|
||||
@@ -56,7 +56,8 @@ const JUMP_ARC_MIN_UP: f32 = 0.5;
|
||||
#[derive(Clone, Copy)]
|
||||
enum OneShotReq {
|
||||
Swing(u32),
|
||||
Emote(u16),
|
||||
/// The id, and whether it came through the emote player's armed-id test.
|
||||
Emote(u16, bool),
|
||||
}
|
||||
|
||||
/// The live one-shot the combat fast path tests: the key bone's clip, else bone 0's (`0x5fe422`).
|
||||
@@ -80,6 +81,31 @@ fn live_oneshot(
|
||||
None
|
||||
}
|
||||
|
||||
/// The id armed on the unit, as `0x5fdb50` reads it: the key bone's while one runs, else bone 0's.
|
||||
/// It is the REQUESTED id, not the fallback clip that plays: op4 stores its raw argument at
|
||||
/// `+0xf8` (`0x71252f`) and the getter returns it (`0x7120d3`). A clip that has finished counts as
|
||||
/// gone: its completion callback re-arms the base.
|
||||
fn armed_id(
|
||||
drv: &AnimDriver,
|
||||
player: &AnimationPlayer,
|
||||
tr: &AnimationTransitions,
|
||||
anims: &ModelAnimations,
|
||||
) -> Option<u16> {
|
||||
let running = |n| player.animation(n).is_some_and(|a| !a.is_finished());
|
||||
if let Some(ov) = drv.overlay.filter(|ov| running(ov.node)) {
|
||||
return Some(ov.id);
|
||||
}
|
||||
let node = tr.get_main_animation().filter(|&n| running(n))?;
|
||||
// The clip's own id only when the mode holds no requested id.
|
||||
drv.active_anim().or_else(|| {
|
||||
anims
|
||||
.clips
|
||||
.iter()
|
||||
.find(|c| c.node == node)
|
||||
.map(|c| c.anim_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a one-shot is live on the unit; the missile queue polls it to launch a missile whose
|
||||
/// release keyframe never fired when the cast ends (the reference's `0x5fc920` → `0x60c9b0`).
|
||||
pub(crate) fn oneshot_is_live(
|
||||
@@ -338,10 +364,11 @@ pub(super) fn drive_animations(
|
||||
pending
|
||||
.entry(e.entity)
|
||||
.or_default()
|
||||
.push((OneShotReq::Emote(e.anim_id), e.seq));
|
||||
.push((OneShotReq::Emote(e.anim_id, e.via_player), e.seq));
|
||||
}
|
||||
// A mounted rider's mount-set emotes go to the mount model (`0x5fe7c1`); the rider keeps the
|
||||
// request for its own CLASS_A and nowhere routes.
|
||||
// request for its own CLASS_A and nowhere routes. The emote player's armed test (`0x5fcd56`)
|
||||
// runs once, on the rider, ahead of that routing, so the forwarded copy carries none.
|
||||
let mut to_mount: Vec<(Entity, (OneShotReq, u64))> = Vec::new();
|
||||
for (rider, reqs) in pending.iter() {
|
||||
let Ok((store, child)) = riders.get(*rider) else {
|
||||
@@ -351,8 +378,18 @@ pub(super) fn drive_animations(
|
||||
continue;
|
||||
}
|
||||
for &(req, seq) in reqs {
|
||||
if matches!(req, OneShotReq::Emote(id) if route_mounted(id).mount) {
|
||||
to_mount.push((child.0, (req, seq)));
|
||||
let OneShotReq::Emote(id, via_player) = req else {
|
||||
continue;
|
||||
};
|
||||
if !route_mounted(id).mount {
|
||||
continue;
|
||||
}
|
||||
let armed = via_player
|
||||
.then(|| units.get(*rider).ok())
|
||||
.flatten()
|
||||
.and_then(|u| armed_id(u.4, u.2, u.3, u.1));
|
||||
if armed != Some(id) {
|
||||
to_mount.push((child.0, (OneShotReq::Emote(id, false), seq)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -731,7 +768,7 @@ pub(super) fn drive_animations(
|
||||
});
|
||||
// In `PlaySeq` order, defense last: the reference plays it from the deferred impact scan,
|
||||
// after every message handler.
|
||||
let mut requests: Vec<u16> = pending
|
||||
let mut requests: Vec<(u16, Option<u16>)> = pending
|
||||
.remove(&entity)
|
||||
.map(|mut v| {
|
||||
v.sort_by_key(|&(_, seq)| seq);
|
||||
@@ -747,19 +784,21 @@ pub(super) fn drive_animations(
|
||||
swing_anim_main(w.armed_main())
|
||||
};
|
||||
debug!("swing: unit {entity} anim {id} (hitInfo {hit_info:#x})");
|
||||
id
|
||||
(id, None)
|
||||
}
|
||||
OneShotReq::Emote(id) => id,
|
||||
// The armed-id test compares the record's own AnimID, before the play
|
||||
// seam's unarmed remap.
|
||||
OneShotReq::Emote(id, via_player) => (id, via_player.then_some(id)),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
requests.extend(defense);
|
||||
requests.extend(defense.map(|id| (id, None)));
|
||||
// At the play seam (`0x5fe2f0`), Special1H/2H with both hands empty becomes
|
||||
// SpecialUnarmed(118), for every request.
|
||||
{
|
||||
let w = wielded.copied().unwrap_or_default();
|
||||
for id in &mut requests {
|
||||
for (id, _) in &mut requests {
|
||||
*id = select::unarmed_special(*id, w.armed_main(), w.armed_off());
|
||||
}
|
||||
}
|
||||
@@ -771,9 +810,22 @@ pub(super) fn drive_animations(
|
||||
&& !airborne_frozen
|
||||
&& live_oneshot(&drv, &player, &tr, anims, catalog).is_none()
|
||||
{
|
||||
requests.extend(drv.deferred.take());
|
||||
requests.extend(drv.deferred.take().map(|id| (id, None)));
|
||||
}
|
||||
for id in requests {
|
||||
for (id, armed_test) in requests {
|
||||
// The emote player's own test first (`0x5fcd56`): an id already armed on the key bone
|
||||
// or bone 0 plays nothing, ahead of the lock and the fast path in `0x5fe2f0`.
|
||||
if let Some(raw) = armed_test {
|
||||
if armed_id(&drv, &player, &tr, anims) == Some(raw) {
|
||||
if benilla_assets::trace::enabled() {
|
||||
benilla_assets::trace::line(
|
||||
"fct",
|
||||
&format!("anim armed-skip unit={entity} id={raw}"),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// The lock first: `0x5fe2f0`'s head guard returns before routing, the fast path and
|
||||
// the dedup, so a locked unit gets no arm and no deferral.
|
||||
if drv.base_lock.refuses() {
|
||||
|
||||
@@ -662,6 +662,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
|
||||
entity: spinner,
|
||||
anim_id: 57,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.world_mut().write_message(SwingMessage {
|
||||
attacker: swinger,
|
||||
@@ -747,12 +748,14 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
|
||||
entity: spin_last,
|
||||
anim_id: 57,
|
||||
seq: 2,
|
||||
via_player: false,
|
||||
});
|
||||
// swing_last: the spin arrives first.
|
||||
app.world_mut().write_message(EmoteAnim {
|
||||
entity: swing_last,
|
||||
anim_id: 57,
|
||||
seq: 3,
|
||||
via_player: false,
|
||||
});
|
||||
app.world_mut().write_message(SwingMessage {
|
||||
attacker: swing_last,
|
||||
@@ -1450,6 +1453,7 @@ fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
fn drv(app: &App, unit: Entity) -> &AnimDriver {
|
||||
@@ -1494,6 +1498,7 @@ fn landing_mid_cast_plays_the_land_pick() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
app.world_mut()
|
||||
@@ -1525,6 +1530,7 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update(); // Swing { 54, under: Jump }
|
||||
app.world_mut().entity_mut(unit).insert(MovementState {
|
||||
@@ -1543,6 +1549,7 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 2,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert!(
|
||||
@@ -1597,6 +1604,7 @@ fn a_moving_jump_cast_masks_onto_the_overlay() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
let drv = app.world().entity(unit).get::<AnimDriver>().unwrap();
|
||||
@@ -1622,6 +1630,7 @@ fn a_jump_over_a_live_cast_transplants_it_to_the_torso() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
{
|
||||
@@ -1774,6 +1783,7 @@ fn a_finished_masked_cast_fades_out_instead_of_snapping() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
{
|
||||
@@ -1903,6 +1913,7 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
|
||||
entity: unit,
|
||||
anim_id: 57,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.world_mut().write_message(SwingMessage {
|
||||
attacker: unit,
|
||||
@@ -2226,6 +2237,7 @@ fn a_root_landing_with_the_cast_returns_the_body_to_neutral() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -2252,6 +2264,7 @@ fn a_run_starting_under_a_cast_still_transplants_it_up() {
|
||||
entity: unit,
|
||||
anim_id: 54,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
|
||||
@@ -2553,6 +2566,7 @@ fn the_mount_transition_takes_bone_0_back_from_a_full_body_one_shot() {
|
||||
entity: unit,
|
||||
anim_id: SPELL_CAST_OMNI,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -2872,6 +2886,7 @@ fn a_mid_volley_fire_clip_re_pulls_and_the_pull_promotes_to_the_hold() {
|
||||
entity: unit,
|
||||
anim_id: 46,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -3022,6 +3037,7 @@ fn every_shot_of_a_volley_re_arms_the_fire_clip_through_the_emote_lane() {
|
||||
entity: unit,
|
||||
anim_id: 46,
|
||||
seq: shot,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(mode(&app), FIRING, "shot {shot} takes bone 0");
|
||||
@@ -3429,6 +3445,7 @@ fn a_special_goes_unarmed_only_when_both_hands_are_empty() {
|
||||
entity: unit,
|
||||
anim_id: 57,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
unit
|
||||
};
|
||||
@@ -3698,6 +3715,7 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
|
||||
entity: unit,
|
||||
anim_id: 57,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert!(edge(&app), "and so does a one-shot");
|
||||
@@ -3729,6 +3747,7 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
|
||||
entity: unit,
|
||||
anim_id: 57,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert!(edge(&app), "the first combat play arms normally");
|
||||
@@ -3806,6 +3825,7 @@ mod base_anim_lock {
|
||||
entity: unit,
|
||||
anim_id,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
@@ -4041,6 +4061,7 @@ mod routing {
|
||||
entity,
|
||||
anim_id,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
@@ -4137,6 +4158,136 @@ mod routing {
|
||||
"no full-body fallback"
|
||||
);
|
||||
}
|
||||
|
||||
/// The armed id is the requested one (`0x712090` returns `+0xf8`, op4's raw argument), not
|
||||
/// the clip a fallback plays: Stand runs on bone 0 while 68 is the requested id.
|
||||
#[test]
|
||||
fn the_armed_id_is_the_requested_one_not_the_fallback_clip() {
|
||||
use super::super::super::select::Mode;
|
||||
let mut app = app();
|
||||
let unit = spawn(&mut app, model(vec![clip(0, 1, true)]), 0);
|
||||
app.update();
|
||||
let armed = |app: &App| {
|
||||
let e = app.world().entity(unit);
|
||||
super::super::armed_id(
|
||||
e.get::<AnimDriver>().unwrap(),
|
||||
e.get::<AnimationPlayer>().unwrap(),
|
||||
e.get::<AnimationTransitions>().unwrap(),
|
||||
e.get::<ModelAnimations>().unwrap(),
|
||||
)
|
||||
};
|
||||
assert_eq!(armed(&app), Some(0), "Stand requested, Stand playing");
|
||||
app.world_mut()
|
||||
.entity_mut(unit)
|
||||
.get_mut::<AnimDriver>()
|
||||
.unwrap()
|
||||
.mode = Mode::Swing {
|
||||
id: CHEER,
|
||||
under: None,
|
||||
};
|
||||
assert_eq!(armed(&app), Some(CHEER), "the requested id, not clip 0");
|
||||
}
|
||||
|
||||
/// A mounted rider's mount-set emote is tested once, on the rider: an id the rider has armed
|
||||
/// is not forwarded to the mount child.
|
||||
#[test]
|
||||
fn a_rider_armed_id_stops_the_forward_to_the_mount() {
|
||||
use super::super::super::select::Mode;
|
||||
let mut app = app();
|
||||
let rider = spawn(&mut app, model(vec![clip(0, 1, true)]), 1);
|
||||
let child = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
model(vec![clip(0, 1, true), clip(SHUFFLE, 2, false)]),
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimDriver::default(),
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.entity_mut(rider)
|
||||
.insert(crate::entities::mount::MountChild(child));
|
||||
app.update();
|
||||
let child_mode = |app: &App| app.world().entity(child).get::<AnimDriver>().unwrap().mode;
|
||||
let send = |app: &mut App, via_player: bool| {
|
||||
app.world_mut().write_message(EmoteAnim {
|
||||
entity: rider,
|
||||
anim_id: SHUFFLE,
|
||||
seq: 1,
|
||||
via_player,
|
||||
});
|
||||
app.update();
|
||||
};
|
||||
// A control: a kit anim (no armed test) is forwarded.
|
||||
send(&mut app, false);
|
||||
assert!(
|
||||
matches!(child_mode(&app), Mode::Swing { id: SHUFFLE, .. }),
|
||||
"the mount-set emote reaches the mount"
|
||||
);
|
||||
app.world_mut()
|
||||
.entity_mut(child)
|
||||
.get_mut::<AnimDriver>()
|
||||
.unwrap()
|
||||
.mode = Mode::Gait;
|
||||
// The rider has it armed: the player's test refuses before the forward.
|
||||
app.world_mut()
|
||||
.entity_mut(rider)
|
||||
.get_mut::<AnimDriver>()
|
||||
.unwrap()
|
||||
.mode = Mode::Swing {
|
||||
id: SHUFFLE,
|
||||
under: None,
|
||||
};
|
||||
send(&mut app, true);
|
||||
assert_eq!(child_mode(&app), Mode::Gait, "not forwarded to the mount");
|
||||
}
|
||||
|
||||
/// `DoEmote`'s local play (`0x5fe2f0`) arms the clip at once; the `SMSG_EMOTE` echo that follows
|
||||
/// through the emote player finds the id armed (`0x5fcd56`) and leaves it running.
|
||||
#[test]
|
||||
fn the_echo_of_a_local_emote_does_not_restart_it() {
|
||||
let mut app = app();
|
||||
let unit = spawn(
|
||||
&mut app,
|
||||
model(vec![clip(0, 1, true), masked(CHEER, 2, 12)]),
|
||||
0,
|
||||
);
|
||||
app.world_mut().entity_mut(unit).insert(MovementState {
|
||||
flags: move_flags::FORWARD,
|
||||
..Default::default()
|
||||
});
|
||||
app.update();
|
||||
let send = |app: &mut App, via_player: bool| {
|
||||
app.world_mut().write_message(EmoteAnim {
|
||||
entity: unit,
|
||||
anim_id: CHEER,
|
||||
seq: 1,
|
||||
via_player,
|
||||
});
|
||||
app.update();
|
||||
};
|
||||
let node = AnimationNodeIndex::new(12);
|
||||
let speed = |app: &App| {
|
||||
app.world()
|
||||
.entity(unit)
|
||||
.get::<AnimationPlayer>()
|
||||
.unwrap()
|
||||
.animation(node)
|
||||
.map(bevy::animation::ActiveAnimation::speed)
|
||||
};
|
||||
send(&mut app, false);
|
||||
assert_eq!(speed(&app), Some(1.0), "the local play armed the key bone");
|
||||
// A fresh arm resets the rate to 1.0, so a marker rate shows a restart.
|
||||
app.world_mut()
|
||||
.entity_mut(unit)
|
||||
.get_mut::<AnimationPlayer>()
|
||||
.unwrap()
|
||||
.animation_mut(node)
|
||||
.unwrap()
|
||||
.set_speed(0.25);
|
||||
send(&mut app, true);
|
||||
assert_eq!(speed(&app), Some(0.25), "the echo did not re-arm the clip");
|
||||
}
|
||||
}
|
||||
|
||||
/// The `UNIT_NPC_EMOTESTATE` resolver (`0x5fd770`): the current interact NPC passes over a state
|
||||
@@ -4303,6 +4454,7 @@ mod emote_state {
|
||||
entity: npc,
|
||||
anim_id: 118,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert!(matches!(mode(&app, npc), Mode::Swing { id: 118, .. }));
|
||||
@@ -4321,6 +4473,7 @@ mod emote_state {
|
||||
entity: partner,
|
||||
anim_id: 118,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
interact_guid(&mut app, Some(partner), PLAYER);
|
||||
@@ -4391,6 +4544,7 @@ mod emote_state {
|
||||
entity: npc,
|
||||
anim_id: 118,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
assert!(matches!(
|
||||
@@ -4427,3 +4581,92 @@ mod emote_state {
|
||||
assert_eq!(gait(&app, swimmer), Some(42), "moving, it swims");
|
||||
}
|
||||
}
|
||||
|
||||
/// The emote player's armed-id test (`0x5fcd56`, `0x5fdb50`): an id already running on bone 0 is
|
||||
/// not played again, whether it is the gait clip or an earlier one-shot; a different id is, and a
|
||||
/// kit anim (`0x5fe2f0` directly) takes no such test.
|
||||
#[test]
|
||||
fn the_emote_player_skips_an_id_already_armed_but_plays_a_different_one() {
|
||||
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
|
||||
use bevy::animation::AnimationClip;
|
||||
|
||||
let mut app = app();
|
||||
let mut asset = |secs: f32| {
|
||||
let mut c = AnimationClip::default();
|
||||
c.set_duration(secs);
|
||||
app.world_mut()
|
||||
.resource_mut::<Assets<AnimationClip>>()
|
||||
.add(c)
|
||||
};
|
||||
let (stand, dance, wave) = (asset(10.0), asset(10.0), asset(10.0));
|
||||
let (graph, nodes) = AnimationGraph::from_clips([stand, dance, wave]);
|
||||
let graph_handle = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<AnimationGraph>>()
|
||||
.add(graph);
|
||||
let mut clips = vec![clip(0, 0, true), clip(69, 0, true), clip(67, 0, false)];
|
||||
for (c, n) in clips.iter_mut().zip(nodes.iter().copied()) {
|
||||
c.node = n;
|
||||
c.duration = 10.0;
|
||||
}
|
||||
let unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
ModelAnimations {
|
||||
graph: graph_handle.clone(),
|
||||
clips,
|
||||
hand_close: [None, None],
|
||||
playable_animation_lookup: Vec::new(),
|
||||
animation_lookup: Vec::new(),
|
||||
global_bones: Vec::new(),
|
||||
first_seq: None,
|
||||
pose: Default::default(),
|
||||
},
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimationGraphHandle(graph_handle),
|
||||
AnimDriver::default(),
|
||||
))
|
||||
.id();
|
||||
app.update(); // settle: Stand holds bone 0
|
||||
let play = |app: &mut App, anim_id: u16, via_player: bool| {
|
||||
app.world_mut().write_message(EmoteAnim {
|
||||
entity: unit,
|
||||
anim_id,
|
||||
seq: 1,
|
||||
via_player,
|
||||
});
|
||||
advance(app, 500);
|
||||
};
|
||||
let swing = |app: &App| match app.world().entity(unit).get::<AnimDriver>().unwrap().mode {
|
||||
super::super::select::Mode::Swing { id, .. } => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
let time = |app: &App, node| {
|
||||
app.world()
|
||||
.entity(unit)
|
||||
.get::<AnimationPlayer>()
|
||||
.unwrap()
|
||||
.animation(node)
|
||||
.map(|a| a.seek_time())
|
||||
};
|
||||
|
||||
// The gait's own id over itself: nothing arms.
|
||||
play(&mut app, 0, true);
|
||||
assert_eq!(swing(&app), None, "Stand over Stand is skipped");
|
||||
// A kit anim has no such test: it arms the same id.
|
||||
play(&mut app, 0, false);
|
||||
assert_eq!(swing(&app), Some(0), "a kit anim is not skipped");
|
||||
// A different id arms and runs.
|
||||
play(&mut app, 69, true);
|
||||
assert_eq!(swing(&app), Some(69), "a different id plays");
|
||||
let t = time(&app, nodes[1]).expect("armed");
|
||||
assert!(t > 0.4, "the dance has run since its arm: {t}");
|
||||
// The same id over itself does not restart: its clip time keeps advancing.
|
||||
play(&mut app, 69, true);
|
||||
let t2 = time(&app, nodes[1]).expect("armed");
|
||||
assert!(t2 > t, "the dance kept advancing, {t} then {t2}");
|
||||
// A different id replaces it.
|
||||
play(&mut app, 67, true);
|
||||
assert_eq!(swing(&app), Some(67));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use bevy::prelude::*;
|
||||
use crate::net::{EmoteKind, EmoteMessage, ObjectStore, RemoteMotion};
|
||||
use crate::sound::EmoteSounds;
|
||||
|
||||
use super::{move_flags, EmoteAnim, Engaged, MovementState};
|
||||
use super::{move_flags, select, EmoteAnim, Engaged, MovementState};
|
||||
|
||||
/// The performer's gate inputs: stand state, move flags (`MovementState` for us, `RemoteMotion`
|
||||
/// for a remote player; a creature's spline carries no swim bit) and the [`Engaged`] marker.
|
||||
@@ -45,7 +45,7 @@ pub(super) fn emote_to_anim(
|
||||
};
|
||||
let (store, movement, remote, engaged) =
|
||||
units.get(entity).unwrap_or((None, None, None, false));
|
||||
if !play_eligible(store, movement, remote, engaged) {
|
||||
if !play_eligible(store, movement, remote, engaged, anim_id) {
|
||||
debug!("emote_anim: suppressed anim {anim_id} for {entity:?}");
|
||||
continue;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ pub(super) fn emote_to_anim(
|
||||
entity,
|
||||
anim_id: anim_id as u16,
|
||||
seq: play_seq.next(),
|
||||
via_player: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -65,10 +66,13 @@ pub(super) fn receive_eligible(stand_state: u8, swimming: bool) -> bool {
|
||||
}
|
||||
|
||||
/// The shared player's half, `0x5fcd20`: no play while channeling (`0x5fcd83`) or in combat
|
||||
/// (`0x5fcd9d`). Its already-armed test (`0x5fcd5d`) is the driver's same-id dedup, and its
|
||||
/// `[+0xd58] & 0x400` test (`0x5fcd8e`) reads an anim-state bit with no counterpart here.
|
||||
fn player_eligible(channeling: bool, in_combat: bool) -> bool {
|
||||
!channeling && !in_combat
|
||||
/// (`0x5fcd9d`), and LiftOff (192) and Land (200) skip the combat test alone (`0x5fcd5f`/`0x5fcd67`
|
||||
/// set the flag that `0x5fcd90` branches past `0x60ecd0` on; the channel test at `0x5fcd83` runs
|
||||
/// first). Its already-armed test (`0x5fcd5d`) is the driver's `armed_id`, and its
|
||||
/// `[+0xd58] & 0x400` test (`0x5fcd8e`, the weapon-visual hold) is [`super::RangedHold`].
|
||||
fn player_eligible(anim_id: u32, channeling: bool, in_combat: bool) -> bool {
|
||||
let exempt = anim_id == u32::from(select::LIFT_OFF) || anim_id == u32::from(select::LAND);
|
||||
!channeling && (exempt || !in_combat)
|
||||
}
|
||||
|
||||
/// The whole gate for one unit, as both producers call it; `engaged` stands for the client's
|
||||
@@ -78,6 +82,7 @@ pub(super) fn play_eligible(
|
||||
movement: Option<&MovementState>,
|
||||
remote: Option<&RemoteMotion>,
|
||||
engaged: bool,
|
||||
anim_id: u32,
|
||||
) -> bool {
|
||||
let fields = store.map(|s| &s.0);
|
||||
let stand_state = fields.map_or(0, |f| f.unit_stand_state());
|
||||
@@ -89,7 +94,53 @@ pub(super) fn play_eligible(
|
||||
!= 0;
|
||||
let channeling = fields.is_some_and(|f| f.unit_channel_spell() != 0);
|
||||
let in_combat = engaged || fields.is_some_and(|f| f.unit_flags() & UNIT_FLAGS_COMBAT_BIT != 0);
|
||||
receive_eligible(stand_state, swimming) && player_eligible(channeling, in_combat)
|
||||
receive_eligible(stand_state, swimming) && player_eligible(anim_id, channeling, in_combat)
|
||||
}
|
||||
|
||||
/// Our own unit's inputs to `DoEmote`'s local-play gate `0x5fd680`.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct LocalPlay {
|
||||
/// The shared dead test `0x605f90`: health 0, `UNIT_DYNFLAG_DEAD` or stand state 7.
|
||||
pub(crate) reads_dead: bool,
|
||||
pub(crate) stand_state: u8,
|
||||
pub(crate) move_flags: u32,
|
||||
pub(crate) vertical_speed: f32,
|
||||
/// On a flying server spline (a taxi), which exempts the turn-shuffle test (`0x5fce47`).
|
||||
pub(crate) flying: bool,
|
||||
/// The loot-kneel latch with its kneeling target (`0x6126b0` and `0x612710`).
|
||||
pub(crate) loot_kneel: bool,
|
||||
pub(crate) channeling: bool,
|
||||
/// The auto-attack target `[+0xc48]` alone (`0x60ecb0`), not the combat flag bit.
|
||||
pub(crate) engaged: bool,
|
||||
}
|
||||
|
||||
/// Move flags that void the turn-shuffle test: HOVER, SWIMMING and `0x800` (`0x5fce5d`).
|
||||
const SHUFFLE_VOID: u32 = move_flags::HOVER | move_flags::SWIMMING | 0x800;
|
||||
|
||||
/// `0x5fd680`, the gate on `DoEmote`'s local play (`0x5ef660`, then `0x5fe2f0` at `0x5ef66f`): the emote plays at once only while
|
||||
/// a direction key is held, and every other case waits for the server's `SMSG_EMOTE`. It refuses
|
||||
/// the dead (`0x605f90`), a unit both jumping and falling far (`0x5fd6a9`), a jump with vertical
|
||||
/// speed (`0x5fd6c5`), a kneeling looter (`0x5fd6e7`), a channel (`0x5fd701`), an auto-attack
|
||||
/// target (`0x5fd721`), a turn shuffle (`0x5fd72c`, approximated) and any stand state but 0 (`0x5fd74d`, and
|
||||
/// the cached `[+0xc1c]` at `0x5fd757`, which tracks it). The `[+0xd58]` test at `0x5fd711` refuses
|
||||
/// only when both 0x400 ([`super::RangedHold`]) and 0x10000 (unmodelled) are set, and so is not
|
||||
/// tested here; the turn-shuffle test approximates `0x5fce30`, which also exempts on the armed id,
|
||||
/// exempts on `d58 & 0xc` and fires on the mouse-turn latch `d58 & 0x1800`. The ranged auto-repeat
|
||||
/// test (`0x5fd735`)
|
||||
/// needs sheath state 2, which the stow `DoEmote` runs first (`0x611cf0(0)`) has cleared.
|
||||
pub(crate) fn local_play_eligible(i: &LocalPlay) -> bool {
|
||||
let f = i.move_flags;
|
||||
let turning = f & (move_flags::TURN_LEFT | move_flags::TURN_RIGHT) != 0;
|
||||
let shuffling = turning && !i.flying && f & SHUFFLE_VOID == 0;
|
||||
!i.reads_dead
|
||||
&& !(f & move_flags::FALLING != 0 && f & move_flags::FALLING_FAR != 0)
|
||||
&& (f & move_flags::FALLING == 0 || i.vertical_speed == 0.0)
|
||||
&& f & move_flags::ANY_MOVE != 0
|
||||
&& !i.loot_kneel
|
||||
&& !i.channeling
|
||||
&& !i.engaged
|
||||
&& !shuffling
|
||||
&& i.stand_state == 0
|
||||
}
|
||||
|
||||
/// The flag half of the in-combat test `0x60ecd0` (`UNIT_FIELD_FLAGS` bit 11); the other half is
|
||||
@@ -132,11 +183,24 @@ mod tests {
|
||||
#[test]
|
||||
fn channeling_or_combat_suppresses_the_play() {
|
||||
assert!(
|
||||
player_eligible(false, false),
|
||||
player_eligible(66, false, false),
|
||||
"idle and out of combat plays"
|
||||
);
|
||||
assert!(!player_eligible(true, false), "channeling suppresses");
|
||||
assert!(!player_eligible(false, true), "in combat suppresses");
|
||||
assert!(!player_eligible(66, true, false), "channeling suppresses");
|
||||
assert!(!player_eligible(66, false, true), "in combat suppresses");
|
||||
}
|
||||
|
||||
/// `0x5fcd5f`/`0x5fcd67`: LiftOff (192) and Land (200) skip the combat test, not the channel.
|
||||
#[test]
|
||||
fn lift_off_and_land_play_in_combat_but_not_while_channeling() {
|
||||
assert!(player_eligible(192, false, true), "LiftOff in combat");
|
||||
assert!(player_eligible(200, false, true), "Land in combat");
|
||||
assert!(!player_eligible(66, false, true), "any other id refuses");
|
||||
assert!(!player_eligible(193, false, true), "the neighbours refuse");
|
||||
assert!(
|
||||
!player_eligible(192, true, true),
|
||||
"channeling still refuses"
|
||||
);
|
||||
}
|
||||
|
||||
/// The attack-target half of `0x60ecd0` (`[+0xc48]`, from ATTACKSTART to ATTACKSTOP) refuses
|
||||
@@ -144,10 +208,114 @@ mod tests {
|
||||
#[test]
|
||||
fn an_engaged_unit_refuses_the_play_without_the_flag_bit() {
|
||||
assert!(
|
||||
play_eligible(None, None, None, false),
|
||||
play_eligible(None, None, None, false, 66),
|
||||
"idle, no store: plays"
|
||||
);
|
||||
assert!(!play_eligible(None, None, None, true), "engaged: refused");
|
||||
assert!(
|
||||
!play_eligible(None, None, None, true, 66),
|
||||
"engaged: refused"
|
||||
);
|
||||
assert!(
|
||||
play_eligible(None, None, None, true, 192),
|
||||
"engaged LiftOff"
|
||||
);
|
||||
assert!(play_eligible(None, None, None, true, 200), "engaged Land");
|
||||
}
|
||||
|
||||
fn moving() -> LocalPlay {
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_emote_plays_only_while_a_direction_key_is_held() {
|
||||
assert!(local_play_eligible(&moving()));
|
||||
for dir in [
|
||||
move_flags::BACKWARD,
|
||||
move_flags::STRAFE_LEFT,
|
||||
move_flags::STRAFE_RIGHT,
|
||||
] {
|
||||
let i = LocalPlay {
|
||||
move_flags: dir,
|
||||
..moving()
|
||||
};
|
||||
assert!(local_play_eligible(&i), "{dir:#x}");
|
||||
}
|
||||
assert!(
|
||||
!local_play_eligible(&LocalPlay::default()),
|
||||
"standing still"
|
||||
);
|
||||
let turn_only = LocalPlay {
|
||||
move_flags: move_flags::TURN_LEFT,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!local_play_eligible(&turn_only), "turning in place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_local_play_gate_refuses_on_its_own() {
|
||||
let base = moving();
|
||||
let refused = [
|
||||
LocalPlay {
|
||||
reads_dead: true,
|
||||
..base
|
||||
},
|
||||
LocalPlay {
|
||||
stand_state: 1,
|
||||
..base
|
||||
},
|
||||
LocalPlay {
|
||||
loot_kneel: true,
|
||||
..base
|
||||
},
|
||||
LocalPlay {
|
||||
channeling: true,
|
||||
..base
|
||||
},
|
||||
LocalPlay {
|
||||
engaged: true,
|
||||
..base
|
||||
},
|
||||
// Both jumping and falling far.
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING | move_flags::FALLING_FAR,
|
||||
..base
|
||||
},
|
||||
// A jump still rising.
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING,
|
||||
vertical_speed: 3.0,
|
||||
..base
|
||||
},
|
||||
// Turning while moving is a turn shuffle.
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
|
||||
..base
|
||||
},
|
||||
];
|
||||
for (n, i) in refused.iter().enumerate() {
|
||||
assert!(!local_play_eligible(i), "case {n}");
|
||||
}
|
||||
// A jump whose vertical speed reads exactly 0.0 (a step-off fall's first tick), and a turn while swimming or on a flying spline, pass.
|
||||
for i in [
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING,
|
||||
..base
|
||||
},
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT | move_flags::SWIMMING,
|
||||
..base
|
||||
},
|
||||
LocalPlay {
|
||||
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
|
||||
flying: true,
|
||||
..base
|
||||
},
|
||||
] {
|
||||
assert!(local_play_eligible(&i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,7 +114,7 @@ pub(super) fn drive_gestures(
|
||||
};
|
||||
let (store, movement, remote, engaged) =
|
||||
units.get(entity).unwrap_or((None, None, None, false));
|
||||
if !super::emote_anim::play_eligible(store, movement, remote, engaged) {
|
||||
if !super::emote_anim::play_eligible(store, movement, remote, engaged, anim_id) {
|
||||
debug!("gesture: {gesture:?} suppressed for {entity:?}");
|
||||
continue;
|
||||
}
|
||||
@@ -122,6 +122,7 @@ pub(super) fn drive_gestures(
|
||||
entity,
|
||||
anim_id: anim_id as u16,
|
||||
seq: play_seq.next(),
|
||||
via_player: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +443,7 @@ fn play_kit(
|
||||
entity,
|
||||
anim_id,
|
||||
seq,
|
||||
via_player: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ pub(crate) use settings::restore_chat_looks;
|
||||
|
||||
pub(crate) struct UiChatPlugin;
|
||||
|
||||
/// The chat line drain, whose `DoEmote` arm writes the local [`crate::creature_anim::EmoteAnim`]:
|
||||
/// the animation chain runs after it, so the same frame's driver sees the play.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct ChatDrain;
|
||||
|
||||
impl Plugin for UiChatPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
@@ -152,7 +157,7 @@ impl Plugin for UiChatPlugin {
|
||||
// After the clears, so a press that stamps the clock and drops the flag is
|
||||
// settled before the idle timer reads it.
|
||||
idle::idle_handler,
|
||||
input::drain_chat_input,
|
||||
input::drain_chat_input.in_set(ChatDrain),
|
||||
// After the box's drain, so a `SendChatMessage` or `SendAddonMessage` from a
|
||||
// slash handler it just ran goes out this frame.
|
||||
input::drain_addon_chat_sends,
|
||||
|
||||
@@ -75,6 +75,9 @@ pub(super) struct ChatProbes<'w, 's> {
|
||||
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
|
||||
/// `/partytest raid` seats us as the leader, and the wire names a leader by guid.
|
||||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||||
/// Our own auto-attack target, a gate of `DoEmote`'s local play.
|
||||
engaged: Query<'w, 's, (), (With<SelfPlayer>, With<crate::creature_anim::Engaged>)>,
|
||||
loot_kneel: Option<Res<'w, crate::ui_loot::LootKneel>>,
|
||||
}
|
||||
|
||||
/// What the drain hands to another subsystem's setter, bundled for the same ceiling. Chat never
|
||||
@@ -85,6 +88,8 @@ pub(super) struct ChatOut<'w, 's> {
|
||||
console: Commands<'w, 's>,
|
||||
stand: MessageWriter<'w, crate::player::StandStateRequest>,
|
||||
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
|
||||
/// `DoEmote`'s local play (`0x5ef660`).
|
||||
anim: MessageWriter<'w, crate::creature_anim::EmoteAnim>,
|
||||
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
|
||||
assist: MessageWriter<'w, crate::target::AssistRequest>,
|
||||
follow: MessageWriter<'w, crate::player::FollowRequest>,
|
||||
@@ -187,6 +192,8 @@ pub(super) fn drain_chat_input(
|
||||
guids,
|
||||
kinds,
|
||||
self_guid,
|
||||
engaged,
|
||||
loot_kneel,
|
||||
} = &probes;
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -738,6 +745,34 @@ pub(super) fn drain_chat_input(
|
||||
.stand
|
||||
.write(crate::player::StandStateRequest { state: state as u8 });
|
||||
}
|
||||
// The play (`0x5ef660`, `0x5fe2f0` at `0x5ef66f`) is the posture branch's else: a posture emote plays no clip,
|
||||
// and the rest play here only while moving. Its `SMSG_EMOTE` echo then finds the
|
||||
// id armed and is skipped (`0x5fcd56`).
|
||||
else if let (Some(anim), Ok((entity, m, _)), Ok(store)) = (
|
||||
emote_id.and_then(|id| emotes.as_deref()?.anim(id)),
|
||||
self_player.single(),
|
||||
self_store.single(),
|
||||
) {
|
||||
let fields = &store.0;
|
||||
let gate = crate::creature_anim::LocalPlay {
|
||||
reads_dead: fields.unit_reads_dead(),
|
||||
stand_state: m.stand_state,
|
||||
move_flags: m.flags,
|
||||
vertical_speed: m.vertical_speed,
|
||||
flying: m.flying,
|
||||
loot_kneel: loot_kneel.as_deref().is_some_and(|k| k.0),
|
||||
channeling: fields.unit_channel_spell() != 0,
|
||||
engaged: !engaged.is_empty(),
|
||||
};
|
||||
if crate::creature_anim::local_play_eligible(&gate) {
|
||||
chat_out.anim.write(crate::creature_anim::EmoteAnim {
|
||||
entity,
|
||||
anim_id: anim as u16,
|
||||
seq: play_seq.next(),
|
||||
via_player: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
let target = emote_target(
|
||||
arg.as_deref(),
|
||||
&selection,
|
||||
|
||||
Reference in New Issue
Block a user