| 1 | // AI heuristic inspired by the expectimax 2048 solver approach described at: |
| 2 | // https://github.com/nneonneo/2048-ai |
| 3 | import gg |
| 4 | import math |
| 5 | import math.easing |
| 6 | import os.asset |
| 7 | import rand |
| 8 | import time |
| 9 | |
| 10 | const zooming_percent_per_frame = 5 |
| 11 | const movement_percent_per_frame = 10 |
| 12 | const tile_text_size_step = 8 |
| 13 | const min_tile_text_size = 8 |
| 14 | |
| 15 | const default_window_width = 544 |
| 16 | const default_window_height = 560 |
| 17 | |
| 18 | const possible_moves = [Direction.up, .right, .down, .left] |
| 19 | const ai_row_states = 1 << 16 |
| 20 | const ai_tt_size = 1 << 18 |
| 21 | const ai_time_budget_us = i64(5_000) |
| 22 | const ai_min_search_depth = 2 |
| 23 | const ai_max_search_depth = 8 |
| 24 | const ai_abort_score = -1.0e30 |
| 25 | const ai_terminal_loss = -1.0e15 |
| 26 | const ai_spawn_two_prob = 0.9 |
| 27 | const ai_spawn_four_prob = 0.1 |
| 28 | const ai_snake_path_row = [0, 1, 2, 3, 7, 6, 5, 4, 8, 9, 10, 11, 15, 14, 13, 12]! |
| 29 | const ai_snake_path_col = [0, 4, 8, 12, 13, 9, 5, 1, 2, 6, 10, 14, 15, 11, 7, 3]! |
| 30 | const 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 | |
| 37 | struct App { |
| 38 | mut: |
| 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 | |
| 59 | struct Ui { |
| 60 | mut: |
| 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 | |
| 73 | struct 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 | |
| 82 | const 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 | |
| 148 | struct Pos { |
| 149 | x int = -1 |
| 150 | y int = -1 |
| 151 | } |
| 152 | |
| 153 | struct Board { |
| 154 | mut: |
| 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 | |
| 161 | struct Undo { |
| 162 | board Board |
| 163 | state GameState |
| 164 | } |
| 165 | |
| 166 | struct TileLine { |
| 167 | mut: |
| 168 | field [5]int |
| 169 | oidxs [5]u32 |
| 170 | points int |
| 171 | shifts int |
| 172 | } |
| 173 | |
| 174 | struct TouchInfo { |
| 175 | mut: |
| 176 | start Touch |
| 177 | end Touch |
| 178 | } |
| 179 | |
| 180 | struct Touch { |
| 181 | mut: |
| 182 | pos Pos |
| 183 | time time.Time |
| 184 | } |
| 185 | |
| 186 | enum TileFormat { |
| 187 | normal |
| 188 | log |
| 189 | exponent |
| 190 | shifts |
| 191 | none |
| 192 | end // To know when to wrap around |
| 193 | } |
| 194 | |
| 195 | enum GameState { |
| 196 | play |
| 197 | over |
| 198 | victory |
| 199 | freeplay |
| 200 | } |
| 201 | |
| 202 | enum LabelKind { |
| 203 | keys |
| 204 | points |
| 205 | moves |
| 206 | tile |
| 207 | victory |
| 208 | game_over |
| 209 | score_end |
| 210 | } |
| 211 | |
| 212 | enum Direction { |
| 213 | up |
| 214 | down |
| 215 | left |
| 216 | right |
| 217 | } |
| 218 | |
| 219 | type AiBoard = u64 |
| 220 | |
| 221 | struct AiEngine { |
| 222 | mut: |
| 223 | initialized bool |
| 224 | row_left []u16 |
| 225 | row_right []u16 |
| 226 | row_heuristic []f64 |
| 227 | tt []AiTtEntry |
| 228 | generation u32 |
| 229 | } |
| 230 | |
| 231 | struct AiTtEntry { |
| 232 | board AiBoard |
| 233 | score f64 |
| 234 | generation u32 |
| 235 | depth u8 |
| 236 | kind u8 |
| 237 | } |
| 238 | |
| 239 | struct AiSearchCtx { |
| 240 | watch time.StopWatch |
| 241 | deadline_us i64 |
| 242 | generation u32 |
| 243 | mut: |
| 244 | nodes u64 |
| 245 | cache_hits u64 |
| 246 | aborted bool |
| 247 | } |
| 248 | |
| 249 | struct AiMoveResult { |
| 250 | mut: |
| 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] |
| 261 | fn avg(a int, b int) int { |
| 262 | return (a + b) / 2 |
| 263 | } |
| 264 | |
| 265 | fn (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 | |
| 276 | fn (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 | |
| 287 | fn (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 | |
| 337 | fn (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 | |
| 356 | fn yx2i(y int, x int) u32 { |
| 357 | return u32(y) << 16 | u32(x) |
| 358 | } |
| 359 | |
| 360 | @[inline] |
| 361 | fn 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] |
| 369 | fn 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] |
| 376 | fn 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 | |
| 384 | fn 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 | |
| 420 | fn 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 | |
| 453 | fn (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] |
| 474 | fn ai_row(board AiBoard, row_idx int) u16 { |
| 475 | return u16((u64(board) >> (row_idx * 16)) & 0xffff) |
| 476 | } |
| 477 | |
| 478 | @[inline] |
| 479 | fn ai_tile(board AiBoard, idx int) u8 { |
| 480 | return u8((u64(board) >> (idx * 4)) & 0xf) |
| 481 | } |
| 482 | |
| 483 | fn 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] |
| 497 | fn 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 | |
| 503 | fn 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] |
| 514 | fn 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 | |
| 524 | fn (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 | |
| 536 | fn (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 | |
| 548 | fn (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 | |
| 554 | fn (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 | |
| 560 | fn (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] |
| 570 | fn (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 | |
| 600 | fn 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] |
| 612 | fn (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] |
| 624 | fn 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 | |
| 636 | fn (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 | |
| 652 | fn (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 | |
| 677 | fn (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 | |
| 713 | fn (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 | |
| 753 | fn (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 | |
| 763 | fn (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] |
| 825 | fn (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 | |
| 840 | fn (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 | |
| 864 | fn (mut b Board) is_game_over() bool { |
| 865 | return !b.has_moves() |
| 866 | } |
| 867 | |
| 868 | @[inline] |
| 869 | fn (mut app App) request_redraw() { |
| 870 | app.needs_redraw = true |
| 871 | } |
| 872 | |
| 873 | fn (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 | |
| 884 | fn (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 | |
| 895 | fn (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] |
| 913 | fn (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] |
| 926 | fn (mut app App) check_for_game_over() { |
| 927 | if app.board.is_game_over() { |
| 928 | app.state = .over |
| 929 | } |
| 930 | } |
| 931 | |
| 932 | fn (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 | |
| 957 | fn (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 | |
| 978 | fn (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 | |
| 994 | fn (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 | |
| 1002 | fn (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 | |
| 1018 | fn (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] |
| 1078 | fn (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 | |
| 1086 | fn (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 | |
| 1115 | fn (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 | |
| 1150 | fn (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 | |
| 1195 | fn (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 | |
| 1278 | fn (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 | |
| 1288 | fn (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 | |
| 1320 | fn (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] |
| 1360 | fn (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] |
| 1365 | fn (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] |
| 1374 | fn (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 | |
| 1384 | fn (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 | |
| 1441 | fn 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 | |
| 1502 | fn 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 | |
| 1541 | fn init(mut app App) { |
| 1542 | app.resize() |
| 1543 | } |
| 1544 | |
| 1545 | fn 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 | |