Skip to main content

steel_core/entity/
callback.rs

1//! Entity lifecycle callbacks for movement and removal tracking.
2
3use std::sync::Weak;
4
5use glam::DVec3;
6use steel_utils::{ChunkPos, WorldAabb};
7
8use super::EntityMoveError;
9use crate::world::World;
10
11/// Reasons an entity can be removed from the world.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RemovalReason {
14    /// Entity was killed/destroyed.
15    Killed,
16    /// Entity was discarded (e.g., too far from players).
17    Discarded,
18    /// Entity unloaded with chunk.
19    UnloadedToChunk,
20    /// Entity moved to another loaded world.
21    ChangedWorld,
22    /// Entity is persisted inside a player `RootVehicle` payload.
23    StoredWithPlayer,
24}
25
26impl RemovalReason {
27    /// Returns true if entity data should be destroyed (not saved).
28    #[must_use]
29    pub const fn should_destroy(self) -> bool {
30        matches!(self, Self::Killed | Self::Discarded)
31    }
32
33    /// Returns true if the entity should be saved when removed.
34    ///
35    /// In vanilla, only `UnloadedToChunk` saves - the entity persists in chunk storage.
36    /// `ChangedWorld` and `StoredWithPlayer` do not save because the entity
37    /// is retained by another owner instead of current-world entity storage.
38    #[must_use]
39    pub const fn should_save(self) -> bool {
40        matches!(self, Self::UnloadedToChunk)
41    }
42}
43
44/// Callback interface for entity lifecycle events.
45pub trait EntityLevelCallback: Send + Sync {
46    /// Returns whether direct local position writes may bypass lifecycle callbacks.
47    fn allows_local_position_update(&self) -> bool {
48        false
49    }
50
51    /// Called before an entity position change is committed.
52    fn validate_move(&self, old_pos: DVec3, new_pos: DVec3) -> Result<(), EntityMoveError>;
53
54    /// Called after an entity position change has been committed.
55    fn on_move_committed(&self, old_pos: DVec3, new_pos: DVec3) -> Result<(), EntityMoveError>;
56
57    /// Called after an entity's collision bounds change without a position change.
58    fn on_bounding_box_changed(&self, _bounding_box: WorldAabb) {}
59
60    /// Called when entity is removed from the world.
61    fn on_remove(&self, reason: RemovalReason);
62}
63
64/// Null callback for entities not yet in the world.
65pub struct NullEntityCallback;
66
67impl EntityLevelCallback for NullEntityCallback {
68    fn allows_local_position_update(&self) -> bool {
69        true
70    }
71
72    fn validate_move(&self, _old_pos: DVec3, _new_pos: DVec3) -> Result<(), EntityMoveError> {
73        Ok(())
74    }
75
76    fn on_move_committed(&self, _old_pos: DVec3, _new_pos: DVec3) -> Result<(), EntityMoveError> {
77        Ok(())
78    }
79
80    fn on_remove(&self, _reason: RemovalReason) {}
81}
82
83/// Callback for entities retained outside live world membership.
84pub struct InactiveEntityCallback {
85    entity_id: i32,
86}
87
88impl InactiveEntityCallback {
89    /// Creates an inactive callback for a retained non-live entity.
90    #[must_use]
91    pub const fn new(entity_id: i32) -> Self {
92        Self { entity_id }
93    }
94}
95
96impl EntityLevelCallback for InactiveEntityCallback {
97    fn validate_move(&self, _old_pos: DVec3, _new_pos: DVec3) -> Result<(), EntityMoveError> {
98        Err(EntityMoveError::Inactive {
99            entity_id: self.entity_id,
100        })
101    }
102
103    fn on_move_committed(&self, _old_pos: DVec3, _new_pos: DVec3) -> Result<(), EntityMoveError> {
104        Err(EntityMoveError::Inactive {
105            entity_id: self.entity_id,
106        })
107    }
108
109    fn on_remove(&self, _reason: RemovalReason) {}
110}
111
112/// Callback for players.
113///
114/// Players are owned by `World.players`, but the world entity manager still
115/// indexes their live position for lookup and tracking updates.
116pub struct PlayerEntityCallback {
117    entity_id: i32,
118    world: Weak<World>,
119}
120
121impl PlayerEntityCallback {
122    /// Creates a new callback for a player.
123    #[must_use]
124    pub const fn new(entity_id: i32, world: Weak<World>) -> Self {
125        Self { entity_id, world }
126    }
127}
128
129impl EntityLevelCallback for PlayerEntityCallback {
130    fn validate_move(&self, old_pos: DVec3, new_pos: DVec3) -> Result<(), EntityMoveError> {
131        let Some(world) = self.world.upgrade() else {
132            return Err(EntityMoveError::NotLive {
133                entity_id: self.entity_id,
134            });
135        };
136
137        world
138            .entity_manager()
139            .validate_move(self.entity_id, new_pos)
140            .inspect_err(|error| {
141                log::warn!("Rejected player entity move from {old_pos:?} to {new_pos:?}: {error}");
142            })
143    }
144
145    fn on_move_committed(&self, old_pos: DVec3, new_pos: DVec3) -> Result<(), EntityMoveError> {
146        let Some(world) = self.world.upgrade() else {
147            return Err(EntityMoveError::NotLive {
148                entity_id: self.entity_id,
149            });
150        };
151
152        let update = world
153            .entity_manager()
154            .commit_move(self.entity_id, new_pos)
155            .inspect_err(|error| {
156                log::warn!(
157                    "Failed to commit player entity move from {old_pos:?} to {new_pos:?}: {error}"
158                );
159            })?;
160
161        if update.section_changed() {
162            world.entity_tracker().on_entity_section_change(
163                self.entity_id,
164                update.old_chunk,
165                update.new_chunk,
166                |chunk| world.get_packet_tracking_players(chunk),
167                |player_id| world.players.get_by_entity_id(player_id),
168            );
169
170            if let Some(player) = world.players.get_by_entity_id(self.entity_id)
171                && let Some(view) = *player.last_tracking_view.lock()
172            {
173                let sent_chunks = player.chunk_sender().lock().sent_chunks_snapshot();
174                world
175                    .entity_tracker()
176                    .update_player(&player, &view, |chunk| sent_chunks.contains(&chunk));
177            }
178        }
179
180        Ok(())
181    }
182
183    fn on_bounding_box_changed(&self, _bounding_box: WorldAabb) {
184        if let Some(world) = self.world.upgrade() {
185            world
186                .entity_manager()
187                .commit_bounding_box_change(self.entity_id);
188        }
189    }
190
191    fn on_remove(&self, _reason: RemovalReason) {
192        // Player removal is handled by World::remove_player, not through this callback
193    }
194}
195
196/// Callback attached to each entity for tracking chunk/section movement.
197pub struct EntityChunkCallback {
198    entity_id: i32,
199    world: Weak<World>,
200}
201
202impl EntityChunkCallback {
203    /// Creates a new callback for an entity.
204    #[must_use]
205    pub const fn new(entity_id: i32, world: Weak<World>) -> Self {
206        Self { entity_id, world }
207    }
208}
209
210impl EntityLevelCallback for EntityChunkCallback {
211    fn validate_move(&self, old_pos: DVec3, new_pos: DVec3) -> Result<(), EntityMoveError> {
212        let Some(world) = self.world.upgrade() else {
213            return Err(EntityMoveError::NotLive {
214                entity_id: self.entity_id,
215            });
216        };
217
218        world
219            .entity_manager()
220            .validate_move(self.entity_id, new_pos)
221            .inspect_err(|error| {
222                log::warn!("Rejected entity move from {old_pos:?} to {new_pos:?}: {error}");
223            })
224    }
225
226    fn on_move_committed(&self, old_pos: DVec3, new_pos: DVec3) -> Result<(), EntityMoveError> {
227        let Some(world) = self.world.upgrade() else {
228            return Err(EntityMoveError::NotLive {
229                entity_id: self.entity_id,
230            });
231        };
232
233        let update = world
234            .entity_manager()
235            .commit_move(self.entity_id, new_pos)
236            .inspect_err(|error| {
237                log::warn!("Failed to commit entity move from {old_pos:?} to {new_pos:?}: {error}");
238            })?;
239
240        world.mark_chunk_dirty(update.new_chunk);
241        if update.chunk_changed() {
242            world.mark_chunk_dirty(update.old_chunk);
243        }
244
245        if update.section_changed() {
246            if update.became_inaccessible() {
247                world.remove_entity_from_tracker(self.entity_id);
248            } else if update.became_accessible() {
249                if let Some(entity) = world.entity_manager().get_by_id(self.entity_id) {
250                    world.add_entity_to_tracker(&entity);
251                }
252            } else if update.new_accessible {
253                world.entity_tracker().on_entity_section_change(
254                    self.entity_id,
255                    update.old_chunk,
256                    update.new_chunk,
257                    |chunk| world.get_packet_tracking_players(chunk),
258                    |player_id| world.players.get_by_entity_id(player_id),
259                );
260            }
261        }
262
263        Ok(())
264    }
265
266    fn on_bounding_box_changed(&self, _bounding_box: WorldAabb) {
267        if let Some(world) = self.world.upgrade() {
268            world
269                .entity_manager()
270                .commit_bounding_box_change(self.entity_id);
271        }
272    }
273
274    fn on_remove(&self, reason: RemovalReason) {
275        let Some(world) = self.world.upgrade() else {
276            return;
277        };
278
279        let entity = world
280            .entity_manager()
281            .remove_live_entity(self.entity_id, reason);
282        if let Some(entity) = entity {
283            world.mark_chunk_dirty(ChunkPos::from_entity_pos(entity.position()));
284        }
285
286        world.remove_entity_from_tracker(self.entity_id);
287    }
288}