1mod 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; const WIDTH: usize = 1280; const 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 .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#[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}