chunk_unloading/
chunk_unloading.rs

1//! Demonstrates chunk unloading strategies.
2//!
3//! Move around with WASD/QE and watch chunks load/unload based on distance.
4//! The UI shows chunk statistics and which unload strategy is active.
5//!
6//! Controls:
7//! - WASD: Move horizontally
8//! - Q/E: Move down/up
9//! - Space: Cycle through unload strategies
10//! - Right-click + drag: Look around
11
12use bevy::{input::mouse::MouseMotion, prelude::*};
13use chunky_bevy::prelude::*;
14
15fn main() {
16    App::new()
17        .add_plugins(DefaultPlugins)
18        .add_plugins(ChunkyPlugin::default())
19        .init_state::<UnloadStrategy>()
20        .add_systems(Startup, setup)
21        .add_systems(
22            Update,
23            (
24                fly_camera,
25                cycle_unload_strategy,
26                update_unload_resources,
27                update_ui,
28                log_unload_events,
29            ),
30        )
31        .run();
32}
33
34#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35enum UnloadStrategy {
36    #[default]
37    None,
38    DistanceOnly,
39    LimitOnly,
40    Hybrid,
41}
42
43impl UnloadStrategy {
44    fn next(self) -> Self {
45        match self {
46            Self::None => Self::DistanceOnly,
47            Self::DistanceOnly => Self::LimitOnly,
48            Self::LimitOnly => Self::Hybrid,
49            Self::Hybrid => Self::None,
50        }
51    }
52
53    fn label(self) -> &'static str {
54        match self {
55            Self::None => "None (chunks never unload)",
56            Self::DistanceOnly => "Distance Only (unload when out of range)",
57            Self::LimitOnly => "Limit Only (LRU eviction at 50 chunks)",
58            Self::Hybrid => "Hybrid (out of range AND over limit)",
59        }
60    }
61}
62
63#[derive(Component)]
64struct FlyCam {
65    speed: f32,
66    sensitivity: f32,
67    pitch: f32,
68    yaw: f32,
69}
70
71impl Default for FlyCam {
72    fn default() -> Self {
73        Self {
74            speed: 30.0,
75            sensitivity: 0.003,
76            pitch: -0.3,
77            yaw: 0.0,
78        }
79    }
80}
81
82#[derive(Component)]
83struct StatsText;
84
85#[derive(Component)]
86struct Player;
87
88fn setup(mut commands: Commands, mut visualizer: ResMut<NextState<ChunkBoundryVisualizer>>) {
89    // Camera with fly controls and chunk loading
90    commands.spawn((
91        Camera3d::default(),
92        Transform::from_xyz(0.0, 20.0, 50.0).looking_at(Vec3::ZERO, Vec3::Y),
93        FlyCam::default(),
94        Player,
95        ChunkLoader(IVec3::new(3, 1, 3)),
96        ChunkUnloadRadius(IVec3::new(5, 2, 5)),
97    ));
98
99    // Light
100    commands.spawn((
101        DirectionalLight {
102            illuminance: 15000.0,
103            shadows_enabled: true,
104            ..default()
105        },
106        Transform::from_xyz(50.0, 100.0, 50.0).looking_at(Vec3::ZERO, Vec3::Y),
107    ));
108
109    // UI
110    commands.spawn((
111        Text::new(""),
112        TextFont {
113            font_size: 18.0,
114            ..default()
115        },
116        TextColor(Color::WHITE),
117        Node {
118            position_type: PositionType::Absolute,
119            top: Val::Px(12.0),
120            left: Val::Px(12.0),
121            ..default()
122        },
123        StatsText,
124    ));
125
126    visualizer.set(ChunkBoundryVisualizer::On);
127}
128
129fn fly_camera(
130    time: Res<Time>,
131    keyboard: Res<ButtonInput<KeyCode>>,
132    mouse_buttons: Res<ButtonInput<MouseButton>>,
133    mut mouse_motion: MessageReader<MouseMotion>,
134    mut query: Query<(&mut Transform, &mut FlyCam)>,
135) {
136    let Ok((mut transform, mut cam)) = query.single_mut() else {
137        return;
138    };
139
140    // Mouse look (right-click held)
141    if mouse_buttons.pressed(MouseButton::Right) {
142        for motion in mouse_motion.read() {
143            cam.yaw -= motion.delta.x * cam.sensitivity;
144            cam.pitch -= motion.delta.y * cam.sensitivity;
145            cam.pitch = cam.pitch.clamp(-1.5, 1.5);
146        }
147    } else {
148        mouse_motion.clear();
149    }
150
151    transform.rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
152
153    // Keyboard movement
154    let mut velocity = Vec3::ZERO;
155    let forward = transform.forward().as_vec3();
156    let right = transform.right().as_vec3();
157
158    if keyboard.pressed(KeyCode::KeyW) {
159        velocity += forward;
160    }
161    if keyboard.pressed(KeyCode::KeyS) {
162        velocity -= forward;
163    }
164    if keyboard.pressed(KeyCode::KeyD) {
165        velocity += right;
166    }
167    if keyboard.pressed(KeyCode::KeyA) {
168        velocity -= right;
169    }
170    if keyboard.pressed(KeyCode::KeyE) {
171        velocity += Vec3::Y;
172    }
173    if keyboard.pressed(KeyCode::KeyQ) {
174        velocity -= Vec3::Y;
175    }
176
177    if velocity != Vec3::ZERO {
178        transform.translation += velocity.normalize() * cam.speed * time.delta_secs();
179    }
180}
181
182fn cycle_unload_strategy(
183    keyboard: Res<ButtonInput<KeyCode>>,
184    current: Res<State<UnloadStrategy>>,
185    mut next: ResMut<NextState<UnloadStrategy>>,
186) {
187    if keyboard.just_pressed(KeyCode::Space) {
188        next.set(current.get().next());
189    }
190}
191
192fn update_unload_resources(
193    mut commands: Commands,
194    strategy: Res<State<UnloadStrategy>>,
195    distance_res: Option<Res<ChunkUnloadByDistance>>,
196    limit_res: Option<Res<ChunkUnloadLimit>>,
197) {
198    if !strategy.is_changed() {
199        return;
200    }
201
202    // Remove existing resources
203    if distance_res.is_some() {
204        commands.remove_resource::<ChunkUnloadByDistance>();
205    }
206    if limit_res.is_some() {
207        commands.remove_resource::<ChunkUnloadLimit>();
208    }
209
210    // Insert based on new strategy
211    match strategy.get() {
212        UnloadStrategy::None => {}
213        UnloadStrategy::DistanceOnly => {
214            commands.insert_resource(ChunkUnloadByDistance);
215        }
216        UnloadStrategy::LimitOnly => {
217            commands.insert_resource(ChunkUnloadLimit { max_chunks: 50 });
218        }
219        UnloadStrategy::Hybrid => {
220            commands.insert_resource(ChunkUnloadByDistance);
221            commands.insert_resource(ChunkUnloadLimit { max_chunks: 50 });
222        }
223    }
224}
225
226fn update_ui(
227    mut text_query: Query<&mut Text, With<StatsText>>,
228    chunks: Query<&ChunkPos, With<Chunk>>,
229    pinned: Query<(), With<ChunkPinned>>,
230    player: Query<&Transform, With<Player>>,
231    strategy: Res<State<UnloadStrategy>>,
232    chunk_manager: Res<ChunkManager>,
233) {
234    let Ok(mut text) = text_query.single_mut() else {
235        return;
236    };
237    let Ok(player_transform) = player.single() else {
238        return;
239    };
240
241    let chunk_count = chunks.iter().count();
242    let pinned_count = pinned.iter().count();
243    let player_chunk = chunk_manager.get_chunk_pos(&player_transform.translation);
244
245    **text = format!(
246        "Chunks: {} (pinned: {})\n\
247         Player chunk: {}\n\
248         \n\
249         Strategy: {}\n\
250         \n\
251         [Space] Cycle strategy\n\
252         [WASD/QE] Move\n\
253         [Right-click + drag] Look",
254        chunk_count,
255        pinned_count,
256        player_chunk,
257        strategy.get().label(),
258    );
259}
260
261fn log_unload_events(mut events: MessageReader<ChunkUnloadEvent>) {
262    for event in events.read() {
263        info!("Chunk {:?} unloaded: {:?}", event.chunk_pos, event.reason);
264    }
265}