v / examples / 2048 / 2048.v
1561 lines · 1467 sloc · 35.65 KB · f46c455e8fe04be0ab5f094d2cd2bd6f8a85f8d9
Raw
1// AI heuristic inspired by the expectimax 2048 solver approach described at:
2// https://github.com/nneonneo/2048-ai
3import gg
4import math
5import math.easing
6import os.asset
7import rand
8import time
9
10const zooming_percent_per_frame = 5
11const movement_percent_per_frame = 10
12const tile_text_size_step = 8
13const min_tile_text_size = 8
14
15const default_window_width = 544
16const default_window_height = 560
17
18const possible_moves = [Direction.up, .right, .down, .left]
19const ai_row_states = 1 << 16
20const ai_tt_size = 1 << 18
21const ai_time_budget_us = i64(5_000)
22const ai_min_search_depth = 2
23const ai_max_search_depth = 8
24const ai_abort_score = -1.0e30
25const ai_terminal_loss = -1.0e15
26const ai_spawn_two_prob = 0.9
27const ai_spawn_four_prob = 0.1
28const ai_snake_path_row = [0, 1, 2, 3, 7, 6, 5, 4, 8, 9, 10, 11, 15, 14, 13, 12]!
29const ai_snake_path_col = [0, 4, 8, 12, 13, 9, 5, 1, 2, 6, 10, 14, 15, 11, 7, 3]!
30const ai_eval_weights = [
31 [90.0, 70.0, 50.0, 30.0]!,
32 [8.0, 6.0, 4.0, 2.0]!,
33 [-2.0, -4.0, -6.0, -8.0]!,
34 [-30.0, -50.0, -70.0, -90.0]!,
35]
36
37struct App {
38mut:
39 gg &gg.Context = unsafe { nil }
40 touch TouchInfo
41 ui Ui
42 theme &Theme = themes[0]
43 theme_idx int
44 board Board
45 undo []Undo
46 atickers [4][4]f64
47 mtickers [4][4]f64
48 state GameState = .play
49 tile_format TileFormat = .normal
50 moves int
51 updates u64
52 needs_redraw bool = true
53
54 is_ai_mode bool
55 ai_fpm u64 = 8
56 ai_engine AiEngine
57}
58
59struct Ui {
60mut:
61 dpi_scale f32
62 tile_size int
63 border_size int
64 padding_size int
65 header_size int
66 font_size int
67 window_width int
68 window_height int
69 x_padding int
70 y_padding int
71}
72
73struct Theme {
74 bg_color gg.Color
75 padding_color gg.Color
76 text_color gg.Color
77 game_over_color gg.Color
78 victory_color gg.Color
79 tile_colors []gg.Color
80}
81
82const themes = [
83 &Theme{
84 bg_color: gg.rgb(250, 248, 239)
85 padding_color: gg.rgb(143, 130, 119)
86 victory_color: gg.rgb(100, 160, 100)
87 game_over_color: gg.rgb(190, 50, 50)
88 text_color: gg.black
89 tile_colors: [
90 gg.rgb(205, 193, 180), // Empty / 0 tile
91 gg.rgb(238, 228, 218), // 2
92 gg.rgb(237, 224, 200), // 4
93 gg.rgb(242, 177, 121), // 8
94 gg.rgb(245, 149, 99), // 16
95 gg.rgb(246, 124, 95), // 32
96 gg.rgb(246, 94, 59), // 64
97 gg.rgb(237, 207, 114), // 128
98 gg.rgb(237, 204, 97), // 256
99 gg.rgb(237, 200, 80), // 512
100 gg.rgb(237, 197, 63), // 1024
101 gg.rgb(237, 194, 46),
102 ]
103 },
104 &Theme{
105 bg_color: gg.rgb(55, 55, 55)
106 padding_color: gg.rgb(68, 60, 59)
107 victory_color: gg.rgb(100, 160, 100)
108 game_over_color: gg.rgb(190, 50, 50)
109 text_color: gg.white
110 tile_colors: [
111 gg.rgb(123, 115, 108),
112 gg.rgb(142, 136, 130),
113 gg.rgb(142, 134, 120),
114 gg.rgb(145, 106, 72),
115 gg.rgb(147, 89, 59),
116 gg.rgb(147, 74, 57),
117 gg.rgb(147, 56, 35),
118 gg.rgb(142, 124, 68),
119 gg.rgb(142, 122, 58),
120 gg.rgb(142, 120, 48),
121 gg.rgb(142, 118, 37),
122 gg.rgb(142, 116, 27),
123 ]
124 },
125 &Theme{
126 bg_color: gg.rgb(38, 38, 66)
127 padding_color: gg.rgb(58, 50, 74)
128 victory_color: gg.rgb(100, 160, 100)
129 game_over_color: gg.rgb(190, 50, 50)
130 text_color: gg.white
131 tile_colors: [
132 gg.rgb(92, 86, 140),
133 gg.rgb(106, 99, 169),
134 gg.rgb(106, 97, 156),
135 gg.rgb(108, 79, 93),
136 gg.rgb(110, 66, 76),
137 gg.rgb(110, 55, 74),
138 gg.rgb(110, 42, 45),
139 gg.rgb(106, 93, 88),
140 gg.rgb(106, 91, 75),
141 gg.rgb(106, 90, 62),
142 gg.rgb(106, 88, 48),
143 gg.rgb(106, 87, 35),
144 ]
145 },
146]
147
148struct Pos {
149 x int = -1
150 y int = -1
151}
152
153struct Board {
154mut:
155 field [4][4]int
156 oidxs [4][4]u32 // old indexes of the fields, when != 0; each index is an encoding of its y,x coordinates = y << 16 | x
157 points int
158 shifts int
159}
160
161struct Undo {
162 board Board
163 state GameState
164}
165
166struct TileLine {
167mut:
168 field [5]int
169 oidxs [5]u32
170 points int
171 shifts int
172}
173
174struct TouchInfo {
175mut:
176 start Touch
177 end Touch
178}
179
180struct Touch {
181mut:
182 pos Pos
183 time time.Time
184}
185
186enum TileFormat {
187 normal
188 log
189 exponent
190 shifts
191 none
192 end // To know when to wrap around
193}
194
195enum GameState {
196 play
197 over
198 victory
199 freeplay
200}
201
202enum LabelKind {
203 keys
204 points
205 moves
206 tile
207 victory
208 game_over
209 score_end
210}
211
212enum Direction {
213 up
214 down
215 left
216 right
217}
218
219type AiBoard = u64
220
221struct AiEngine {
222mut:
223 initialized bool
224 row_left []u16
225 row_right []u16
226 row_heuristic []f64
227 tt []AiTtEntry
228 generation u32
229}
230
231struct AiTtEntry {
232 board AiBoard
233 score f64
234 generation u32
235 depth u8
236 kind u8
237}
238
239struct AiSearchCtx {
240 watch time.StopWatch
241 deadline_us i64
242 generation u32
243mut:
244 nodes u64
245 cache_hits u64
246 aborted bool
247}
248
249struct AiMoveResult {
250mut:
251 move Direction
252 score f64
253 depth int
254 nodes u64
255 cache_hits u64
256 valid bool
257}
258
259// Utility functions
260@[inline]
261fn avg(a int, b int) int {
262 return (a + b) / 2
263}
264
265fn (b Board) transpose() Board {
266 mut res := b
267 for y in 0 .. 4 {
268 for x in 0 .. 4 {
269 res.field[y][x] = b.field[x][y]
270 res.oidxs[y][x] = b.oidxs[x][y]
271 }
272 }
273 return res
274}
275
276fn (b Board) hmirror() Board {
277 mut res := b
278 for y in 0 .. 4 {
279 for x in 0 .. 4 {
280 res.field[y][x] = b.field[y][3 - x]
281 res.oidxs[y][x] = b.oidxs[y][3 - x]
282 }
283 }
284 return res
285}
286
287fn (t TileLine) to_left() TileLine {
288 right_border_idx := 4
289 mut res := t
290 mut zeros := 0
291 mut nonzeros := 0
292 // gather meta info about the line:
293 for x in res.field {
294 if x == 0 {
295 zeros++
296 } else {
297 nonzeros++
298 }
299 }
300 if nonzeros == 0 {
301 // when all the tiles are empty, there is nothing left to do
302 return res
303 }
304 if zeros > 0 {
305 // we have some 0s, do shifts to compact them:
306 mut remaining_zeros := zeros
307 for x := 0; x < right_border_idx - 1; x++ {
308 for res.field[x] == 0 && remaining_zeros > 0 {
309 res.shifts++
310 for k := x; k < right_border_idx; k++ {
311 res.field[k] = res.field[k + 1]
312 res.oidxs[k] = res.oidxs[k + 1]
313 }
314 remaining_zeros--
315 }
316 }
317 }
318 // At this point, the non 0 tiles are all on the left, with no empty spaces
319 // between them. we can safely merge them, when they have the same value:
320 for x := 0; x < right_border_idx - 1; x++ {
321 if res.field[x] == 0 {
322 break
323 }
324 if res.field[x] == res.field[x + 1] {
325 for k := x; k < right_border_idx; k++ {
326 res.field[k] = res.field[k + 1]
327 res.oidxs[k] = res.oidxs[k + 1]
328 }
329 res.shifts++
330 res.field[x]++
331 res.points += 1 << res.field[x]
332 }
333 }
334 return res
335}
336
337fn (b Board) to_left() Board {
338 mut res := b
339 for y in 0 .. 4 {
340 mut hline := TileLine{}
341 for x in 0 .. 4 {
342 hline.field[x] = b.field[y][x]
343 hline.oidxs[x] = b.oidxs[y][x]
344 }
345 reshline := hline.to_left()
346 res.shifts += reshline.shifts
347 res.points += reshline.points
348 for x in 0 .. 4 {
349 res.field[y][x] = reshline.field[x]
350 res.oidxs[y][x] = reshline.oidxs[x]
351 }
352 }
353 return res
354}
355
356fn yx2i(y int, x int) u32 {
357 return u32(y) << 16 | u32(x)
358}
359
360@[inline]
361fn quantized_tile_text_size(size int) int {
362 if size < min_tile_text_size {
363 return 0
364 }
365 return size / tile_text_size_step * tile_text_size_step
366}
367
368@[inline]
369fn animated_tile_text_size(base_size int, animation_scale f64) int {
370 // gg/fontstash caches glyphs by size, so quantize the zoom animation to keep the
371 // atlas stable during long autoplay sessions.
372 return quantized_tile_text_size(int(animation_scale * (base_size - 1)))
373}
374
375@[inline]
376fn reverse_row(row u16) u16 {
377 part0 := u16((row & 0x000f) << 12)
378 part1 := u16((row & 0x00f0) << 4)
379 part2 := u16((row & 0x0f00) >> 4)
380 part3 := u16((row & 0xf000) >> 12)
381 return part0 | part1 | part2 | part3
382}
383
384fn build_ai_row_left(row u16) (u16, bool) {
385 mut tiles := [4]u8{}
386 mut compact := [4]u8{}
387 mut compact_len := 0
388 for idx in 0 .. 4 {
389 tiles[idx] = u8((row >> (idx << 2)) & 0xf)
390 if tiles[idx] != 0 {
391 compact[compact_len] = tiles[idx]
392 compact_len++
393 }
394 }
395 mut merged := [4]u8{}
396 mut read_idx := 0
397 mut write_idx := 0
398 for read_idx < compact_len {
399 value := compact[read_idx]
400 if read_idx + 1 < compact_len && compact[read_idx + 1] == value {
401 merged[write_idx] = value + 1
402 read_idx += 2
403 } else {
404 merged[write_idx] = value
405 read_idx++
406 }
407 write_idx++
408 }
409 mut res := u16(0)
410 mut changed := false
411 for idx in 0 .. 4 {
412 res |= u16(merged[idx]) << (idx << 2)
413 if merged[idx] != tiles[idx] {
414 changed = true
415 }
416 }
417 return res, changed
418}
419
420fn ai_row_heuristic(row u16) f64 {
421 mut tiles := [4]int{}
422 mut empty_tiles := 0
423 mut mergeable_pairs := 0
424 mut smoothness := 0.0
425 mut monotonicity := 0.0
426 for idx in 0 .. 4 {
427 tiles[idx] = int((row >> (idx << 2)) & 0xf)
428 if tiles[idx] == 0 {
429 empty_tiles++
430 }
431 }
432 for idx in 0 .. 3 {
433 curr := tiles[idx]
434 next := tiles[idx + 1]
435 if curr != 0 && next != 0 {
436 smoothness -= math.abs(curr - next)
437 if curr == next {
438 mergeable_pairs++
439 }
440 }
441 }
442 mut left_penalty := 0.0
443 mut right_penalty := 0.0
444 for idx in 0 .. 3 {
445 left_penalty += math.max(0, tiles[idx + 1] - tiles[idx])
446 right_penalty += math.max(0, tiles[idx] - tiles[idx + 1])
447 }
448 monotonicity = -math.min(left_penalty, right_penalty)
449 return f64(empty_tiles) * 240.0 + f64(mergeable_pairs) * 500.0 + smoothness * 20.0 +
450 monotonicity * 70.0
451}
452
453fn (mut ai AiEngine) ensure_ready() {
454 if ai.initialized {
455 return
456 }
457 ai.row_left = []u16{len: ai_row_states}
458 ai.row_right = []u16{len: ai_row_states}
459 ai.row_heuristic = []f64{len: ai_row_states}
460 ai.tt = []AiTtEntry{len: ai_tt_size}
461 for i in 0 .. ai_row_states {
462 row := u16(i)
463 left, _ := build_ai_row_left(row)
464 reversed := reverse_row(row)
465 right_reversed, _ := build_ai_row_left(reversed)
466 ai.row_left[i] = left
467 ai.row_right[i] = reverse_row(right_reversed)
468 ai.row_heuristic[i] = ai_row_heuristic(row)
469 }
470 ai.initialized = true
471}
472
473@[inline]
474fn ai_row(board AiBoard, row_idx int) u16 {
475 return u16((u64(board) >> (row_idx * 16)) & 0xffff)
476}
477
478@[inline]
479fn ai_tile(board AiBoard, idx int) u8 {
480 return u8((u64(board) >> (idx * 4)) & 0xf)
481}
482
483fn ai_transpose(board AiBoard) AiBoard {
484 mut res := AiBoard(0)
485 for y in 0 .. 4 {
486 for x in 0 .. 4 {
487 src_idx := y << 2 + x
488 dst_idx := x << 2 + y
489 value := AiBoard(ai_tile(board, src_idx))
490 res |= value << (dst_idx * 4)
491 }
492 }
493 return res
494}
495
496@[inline]
497fn ai_pack_exponent(exponent int) AiBoard {
498 // AiBoard stores 4-bit exponents, so clamp larger freeplay tiles to avoid corrupting
499 // adjacent cells in the packed representation.
500 return AiBoard(u64(math.min(exponent, 15)))
501}
502
503fn board_to_ai(board Board) AiBoard {
504 mut res := AiBoard(0)
505 for y in 0 .. 4 {
506 for x in 0 .. 4 {
507 res |= ai_pack_exponent(board.field[y][x]) << ((y << 2 + x) << 2)
508 }
509 }
510 return res
511}
512
513@[inline]
514fn ai_empty_count(board AiBoard) int {
515 mut empty_tiles := 0
516 for idx in 0 .. 16 {
517 if ai_tile(board, idx) == 0 {
518 empty_tiles++
519 }
520 }
521 return empty_tiles
522}
523
524fn (ai &AiEngine) move_left(board AiBoard) (AiBoard, bool) {
525 mut res := AiBoard(0)
526 mut changed := false
527 for row_idx in 0 .. 4 {
528 row := ai_row(board, row_idx)
529 next := ai.row_left[int(row)]
530 res |= AiBoard(next) << (row_idx << 4)
531 changed = changed || row != next
532 }
533 return res, changed
534}
535
536fn (ai &AiEngine) move_right(board AiBoard) (AiBoard, bool) {
537 mut res := AiBoard(0)
538 mut changed := false
539 for row_idx in 0 .. 4 {
540 row := ai_row(board, row_idx)
541 next := ai.row_right[int(row)]
542 res |= AiBoard(next) << (row_idx << 4)
543 changed = changed || row != next
544 }
545 return res, changed
546}
547
548fn (ai &AiEngine) move_up(board AiBoard) (AiBoard, bool) {
549 transposed := ai_transpose(board)
550 moved, changed := ai.move_left(transposed)
551 return ai_transpose(moved), changed
552}
553
554fn (ai &AiEngine) move_down(board AiBoard) (AiBoard, bool) {
555 transposed := ai_transpose(board)
556 moved, changed := ai.move_right(transposed)
557 return ai_transpose(moved), changed
558}
559
560fn (ai &AiEngine) move_board(board AiBoard, move Direction) (AiBoard, bool) {
561 return match move {
562 .left { ai.move_left(board) }
563 .right { ai.move_right(board) }
564 .up { ai.move_up(board) }
565 .down { ai.move_down(board) }
566 }
567}
568
569@[direct_array_access]
570fn (ai &AiEngine) evaluate(board AiBoard) f64 {
571 mut score := 0.0
572 transposed := ai_transpose(board)
573 for row_idx in 0 .. 4 {
574 score += ai.row_heuristic[int(ai_row(board, row_idx))]
575 score += ai.row_heuristic[int(ai_row(transposed, row_idx))]
576 }
577 mut max_tile := u8(0)
578 mut max_in_corner := false
579 for y in 0 .. 4 {
580 for x in 0 .. 4 {
581 value := ai_tile(board, y << 2 + x)
582 score += f64(value) * ai_eval_weights[y][x]
583 if value > max_tile {
584 max_tile = value
585 max_in_corner = (x == 0 && y == 0)
586 }
587 }
588 }
589 if max_in_corner {
590 score += 1500.0 + f64(max_tile) * 180.0
591 } else {
592 score -= 300.0 + f64(max_tile) * 80.0
593 }
594 score += f64(ai_empty_count(board)) * 500.0
595 score += ai_snake_score(board, ai_snake_path_row)
596 score += ai_snake_score(board, ai_snake_path_col)
597 return score
598}
599
600fn ai_snake_score(board AiBoard, path [16]int) f64 {
601 mut score := 0.0
602 mut weight := 1.0
603 for idx in path {
604 value := f64(ai_tile(board, idx))
605 score += value * value * weight
606 weight *= 0.5
607 }
608 return score * 220.0
609}
610
611@[inline]
612fn (mut ctx AiSearchCtx) should_abort() bool {
613 if ctx.aborted {
614 return true
615 }
616 if ctx.nodes & 1023 == 0 && ctx.watch.elapsed().microseconds() >= ctx.deadline_us {
617 ctx.aborted = true
618 return true
619 }
620 return false
621}
622
623@[inline]
624fn ai_tt_index(board AiBoard, depth int, kind u8) int {
625 mut h := u64(board)
626 h ^= u64(depth) * 0x9e3779b97f4a7c15
627 h ^= u64(kind) * 0xbf58476d1ce4e5b9
628 h ^= h >> 30
629 h *= 0xbf58476d1ce4e5b9
630 h ^= h >> 27
631 h *= 0x94d049bb133111eb
632 h ^= h >> 31
633 return int(h & u64(ai_tt_size - 1))
634}
635
636fn (mut ai AiEngine) tt_lookup(board AiBoard, depth int, kind u8, mut ctx AiSearchCtx) ?f64 {
637 start_idx := ai_tt_index(board, depth, kind)
638 for probe in 0 .. 4 {
639 idx := (start_idx + probe) & (ai_tt_size - 1)
640 entry := ai.tt[idx]
641 if entry.generation != ctx.generation {
642 continue
643 }
644 if entry.board == board && entry.kind == kind && int(entry.depth) >= depth {
645 ctx.cache_hits++
646 return entry.score
647 }
648 }
649 return none
650}
651
652fn (mut ai AiEngine) tt_store(board AiBoard, depth int, kind u8, score f64, ctx AiSearchCtx) {
653 start_idx := ai_tt_index(board, depth, kind)
654 mut best_idx := start_idx
655 mut found_slot := false
656 for probe in 0 .. 4 {
657 idx := (start_idx + probe) & (ai_tt_size - 1)
658 entry := ai.tt[idx]
659 if entry.generation != ctx.generation || entry.board == board || int(entry.depth) <= depth {
660 best_idx = idx
661 found_slot = true
662 break
663 }
664 }
665 if !found_slot {
666 best_idx = start_idx
667 }
668 ai.tt[best_idx] = AiTtEntry{
669 board: board
670 score: score
671 generation: ctx.generation
672 depth: u8(depth)
673 kind: kind
674 }
675}
676
677fn (mut ai AiEngine) expectimax_max(board AiBoard, depth int, mut ctx AiSearchCtx) f64 {
678 ctx.nodes++
679 if ctx.should_abort() {
680 return ai_abort_score
681 }
682 if cached := ai.tt_lookup(board, depth, 0, mut ctx) {
683 return cached
684 }
685 if depth == 0 {
686 score := ai.evaluate(board)
687 ai.tt_store(board, depth, 0, score, ctx)
688 return score
689 }
690 mut best_score := ai_terminal_loss
691 mut has_move := false
692 for move in possible_moves {
693 next_board, is_valid := ai.move_board(board, move)
694 if !is_valid {
695 continue
696 }
697 has_move = true
698 score := ai.expectimax_chance(next_board, depth - 1, mut ctx)
699 if ctx.aborted {
700 return ai_abort_score
701 }
702 if score > best_score {
703 best_score = score
704 }
705 }
706 if !has_move {
707 return ai_terminal_loss
708 }
709 ai.tt_store(board, depth, 0, best_score, ctx)
710 return best_score
711}
712
713fn (mut ai AiEngine) expectimax_chance(board AiBoard, depth int, mut ctx AiSearchCtx) f64 {
714 ctx.nodes++
715 if ctx.should_abort() {
716 return ai_abort_score
717 }
718 if cached := ai.tt_lookup(board, depth, 1, mut ctx) {
719 return cached
720 }
721 empty_tiles := ai_empty_count(board)
722 if empty_tiles == 0 {
723 score := ai.expectimax_max(board, depth, mut ctx)
724 if !ctx.aborted {
725 ai.tt_store(board, depth, 1, score, ctx)
726 }
727 return score
728 }
729 cell_weight := 1.0 / f64(empty_tiles)
730 mut total_score := 0.0
731 for idx in 0 .. 16 {
732 if ai_tile(board, idx) != 0 {
733 continue
734 }
735 shift := idx << 2
736 two_board := board | (AiBoard(1) << shift)
737 two_score := ai.expectimax_max(two_board, depth, mut ctx)
738 if ctx.aborted {
739 return ai_abort_score
740 }
741 four_board := board | (AiBoard(2) << shift)
742 four_score := ai.expectimax_max(four_board, depth, mut ctx)
743 if ctx.aborted {
744 return ai_abort_score
745 }
746 total_score += cell_weight * (ai_spawn_two_prob * two_score +
747 ai_spawn_four_prob * four_score)
748 }
749 ai.tt_store(board, depth, 1, total_score, ctx)
750 return total_score
751}
752
753fn (mut ai AiEngine) first_valid_move(board AiBoard) ?Direction {
754 for move in possible_moves {
755 _, is_valid := ai.move_board(board, move)
756 if is_valid {
757 return move
758 }
759 }
760 return none
761}
762
763fn (mut ai AiEngine) best_move(board AiBoard) AiMoveResult {
764 ai.ensure_ready()
765 ai.generation++
766 mut ctx := AiSearchCtx{
767 watch: time.new_stopwatch()
768 deadline_us: ai_time_budget_us
769 generation: ai.generation
770 }
771 mut best := AiMoveResult{}
772 if fallback := ai.first_valid_move(board) {
773 best = AiMoveResult{
774 move: fallback
775 score: ai_terminal_loss
776 valid: true
777 }
778 } else {
779 return best
780 }
781 empty_tiles := ai_empty_count(board)
782 mut depth_limit := ai_max_search_depth
783 if empty_tiles >= 6 {
784 depth_limit = 6
785 }
786 for depth := ai_min_search_depth; depth <= depth_limit; depth++ {
787 mut iter_best := AiMoveResult{
788 score: ai_terminal_loss
789 }
790 mut iter_valid := false
791 for move in possible_moves {
792 next_board, is_valid := ai.move_board(board, move)
793 if !is_valid {
794 continue
795 }
796 score := ai.expectimax_chance(next_board, depth - 1, mut ctx)
797 if ctx.aborted {
798 break
799 }
800 if !iter_valid || score > iter_best.score {
801 iter_best = AiMoveResult{
802 move: move
803 score: score
804 depth: depth
805 valid: true
806 }
807 iter_valid = true
808 }
809 }
810 if ctx.aborted {
811 break
812 }
813 if iter_valid {
814 best = iter_best
815 best.nodes = ctx.nodes
816 best.cache_hits = ctx.cache_hits
817 }
818 }
819 best.nodes = ctx.nodes
820 best.cache_hits = ctx.cache_hits
821 return best
822}
823
824@[inline]
825fn (b Board) has_moves() bool {
826 for y in 0 .. 4 {
827 for x in 0 .. 4 {
828 value := b.field[y][x]
829 if value == 0 {
830 return true
831 }
832 if (x < 3 && value == b.field[y][x + 1]) || (y < 3 && value == b.field[y + 1][x]) {
833 return true
834 }
835 }
836 }
837 return false
838}
839
840fn (mut b Board) move(d Direction) (Board, bool) {
841 for y in 0 .. 4 {
842 for x in 0 .. 4 {
843 b.oidxs[y][x] = yx2i(y, x)
844 }
845 }
846 new := match d {
847 .left { b.to_left() }
848 .right { b.hmirror().to_left().hmirror() }
849 .up { b.transpose().to_left().transpose() }
850 .down { b.transpose().hmirror().to_left().hmirror().transpose() }
851 }
852
853 // If the board hasn't changed, it's an illegal move, don't allow it.
854 for y in 0 .. 4 {
855 for x in 0 .. 4 {
856 if b.field[y][x] != new.field[y][x] {
857 return new, true
858 }
859 }
860 }
861 return new, false
862}
863
864fn (mut b Board) is_game_over() bool {
865 return !b.has_moves()
866}
867
868@[inline]
869fn (mut app App) request_redraw() {
870 app.needs_redraw = true
871}
872
873fn (app &App) has_pending_animation() bool {
874 for y in 0 .. 4 {
875 for x in 0 .. 4 {
876 if app.atickers[y][x] > 0.0 || app.mtickers[y][x] > 0.0 {
877 return true
878 }
879 }
880 }
881 return false
882}
883
884fn (mut app App) update_tickers() {
885 for y in 0 .. 4 {
886 for x in 0 .. 4 {
887 app.atickers[y][x] = math.clip(app.atickers[y][x] - f64(zooming_percent_per_frame) / 100.0,
888 0.0, 1.0)
889 app.mtickers[y][x] = math.clip(app.mtickers[y][x] - f64(movement_percent_per_frame) / 100.0,
890 0.0, 1.0)
891 }
892 }
893}
894
895fn (mut app App) new_game() {
896 app.board = Board{}
897 for y in 0 .. 4 {
898 for x in 0 .. 4 {
899 app.board.field[y][x] = 0
900 app.atickers[y][x] = 0
901 app.mtickers[y][x] = 0
902 }
903 }
904 app.state = .play
905 app.undo = []Undo{cap: 4096}
906 app.moves = 0
907 app.new_random_tile()
908 app.new_random_tile()
909 app.request_redraw()
910}
911
912@[inline]
913fn (mut app App) check_for_victory() {
914 for y in 0 .. 4 {
915 for x in 0 .. 4 {
916 fidx := app.board.field[y][x]
917 if fidx == 11 {
918 app.state = .victory
919 return
920 }
921 }
922 }
923}
924
925@[inline]
926fn (mut app App) check_for_game_over() {
927 if app.board.is_game_over() {
928 app.state = .over
929 }
930}
931
932fn (mut b Board) place_random_tile() (Pos, int) {
933 mut etiles := [16]Pos{}
934 mut empty_tiles_max := 0
935 for y in 0 .. 4 {
936 for x in 0 .. 4 {
937 fidx := b.field[y][x]
938 if fidx == 0 {
939 etiles[empty_tiles_max] = Pos{x, y}
940 empty_tiles_max++
941 }
942 }
943 }
944 if empty_tiles_max > 0 {
945 new_random_tile_index := rand.intn(empty_tiles_max) or { 0 }
946 empty_pos := etiles[new_random_tile_index]
947 // 10% chance of getting a `4` tile
948 value := rand.f64n(1.0) or { 0.0 }
949 random_value := if value < 0.9 { 1 } else { 2 }
950 b.field[empty_pos.y][empty_pos.x] = random_value
951 b.oidxs[empty_pos.y][empty_pos.x] = yx2i(empty_pos.y, empty_pos.x)
952 return empty_pos, random_value
953 }
954 return Pos{}, 0
955}
956
957fn (mut app App) new_random_tile() {
958 // do not animate empty fields:
959 for y in 0 .. 4 {
960 for x in 0 .. 4 {
961 fidx := app.board.field[y][x]
962 if fidx == 0 {
963 app.atickers[y][x] = 0
964 app.board.oidxs[y][x] = 0xFFFF_FFFF
965 }
966 }
967 }
968 empty_pos, random_value := app.board.place_random_tile()
969 if random_value > 0 {
970 app.atickers[empty_pos.y][empty_pos.x] = 1.0
971 }
972 if app.state != .freeplay {
973 app.check_for_victory()
974 }
975 app.check_for_game_over()
976}
977
978fn (mut app App) apply_new_board(new Board) {
979 old := app.board
980 app.moves++
981 for y in 0 .. 4 {
982 for x in 0 .. 4 {
983 if old.oidxs[y][x] != new.oidxs[y][x] {
984 app.mtickers[y][x] = 1.0
985 }
986 }
987 }
988 app.board = new
989 app.undo << Undo{old, app.state}
990 app.new_random_tile()
991 app.request_redraw()
992}
993
994fn (mut app App) move(d Direction) {
995 new, is_valid := app.board.move(d)
996 if !is_valid {
997 return
998 }
999 app.apply_new_board(new)
1000}
1001
1002fn (mut app App) ai_move() {
1003 think_watch := time.new_stopwatch()
1004 search_result := app.ai_engine.best_move(board_to_ai(app.board))
1005 if !search_result.valid {
1006 return
1007 }
1008 elapsed_us := think_watch.elapsed().microseconds()
1009 cache_rate := if search_result.nodes > 0 {
1010 100.0 * f64(search_result.cache_hits) / f64(search_result.nodes)
1011 } else {
1012 0.0
1013 }
1014 eprintln('AI ${elapsed_us:5}µs | depth ${search_result.depth:2} | nodes ${search_result.nodes:7} | cache ${cache_rate:5.1f}% | move ${search_result.move:5} | score ${search_result.score:9.2f}')
1015 app.move(search_result.move)
1016}
1017
1018fn (app &App) label_format(kind LabelKind) gg.TextCfg {
1019 match kind {
1020 .keys {
1021 return gg.TextCfg{
1022 color: gg.Color{150, 150, 255, 200}
1023 align: .center
1024 vertical_align: .bottom
1025 size: app.ui.font_size / 4
1026 }
1027 }
1028 .points {
1029 return gg.TextCfg{
1030 color: if app.state in [.over, .victory] { gg.white } else { app.theme.text_color }
1031 align: .left
1032 size: app.ui.font_size / 2
1033 }
1034 }
1035 .moves {
1036 return gg.TextCfg{
1037 color: if app.state in [.over, .victory] { gg.white } else { app.theme.text_color }
1038 align: .right
1039 size: app.ui.font_size / 2
1040 }
1041 }
1042 .tile {
1043 return gg.TextCfg{
1044 color: app.theme.text_color
1045 align: .center
1046 vertical_align: .middle
1047 size: app.ui.font_size
1048 }
1049 }
1050 .victory {
1051 return gg.TextCfg{
1052 color: app.theme.victory_color
1053 align: .center
1054 vertical_align: .middle
1055 size: app.ui.font_size * 2
1056 }
1057 }
1058 .game_over {
1059 return gg.TextCfg{
1060 color: app.theme.game_over_color
1061 align: .center
1062 vertical_align: .middle
1063 size: app.ui.font_size * 2
1064 }
1065 }
1066 .score_end {
1067 return gg.TextCfg{
1068 color: gg.white
1069 align: .center
1070 vertical_align: .middle
1071 size: app.ui.font_size * 3 / 4
1072 }
1073 }
1074 }
1075}
1076
1077@[inline]
1078fn (mut app App) set_theme(idx int) {
1079 theme := themes[idx]
1080 app.theme_idx = idx
1081 app.theme = theme
1082 app.gg.set_bg_color(theme.bg_color)
1083 app.request_redraw()
1084}
1085
1086fn (mut app App) resize() {
1087 mut s := app.gg.scale
1088 if s == 0.0 {
1089 s = 1.0
1090 }
1091 window_size := app.gg.window_size()
1092 w := window_size.width
1093 h := window_size.height
1094 m := f32(math.min(w, h))
1095 app.ui.dpi_scale = s
1096 app.ui.window_width = w
1097 app.ui.window_height = h
1098 app.ui.padding_size = int(m / 38)
1099 app.ui.header_size = app.ui.padding_size
1100 app.ui.border_size = app.ui.padding_size * 2
1101 app.ui.tile_size = int((m - app.ui.padding_size * 5 - app.ui.border_size * 2) / 4)
1102 app.ui.font_size = int(m / 10)
1103 // If the window's height is greater than its width, center the board vertically.
1104 // If not, center it horizontally
1105 if w > h {
1106 app.ui.y_padding = 0
1107 app.ui.x_padding = (app.ui.window_width - app.ui.window_height) / 2
1108 } else {
1109 app.ui.y_padding = (app.ui.window_height - app.ui.window_width - app.ui.header_size) / 2
1110 app.ui.x_padding = 0
1111 }
1112 app.request_redraw()
1113}
1114
1115fn (app &App) draw() {
1116 xpad, ypad := app.ui.x_padding, app.ui.y_padding
1117 ww := app.ui.window_width
1118 wh := app.ui.window_height
1119 m := math.min(ww, wh)
1120 labelx := xpad + app.ui.border_size
1121 labely := ypad + app.ui.border_size / 2
1122 app.draw_tiles()
1123 // TODO: Make transparency work in `gg`
1124 if app.state == .over {
1125 app.gg.draw_rect_filled(0, 0, ww, wh, gg.rgba(10, 0, 0, 180))
1126 app.gg.draw_text(ww / 2, (m * 4 / 10) + ypad, 'Game Over', app.label_format(.game_over))
1127 f := app.label_format(.tile)
1128 msg := $if android { 'Tap to restart' } $else { 'Press `r` to restart' }
1129 app.gg.draw_text(ww / 2, (m * 6 / 10) + ypad, msg, gg.TextCfg{
1130 ...f
1131 color: gg.white
1132 size: f.size * 3 / 4
1133 })
1134 }
1135 if app.state == .victory {
1136 app.gg.draw_rect_filled(0, 0, ww, wh, gg.rgba(0, 10, 0, 180))
1137 app.gg.draw_text(ww / 2, (m * 4 / 10) + ypad, 'Victory!', app.label_format(.victory))
1138 // f := app.label_format(.tile)
1139 msg1 := $if android { 'Tap to continue' } $else { 'Press `space` to continue' }
1140 msg2 := $if android { 'Tap to restart' } $else { 'Press `r` to restart' }
1141 app.gg.draw_text(ww / 2, (m * 6 / 10) + ypad, msg1, app.label_format(.score_end))
1142 app.gg.draw_text(ww / 2, (m * 8 / 10) + ypad, msg2, app.label_format(.score_end))
1143 }
1144 // Draw at the end, so that it's on top of the victory / game over overlays
1145 app.gg.draw_text(labelx, labely, 'Points: ${app.board.points}', app.label_format(.points))
1146 app.gg.draw_text(ww - labelx, labely, 'Moves: ${app.moves}', app.label_format(.moves))
1147 app.gg.draw_text(ww / 2, wh, 'Controls: WASD,V,<=,T,Enter,ESC', app.label_format(.keys))
1148}
1149
1150fn (app &App) draw_tiles() {
1151 xstart := app.ui.x_padding + app.ui.border_size
1152 ystart := app.ui.y_padding + app.ui.border_size + app.ui.header_size
1153 toffset := app.ui.tile_size + app.ui.padding_size
1154 tiles_size := math.min(app.ui.window_width, app.ui.window_height) - app.ui.border_size * 2
1155 // Draw the padding around the tiles
1156 app.gg.draw_rounded_rect_filled(xstart, ystart, tiles_size, tiles_size, tiles_size / 24,
1157 app.theme.padding_color)
1158
1159 // Draw empty tiles:
1160 for y in 0 .. 4 {
1161 for x in 0 .. 4 {
1162 tw := app.ui.tile_size
1163 th := tw // square tiles, w == h
1164 xoffset := xstart + app.ui.padding_size + x * toffset
1165 yoffset := ystart + app.ui.padding_size + y * toffset
1166 app.gg.draw_rounded_rect_filled(xoffset, yoffset, tw, th, tw / 8,
1167 app.theme.tile_colors[0])
1168 }
1169 }
1170
1171 // Draw the already placed and potentially moving tiles:
1172 for y in 0 .. 4 {
1173 for x in 0 .. 4 {
1174 tidx := app.board.field[y][x]
1175 oidx := app.board.oidxs[y][x]
1176 if tidx == 0 || oidx == 0xFFFF_FFFF {
1177 continue
1178 }
1179 app.draw_one_tile(x, y, tidx)
1180 }
1181 }
1182
1183 // Draw the newly placed random tiles on top of everything else:
1184 for y in 0 .. 4 {
1185 for x in 0 .. 4 {
1186 tidx := app.board.field[y][x]
1187 oidx := app.board.oidxs[y][x]
1188 if oidx == 0xFFFF_FFFF && tidx != 0 {
1189 app.draw_one_tile(x, y, tidx)
1190 }
1191 }
1192 }
1193}
1194
1195fn (app &App) draw_one_tile(x int, y int, tidx int) {
1196 xstart := app.ui.x_padding + app.ui.border_size
1197 ystart := app.ui.y_padding + app.ui.border_size + app.ui.header_size
1198 toffset := app.ui.tile_size + app.ui.padding_size
1199 oidx := app.board.oidxs[y][x]
1200 oy := oidx >> 16
1201 ox := oidx & 0xFFFF
1202 mut dx := 0
1203 mut dy := 0
1204 if oidx != 0xFFFF_FFFF {
1205 scaling := app.ui.tile_size * easing.in_out_quint(app.mtickers[y][x])
1206 if ox != x {
1207 dx = math.clip(int(scaling * (f64(ox) - f64(x))), -4 * app.ui.tile_size,
1208 4 * app.ui.tile_size)
1209 }
1210 if oy != y {
1211 dy = math.clip(int(scaling * (f64(oy) - f64(y))), -4 * app.ui.tile_size,
1212 4 * app.ui.tile_size)
1213 }
1214 }
1215 tile_color := if tidx < app.theme.tile_colors.len {
1216 app.theme.tile_colors[tidx]
1217 } else {
1218 // If there isn't a specific color for this tile, reuse the last color available
1219 app.theme.tile_colors.last()
1220 }
1221 anim_size := 1.0 - app.atickers[y][x]
1222 tw := int(f64(anim_size * app.ui.tile_size))
1223 th := tw // square tiles, w == h
1224 xoffset := dx + xstart + app.ui.padding_size + x * toffset + (app.ui.tile_size - tw) / 2
1225 yoffset := dy + ystart + app.ui.padding_size + y * toffset + (app.ui.tile_size - th) / 2
1226 app.gg.draw_rounded_rect_filled(xoffset, yoffset, tw, th, tw / 8, tile_color)
1227 if tidx != 0 { // 0 == blank spot
1228 xpos := xoffset + tw / 2
1229 ypos := yoffset + th / 2
1230 mut fmt := app.label_format(.tile)
1231 text_size := animated_tile_text_size(fmt.size, anim_size)
1232 if text_size == 0 {
1233 return
1234 }
1235 fmt = gg.TextCfg{
1236 ...fmt
1237 size: text_size
1238 }
1239 match app.tile_format {
1240 .normal {
1241 app.gg.draw_text(xpos, ypos, '${1 << tidx}', fmt)
1242 }
1243 .log {
1244 app.gg.draw_text(xpos, ypos, '${tidx}', fmt)
1245 }
1246 .exponent {
1247 app.gg.draw_text(xpos, ypos, '2', fmt)
1248 fs2 := quantized_tile_text_size(int(f32(fmt.size) * 0.67))
1249 if fs2 > 0 {
1250 app.gg.draw_text(xpos + app.ui.tile_size / 10, ypos - app.ui.tile_size / 8,
1251 '${tidx}', gg.TextCfg{
1252 ...fmt
1253 size: fs2
1254 align: gg.HorizontalAlign.left
1255 })
1256 }
1257 }
1258 .shifts {
1259 fs2 := quantized_tile_text_size(int(f32(fmt.size) * 0.6))
1260 if fs2 > 0 {
1261 app.gg.draw_text(xpos, ypos, '2<<${tidx - 1}', gg.TextCfg{
1262 ...fmt
1263 size: fs2
1264 })
1265 }
1266 }
1267 .none {} // Don't draw any text here, colors only
1268 .end {} // Should never get here
1269 }
1270
1271 // oidx_fmt := gg.TextCfg{...fmt,size: 14}
1272 // app.gg.draw_text(xoffset + 50, yoffset + 15, 'y:${oidx >> 16}|x:${oidx & 0xFFFF}|m:${app.mtickers[y][x]:5.3f}', oidx_fmt)
1273 // app.gg.draw_text(xoffset + 52, yoffset + 30, 'ox:${ox}|oy:${oy}', oidx_fmt)
1274 // app.gg.draw_text(xoffset + 52, yoffset + 85, 'dx:${dx}|dy:${dy}', oidx_fmt)
1275 }
1276}
1277
1278fn (mut app App) handle_touches() {
1279 s, e := app.touch.start, app.touch.end
1280 adx, ady := math.abs(e.pos.x - s.pos.x), math.abs(e.pos.y - s.pos.y)
1281 if math.max(adx, ady) < 10 {
1282 app.handle_tap()
1283 } else {
1284 app.handle_swipe()
1285 }
1286}
1287
1288fn (mut app App) handle_tap() {
1289 _, ypad := app.ui.x_padding, app.ui.y_padding
1290 w, h := app.ui.window_width, app.ui.window_height
1291 m := math.min(w, h)
1292 s, e := app.touch.start, app.touch.end
1293 avgx, avgy := avg(s.pos.x, e.pos.x), avg(s.pos.y, e.pos.y)
1294 // TODO: Replace "touch spots" with actual buttons
1295 // bottom left -> change theme
1296 if avgx < 50 && h - avgy < 50 {
1297 app.next_theme()
1298 }
1299 // bottom right -> change tile format
1300 if w - avgx < 50 && h - avgy < 50 {
1301 app.next_tile_format()
1302 }
1303 if app.state == .victory {
1304 if avgy > (m / 2) + ypad {
1305 if avgy < (m * 7 / 10) + ypad {
1306 app.state = .freeplay
1307 } else if avgy < (m * 9 / 10) + ypad {
1308 app.new_game()
1309 } else {
1310 // TODO: remove and implement an actual way to toggle themes on mobile
1311 }
1312 }
1313 } else if app.state == .over {
1314 if avgy > (m / 2) + ypad && avgy < (m * 7 / 10) + ypad {
1315 app.new_game()
1316 }
1317 }
1318}
1319
1320fn (mut app App) handle_swipe() {
1321 // Currently, swipes are only used to move the tiles.
1322 // If the user's not playing, exit early to avoid all the unnecessary calculations
1323 if app.state !in [.play, .freeplay] {
1324 return
1325 }
1326 s, e := app.touch.start, app.touch.end
1327 w, h := app.ui.window_width, app.ui.window_height
1328 dx, dy := e.pos.x - s.pos.x, e.pos.y - s.pos.y
1329 adx, ady := math.abs(dx), math.abs(dy)
1330 dmin := if math.min(adx, ady) > 0 { math.min(adx, ady) } else { 1 }
1331 dmax := if math.max(adx, ady) > 0 { math.max(adx, ady) } else { 1 }
1332 tdiff := (e.time - s.time).milliseconds()
1333 // TODO: make this calculation more accurate (don't use arbitrary numbers)
1334 distance_factor := f64(math.min(w, h)) * f64(tdiff) / 100.0
1335 min_swipe_distance := int(math.sqrt(distance_factor)) + 20
1336 if dmax < min_swipe_distance {
1337 return
1338 }
1339 // Swipe was too short
1340 if dmax / dmin < 2 {
1341 return
1342 }
1343 // Swiped diagonally
1344 if adx > ady {
1345 if dx < 0 {
1346 app.move(.left)
1347 } else {
1348 app.move(.right)
1349 }
1350 } else {
1351 if dy < 0 {
1352 app.move(.up)
1353 } else {
1354 app.move(.down)
1355 }
1356 }
1357}
1358
1359@[inline]
1360fn (mut app App) next_theme() {
1361 app.set_theme(if app.theme_idx == themes.len - 1 { 0 } else { app.theme_idx + 1 })
1362}
1363
1364@[inline]
1365fn (mut app App) next_tile_format() {
1366 app.tile_format = unsafe { TileFormat(int(app.tile_format) + 1) }
1367 if app.tile_format == .end {
1368 app.tile_format = .normal
1369 }
1370 app.request_redraw()
1371}
1372
1373@[inline]
1374fn (mut app App) undo() {
1375 if app.undo.len > 0 {
1376 undo := app.undo.pop()
1377 app.board = undo.board
1378 app.state = undo.state
1379 app.moves--
1380 app.request_redraw()
1381 }
1382}
1383
1384fn (mut app App) on_key_down(key gg.KeyCode) {
1385 // these keys are independent from the game state:
1386 match key {
1387 .v {
1388 app.is_ai_mode = !app.is_ai_mode
1389 app.request_redraw()
1390 }
1391 .page_up {
1392 app.ai_fpm = dump(math.min(app.ai_fpm + 1, 60))
1393 app.request_redraw()
1394 }
1395 .page_down {
1396 app.ai_fpm = dump(math.max(app.ai_fpm - 1, 1))
1397 app.request_redraw()
1398 }
1399 //
1400 .escape {
1401 app.gg.quit()
1402 }
1403 .n, .r {
1404 app.new_game()
1405 }
1406 .backspace {
1407 app.undo()
1408 }
1409 .enter {
1410 app.next_tile_format()
1411 }
1412 .j {
1413 app.state = .over
1414 app.request_redraw()
1415 }
1416 .t {
1417 app.next_theme()
1418 }
1419 else {}
1420 }
1421
1422 if app.state in [.play, .freeplay] {
1423 if !app.is_ai_mode {
1424 match key {
1425 .w, .up { app.move(.up) }
1426 .a, .left { app.move(.left) }
1427 .s, .down { app.move(.down) }
1428 .d, .right { app.move(.right) }
1429 else {}
1430 }
1431 }
1432 }
1433 if app.state == .victory {
1434 if key == .space {
1435 app.state = .freeplay
1436 app.request_redraw()
1437 }
1438 }
1439}
1440
1441fn on_event(e &gg.Event, mut app App) {
1442 match e.typ {
1443 .key_down {
1444 app.on_key_down(e.key_code)
1445 }
1446 .resized, .restored, .resumed {
1447 app.resize()
1448 }
1449 .touches_began {
1450 if e.num_touches > 0 {
1451 t := e.touches[0]
1452 app.touch.start = Touch{
1453 pos: Pos{
1454 x: int(t.pos_x / app.ui.dpi_scale)
1455 y: int(t.pos_y / app.ui.dpi_scale)
1456 }
1457 time: time.now()
1458 }
1459 }
1460 }
1461 .touches_ended {
1462 if e.num_touches > 0 {
1463 t := e.touches[0]
1464 app.touch.end = Touch{
1465 pos: Pos{
1466 x: int(t.pos_x / app.ui.dpi_scale)
1467 y: int(t.pos_y / app.ui.dpi_scale)
1468 }
1469 time: time.now()
1470 }
1471 app.handle_touches()
1472 }
1473 }
1474 .mouse_down {
1475 app.touch.start = Touch{
1476 pos: Pos{
1477 x: int(e.mouse_x / app.ui.dpi_scale)
1478 y: int(e.mouse_y / app.ui.dpi_scale)
1479 }
1480 time: time.now()
1481 }
1482 }
1483 .mouse_up {
1484 app.touch.end = Touch{
1485 pos: Pos{
1486 x: int(e.mouse_x / app.ui.dpi_scale)
1487 y: int(e.mouse_y / app.ui.dpi_scale)
1488 }
1489 time: time.now()
1490 }
1491 app.handle_touches()
1492 }
1493 else {}
1494 }
1495
1496 if e.typ in [.key_down, .touches_began, .touches_ended, .mouse_down, .mouse_up, .resized,
1497 .restored, .focused, .resumed] {
1498 app.request_redraw()
1499 }
1500}
1501
1502fn frame(mut app App) {
1503 is_ai_running := app.is_ai_mode && app.state in [.play, .freeplay]
1504 mut has_pending_animation := app.has_pending_animation()
1505 mut do_update := false
1506 if (app.needs_redraw || has_pending_animation || is_ai_running)
1507 && app.gg.timer.elapsed().milliseconds() > 15 {
1508 app.gg.timer.restart()
1509 do_update = true
1510 app.updates++
1511 }
1512 if do_update {
1513 app.update_tickers()
1514 has_pending_animation = app.has_pending_animation()
1515 }
1516 if app.needs_redraw || (do_update && (has_pending_animation || is_ai_running)) {
1517 app.gg.begin()
1518 app.draw()
1519 app.gg.end()
1520 app.needs_redraw = false
1521 }
1522 if do_update && is_ai_running && app.updates % app.ai_fpm == 0 {
1523 app.ai_move()
1524 }
1525 if has_pending_animation || is_ai_running {
1526 app.request_redraw()
1527 }
1528 if do_update && app.updates % 120 == 0 {
1529 // do GC once per 2 seconds
1530 // eprintln('> gc_memory_use: ${gc_memory_use()}')
1531 if gc_is_enabled() {
1532 // Avoid assert error when built with `-cg` on some systems
1533 gc_disable()
1534 }
1535 gc_enable()
1536 gc_collect()
1537 gc_disable()
1538 }
1539}
1540
1541fn init(mut app App) {
1542 app.resize()
1543}
1544
1545fn main() {
1546 mut app := &App{}
1547 app.new_game()
1548 app.gg = gg.new_context(
1549 bg_color: app.theme.bg_color
1550 width: default_window_width
1551 height: default_window_height
1552 sample_count: 2 // higher quality curves
1553 window_title: 'V 2048'
1554 frame_fn: frame
1555 event_fn: on_event
1556 init_fn: init
1557 user_data: app
1558 font_path: asset.get_path('../assets', 'fonts/RobotoMono-Regular.ttf')
1559 )
1560 app.gg.run()
1561}
1562