snake/
snake.rs

1//! Snake with adjustable grid size and tick rate.
2
3mod utils;
4use utils::*;
5
6use std::{
7    collections::{BTreeMap, HashMap, VecDeque},
8    time::Duration,
9};
10
11use bevy::prelude::*;
12use bevy_rand::prelude::*;
13use haalka::{grid::GRID_TRACK_FLOAT_PRECISION_SLACK, prelude::*};
14use rand::prelude::*;
15use strum::{EnumIter, IntoEnumIterator};
16
17fn main() {
18    App::new()
19        .add_plugins((examples_plugin, EntropyPlugin::<ChaCha8Rng>::default()))
20        .add_systems(
21            Startup,
22            (
23                |world: &mut World| {
24                    ui_root().spawn(world);
25                },
26                camera,
27                |mut commands: Commands| commands.trigger(Restart),
28            ),
29        )
30        .add_systems(Update, direction)
31        .add_systems(
32            FixedUpdate,
33            ((consume_queued_direction, tick)
34                .chain()
35                .run_if(not(resource_exists::<Paused>)),)
36                .chain(),
37        )
38        .insert_resource(DirectionResource(Direction::Right))
39        .insert_resource(Time::<Fixed>::from_seconds(1. / STARTING_TICKS_PER_SECOND as f64))
40        .insert_resource(QueuedDirectionOption(None))
41        .add_observer(on_restart)
42        .add_observer(on_spawn_food)
43        .add_observer(on_grid_size_change)
44        .run();
45}
46
47const STARTING_SIZE: usize = 20;
48const SIDE: usize = 720; // TODO: reactively auto fit to height
49const WIDTH: usize = 1280; // TODO: reactively auto fit to height
50const EMPTY_COLOR: Color = Color::srgb(91. / 255., 206. / 255., 250. / 255.);
51const SNAKE_COLOR: Color = Color::srgb(245. / 255., 169. / 255., 184. / 255.);
52const FOOD_COLOR: Color = Color::srgb(1., 1., 1.);
53const STARTING_TICKS_PER_SECOND: u32 = 10;
54const FONT_SIZE: f32 = 25.;
55
56#[derive(Resource)]
57struct Paused;
58
59#[derive(Clone, Copy, Debug, PartialEq)]
60enum Cell {
61    Empty,
62    Snake,
63    Food,
64}
65
66impl From<Cell> for BackgroundColor {
67    fn from(val: Cell) -> Self {
68        match val {
69            Cell::Empty => EMPTY_COLOR,
70            Cell::Snake => SNAKE_COLOR,
71            Cell::Food => FOOD_COLOR,
72        }
73        .into()
74    }
75}
76
77static TICK_RATE: LazyLock<Mutable<u32>> = LazyLock::new(|| Mutable::new(STARTING_TICKS_PER_SECOND));
78
79static SCORE: LazyLock<Mutable<u32>> = LazyLock::new(default);
80
81static GRID_SIZE: LazyLock<Mutable<usize>> = LazyLock::new(|| Mutable::new(STARTING_SIZE));
82
83type CellsType = MutableBTreeMap<(usize, usize), Mutable<Cell>>;
84
85static CELLS: LazyLock<CellsType> = LazyLock::new(|| {
86    (0..STARTING_SIZE)
87        .flat_map(|x| (0..STARTING_SIZE).map(move |y| ((x, y), Mutable::new(Cell::Empty))))
88        .collect::<BTreeMap<_, _>>()
89        .into()
90});
91
92fn grid(size: Mutable<usize>, cells: CellsType) -> impl Element {
93    let cell_size = size
94        .signal()
95        // TODO: see https://github.com/bevyengine/bevy/issues/12152 for why this slack is necessary
96        .map(|size| (SIDE as f32 - GRID_TRACK_FLOAT_PRECISION_SLACK) / size as f32)
97        .broadcast();
98    Grid::<Node>::new()
99        .with_node(|mut node| {
100            node.width = Val::Px(SIDE as f32);
101            node.height = Val::Px(SIDE as f32);
102        })
103        .row_wrap_cell_width_signal(cell_size.signal())
104        .cells_signal_vec(
105            cells
106                .entries_cloned()
107                .sort_by_cloned(|(left, _), (right, _)| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)))
108                .map(move |(_, cell)| {
109                    El::<Node>::new()
110                        .on_signal_with_node(cell_size.signal().map(Val::Px), |mut node, size| {
111                            node.width = size;
112                            node.height = size;
113                        })
114                        .background_color_signal(cell.signal().dedupe().map(Into::<BackgroundColor>::into))
115                }),
116        )
117}
118
119fn hud(score: Mutable<u32>, size: Mutable<usize>, tick_rate: Mutable<u32>) -> impl Element {
120    Column::<Node>::new()
121        .with_node(|mut node| {
122            node.width = Val::Px((WIDTH - SIDE) as f32);
123            node.row_gap = Val::Px(10.);
124        })
125        .align_content(Align::center())
126        .item(
127            El::<Text>::new()
128                .text_font(TextFont::from_font_size(250.))
129                .text_signal(score.signal_ref(ToString::to_string).map(Text)),
130        )
131        .item(
132            Row::<Node>::new()
133                .with_node(|mut node| node.column_gap = Val::Px(10.))
134                .item(
135                    El::<Text>::new()
136                        .text_font(TextFont::from_font_size(FONT_SIZE))
137                        .text(Text::new("grid size:")),
138                )
139                .item(
140                    El::<Text>::new()
141                        .text_font(TextFont::from_font_size(FONT_SIZE))
142                        .text_signal(size.signal_ref(ToString::to_string).map(Text)),
143                )
144                .item(text_button("-").on_pressing_with_system_with_sleep_throttle(
145                    |_: In<_>, mut commands: Commands| {
146                        commands.trigger(GridSizeChange::Decr);
147                    },
148                    Duration::from_millis(100),
149                ))
150                .item(text_button("+").on_pressing_with_system_with_sleep_throttle(
151                    |_: In<_>, mut commands: Commands| {
152                        commands.trigger(GridSizeChange::Incr);
153                    },
154                    Duration::from_millis(100),
155                )),
156        )
157        .item(
158            Row::<Node>::new()
159                .with_node(|mut node| node.column_gap = Val::Px(10.))
160                .item(
161                    El::<Text>::new()
162                        .text_font(TextFont::from_font_size(FONT_SIZE))
163                        .text(Text::new("tick rate:")),
164                )
165                .item(
166                    El::<Text>::new()
167                        .text_font(TextFont::from_font_size(FONT_SIZE))
168                        .text_signal(tick_rate.signal_ref(ToString::to_string).map(Text)),
169                )
170                .item(text_button("-").on_pressing_with_system_with_sleep_throttle(
171                    |_: In<_>, world: &mut World| {
172                        let cur_rate = TICK_RATE.get();
173                        if cur_rate > 1 {
174                            TICK_RATE.update(|rate| rate - 1);
175                            world.insert_resource(Time::<Fixed>::from_seconds(1. / (cur_rate - 1) as f64));
176                        }
177                    },
178                    Duration::from_millis(100),
179                ))
180                .item(text_button("+").on_pressing_with_system_with_sleep_throttle(
181                    |_: In<_>, world: &mut World| {
182                        let cur_rate = TICK_RATE.get();
183                        TICK_RATE.update(|rate| rate + 1);
184                        world.insert_resource(Time::<Fixed>::from_seconds(1. / (cur_rate + 1) as f64));
185                    },
186                    Duration::from_millis(100),
187                )),
188        )
189}
190
191fn ui_root() -> impl Element {
192    Stack::<Node>::new()
193        .with_node(|mut node| {
194            node.width = Val::Percent(100.);
195            node.height = Val::Percent(100.);
196        })
197        .cursor(CursorIcon::default())
198        .layer(
199            Row::<Node>::new()
200                .align(Align::center())
201                .item(grid(GRID_SIZE.clone(), CELLS.clone()))
202                .item(hud(SCORE.clone(), GRID_SIZE.clone(), TICK_RATE.clone())),
203        )
204        .layer_signal(GAME_OVER.signal().dedupe().map_true(restart_button))
205}
206
207fn restart_button() -> impl Element {
208    let hovered = Mutable::new(false);
209    El::<Node>::new()
210        .align(Align::center())
211        .with_node(|mut node| {
212            node.width = Val::Px(250.);
213            node.height = Val::Px(80.);
214        })
215        .cursor(CursorIcon::System(SystemCursorIcon::Pointer))
216        .background_color_signal(
217            hovered
218                .signal()
219                .map_bool(|| bevy::color::palettes::basic::GRAY.into(), || Color::BLACK)
220                .map(BackgroundColor),
221        )
222        .hovered_sync(hovered)
223        .align_content(Align::center())
224        .on_click_with_system(|_: In<_>, mut commands: Commands| commands.trigger(Restart))
225        .child(
226            El::<Text>::new()
227                .text_font(TextFont::from_font_size(50.))
228                .text_color(TextColor(Color::WHITE))
229                .text(Text::new("restart")),
230        )
231}
232
233#[derive(Event, Clone, Copy)]
234enum GridSizeChange {
235    Incr,
236    Decr,
237}
238
239fn on_grid_size_change(event: Trigger<GridSizeChange>, mut commands: Commands) {
240    let event = *event;
241    let cur_size = GRID_SIZE.get();
242    match event {
243        GridSizeChange::Incr => {
244            let mut cells_lock = CELLS.lock_mut();
245            for i in 0..cur_size + 1 {
246                cells_lock.insert_cloned((i, cur_size), Mutable::new(Cell::Empty));
247                cells_lock.insert_cloned((cur_size, i), Mutable::new(Cell::Empty));
248            }
249            GRID_SIZE.update(|size| size + 1);
250        }
251        GridSizeChange::Decr => {
252            if cur_size > 2 {
253                let mut cells_lock = CELLS.lock_mut();
254                let indices = (0..cur_size)
255                    .map(|i| (i, cur_size - 1))
256                    .chain((0..cur_size).map(|i| (cur_size - 1, i)))
257                    .collect::<Vec<_>>();
258                if indices.iter().all(|index| {
259                    cells_lock
260                        .get(index)
261                        .map(|cell| !matches!(cell.get(), Cell::Snake))
262                        .unwrap_or(false)
263                }) {
264                    let mut removed = vec![];
265                    for index in indices {
266                        removed.push(cells_lock.remove(&index));
267                    }
268                    if removed
269                        .into_iter()
270                        .flatten()
271                        .any(|removed| matches!(removed.get(), Cell::Food))
272                    {
273                        commands.trigger(SpawnFood);
274                    }
275                    GRID_SIZE.update(|size| size - 1);
276                }
277            }
278        }
279    }
280}
281
282fn text_button(text_: &str) -> impl Element + PointerEventAware {
283    let hovered = Mutable::new(false);
284    El::<Node>::new()
285        .with_node(|mut node| node.width = Val::Px(45.0))
286        .align_content(Align::center())
287        .cursor(CursorIcon::System(SystemCursorIcon::Pointer))
288        .background_color_signal(
289            hovered
290                .signal()
291                .map_bool(|| SNAKE_COLOR, || EMPTY_COLOR)
292                .map(BackgroundColor),
293        )
294        .hovered_sync(hovered)
295        .child(
296            El::<Text>::new()
297                .text_font(TextFont::from_font_size(FONT_SIZE))
298                .text(Text::new(text_)),
299        )
300}
301
302// u could also just scan the cells every tick, but i'm just caching it
303#[derive(Resource)]
304struct Snake(VecDeque<(usize, usize)>);
305
306static GAME_OVER: LazyLock<Mutable<bool>> = LazyLock::new(default);
307
308#[derive(Clone, Copy, EnumIter, PartialEq, Debug)]
309enum Direction {
310    Up,
311    Down,
312    Left,
313    Right,
314}
315
316impl Direction {
317    fn opposite(&self) -> Self {
318        match self {
319            Direction::Up => Direction::Down,
320            Direction::Down => Direction::Up,
321            Direction::Left => Direction::Right,
322            Direction::Right => Direction::Left,
323        }
324    }
325}
326
327#[derive(Resource)]
328struct DirectionResource(Direction);
329
330fn tick(mut snake: ResMut<Snake>, direction: Res<DirectionResource>, mut commands: Commands) {
331    let (mut x, mut y) = snake.0.front().copied().unwrap();
332    (x, y) = match direction.0 {
333        Direction::Up => (x, if y == GRID_SIZE.get() - 1 { 0 } else { y + 1 }),
334        Direction::Down => (x, y.checked_sub(1).unwrap_or_else(|| GRID_SIZE.get() - 1)),
335        Direction::Left => (x.checked_sub(1).unwrap_or_else(|| GRID_SIZE.get() - 1), y),
336        Direction::Right => (if x == GRID_SIZE.get() - 1 { 0 } else { x + 1 }, y),
337    };
338    snake.0.push_front((x, y));
339    let cells_lock = CELLS.lock_ref();
340    if let Some(new) = cells_lock.get(&(x, y)) {
341        match new.get() {
342            Cell::Snake => {
343                GAME_OVER.set(true);
344                commands.insert_resource(Paused);
345            }
346            cell @ (Cell::Food | Cell::Empty) => {
347                new.set(Cell::Snake);
348                match cell {
349                    Cell::Food => {
350                        SCORE.update(|score| score + 1);
351                        commands.trigger(SpawnFood);
352                    }
353                    Cell::Empty => {
354                        if let Some((x, y)) = snake.0.pop_back()
355                            && let Some(cell) = cells_lock.get(&(x, y))
356                        {
357                            cell.set(Cell::Empty);
358                        }
359                    }
360                    _ => (),
361                }
362            }
363        }
364    }
365}
366
367#[derive(Event, Default)]
368struct SpawnFood;
369
370fn on_spawn_food(_: Trigger<SpawnFood>, mut rng: GlobalEntropy<ChaCha8Rng>) {
371    let cells_lock = CELLS.lock_ref();
372    let empty_cells = cells_lock
373        .iter()
374        .filter_map(|(position, cell)| matches!(cell.get(), Cell::Empty).then_some(position));
375    cells_lock
376        .get(empty_cells.choose(rng.as_mut()).unwrap())
377        .unwrap()
378        .set(Cell::Food);
379}
380
381#[derive(Event, Default)]
382struct Restart;
383
384fn on_restart(_: Trigger<Restart>, mut commands: Commands) {
385    for (_, cell) in CELLS.lock_ref().iter() {
386        cell.set(Cell::Empty);
387    }
388    let size = GRID_SIZE.get();
389    let init_snake = vec![(size / 2, size / 2 - 1), (size / 2 - 1, size / 2 - 1)];
390    let cells_lock = CELLS.lock_ref();
391    for &(x, y) in init_snake.iter() {
392        cells_lock.get(&(x, y)).unwrap().set_neq(Cell::Snake);
393    }
394    commands.insert_resource(Snake(VecDeque::from(init_snake)));
395    commands.insert_resource(QueuedDirectionOption(None));
396    commands.insert_resource(DirectionResource(Direction::Right));
397    commands.trigger(SpawnFood);
398    commands.remove_resource::<Paused>();
399    SCORE.set_neq(0);
400    GAME_OVER.set_neq(false);
401}
402
403#[derive(Resource)]
404struct QueuedDirectionOption(Option<Direction>);
405
406fn direction(keys: ResMut<ButtonInput<KeyCode>>, mut queued_direction_option: ResMut<QueuedDirectionOption>) {
407    let map = HashMap::from([
408        (KeyCode::KeyW, Direction::Up),
409        (KeyCode::KeyA, Direction::Left),
410        (KeyCode::KeyS, Direction::Down),
411        (KeyCode::KeyD, Direction::Right),
412        (KeyCode::ArrowUp, Direction::Up),
413        (KeyCode::ArrowLeft, Direction::Left),
414        (KeyCode::ArrowDown, Direction::Down),
415        (KeyCode::ArrowRight, Direction::Right),
416    ]);
417    for (key, key_dir) in map.iter() {
418        if keys.pressed(*key) {
419            queued_direction_option.0 = Some(*key_dir);
420            return;
421        }
422    }
423}
424
425fn consume_queued_direction(
426    mut queued_direction_option: ResMut<QueuedDirectionOption>,
427    mut cur_dir: ResMut<DirectionResource>,
428) {
429    if let Some(queued_direction) = queued_direction_option.0.take() {
430        for direction in Direction::iter() {
431            if cur_dir.0 == direction && cur_dir.0.opposite() == queued_direction {
432                return;
433            }
434        }
435        cur_dir.0 = queued_direction;
436    }
437}
438
439fn camera(mut commands: Commands) {
440    commands.spawn(Camera2d);
441}