perf(engine): route remaining drag card→entity lookups through CardEntityIndex

Replace O(n) `Query::iter().find()` card scans with O(1) `CardEntityIndex`
lookups in the mouse and touch drag pipelines (`follow_drag`, `end_drag`,
`touch_follow_drag`, `touch_end_drag`) and `update_drag_shadow` — 7 sites
across 5 systems. Each ran per dragged card per frame during a drag.

`InputPlugin` now defensively `init_resource::<CardEntityIndex>()` (idempotent;
`CardPlugin` still owns and rebuilds it) so the plugin is self-sufficient in
tests. The lone remaining card-keyed `.find` is a `#[cfg(test)]` world-query
helper, which is the correct pattern there.

Completes the CardEntityIndex migration started in ef1efdc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-11 11:05:24 -07:00
parent 372b6423d8
commit 424c8b2d50
2 changed files with 27 additions and 22 deletions
+4 -3
View File
@@ -1487,6 +1487,7 @@ fn update_drag_shadow(
drag: Res<DragState>, drag: Res<DragState>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
card_entities: Query<(&CardEntity, &Transform)>, card_entities: Query<(&CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
mut shadow: Local<Option<Entity>>, mut shadow: Local<Option<Entity>>,
) { ) {
if drag.is_idle() { if drag.is_idle() {
@@ -1503,9 +1504,9 @@ fn update_drag_shadow(
// Find the world position of the first (top) dragged card. // Find the world position of the first (top) dragged card.
let top_pos = drag.cards.first().and_then(|first_card| { let top_pos = drag.cards.first().and_then(|first_card| {
card_entities card_index
.iter() .get(first_card)
.find(|(marker, _)| marker.card == *first_card) .and_then(|entity| card_entities.get(entity).ok())
.map(|(_, t)| t.translation) .map(|(_, t)| t.translation)
}); });
+23 -19
View File
@@ -33,7 +33,9 @@ use solitaire_core::game_state::GameState;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve}; use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC}; use crate::card_plugin::{
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent, DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
@@ -121,6 +123,10 @@ impl Plugin for InputPlugin {
.init_resource::<HintSolverConfig>() .init_resource::<HintSolverConfig>()
.init_resource::<crate::pending_hint::PendingHintTask>() .init_resource::<crate::pending_hint::PendingHintTask>()
.init_resource::<GameInputConsumedResource>() .init_resource::<GameInputConsumedResource>()
// The drag systems resolve cards via `CardEntityIndex`; `CardPlugin`
// owns and rebuilds it, but init here too so `InputPlugin` is
// self-sufficient in tests (idempotent if already registered).
.init_resource::<CardEntityIndex>()
.add_message::<StartZenRequestEvent>() .add_message::<StartZenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ForfeitRequestEvent>() .add_message::<ForfeitRequestEvent>()
@@ -674,6 +680,7 @@ fn follow_drag(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
tuning: Res<AnimationTuning>, tuning: Res<AnimationTuning>,
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>, mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
card_index: Res<CardEntityIndex>,
) { ) {
// Skip if idle or if a touch drag is running. // Skip if idle or if a touch drag is running.
if drag.is_idle() || drag.active_touch_id.is_some() { if drag.is_idle() || drag.active_touch_id.is_some() {
@@ -704,9 +711,8 @@ fn follow_drag(
// Elevate cards: push to DRAG_Z and dim slightly so the board // Elevate cards: push to DRAG_Z and dim slightly so the board
// beneath stays readable. // beneath stays readable.
for (i, card) in drag.cards.iter().enumerate() { for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_transforms if let Some(entity) = card_index.get(card)
.iter_mut() && let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
.find(|(ce, _, _)| ce.card == *card)
{ {
transform.translation.z = dragged_card_z(i); transform.translation.z = dragged_card_z(i);
sprite.color.set_alpha(0.85); sprite.color.set_alpha(0.85);
@@ -719,9 +725,8 @@ fn follow_drag(
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac; let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, card) in drag.cards.iter().enumerate() { for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) = card_transforms if let Some(entity) = card_index.get(card)
.iter_mut() && let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
.find(|(ce, _, _)| ce.card == *card)
{ {
transform.translation.x = bottom_pos.x; transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32; transform.translation.y = bottom_pos.y + fan * i as f32;
@@ -743,6 +748,7 @@ fn end_drag(
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut commands: Commands, mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>, card_entities: Query<(Entity, &CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
drag.clear(); drag.clear();
@@ -830,9 +836,8 @@ fn end_drag(
continue; continue;
}; };
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) = card_entities if let Some(entity) = card_index.get(card)
.iter() && let Ok((_, _, transform)) = card_entities.get(entity)
.find(|(_, ce, _)| ce.card == *card)
{ {
let drag_pos = transform.translation.truncate(); let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z; let drag_z = transform.translation.z;
@@ -930,6 +935,7 @@ fn touch_follow_drag(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
tuning: Res<AnimationTuning>, tuning: Res<AnimationTuning>,
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>, mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
card_index: Res<CardEntityIndex>,
) { ) {
let Some(active_id) = drag.active_touch_id else { let Some(active_id) = drag.active_touch_id else {
return; // Mouse drag or idle. return; // Mouse drag or idle.
@@ -957,9 +963,8 @@ fn touch_follow_drag(
drag.committed = true; drag.committed = true;
for (i, card) in drag.cards.iter().enumerate() { for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_transforms if let Some(entity) = card_index.get(card)
.iter_mut() && let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
.find(|(ce, _, _)| ce.card == *card)
{ {
transform.translation.z = dragged_card_z(i); transform.translation.z = dragged_card_z(i);
sprite.color.set_alpha(0.85); sprite.color.set_alpha(0.85);
@@ -971,9 +976,8 @@ fn touch_follow_drag(
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac; let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, card) in drag.cards.iter().enumerate() { for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) = card_transforms if let Some(entity) = card_index.get(card)
.iter_mut() && let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
.find(|(ce, _, _)| ce.card == *card)
{ {
transform.translation.x = bottom_pos.x; transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32; transform.translation.y = bottom_pos.y + fan * i as f32;
@@ -998,6 +1002,7 @@ fn touch_end_drag(
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut commands: Commands, mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>, card_entities: Query<(Entity, &CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
) { ) {
let Some(active_id) = drag.active_touch_id else { let Some(active_id) = drag.active_touch_id else {
return; // Mouse drag or idle. return; // Mouse drag or idle.
@@ -1070,9 +1075,8 @@ fn touch_end_drag(
continue; continue;
}; };
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) = card_entities if let Some(entity) = card_index.get(card)
.iter() && let Ok((_, _, transform)) = card_entities.get(entity)
.find(|(_, ce, _)| ce.card == *card)
{ {
let drag_pos = transform.translation.truncate(); let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z; let drag_z = transform.translation.z;