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:
Mathih13
2026-10-02 06:38:12 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent 2ce6a3e881
commit e620a0d095
7 changed files with 449 additions and 62 deletions
+7 -2
View File
@@ -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) {
+4
View File
@@ -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.
+54 -7
View File
@@ -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));
}
}
+267 -17
View File
@@ -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() {
+69 -35
View File
@@ -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)]
+8 -1
View File
@@ -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)]);
}