1 | import gg |
2 | import gx |
3 | import math |
4 | import os |
5 | import rand |
6 | import time |
7 | |
8 | struct App { |
9 | mut: |
10 | gg &gg.Context = unsafe { nil } |
11 | touch TouchInfo |
12 | ui Ui |
13 | theme &Theme = themes[0] |
14 | theme_idx int |
15 | board Board |
16 | undo []Undo |
17 | atickers [4][4]int |
18 | state GameState = .play |
19 | tile_format TileFormat = .normal |
20 | moves int |
21 | perf &Perf = unsafe { nil } |
22 | is_ai_mode bool |
23 | } |
24 | |
25 | struct Ui { |
26 | mut: |
27 | dpi_scale f32 |
28 | tile_size int |
29 | border_size int |
30 | padding_size int |
31 | header_size int |
32 | font_size int |
33 | window_width int |
34 | window_height int |
35 | x_padding int |
36 | y_padding int |
37 | } |
38 | |
39 | struct Theme { |
40 | bg_color gx.Color |
41 | padding_color gx.Color |
42 | text_color gx.Color |
43 | game_over_color gx.Color |
44 | victory_color gx.Color |
45 | tile_colors []gx.Color |
46 | } |
47 | |
48 | const ( |
49 | themes = [ |
50 | &Theme{ |
51 | bg_color: gx.rgb(250, 248, 239) |
52 | padding_color: gx.rgb(143, 130, 119) |
53 | victory_color: gx.rgb(100, 160, 100) |
54 | game_over_color: gx.rgb(190, 50, 50) |
55 | text_color: gx.black |
56 | tile_colors: [ |
57 | gx.rgb(205, 193, 180), // Empty / 0 tile |
58 | gx.rgb(238, 228, 218), // 2 |
59 | gx.rgb(237, 224, 200), // 4 |
60 | gx.rgb(242, 177, 121), // 8 |
61 | gx.rgb(245, 149, 99), // 16 |
62 | gx.rgb(246, 124, 95), // 32 |
63 | gx.rgb(246, 94, 59), // 64 |
64 | gx.rgb(237, 207, 114), // 128 |
65 | gx.rgb(237, 204, 97), // 256 |
66 | gx.rgb(237, 200, 80), // 512 |
67 | gx.rgb(237, 197, 63), // 1024 |
68 | gx.rgb(237, 194, 46), |
69 | ] |
70 | }, |
71 | &Theme{ |
72 | bg_color: gx.rgb(55, 55, 55) |
73 | padding_color: gx.rgb(68, 60, 59) |
74 | victory_color: gx.rgb(100, 160, 100) |
75 | game_over_color: gx.rgb(190, 50, 50) |
76 | text_color: gx.white |
77 | tile_colors: [ |
78 | gx.rgb(123, 115, 108), |
79 | gx.rgb(142, 136, 130), |
80 | gx.rgb(142, 134, 120), |
81 | gx.rgb(145, 106, 72), |
82 | gx.rgb(147, 89, 59), |
83 | gx.rgb(147, 74, 57), |
84 | gx.rgb(147, 56, 35), |
85 | gx.rgb(142, 124, 68), |
86 | gx.rgb(142, 122, 58), |
87 | gx.rgb(142, 120, 48), |
88 | gx.rgb(142, 118, 37), |
89 | gx.rgb(142, 116, 27), |
90 | ] |
91 | }, |
92 | &Theme{ |
93 | bg_color: gx.rgb(38, 38, 66) |
94 | padding_color: gx.rgb(58, 50, 74) |
95 | victory_color: gx.rgb(100, 160, 100) |
96 | game_over_color: gx.rgb(190, 50, 50) |
97 | text_color: gx.white |
98 | tile_colors: [ |
99 | gx.rgb(92, 86, 140), |
100 | gx.rgb(106, 99, 169), |
101 | gx.rgb(106, 97, 156), |
102 | gx.rgb(108, 79, 93), |
103 | gx.rgb(110, 66, 76), |
104 | gx.rgb(110, 55, 74), |
105 | gx.rgb(110, 42, 45), |
106 | gx.rgb(106, 93, 88), |
107 | gx.rgb(106, 91, 75), |
108 | gx.rgb(106, 90, 62), |
109 | gx.rgb(106, 88, 48), |
110 | gx.rgb(106, 87, 35), |
111 | ] |
112 | }, |
113 | ] |
114 | window_title = 'V 2048' |
115 | default_window_width = 544 |
116 | default_window_height = 560 |
117 | animation_length = 10 // frames |
118 | frames_per_ai_move = 8 |
119 | possible_moves = [Direction.up, .right, .down, .left] |
120 | predictions_per_move = 200 |
121 | prediction_depth = 8 |
122 | ) |
123 | |
124 | // Used for performance monitoring when `-d showfps` is passed, unused / optimized out otherwise |
125 | struct Perf { |
126 | mut: |
127 | frame int |
128 | frame_old int |
129 | frame_sw time.StopWatch = time.new_stopwatch() |
130 | second_sw time.StopWatch = time.new_stopwatch() |
131 | } |
132 | |
133 | struct Pos { |
134 | x int = -1 |
135 | y int = -1 |
136 | } |
137 | |
138 | struct Board { |
139 | mut: |
140 | field [4][4]int |
141 | points int |
142 | shifts int |
143 | } |
144 | |
145 | struct Undo { |
146 | board Board |
147 | state GameState |
148 | } |
149 | |
150 | struct TileLine { |
151 | ypos int |
152 | mut: |
153 | field [5]int |
154 | points int |
155 | shifts int |
156 | } |
157 | |
158 | struct TouchInfo { |
159 | mut: |
160 | start Touch |
161 | end Touch |
162 | } |
163 | |
164 | struct Touch { |
165 | mut: |
166 | pos Pos |
167 | time time.Time |
168 | } |
169 | |
170 | enum TileFormat { |
171 | normal |
172 | log |
173 | exponent |
174 | shifts |
175 | none_ |
176 | end_ // To know when to wrap around |
177 | } |
178 | |
179 | enum GameState { |
180 | play |
181 | over |
182 | victory |
183 | freeplay |
184 | } |
185 | |
186 | enum LabelKind { |
187 | points |
188 | moves |
189 | tile |
190 | victory |
191 | game_over |
192 | score_end |
193 | } |
194 | |
195 | enum Direction { |
196 | up |
197 | down |
198 | left |
199 | right |
200 | } |
201 | |
202 | // Utility functions |
203 | [inline] |
204 | fn avg(a int, b int) int { |
205 | return (a + b) / 2 |
206 | } |
207 | |
208 | fn (b Board) transpose() Board { |
209 | mut res := b |
210 | for y in 0 .. 4 { |
211 | for x in 0 .. 4 { |
212 | res.field[y][x] = b.field[x][y] |
213 | } |
214 | } |
215 | return res |
216 | } |
217 | |
218 | fn (b Board) hmirror() Board { |
219 | mut res := b |
220 | for y in 0 .. 4 { |
221 | for x in 0 .. 4 { |
222 | res.field[y][x] = b.field[y][3 - x] |
223 | } |
224 | } |
225 | return res |
226 | } |
227 | |
228 | fn (t TileLine) to_left() TileLine { |
229 | right_border_idx := 4 |
230 | mut res := t |
231 | mut zeros := 0 |
232 | mut nonzeros := 0 |
233 | // gather meta info about the line: |
234 | for x in res.field { |
235 | if x == 0 { |
236 | zeros++ |
237 | } else { |
238 | nonzeros++ |
239 | } |
240 | } |
241 | if nonzeros == 0 { |
242 | // when all the tiles are empty, there is nothing left to do |
243 | return res |
244 | } |
245 | if zeros > 0 { |
246 | // we have some 0s, do shifts to compact them: |
247 | mut remaining_zeros := zeros |
248 | for x := 0; x < right_border_idx - 1; x++ { |
249 | for res.field[x] == 0 && remaining_zeros > 0 { |
250 | res.shifts++ |
251 | for k := x; k < right_border_idx; k++ { |
252 | res.field[k] = res.field[k + 1] |
253 | } |
254 | remaining_zeros-- |
255 | } |
256 | } |
257 | } |
258 | // At this point, the non 0 tiles are all on the left, with no empty spaces |
259 | // between them. we can safely merge them, when they have the same value: |
260 | for x := 0; x < right_border_idx - 1; x++ { |
261 | if res.field[x] == 0 { |
262 | break |
263 | } |
264 | if res.field[x] == res.field[x + 1] { |
265 | for k := x; k < right_border_idx; k++ { |
266 | res.field[k] = res.field[k + 1] |
267 | } |
268 | res.shifts++ |
269 | res.field[x]++ |
270 | res.points += 1 << res.field[x] |
271 | } |
272 | } |
273 | return res |
274 | } |
275 | |
276 | fn (b Board) to_left() Board { |
277 | mut res := b |
278 | for y in 0 .. 4 { |
279 | mut hline := TileLine{ |
280 | ypos: y |
281 | } |
282 | for x in 0 .. 4 { |
283 | hline.field[x] = b.field[y][x] |
284 | } |
285 | reshline := hline.to_left() |
286 | res.shifts += reshline.shifts |
287 | res.points += reshline.points |
288 | for x in 0 .. 4 { |
289 | res.field[y][x] = reshline.field[x] |
290 | } |
291 | } |
292 | return res |
293 | } |
294 | |
295 | fn (b Board) move(d Direction) (Board, bool) { |
296 | new := match d { |
297 | .left { b.to_left() } |
298 | .right { b.hmirror().to_left().hmirror() } |
299 | .up { b.transpose().to_left().transpose() } |
300 | .down { b.transpose().hmirror().to_left().hmirror().transpose() } |
301 | } |
302 | // If the board hasn't changed, it's an illegal move, don't allow it. |
303 | for x in 0 .. 4 { |
304 | for y in 0 .. 4 { |
305 | if b.field[x][y] != new.field[x][y] { |
306 | return new, true |
307 | } |
308 | } |
309 | } |
310 | return new, false |
311 | } |
312 | |
313 | fn (mut b Board) is_game_over() bool { |
314 | for y in 0 .. 4 { |
315 | for x in 0 .. 4 { |
316 | fidx := b.field[y][x] |
317 | if fidx == 0 { |
318 | // there are remaining zeros |
319 | return false |
320 | } |
321 | if (x > 0 && fidx == b.field[y][x - 1]) |
322 | || (x < 4 - 1 && fidx == b.field[y][x + 1]) |
323 | || (y > 0 && fidx == b.field[y - 1][x]) |
324 | || (y < 4 - 1 && fidx == b.field[y + 1][x]) { |
325 | // there are remaining merges |
326 | return false |
327 | } |
328 | } |
329 | } |
330 | return true |
331 | } |
332 | |
333 | fn (mut app App) update_tickers() { |
334 | for y in 0 .. 4 { |
335 | for x in 0 .. 4 { |
336 | mut old := app.atickers[y][x] |
337 | if old > 0 { |
338 | old-- |
339 | app.atickers[y][x] = old |
340 | } |
341 | } |
342 | } |
343 | } |
344 | |
345 | fn (mut app App) new_game() { |
346 | app.board = Board{} |
347 | for y in 0 .. 4 { |
348 | for x in 0 .. 4 { |
349 | app.board.field[y][x] = 0 |
350 | app.atickers[y][x] = 0 |
351 | } |
352 | } |
353 | app.state = .play |
354 | app.undo = []Undo{cap: 4096} |
355 | app.moves = 0 |
356 | app.new_random_tile() |
357 | app.new_random_tile() |
358 | } |
359 | |
360 | [inline] |
361 | fn (mut app App) check_for_victory() { |
362 | for y in 0 .. 4 { |
363 | for x in 0 .. 4 { |
364 | fidx := app.board.field[y][x] |
365 | if fidx == 11 { |
366 | app.state = .victory |
367 | return |
368 | } |
369 | } |
370 | } |
371 | } |
372 | |
373 | [inline] |
374 | fn (mut app App) check_for_game_over() { |
375 | if app.board.is_game_over() { |
376 | app.state = .over |
377 | } |
378 | } |
379 | |
380 | fn (mut b Board) place_random_tile() (Pos, int) { |
381 | mut etiles := [16]Pos{} |
382 | mut empty_tiles_max := 0 |
383 | for y in 0 .. 4 { |
384 | for x in 0 .. 4 { |
385 | fidx := b.field[y][x] |
386 | if fidx == 0 { |
387 | etiles[empty_tiles_max] = Pos{x, y} |
388 | empty_tiles_max++ |
389 | } |
390 | } |
391 | } |
392 | if empty_tiles_max > 0 { |
393 | new_random_tile_index := rand.intn(empty_tiles_max) or { 0 } |
394 | empty_pos := etiles[new_random_tile_index] |
395 | // 10% chance of getting a `4` tile |
396 | value := rand.f64n(1.0) or { 0.0 } |
397 | random_value := if value < 0.9 { 1 } else { 2 } |
398 | b.field[empty_pos.y][empty_pos.x] = random_value |
399 | return empty_pos, random_value |
400 | } |
401 | return Pos{}, 0 |
402 | } |
403 | |
404 | fn (mut app App) new_random_tile() { |
405 | for y in 0 .. 4 { |
406 | for x in 0 .. 4 { |
407 | fidx := app.board.field[y][x] |
408 | if fidx == 0 { |
409 | app.atickers[y][x] = 0 |
410 | } |
411 | } |
412 | } |
413 | empty_pos, random_value := app.board.place_random_tile() |
414 | if random_value > 0 { |
415 | app.atickers[empty_pos.y][empty_pos.x] = animation_length |
416 | } |
417 | if app.state != .freeplay { |
418 | app.check_for_victory() |
419 | } |
420 | app.check_for_game_over() |
421 | } |
422 | |
423 | fn (mut app App) apply_new_board(new Board) { |
424 | old := app.board |
425 | app.moves++ |
426 | app.board = new |
427 | app.undo << Undo{old, app.state} |
428 | app.new_random_tile() |
429 | } |
430 | |
431 | fn (mut app App) move(d Direction) { |
432 | new, is_valid := app.board.move(d) |
433 | if !is_valid { |
434 | return |
435 | } |
436 | app.apply_new_board(new) |
437 | } |
438 | |
439 | struct Prediction { |
440 | mut: |
441 | move Direction |
442 | mpoints f64 |
443 | mcmoves f64 |
444 | } |
445 | |
446 | fn (p Prediction) str() string { |
447 | return '{ move: ${p.move:5}, mpoints: ${p.mpoints:6.2f}, mcmoves: ${p.mcmoves:6.2f} }' |
448 | } |
449 | |
450 | fn (mut app App) ai_move() { |
451 | mut predictions := [4]Prediction{} |
452 | mut is_valid := false |
453 | think_watch := time.new_stopwatch() |
454 | for move in possible_moves { |
455 | move_idx := int(move) |
456 | predictions[move_idx].move = move |
457 | mut mpoints := 0 |
458 | mut mcmoves := 0 |
459 | for _ in 0 .. predictions_per_move { |
460 | mut cboard := app.board |
461 | cboard, is_valid = cboard.move(move) |
462 | if !is_valid || cboard.is_game_over() { |
463 | continue |
464 | } |
465 | mpoints += cboard.points |
466 | cboard.place_random_tile() |
467 | mut cmoves := 0 |
468 | for !cboard.is_game_over() { |
469 | nmove := possible_moves[rand.intn(possible_moves.len) or { 0 }] |
470 | cboard, is_valid = cboard.move(nmove) |
471 | if !is_valid { |
472 | continue |
473 | } |
474 | cboard.place_random_tile() |
475 | cmoves++ |
476 | if cmoves > prediction_depth { |
477 | break |
478 | } |
479 | } |
480 | mpoints += cboard.points |
481 | mcmoves += cmoves |
482 | } |
483 | predictions[move_idx].mpoints = f64(mpoints) / predictions_per_move |
484 | predictions[move_idx].mcmoves = f64(mcmoves) / predictions_per_move |
485 | } |
486 | think_time := think_watch.elapsed().milliseconds() |
487 | mut bestprediction := Prediction{ |
488 | mpoints: -1 |
489 | } |
490 | for move_idx in 0 .. possible_moves.len { |
491 | if bestprediction.mpoints < predictions[move_idx].mpoints { |
492 | bestprediction = predictions[move_idx] |
493 | } |
494 | } |
495 | eprintln('Simulation time: ${think_time:4}ms | best ${bestprediction}') |
496 | app.move(bestprediction.move) |
497 | } |
498 | |
499 | fn (app &App) label_format(kind LabelKind) gx.TextCfg { |
500 | match kind { |
501 | .points { |
502 | return gx.TextCfg{ |
503 | color: if app.state in [.over, .victory] { gx.white } else { app.theme.text_color } |
504 | align: .left |
505 | size: app.ui.font_size / 2 |
506 | } |
507 | } |
508 | .moves { |
509 | return gx.TextCfg{ |
510 | color: if app.state in [.over, .victory] { gx.white } else { app.theme.text_color } |
511 | align: .right |
512 | size: app.ui.font_size / 2 |
513 | } |
514 | } |
515 | .tile { |
516 | return gx.TextCfg{ |
517 | color: app.theme.text_color |
518 | align: .center |
519 | vertical_align: .middle |
520 | size: app.ui.font_size |
521 | } |
522 | } |
523 | .victory { |
524 | return gx.TextCfg{ |
525 | color: app.theme.victory_color |
526 | align: .center |
527 | vertical_align: .middle |
528 | size: app.ui.font_size * 2 |
529 | } |
530 | } |
531 | .game_over { |
532 | return gx.TextCfg{ |
533 | color: app.theme.game_over_color |
534 | align: .center |
535 | vertical_align: .middle |
536 | size: app.ui.font_size * 2 |
537 | } |
538 | } |
539 | .score_end { |
540 | return gx.TextCfg{ |
541 | color: gx.white |
542 | align: .center |
543 | vertical_align: .middle |
544 | size: app.ui.font_size * 3 / 4 |
545 | } |
546 | } |
547 | } |
548 | } |
549 | |
550 | [inline] |
551 | fn (mut app App) set_theme(idx int) { |
552 | theme := themes[idx] |
553 | app.theme_idx = idx |
554 | app.theme = theme |
555 | app.gg.set_bg_color(theme.bg_color) |
556 | } |
557 | |
558 | fn (mut app App) resize() { |
559 | mut s := app.gg.scale |
560 | if s == 0.0 { |
561 | s = 1.0 |
562 | } |
563 | window_size := app.gg.window_size() |
564 | w := window_size.width |
565 | h := window_size.height |
566 | m := f32(math.min(w, h)) |
567 | app.ui.dpi_scale = s |
568 | app.ui.window_width = w |
569 | app.ui.window_height = h |
570 | app.ui.padding_size = int(m / 38) |
571 | app.ui.header_size = app.ui.padding_size |
572 | app.ui.border_size = app.ui.padding_size * 2 |
573 | app.ui.tile_size = int((m - app.ui.padding_size * 5 - app.ui.border_size * 2) / 4) |
574 | app.ui.font_size = int(m / 10) |
575 | // If the window's height is greater than its width, center the board vertically. |
576 | // If not, center it horizontally |
577 | if w > h { |
578 | app.ui.y_padding = 0 |
579 | app.ui.x_padding = (app.ui.window_width - app.ui.window_height) / 2 |
580 | } else { |
581 | app.ui.y_padding = (app.ui.window_height - app.ui.window_width - app.ui.header_size) / 2 |
582 | app.ui.x_padding = 0 |
583 | } |
584 | } |
585 | |
586 | fn (app &App) draw() { |
587 | xpad, ypad := app.ui.x_padding, app.ui.y_padding |
588 | ww := app.ui.window_width |
589 | wh := app.ui.window_height |
590 | m := math.min(ww, wh) |
591 | labelx := xpad + app.ui.border_size |
592 | labely := ypad + app.ui.border_size / 2 |
593 | app.draw_tiles() |
594 | // TODO: Make transparency work in `gg` |
595 | if app.state == .over { |
596 | app.gg.draw_rect_filled(0, 0, ww, wh, gx.rgba(10, 0, 0, 180)) |
597 | app.gg.draw_text(ww / 2, (m * 4 / 10) + ypad, 'Game Over', app.label_format(.game_over)) |
598 | f := app.label_format(.tile) |
599 | msg := $if android { 'Tap to restart' } $else { 'Press `r` to restart' } |
600 | app.gg.draw_text(ww / 2, (m * 6 / 10) + ypad, msg, gx.TextCfg{ |
601 | ...f |
602 | color: gx.white |
603 | size: f.size * 3 / 4 |
604 | }) |
605 | } |
606 | if app.state == .victory { |
607 | app.gg.draw_rect_filled(0, 0, ww, wh, gx.rgba(0, 10, 0, 180)) |
608 | app.gg.draw_text(ww / 2, (m * 4 / 10) + ypad, 'Victory!', app.label_format(.victory)) |
609 | // f := app.label_format(.tile) |
610 | msg1 := $if android { 'Tap to continue' } $else { 'Press `space` to continue' } |
611 | msg2 := $if android { 'Tap to restart' } $else { 'Press `r` to restart' } |
612 | app.gg.draw_text(ww / 2, (m * 6 / 10) + ypad, msg1, app.label_format(.score_end)) |
613 | app.gg.draw_text(ww / 2, (m * 8 / 10) + ypad, msg2, app.label_format(.score_end)) |
614 | } |
615 | // Draw at the end, so that it's on top of the victory / game over overlays |
616 | app.gg.draw_text(labelx, labely, 'Points: ${app.board.points}', app.label_format(.points)) |
617 | app.gg.draw_text(ww - labelx, labely, 'Moves: ${app.moves}', app.label_format(.moves)) |
618 | } |
619 | |
620 | fn (app &App) draw_tiles() { |
621 | xstart := app.ui.x_padding + app.ui.border_size |
622 | ystart := app.ui.y_padding + app.ui.border_size + app.ui.header_size |
623 | toffset := app.ui.tile_size + app.ui.padding_size |
624 | tiles_size := math.min(app.ui.window_width, app.ui.window_height) - app.ui.border_size * 2 |
625 | // Draw the padding around the tiles |
626 | app.gg.draw_rounded_rect_filled(xstart, ystart, tiles_size, tiles_size, tiles_size / 24, |
627 | app.theme.padding_color) |
628 | // Draw the actual tiles |
629 | for y in 0 .. 4 { |
630 | for x in 0 .. 4 { |
631 | tidx := app.board.field[y][x] |
632 | tile_color := if tidx < app.theme.tile_colors.len { |
633 | app.theme.tile_colors[tidx] |
634 | } else { |
635 | // If there isn't a specific color for this tile, reuse the last color available |
636 | app.theme.tile_colors.last() |
637 | } |
638 | anim_size := animation_length - app.atickers[y][x] |
639 | tw := int(f64(app.ui.tile_size) / animation_length * anim_size) |
640 | th := tw // square tiles, w == h |
641 | xoffset := xstart + app.ui.padding_size + x * toffset + (app.ui.tile_size - tw) / 2 |
642 | yoffset := ystart + app.ui.padding_size + y * toffset + (app.ui.tile_size - th) / 2 |
643 | app.gg.draw_rounded_rect_filled(xoffset, yoffset, tw, th, tw / 8, tile_color) |
644 | if tidx != 0 { // 0 == blank spot |
645 | xpos := xoffset + tw / 2 |
646 | ypos := yoffset + th / 2 |
647 | mut fmt := app.label_format(.tile) |
648 | fmt = gx.TextCfg{ |
649 | ...fmt |
650 | size: int(f32(fmt.size - 1) / animation_length * anim_size) |
651 | } |
652 | match app.tile_format { |
653 | .normal { |
654 | app.gg.draw_text(xpos, ypos, '${1 << tidx}', fmt) |
655 | } |
656 | .log { |
657 | app.gg.draw_text(xpos, ypos, '${tidx}', fmt) |
658 | } |
659 | .exponent { |
660 | app.gg.draw_text(xpos, ypos, '2', fmt) |
661 | fs2 := int(f32(fmt.size) * 0.67) |
662 | app.gg.draw_text(xpos + app.ui.tile_size / 10, ypos - app.ui.tile_size / 8, |
663 | '${tidx}', gx.TextCfg{ |
664 | ...fmt |
665 | size: fs2 |
666 | align: gx.HorizontalAlign.left |
667 | }) |
668 | } |
669 | .shifts { |
670 | fs2 := int(f32(fmt.size) * 0.6) |
671 | app.gg.draw_text(xpos, ypos, '2<<${tidx - 1}', gx.TextCfg{ |
672 | ...fmt |
673 | size: fs2 |
674 | }) |
675 | } |
676 | .none_ {} // Don't draw any text here, colors only |
677 | .end_ {} // Should never get here |
678 | } |
679 | } |
680 | } |
681 | } |
682 | } |
683 | |
684 | fn (mut app App) handle_touches() { |
685 | s, e := app.touch.start, app.touch.end |
686 | adx, ady := math.abs(e.pos.x - s.pos.x), math.abs(e.pos.y - s.pos.y) |
687 | if math.max(adx, ady) < 10 { |
688 | app.handle_tap() |
689 | } else { |
690 | app.handle_swipe() |
691 | } |
692 | } |
693 | |
694 | fn (mut app App) handle_tap() { |
695 | _, ypad := app.ui.x_padding, app.ui.y_padding |
696 | w, h := app.ui.window_width, app.ui.window_height |
697 | m := math.min(w, h) |
698 | s, e := app.touch.start, app.touch.end |
699 | avgx, avgy := avg(s.pos.x, e.pos.x), avg(s.pos.y, e.pos.y) |
700 | // TODO: Replace "touch spots" with actual buttons |
701 | // bottom left -> change theme |
702 | if avgx < 50 && h - avgy < 50 { |
703 | app.next_theme() |
704 | } |
705 | // bottom right -> change tile format |
706 | if w - avgx < 50 && h - avgy < 50 { |
707 | app.next_tile_format() |
708 | } |
709 | if app.state == .victory { |
710 | if avgy > (m / 2) + ypad { |
711 | if avgy < (m * 7 / 10) + ypad { |
712 | app.state = .freeplay |
713 | } else if avgy < (m * 9 / 10) + ypad { |
714 | app.new_game() |
715 | } else { |
716 | // TODO remove and implement an actual way to toggle themes on mobile |
717 | } |
718 | } |
719 | } else if app.state == .over { |
720 | if avgy > (m / 2) + ypad && avgy < (m * 7 / 10) + ypad { |
721 | app.new_game() |
722 | } |
723 | } |
724 | } |
725 | |
726 | fn (mut app App) handle_swipe() { |
727 | // Currently, swipes are only used to move the tiles. |
728 | // If the user's not playing, exit early to avoid all the unnecessary calculations |
729 | if app.state !in [.play, .freeplay] { |
730 | return |
731 | } |
732 | s, e := app.touch.start, app.touch.end |
733 | w, h := app.ui.window_width, app.ui.window_height |
734 | dx, dy := e.pos.x - s.pos.x, e.pos.y - s.pos.y |
735 | adx, ady := math.abs(dx), math.abs(dy) |
736 | dmin := if math.min(adx, ady) > 0 { math.min(adx, ady) } else { 1 } |
737 | dmax := if math.max(adx, ady) > 0 { math.max(adx, ady) } else { 1 } |
738 | tdiff := int(e.time.unix_time_milli() - s.time.unix_time_milli()) |
739 | // TODO: make this calculation more accurate (don't use arbitrary numbers) |
740 | min_swipe_distance := int(math.sqrt(math.min(w, h) * tdiff / 100)) + 20 |
741 | if dmax < min_swipe_distance { |
742 | return |
743 | } |
744 | // Swipe was too short |
745 | if dmax / dmin < 2 { |
746 | return |
747 | } |
748 | // Swiped diagonally |
749 | if adx > ady { |
750 | if dx < 0 { |
751 | app.move(.left) |
752 | } else { |
753 | app.move(.right) |
754 | } |
755 | } else { |
756 | if dy < 0 { |
757 | app.move(.up) |
758 | } else { |
759 | app.move(.down) |
760 | } |
761 | } |
762 | } |
763 | |
764 | [inline] |
765 | fn (mut app App) next_theme() { |
766 | app.set_theme(if app.theme_idx == themes.len - 1 { 0 } else { app.theme_idx + 1 }) |
767 | } |
768 | |
769 | [inline] |
770 | fn (mut app App) next_tile_format() { |
771 | app.tile_format = unsafe { TileFormat(int(app.tile_format) + 1) } |
772 | if app.tile_format == .end_ { |
773 | app.tile_format = .normal |
774 | } |
775 | } |
776 | |
777 | [inline] |
778 | fn (mut app App) undo() { |
779 | if app.undo.len > 0 { |
780 | undo := app.undo.pop() |
781 | app.board = undo.board |
782 | app.state = undo.state |
783 | app.moves-- |
784 | } |
785 | } |
786 | |
787 | fn (mut app App) on_key_down(key gg.KeyCode) { |
788 | // these keys are independent from the game state: |
789 | match key { |
790 | .c { app.is_ai_mode = !app.is_ai_mode } |
791 | .escape { app.gg.quit() } |
792 | .n, .r { app.new_game() } |
793 | .backspace { app.undo() } |
794 | .enter { app.next_tile_format() } |
795 | .j { app.state = .over } |
796 | .t { app.next_theme() } |
797 | else {} |
798 | } |
799 | if app.state in [.play, .freeplay] { |
800 | if !app.is_ai_mode { |
801 | match key { |
802 | .w, .up { app.move(.up) } |
803 | .a, .left { app.move(.left) } |
804 | .s, .down { app.move(.down) } |
805 | .d, .right { app.move(.right) } |
806 | else {} |
807 | } |
808 | } |
809 | } |
810 | if app.state == .victory { |
811 | if key == .space { |
812 | app.state = .freeplay |
813 | } |
814 | } |
815 | } |
816 | |
817 | fn on_event(e &gg.Event, mut app App) { |
818 | match e.typ { |
819 | .key_down { |
820 | app.on_key_down(e.key_code) |
821 | } |
822 | .resized, .restored, .resumed { |
823 | app.resize() |
824 | } |
825 | .touches_began { |
826 | if e.num_touches > 0 { |
827 | t := e.touches[0] |
828 | app.touch.start = Touch{ |
829 | pos: Pos{ |
830 | x: int(t.pos_x / app.ui.dpi_scale) |
831 | y: int(t.pos_y / app.ui.dpi_scale) |
832 | } |
833 | time: time.now() |
834 | } |
835 | } |
836 | } |
837 | .touches_ended { |
838 | if e.num_touches > 0 { |
839 | t := e.touches[0] |
840 | app.touch.end = Touch{ |
841 | pos: Pos{ |
842 | x: int(t.pos_x / app.ui.dpi_scale) |
843 | y: int(t.pos_y / app.ui.dpi_scale) |
844 | } |
845 | time: time.now() |
846 | } |
847 | app.handle_touches() |
848 | } |
849 | } |
850 | .mouse_down { |
851 | app.touch.start = Touch{ |
852 | pos: Pos{ |
853 | x: int(e.mouse_x / app.ui.dpi_scale) |
854 | y: int(e.mouse_y / app.ui.dpi_scale) |
855 | } |
856 | time: time.now() |
857 | } |
858 | } |
859 | .mouse_up { |
860 | app.touch.end = Touch{ |
861 | pos: Pos{ |
862 | x: int(e.mouse_x / app.ui.dpi_scale) |
863 | y: int(e.mouse_y / app.ui.dpi_scale) |
864 | } |
865 | time: time.now() |
866 | } |
867 | app.handle_touches() |
868 | } |
869 | else {} |
870 | } |
871 | } |
872 | |
873 | fn frame(mut app App) { |
874 | $if showfps ? { |
875 | app.perf.frame_sw.restart() |
876 | } |
877 | app.gg.begin() |
878 | app.update_tickers() |
879 | app.draw() |
880 | app.perf.frame++ |
881 | if app.is_ai_mode && app.state in [.play, .freeplay] && app.perf.frame % frames_per_ai_move == 0 { |
882 | app.ai_move() |
883 | } |
884 | $if showfps ? { |
885 | app.showfps() |
886 | } |
887 | app.gg.end() |
888 | } |
889 | |
890 | fn init(mut app App) { |
891 | app.resize() |
892 | $if showfps ? { |
893 | app.perf.frame_sw.restart() |
894 | app.perf.second_sw.restart() |
895 | } |
896 | } |
897 | |
898 | fn (mut app App) showfps() { |
899 | println(app.perf.frame_sw.elapsed().microseconds()) |
900 | f := app.perf.frame |
901 | if (f & 127) == 0 { |
902 | last_frame_us := app.perf.frame_sw.elapsed().microseconds() |
903 | ticks := f64(app.perf.second_sw.elapsed().milliseconds()) |
904 | fps := f64(app.perf.frame - app.perf.frame_old) * ticks / 1000 / 4.5 |
905 | last_fps := 128000.0 / ticks |
906 | eprintln('frame ${f:-5} | avg. fps: ${fps:-5.1f} | avg. last 128 fps: ${last_fps:-5.1f} | last frame time: ${last_frame_us:-4}µs') |
907 | app.perf.second_sw.restart() |
908 | app.perf.frame_old = f |
909 | } |
910 | } |
911 | |
912 | fn main() { |
913 | mut app := &App{} |
914 | app.new_game() |
915 | mut font_path := os.resource_abs_path(os.join_path('..', 'assets', 'fonts', 'RobotoMono-Regular.ttf')) |
916 | $if android { |
917 | font_path = 'fonts/RobotoMono-Regular.ttf' |
918 | } |
919 | app.perf = &Perf{} |
920 | app.gg = gg.new_context( |
921 | bg_color: app.theme.bg_color |
922 | width: default_window_width |
923 | height: default_window_height |
924 | sample_count: 4 // higher quality curves |
925 | create_window: true |
926 | window_title: 'V 2048' |
927 | frame_fn: frame |
928 | event_fn: on_event |
929 | init_fn: init |
930 | user_data: app |
931 | font_path: font_path |
932 | ) |
933 | app.gg.run() |
934 | } |