feat: let a crate on top interact with an object it picks (#753)
- A crate on top of benilla interacts with an object it picks through `Interact`, the object's own dispatcher with no select, and reads the streamed objects through `Objects` - A right-click on a GameObject, a corpse or empty world no longer marks the selection changed; only the unit leg's select touches it, as before the click took the pick - Interact's attack leg selects its unit through StartAttack, as the click does - An outside crate test reads Objects and writes Interact - Wrap the Interact finish's doc lines at the column limit - The outside-crate test's comment says what it picks Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Sam
parent
2ce6a3e881
commit
e620a0d095
@@ -681,12 +681,17 @@ pub(crate) mod schedule_tests {
|
||||
/// already pair with: the steer is chained after follow's and writes the same `Player`
|
||||
/// fields, the arrival runs the click's dispatchers, and their `Transform` reads are the
|
||||
/// disjoint-lane kind. The dispatchers also carry the walk's start (`Player`,
|
||||
/// `FollowState`, `Approach`, the stand request), which only a click or an arrival writes.
|
||||
/// `FollowState`, `Approach`, the stand request), which only a click, an arrival or a
|
||||
/// crate's interaction writes;
|
||||
/// - a crate's interaction (`target::click::act_on_interact`) against what
|
||||
/// `act_on_right_click` and the cursor classifier already pair with: it runs the click's
|
||||
/// dispatchers over the classifier's reading of its object, and its attack leg
|
||||
/// selects as the click does, so it writes `Selection` where the click's drain does.
|
||||
///
|
||||
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
|
||||
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
|
||||
/// that commutes by construction belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_930;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_980;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -197,6 +197,10 @@ pub use benilla_world::build_id::BuildId;
|
||||
/// `benilla-worldview` shim.
|
||||
pub use benilla_world::worldview::run as run_worldview;
|
||||
pub use bevy::app::AppExit;
|
||||
// A crate on top of benilla reads objects and interacts through these, with no protocol dependency.
|
||||
pub use benilla_protocol::{messages::ObjectType, ObjectFields};
|
||||
pub use net::Objects;
|
||||
pub use target::Interact;
|
||||
|
||||
/// Builds and runs the client app. `build` is the launcher's compile-time git stamp, passed in as
|
||||
/// data so the sha lives in the shim's fingerprint and a commit does not recompile this crate.
|
||||
|
||||
@@ -418,23 +418,24 @@ pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc<std::sync::atomic::Atom
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GuidIndex(pub(crate) HashMap<u64, Entity>);
|
||||
|
||||
/// The object manager's guid lookup (`ClntObjMgrObjectPtr 0x468460`): a guid to its descriptor
|
||||
/// store, whatever the kind, and an item's countdown cells. Read-only.
|
||||
/// The streamed objects, whatever the kind, with their descriptor stores, our own player, and an
|
||||
/// item's countdown cells. Read-only. The by-guid lookups are `ClntObjMgrObjectPtr` (`0x468460`).
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct Objects<'w, 's> {
|
||||
pub struct Objects<'w, 's> {
|
||||
index: Res<'w, GuidIndex>,
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
countdowns: Query<'w, 's, &'static crate::items::Countdowns>,
|
||||
me: Query<'w, 's, Entity, With<SelfPlayer>>,
|
||||
}
|
||||
|
||||
impl Objects<'_, '_> {
|
||||
/// The entity behind a guid, if streamed.
|
||||
pub(crate) fn entity(&self, guid: u64) -> Option<Entity> {
|
||||
/// The entity behind a guid, if streamed (`ClntObjMgrObjectPtr`, `0x468460`).
|
||||
pub fn entity(&self, guid: u64) -> Option<Entity> {
|
||||
self.index.0.get(&guid).copied()
|
||||
}
|
||||
|
||||
/// A streamed object's merged descriptor fields.
|
||||
pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> {
|
||||
/// A streamed object's merged descriptor fields, found as `0x468460` does, by guid.
|
||||
pub fn object(&self, guid: u64) -> Option<&ObjectFields> {
|
||||
self.index
|
||||
.0
|
||||
.get(&guid)
|
||||
@@ -442,6 +443,19 @@ impl Objects<'_, '_> {
|
||||
.map(|s| &s.0)
|
||||
}
|
||||
|
||||
/// Every streamed object, items included.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u64, Entity, &ObjectFields)> + '_ {
|
||||
self.index
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|(&guid, &e)| Some((guid, e, &self.stores.get(e).ok()?.0)))
|
||||
}
|
||||
|
||||
/// Our own player, once in the world.
|
||||
pub fn player(&self) -> Option<Entity> {
|
||||
self.me.single().ok()
|
||||
}
|
||||
|
||||
/// An item object's countdown cells.
|
||||
pub(crate) fn countdowns(&self, guid: u64) -> Option<&crate::items::Countdowns> {
|
||||
self.index
|
||||
@@ -2453,4 +2467,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_lists_every_streamed_object_and_names_our_own() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
const ME: u64 = 0x5E1F;
|
||||
const BOAR: u64 = 0xF130_0000_0000_0042;
|
||||
let mut world = World::new();
|
||||
world.init_resource::<GuidIndex>();
|
||||
let fields = |health| {
|
||||
ObjectStore(ObjectFields::from_pairs(&[(
|
||||
benilla_protocol::field::FIELD_UNIT_HEALTH,
|
||||
health,
|
||||
)]))
|
||||
};
|
||||
let me = world.spawn((SelfPlayer, Guid(ME), fields(256))).id();
|
||||
let boar = world.spawn((Guid(BOAR), fields(40))).id();
|
||||
world
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.extend([(ME, me), (BOAR, boar)]);
|
||||
let (listed, player) = world
|
||||
.run_system_once(|objects: Objects| {
|
||||
let mut listed: Vec<_> = objects
|
||||
.iter()
|
||||
.map(|(guid, e, f)| (guid, e, f.unit_health()))
|
||||
.collect();
|
||||
listed.sort_by_key(|&(guid, ..)| guid);
|
||||
(listed, objects.player())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(listed, [(ME, me, Some(256)), (BOAR, boar, Some(40))]);
|
||||
assert_eq!(player, Some(me));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +177,9 @@ pub(crate) struct Feedback<'w> {
|
||||
}
|
||||
|
||||
/// The dispatchers, which take the object (`0x5f0130`, `0x5df2a0`, `0x5df130`, `0x5f86b0`,
|
||||
/// `0x5f05e0`): a right-click reaches them through [`act_on_right_click`] with Click to Move's
|
||||
/// walk, an approach's arrival through [`act_on_arrival`] without it.
|
||||
/// `0x5f05e0`): a right-click reaches them through [`act_on_right_click`] and a crate's
|
||||
/// [`Interact`] through [`act_on_interact`], both with Click to Move's walk, an approach's arrival
|
||||
/// through [`act_on_arrival`] without it.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct Dispatch<'w, 's> {
|
||||
seam: crate::creature_anim::AttackSeam<'w, 's>,
|
||||
@@ -620,11 +621,8 @@ impl Dispatch<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// On a clean right-click, select the press pick's unit and act by the cursor's classification
|
||||
/// (the INTERACT leg `0x492820`): a GameObject, a corpse, an attack, loot, skin, or the
|
||||
/// `UNIT_NPC_FLAGS` ladder ([`service_arm`]). The range gray (`unable`) suppresses every send but
|
||||
/// attack, which the server holds until in reach; with Click to Move on, each dispatcher walks
|
||||
/// there instead (`CanAutoInteract` is their approach flag, `0x60c170`, `0x5d6c6f`).
|
||||
/// On a clean right-click, the INTERACT leg `0x492820` on the press pick: select it, then
|
||||
/// [`interact`].
|
||||
pub(super) fn act_on_right_click(
|
||||
mut clicks: MessageReader<WorldRightClick>,
|
||||
// The press pick: the reference picks once, on the down edge (`0x481f00`).
|
||||
@@ -635,6 +633,24 @@ pub(super) fn act_on_right_click(
|
||||
if clicks.read().last().is_none() {
|
||||
return;
|
||||
}
|
||||
interact(&mut dispatch, &press, Select::First(&mut selection));
|
||||
}
|
||||
|
||||
/// Where [`interact`] selects its unit, if it does: the world click's entry (`0x492820`) selects
|
||||
/// first, whatever leg follows; a crate's [`Interact`] runs the dispatcher alone, and only its
|
||||
/// attack leg selects, through StartAttack (`0x5ecb70`, `SetSelection` at `0x5eccaa`).
|
||||
enum Select<'a, 'w> {
|
||||
First(&'a mut ResMut<'w, Selection>),
|
||||
OnAttack(&'a mut ResMut<'w, Selection>),
|
||||
}
|
||||
|
||||
/// Act on a pick by the cursor's classification of it: a GameObject, a corpse, an attack, loot,
|
||||
/// skin, or the `UNIT_NPC_FLAGS` ladder ([`service_arm`]); only the click selects first
|
||||
/// (`0x493540`), and the attack leg of an [`Interact`] selects through StartAttack. The range gray
|
||||
/// (`unable`) suppresses every send but attack, which the server holds until in reach; with Click
|
||||
/// to Move on, each dispatcher walks there instead (`CanAutoInteract` is their approach flag,
|
||||
/// `0x60c170`, `0x5d6c6f`).
|
||||
fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
|
||||
let (hovered, hovered_object, cursor) = (&press.hovered, &press.object, &press.cursor);
|
||||
let walk = dispatch.auto.can_auto_interact();
|
||||
let self_mounted = dispatch.mounted();
|
||||
@@ -784,16 +800,24 @@ pub(super) fn act_on_right_click(
|
||||
let me = dispatch.me();
|
||||
// A mid-combat click on a vendor or corpse switches and stops, never swings (`0x5ecb70`); the
|
||||
// sword, not the fork, as the re-swing also needs the player's own legs.
|
||||
let outcome = scan::commit(
|
||||
&mut selection,
|
||||
&mut dispatch.seam,
|
||||
entity,
|
||||
guid,
|
||||
target,
|
||||
me.is_some_and(|(_, _, e)| e),
|
||||
me.map(|(_, g, _)| g),
|
||||
press.attack(),
|
||||
);
|
||||
// Deref'd only where it commits: a leg that does not select leaves the change tick alone.
|
||||
let commit = |seam: &mut crate::creature_anim::AttackSeam,
|
||||
selection: &mut ResMut<Selection>| {
|
||||
scan::commit(
|
||||
selection,
|
||||
seam,
|
||||
entity,
|
||||
guid,
|
||||
target,
|
||||
me.is_some_and(|(_, _, e)| e),
|
||||
me.map(|(_, g, _)| g),
|
||||
press.attack(),
|
||||
)
|
||||
};
|
||||
let mut outcome = match &mut select {
|
||||
Select::First(selection) => commit(&mut dispatch.seam, selection),
|
||||
Select::OnAttack(_) => scan::CommitOutcome::default(),
|
||||
};
|
||||
match unit_branch(attack, dead_fork, leg) {
|
||||
UnitBranch::Attack => {
|
||||
// Silent after the select: the click's `0x60c247 call 0x5ecb70` has no `DisplayError`,
|
||||
@@ -804,6 +828,11 @@ pub(super) fn act_on_right_click(
|
||||
{
|
||||
// refused: the selection stands, no swing, nothing said
|
||||
} else {
|
||||
// StartAttack's own select (`0x5eccaa`), past its gates and before the swing
|
||||
// (`0x5eccfd`): the click's commit, with its stop and re-swing mid-combat.
|
||||
if let Select::OnAttack(selection) = &mut select {
|
||||
outcome = commit(&mut dispatch.seam, selection);
|
||||
}
|
||||
debug!("right-click attack: {guid:#x}");
|
||||
// `0x5ecb70`'s body through the seam; `swung` means the commit's re-swing already
|
||||
// went out, which `0x5eccda` keeps from sending twice.
|
||||
@@ -850,6 +879,44 @@ pub(super) fn act_on_right_click(
|
||||
}
|
||||
}
|
||||
|
||||
/// Interact with an object by running its own virtual dispatcher (slot `+0x60`): `0x60bea0` for a
|
||||
/// unit or player, `0x5d6bf0` for a corpse, `0x5f8660` for a GameObject. It skips the world click's
|
||||
/// select-first entry (`0x492820`); the attack leg selects through StartAttack (`0x5ecb70`), and
|
||||
/// the service, loot, skin, corpse and GameObject legs select nothing.
|
||||
#[derive(Message, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Interact(pub Entity);
|
||||
|
||||
/// Each [`Interact`], its object classified as a press over it would be.
|
||||
pub(super) fn act_on_interact(
|
||||
mut requests: MessageReader<Interact>,
|
||||
mut selection: ResMut<Selection>,
|
||||
kinds: Query<(&Guid, &crate::net::NetEntity)>,
|
||||
mut set: ParamSet<(cursor_mode::CursorInputs, Dispatch)>,
|
||||
) {
|
||||
use benilla_protocol::EntityKind;
|
||||
for &Interact(entity) in requests.read() {
|
||||
let Ok((guid, net)) = kinds.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
let mut pick = PressPick::default();
|
||||
match net.kind {
|
||||
EntityKind::Unit | EntityKind::Player => {
|
||||
(pick.hovered.target, pick.hovered.guid) = (Some(entity), Some(guid.0));
|
||||
}
|
||||
EntityKind::Corpse => {
|
||||
(pick.hovered.corpse, pick.hovered.corpse_guid) = (Some(entity), Some(guid.0));
|
||||
}
|
||||
EntityKind::GameObject => {
|
||||
(pick.object.target, pick.object.guid) = (Some(entity), Some(guid.0));
|
||||
}
|
||||
EntityKind::DynamicObject | EntityKind::Other => continue,
|
||||
}
|
||||
(pick.cursor, pick.attack_fork) =
|
||||
cursor_mode::classify(&set.p0(), &pick.hovered, &pick.object);
|
||||
interact(&mut set.p1(), &pick, Select::OnAttack(&mut selection));
|
||||
}
|
||||
}
|
||||
|
||||
/// The verb an approach owed, once it arrived (`0x60fa20`, run at the stop): the dispatcher alone,
|
||||
/// with no select, no cursor and no walk but Use's. Each looks its object up by type (`0x468460`),
|
||||
/// and the loot's asks for a unit (`0x60fa41`), so a corpse object walked to is not looted.
|
||||
@@ -1928,6 +1995,10 @@ mod tests {
|
||||
world.init_resource::<crate::player::Approach>();
|
||||
world.init_resource::<crate::player::FollowState>();
|
||||
world.init_resource::<crate::player::Player>();
|
||||
world.init_resource::<Messages<Interact>>();
|
||||
world.init_resource::<crate::net::Reputations>();
|
||||
world.init_resource::<crate::ui_loot::LootConfig>();
|
||||
world.init_resource::<ButtonInput<KeyCode>>();
|
||||
world.spawn((SelfPlayer, Guid(ME)));
|
||||
let boar = world
|
||||
.spawn((Guid(BOAR), store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)])))
|
||||
@@ -2082,6 +2153,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_right_click_on_a_gameobject_leaves_the_selection_untouched() {
|
||||
const STONE: u64 = 0x5703;
|
||||
let (mut world, _boar) = right_click_world();
|
||||
let stone = world
|
||||
.spawn((Guid(STONE), store(&[(GO_TYPE_FIELD, 23)])))
|
||||
.id();
|
||||
*world.resource_mut::<PressPick>() = PressPick {
|
||||
object: HoveredObject {
|
||||
target: Some(stone),
|
||||
guid: Some(STONE),
|
||||
distance: 5.0,
|
||||
},
|
||||
cursor: WorldCursor {
|
||||
kind: cursor_mode::CursorKind::Interact,
|
||||
unable: false,
|
||||
},
|
||||
..PressPick::default()
|
||||
};
|
||||
world
|
||||
.resource_mut::<Messages<WorldRightClick>>()
|
||||
.write(WorldRightClick);
|
||||
world.clear_trackers();
|
||||
world.run_system_once(act_on_right_click).unwrap();
|
||||
assert!(!world.is_resource_changed::<Selection>());
|
||||
}
|
||||
|
||||
const VENDOR: u64 = 0x7E0D;
|
||||
|
||||
/// Our body alive at the origin, Click to Move set as `walk`, and a vendor at `at`.
|
||||
@@ -2237,6 +2335,158 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A crate's [`Interact`] on `entity`, the selection's change tick cleared first.
|
||||
fn interact_with(world: &mut World, entity: Entity) {
|
||||
world.clear_trackers();
|
||||
world
|
||||
.resource_mut::<Messages<Interact>>()
|
||||
.write(Interact(entity));
|
||||
world.run_system_once(act_on_interact).unwrap();
|
||||
}
|
||||
|
||||
/// The vendor leg (`0x5f0130`) runs the dispatcher alone: no select.
|
||||
#[test]
|
||||
fn an_interaction_opens_a_vendor_in_reach_and_leaves_the_selection() {
|
||||
let (mut world, vendor, rx) = walking_world(Vec3::new(2.5, 0.0, 0.0), false);
|
||||
interact_with(&mut world, vendor);
|
||||
let sent: Vec<ClientCommand> = rx.try_iter().collect();
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::ListInventory { guid: VENDOR }]
|
||||
),
|
||||
"{sent:?}"
|
||||
);
|
||||
assert_eq!(world.resource::<Selection>().guid, None);
|
||||
assert!(!world.is_resource_changed::<Selection>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_far_vendor_interaction_walks_only_with_click_to_move_on() {
|
||||
for walk in [true, false] {
|
||||
let (mut world, vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), walk);
|
||||
interact_with(&mut world, vendor);
|
||||
assert_eq!(
|
||||
world.resource::<crate::player::Approach>().active(),
|
||||
walk,
|
||||
"AutoInteract {walk}"
|
||||
);
|
||||
let sent: Vec<ClientCommand> = rx.try_iter().collect();
|
||||
assert!(sent.is_empty(), "{sent:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x5f8660` → `0x5f86b0`: in reach the object is used; out of reach, with no walk, the range
|
||||
/// arm raises `ERR_USE_TOO_FAR` (`0x5f874b`) and sends nothing. Neither selects.
|
||||
#[test]
|
||||
fn an_interaction_uses_a_gameobject_in_reach_and_refuses_one_out_of_it() {
|
||||
for (x, used) in [(2.0, true), (30.0, false)] {
|
||||
let (mut world, _vendor, rx) = walking_world(Vec3::ZERO, false);
|
||||
let chest = spawn_chest(&mut world, Vec3::new(x, 0.0, 0.0));
|
||||
interact_with(&mut world, chest);
|
||||
let sent: Vec<ClientCommand> = rx.try_iter().collect();
|
||||
assert_eq!(
|
||||
matches!(sent.as_slice(), [ClientCommand::GameObjUse { guid: CHEST }]),
|
||||
used,
|
||||
"{x} yd: {sent:?}"
|
||||
);
|
||||
let keys: Vec<&str> = world
|
||||
.resource::<crate::ui_action::UiErrorKeys>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|e| e.key)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
if used {
|
||||
vec![]
|
||||
} else {
|
||||
vec!["ERR_USE_TOO_FAR"]
|
||||
},
|
||||
"{x} yd"
|
||||
);
|
||||
assert!(!world.is_resource_changed::<Selection>(), "{x} yd");
|
||||
}
|
||||
}
|
||||
|
||||
const WOLF: u64 = 0xF130_0000_0000_0299;
|
||||
|
||||
/// Our body hostile to nobody in particular and a wolf three yards off, the attack cursor on
|
||||
/// it.
|
||||
fn wolf_world() -> (World, Entity, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
const F_UNIT_FLAGS: u16 = benilla_protocol::field::FIELD_UNIT_FLAGS;
|
||||
let (mut world, _vendor, rx) = walking_world(Vec3::ZERO, false);
|
||||
let me = world
|
||||
.query_filtered::<Entity, With<SelfPlayer>>()
|
||||
.single(&world)
|
||||
.unwrap();
|
||||
world.entity_mut(me).insert(store(&[
|
||||
(F_HEALTH, 100),
|
||||
(F_MAXHEALTH, 100),
|
||||
(F_UNIT_FLAGS, 0x8),
|
||||
]));
|
||||
let wolf = world
|
||||
.spawn((
|
||||
Guid(WOLF),
|
||||
store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]),
|
||||
Transform::from_xyz(3.0, 0.0, 0.0),
|
||||
crate::net::NetEntity {
|
||||
kind: benilla_protocol::EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
(world, wolf, rx)
|
||||
}
|
||||
|
||||
/// The attack leg `0x60c247` calls StartAttack (`0x5ecb70`), which selects the target
|
||||
/// (`SetSelection`, `0x5eccaa`) before the swing (`0x5eccfd`), as the right-click does.
|
||||
#[test]
|
||||
fn an_interaction_attacks_an_attackable_unit_and_selects_it() {
|
||||
let (mut world, wolf, rx) = wolf_world();
|
||||
interact_with(&mut world, wolf);
|
||||
let sent: Vec<ClientCommand> = rx.try_iter().collect();
|
||||
let at = |want: fn(&ClientCommand) -> bool| sent.iter().position(want);
|
||||
let select = at(|c| matches!(c, ClientCommand::SetSelection { guid: WOLF }));
|
||||
let swing = at(|c| matches!(c, ClientCommand::AttackSwing { guid: WOLF }));
|
||||
assert!(
|
||||
select.is_some() && swing.is_some() && select < swing,
|
||||
"{sent:?}"
|
||||
);
|
||||
assert_eq!(world.resource::<Selection>().guid, Some(WOLF));
|
||||
}
|
||||
|
||||
/// Mid-combat on another target the select is the click's: stop, select, swing.
|
||||
#[test]
|
||||
fn an_interaction_switching_targets_mid_combat_stops_selects_and_swings() {
|
||||
const OLD: u64 = 0xF130_0000_0000_0111;
|
||||
let (mut world, wolf, rx) = wolf_world();
|
||||
let old = world.spawn((Guid(OLD), store(&[(F_HEALTH, 100)]))).id();
|
||||
{
|
||||
let mut selection = world.resource_mut::<Selection>();
|
||||
selection.guid = Some(OLD);
|
||||
selection.target = Some(old);
|
||||
}
|
||||
let me = world
|
||||
.query_filtered::<Entity, With<SelfPlayer>>()
|
||||
.single(&world)
|
||||
.unwrap();
|
||||
world.entity_mut(me).insert(Engaged(OLD));
|
||||
interact_with(&mut world, wolf);
|
||||
let sent: Vec<&str> = rx
|
||||
.try_iter()
|
||||
.map(|c| match c {
|
||||
ClientCommand::AttackStop => "stop",
|
||||
ClientCommand::SetSelection { guid: WOLF } => "select",
|
||||
ClientCommand::AttackSwing { guid: WOLF } => "swing",
|
||||
_ => "other",
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sent, ["stop", "select", "swing"]);
|
||||
assert_eq!(world.resource::<Selection>().guid, Some(WOLF));
|
||||
}
|
||||
|
||||
/// `0x5df2a0`: beyond melee reach the walk comes first and nothing is looted yet.
|
||||
#[test]
|
||||
fn a_far_body_to_loot_is_walked_to_before_the_loot() {
|
||||
|
||||
@@ -520,38 +520,79 @@ fn corpse_cursor(f: CorpseFacts) -> Option<(CursorKind, bool)> {
|
||||
None // the reference's "else clear": Point
|
||||
}
|
||||
|
||||
/// Resolve this frame's [`WorldCursor`]; no hover, or nothing resolvable, reads Point.
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// Resolve this frame's [`WorldCursor`] over the hover.
|
||||
pub(super) fn classify_cursor(
|
||||
hovered: Res<Hovered>,
|
||||
hovered_object: Res<HoveredObject>,
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
inputs: CursorInputs,
|
||||
(mut cursor, mut fork): (ResMut<WorldCursor>, ResMut<AttackFork>),
|
||||
// `control_lost` is the attack leg's `[0xb4b3e4]` clear.
|
||||
(player, approach): (Res<crate::player::Player>, Res<crate::player::Approach>),
|
||||
units: Query<(
|
||||
&Transform,
|
||||
Option<&ObjectStore>,
|
||||
Option<&crate::go_anim::GoAnim>,
|
||||
)>,
|
||||
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
|
||||
go_inputs: super::lock::GoLockInputs,
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
// `[0xb700e4]` and `[0xb700e8]`, the skin legs' learned-spell latches.
|
||||
learned: Res<crate::ui_action::LearnedAbilities>,
|
||||
quest: Res<crate::ui_quest::QuestGiver>,
|
||||
loot_cfg: Res<crate::ui_loot::LootConfig>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
// `[player+0x1d28/2c]`, the object whose loot window is open.
|
||||
loot_latch: Res<crate::ui_loot::LootLatch>,
|
||||
// `[0xb72038]`, the queued area; absent in a headless build, which reads as not queued.
|
||||
stone: Option<Res<crate::ui_dialog_verbs::MeetingStone>>,
|
||||
) {
|
||||
let (want, want_fork) = classify(&inputs, &hovered, &hovered_object);
|
||||
if *cursor != want {
|
||||
*cursor = want;
|
||||
}
|
||||
if *fork != want_fork {
|
||||
*fork = want_fork;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct CursorInputs<'w, 's> {
|
||||
factions: Option<Res<'w, Factions>>,
|
||||
reputations: Res<'w, Reputations>,
|
||||
// `control_lost` is the attack leg's `[0xb4b3e4]` clear.
|
||||
player: Res<'w, crate::player::Player>,
|
||||
approach: Res<'w, crate::player::Approach>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
units: Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
&'static Transform,
|
||||
Option<&'static ObjectStore>,
|
||||
Option<&'static crate::go_anim::GoAnim>,
|
||||
),
|
||||
>,
|
||||
self_q: Query<'w, 's, (&'static Transform, &'static ObjectStore), With<SelfPlayer>>,
|
||||
go_inputs: super::lock::GoLockInputs<'w, 's>,
|
||||
player_actions: Res<'w, crate::ui_action::PlayerActions>,
|
||||
// `[0xb700e4]` and `[0xb700e8]`, the skin legs' learned-spell latches.
|
||||
learned: Res<'w, crate::ui_action::LearnedAbilities>,
|
||||
quest: Res<'w, crate::ui_quest::QuestGiver>,
|
||||
loot_cfg: Res<'w, crate::ui_loot::LootConfig>,
|
||||
keys: Res<'w, ButtonInput<KeyCode>>,
|
||||
// `[player+0x1d28/2c]`, the object whose loot window is open.
|
||||
loot_latch: Res<'w, crate::ui_loot::LootLatch>,
|
||||
// `[0xb72038]`, the queued area; absent in a headless build, which reads as not queued.
|
||||
stone: Option<Res<'w, crate::ui_dialog_verbs::MeetingStone>>,
|
||||
}
|
||||
|
||||
/// The cursor and the attack fork over a pick; no pick, or nothing resolvable, reads Point.
|
||||
pub(super) fn classify(
|
||||
inputs: &CursorInputs,
|
||||
hovered: &Hovered,
|
||||
hovered_object: &HoveredObject,
|
||||
) -> (WorldCursor, AttackFork) {
|
||||
let CursorInputs {
|
||||
factions,
|
||||
reputations,
|
||||
player,
|
||||
approach,
|
||||
units,
|
||||
self_q,
|
||||
go_inputs,
|
||||
player_actions,
|
||||
learned,
|
||||
quest,
|
||||
loot_cfg,
|
||||
keys,
|
||||
loot_latch,
|
||||
stone,
|
||||
} = inputs;
|
||||
let can_auto_interact = crate::player::can_auto_interact(
|
||||
approach.enabled,
|
||||
self_q.single().ok().map(|(_, s)| s),
|
||||
&player,
|
||||
player,
|
||||
);
|
||||
// A GameObject that is not highlightable clears the cursor, as the reference's handler does.
|
||||
let resolve_go = || {
|
||||
@@ -644,7 +685,7 @@ pub(super) fn classify_cursor(
|
||||
if super::can_interact(
|
||||
Some(store),
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
reputations,
|
||||
Some(self_store),
|
||||
) {
|
||||
let status = hovered.guid.and_then(|g| quest.status(g));
|
||||
@@ -658,7 +699,7 @@ pub(super) fn classify_cursor(
|
||||
if !super::can_attack(
|
||||
Some(store),
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
reputations,
|
||||
Some(self_store),
|
||||
) {
|
||||
return None;
|
||||
@@ -684,7 +725,7 @@ pub(super) fn classify_cursor(
|
||||
shift_held: shift,
|
||||
})
|
||||
};
|
||||
let resolved = if go_is_nearest(&hovered, &hovered_object) {
|
||||
let resolved = if go_is_nearest(hovered, hovered_object) {
|
||||
resolve_go()
|
||||
} else if hovered.corpse.is_some() {
|
||||
resolve_corpse()
|
||||
@@ -692,14 +733,7 @@ pub(super) fn classify_cursor(
|
||||
resolve_unit()
|
||||
};
|
||||
let (kind, unable) = resolved.unwrap_or((CursorKind::Point, false));
|
||||
let want = WorldCursor { kind, unable };
|
||||
if *cursor != want {
|
||||
*cursor = want;
|
||||
}
|
||||
let want_fork = AttackFork(attack_fork.get());
|
||||
if *fork != want_fork {
|
||||
*fork = want_fork;
|
||||
}
|
||||
(WorldCursor { kind, unable }, AttackFork(attack_fork.get()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
|
||||
pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant};
|
||||
|
||||
pub(crate) use click::DeselectGuid;
|
||||
pub use click::Interact;
|
||||
|
||||
/// Our target, set the instant we click, as the 1.12 client does without waiting for the server,
|
||||
/// and cleared on deselect or when it streams out. `CMSG_SET_SELECTION` carries the guid, which the
|
||||
@@ -286,6 +287,7 @@ impl Plugin for TargetPlugin {
|
||||
.add_message::<TargetByNameRequest>()
|
||||
.add_message::<AssistRequest>()
|
||||
.add_message::<click::DeselectGuid>()
|
||||
.add_message::<Interact>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
(
|
||||
@@ -327,7 +329,12 @@ impl Plugin for TargetPlugin {
|
||||
crate::spell::targeting::commit_ground_cast_on_click,
|
||||
crate::spell::targeting::commit_object_cast_on_click,
|
||||
// The verb a walk owes runs at its stop, ahead of this frame's click.
|
||||
(click::act_on_arrival, click::act_on_right_click).chain(),
|
||||
(
|
||||
click::act_on_arrival,
|
||||
click::act_on_right_click,
|
||||
click::act_on_interact,
|
||||
)
|
||||
.chain(),
|
||||
// The loot close's guid-scoped deselect.
|
||||
click::clear_target_requests,
|
||||
// The script calls that touch the selection, the cast or the targeting cursor
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! The seam `docs/CONTRIBUTING.md` ("Building on top") opens to a crate: read the streamed objects
|
||||
//! through [`benilla_app::Objects`] and interact with one through [`benilla_app::Interact`], from
|
||||
//! outside the crate. It fails to build if either goes private, with no feature needed.
|
||||
|
||||
use benilla_app::{Interact, Objects};
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// What such a crate writes: an object it found, interacted with.
|
||||
fn interact_with_the_nearest(objects: Objects, mut interact: MessageWriter<Interact>) {
|
||||
if let Some((_, entity, _)) = objects.iter().next() {
|
||||
interact.write(Interact(entity));
|
||||
}
|
||||
}
|
||||
|
||||
/// A key press such a crate answers: the guid it picked, interacted with.
|
||||
fn interact_with_guid(guid: u64, objects: &Objects, interact: &mut MessageWriter<Interact>) {
|
||||
if let Some(entity) = objects.entity(guid) {
|
||||
interact.write(Interact(entity));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_a_system<M>(_: impl IntoSystem<(), (), M>) {}
|
||||
|
||||
#[test]
|
||||
fn a_crate_on_top_reads_objects_and_writes_interact() {
|
||||
is_a_system(interact_with_the_nearest);
|
||||
let _ = interact_with_guid;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_message::<Interact>();
|
||||
let entity = app.world_mut().spawn_empty().id();
|
||||
app.world_mut().write_message(Interact(entity));
|
||||
let sent: Vec<Interact> = app
|
||||
.world()
|
||||
.resource::<Messages<Interact>>()
|
||||
.iter_current_update_messages()
|
||||
.copied()
|
||||
.collect();
|
||||
assert_eq!(sent, [Interact(entity)]);
|
||||
}
|
||||
Reference in New Issue
Block a user