1562 lines
36 KiB
V
1562 lines
36 KiB
V
// AI heuristic inspired by the expectimax 2048 solver approach described at:
|
|
// https://github.com/nneonneo/2048-ai
|
|
import gg
|
|
import math
|
|
import math.easing
|
|
import os.asset
|
|
import rand
|
|
import time
|
|
|
|
const zooming_percent_per_frame = 5
|
|
const movement_percent_per_frame = 10
|
|
const tile_text_size_step = 8
|
|
const min_tile_text_size = 8
|
|
|
|
const default_window_width = 544
|
|
const default_window_height = 560
|
|
|
|
const possible_moves = [Direction.up, .right, .down, .left]
|
|
const ai_row_states = 1 << 16
|
|
const ai_tt_size = 1 << 18
|
|
const ai_time_budget_us = i64(5_000)
|
|
const ai_min_search_depth = 2
|
|
const ai_max_search_depth = 8
|
|
const ai_abort_score = -1.0e30
|
|
const ai_terminal_loss = -1.0e15
|
|
const ai_spawn_two_prob = 0.9
|
|
const ai_spawn_four_prob = 0.1
|
|
const ai_snake_path_row = [0, 1, 2, 3, 7, 6, 5, 4, 8, 9, 10, 11, 15, 14, 13, 12]!
|
|
const ai_snake_path_col = [0, 4, 8, 12, 13, 9, 5, 1, 2, 6, 10, 14, 15, 11, 7, 3]!
|
|
const ai_eval_weights = [
|
|
[90.0, 70.0, 50.0, 30.0]!,
|
|
[8.0, 6.0, 4.0, 2.0]!,
|
|
[-2.0, -4.0, -6.0, -8.0]!,
|
|
[-30.0, -50.0, -70.0, -90.0]!,
|
|
]
|
|
|
|
struct App {
|
|
mut:
|
|
gg &gg.Context = unsafe { nil }
|
|
touch TouchInfo
|
|
ui Ui
|
|
theme &Theme = themes[0]
|
|
theme_idx int
|
|
board Board
|
|
undo []Undo
|
|
atickers [4][4]f64
|
|
mtickers [4][4]f64
|
|
state GameState = .play
|
|
tile_format TileFormat = .normal
|
|
moves int
|
|
updates u64
|
|
needs_redraw bool = true
|
|
|
|
is_ai_mode bool
|
|
ai_fpm u64 = 8
|
|
ai_engine AiEngine
|
|
}
|
|
|
|
struct Ui {
|
|
mut:
|
|
dpi_scale f32
|
|
tile_size int
|
|
border_size int
|
|
padding_size int
|
|
header_size int
|
|
font_size int
|
|
window_width int
|
|
window_height int
|
|
x_padding int
|
|
y_padding int
|
|
}
|
|
|
|
struct Theme {
|
|
bg_color gg.Color
|
|
padding_color gg.Color
|
|
text_color gg.Color
|
|
game_over_color gg.Color
|
|
victory_color gg.Color
|
|
tile_colors []gg.Color
|
|
}
|
|
|
|
const themes = [
|
|
&Theme{
|
|
bg_color: gg.rgb(250, 248, 239)
|
|
padding_color: gg.rgb(143, 130, 119)
|
|
victory_color: gg.rgb(100, 160, 100)
|
|
game_over_color: gg.rgb(190, 50, 50)
|
|
text_color: gg.black
|
|
tile_colors: [
|
|
gg.rgb(205, 193, 180), // Empty / 0 tile
|
|
gg.rgb(238, 228, 218), // 2
|
|
gg.rgb(237, 224, 200), // 4
|
|
gg.rgb(242, 177, 121), // 8
|
|
gg.rgb(245, 149, 99), // 16
|
|
gg.rgb(246, 124, 95), // 32
|
|
gg.rgb(246, 94, 59), // 64
|
|
gg.rgb(237, 207, 114), // 128
|
|
gg.rgb(237, 204, 97), // 256
|
|
gg.rgb(237, 200, 80), // 512
|
|
gg.rgb(237, 197, 63), // 1024
|
|
gg.rgb(237, 194, 46),
|
|
]
|
|
},
|
|
&Theme{
|
|
bg_color: gg.rgb(55, 55, 55)
|
|
padding_color: gg.rgb(68, 60, 59)
|
|
victory_color: gg.rgb(100, 160, 100)
|
|
game_over_color: gg.rgb(190, 50, 50)
|
|
text_color: gg.white
|
|
tile_colors: [
|
|
gg.rgb(123, 115, 108),
|
|
gg.rgb(142, 136, 130),
|
|
gg.rgb(142, 134, 120),
|
|
gg.rgb(145, 106, 72),
|
|
gg.rgb(147, 89, 59),
|
|
gg.rgb(147, 74, 57),
|
|
gg.rgb(147, 56, 35),
|
|
gg.rgb(142, 124, 68),
|
|
gg.rgb(142, 122, 58),
|
|
gg.rgb(142, 120, 48),
|
|
gg.rgb(142, 118, 37),
|
|
gg.rgb(142, 116, 27),
|
|
]
|
|
},
|
|
&Theme{
|
|
bg_color: gg.rgb(38, 38, 66)
|
|
padding_color: gg.rgb(58, 50, 74)
|
|
victory_color: gg.rgb(100, 160, 100)
|
|
game_over_color: gg.rgb(190, 50, 50)
|
|
text_color: gg.white
|
|
tile_colors: [
|
|
gg.rgb(92, 86, 140),
|
|
gg.rgb(106, 99, 169),
|
|
gg.rgb(106, 97, 156),
|
|
gg.rgb(108, 79, 93),
|
|
gg.rgb(110, 66, 76),
|
|
gg.rgb(110, 55, 74),
|
|
gg.rgb(110, 42, 45),
|
|
gg.rgb(106, 93, 88),
|
|
gg.rgb(106, 91, 75),
|
|
gg.rgb(106, 90, 62),
|
|
gg.rgb(106, 88, 48),
|
|
gg.rgb(106, 87, 35),
|
|
]
|
|
},
|
|
]
|
|
|
|
struct Pos {
|
|
x int = -1
|
|
y int = -1
|
|
}
|
|
|
|
struct Board {
|
|
mut:
|
|
field [4][4]int
|
|
oidxs [4][4]u32 // old indexes of the fields, when != 0; each index is an encoding of its y,x coordinates = y << 16 | x
|
|
points int
|
|
shifts int
|
|
}
|
|
|
|
struct Undo {
|
|
board Board
|
|
state GameState
|
|
}
|
|
|
|
struct TileLine {
|
|
mut:
|
|
field [5]int
|
|
oidxs [5]u32
|
|
points int
|
|
shifts int
|
|
}
|
|
|
|
struct TouchInfo {
|
|
mut:
|
|
start Touch
|
|
end Touch
|
|
}
|
|
|
|
struct Touch {
|
|
mut:
|
|
pos Pos
|
|
time time.Time
|
|
}
|
|
|
|
enum TileFormat {
|
|
normal
|
|
log
|
|
exponent
|
|
shifts
|
|
none
|
|
end // To know when to wrap around
|
|
}
|
|
|
|
enum GameState {
|
|
play
|
|
over
|
|
victory
|
|
freeplay
|
|
}
|
|
|
|
enum LabelKind {
|
|
keys
|
|
points
|
|
moves
|
|
tile
|
|
victory
|
|
game_over
|
|
score_end
|
|
}
|
|
|
|
enum Direction {
|
|
up
|
|
down
|
|
left
|
|
right
|
|
}
|
|
|
|
type AiBoard = u64
|
|
|
|
struct AiEngine {
|
|
mut:
|
|
initialized bool
|
|
row_left []u16
|
|
row_right []u16
|
|
row_heuristic []f64
|
|
tt []AiTtEntry
|
|
generation u32
|
|
}
|
|
|
|
struct AiTtEntry {
|
|
board AiBoard
|
|
score f64
|
|
generation u32
|
|
depth u8
|
|
kind u8
|
|
}
|
|
|
|
struct AiSearchCtx {
|
|
watch time.StopWatch
|
|
deadline_us i64
|
|
generation u32
|
|
mut:
|
|
nodes u64
|
|
cache_hits u64
|
|
aborted bool
|
|
}
|
|
|
|
struct AiMoveResult {
|
|
mut:
|
|
move Direction
|
|
score f64
|
|
depth int
|
|
nodes u64
|
|
cache_hits u64
|
|
valid bool
|
|
}
|
|
|
|
// Utility functions
|
|
@[inline]
|
|
fn avg(a int, b int) int {
|
|
return (a + b) / 2
|
|
}
|
|
|
|
fn (b Board) transpose() Board {
|
|
mut res := b
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
res.field[y][x] = b.field[x][y]
|
|
res.oidxs[y][x] = b.oidxs[x][y]
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
fn (b Board) hmirror() Board {
|
|
mut res := b
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
res.field[y][x] = b.field[y][3 - x]
|
|
res.oidxs[y][x] = b.oidxs[y][3 - x]
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
fn (t TileLine) to_left() TileLine {
|
|
right_border_idx := 4
|
|
mut res := t
|
|
mut zeros := 0
|
|
mut nonzeros := 0
|
|
// gather meta info about the line:
|
|
for x in res.field {
|
|
if x == 0 {
|
|
zeros++
|
|
} else {
|
|
nonzeros++
|
|
}
|
|
}
|
|
if nonzeros == 0 {
|
|
// when all the tiles are empty, there is nothing left to do
|
|
return res
|
|
}
|
|
if zeros > 0 {
|
|
// we have some 0s, do shifts to compact them:
|
|
mut remaining_zeros := zeros
|
|
for x := 0; x < right_border_idx - 1; x++ {
|
|
for res.field[x] == 0 && remaining_zeros > 0 {
|
|
res.shifts++
|
|
for k := x; k < right_border_idx; k++ {
|
|
res.field[k] = res.field[k + 1]
|
|
res.oidxs[k] = res.oidxs[k + 1]
|
|
}
|
|
remaining_zeros--
|
|
}
|
|
}
|
|
}
|
|
// At this point, the non 0 tiles are all on the left, with no empty spaces
|
|
// between them. we can safely merge them, when they have the same value:
|
|
for x := 0; x < right_border_idx - 1; x++ {
|
|
if res.field[x] == 0 {
|
|
break
|
|
}
|
|
if res.field[x] == res.field[x + 1] {
|
|
for k := x; k < right_border_idx; k++ {
|
|
res.field[k] = res.field[k + 1]
|
|
res.oidxs[k] = res.oidxs[k + 1]
|
|
}
|
|
res.shifts++
|
|
res.field[x]++
|
|
res.points += 1 << res.field[x]
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
fn (b Board) to_left() Board {
|
|
mut res := b
|
|
for y in 0 .. 4 {
|
|
mut hline := TileLine{}
|
|
for x in 0 .. 4 {
|
|
hline.field[x] = b.field[y][x]
|
|
hline.oidxs[x] = b.oidxs[y][x]
|
|
}
|
|
reshline := hline.to_left()
|
|
res.shifts += reshline.shifts
|
|
res.points += reshline.points
|
|
for x in 0 .. 4 {
|
|
res.field[y][x] = reshline.field[x]
|
|
res.oidxs[y][x] = reshline.oidxs[x]
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
fn yx2i(y int, x int) u32 {
|
|
return u32(y) << 16 | u32(x)
|
|
}
|
|
|
|
@[inline]
|
|
fn quantized_tile_text_size(size int) int {
|
|
if size < min_tile_text_size {
|
|
return 0
|
|
}
|
|
return size / tile_text_size_step * tile_text_size_step
|
|
}
|
|
|
|
@[inline]
|
|
fn animated_tile_text_size(base_size int, animation_scale f64) int {
|
|
// gg/fontstash caches glyphs by size, so quantize the zoom animation to keep the
|
|
// atlas stable during long autoplay sessions.
|
|
return quantized_tile_text_size(int(animation_scale * (base_size - 1)))
|
|
}
|
|
|
|
@[inline]
|
|
fn reverse_row(row u16) u16 {
|
|
part0 := u16((row & 0x000f) << 12)
|
|
part1 := u16((row & 0x00f0) << 4)
|
|
part2 := u16((row & 0x0f00) >> 4)
|
|
part3 := u16((row & 0xf000) >> 12)
|
|
return part0 | part1 | part2 | part3
|
|
}
|
|
|
|
fn build_ai_row_left(row u16) (u16, bool) {
|
|
mut tiles := [4]u8{}
|
|
mut compact := [4]u8{}
|
|
mut compact_len := 0
|
|
for idx in 0 .. 4 {
|
|
tiles[idx] = u8((row >> (idx << 2)) & 0xf)
|
|
if tiles[idx] != 0 {
|
|
compact[compact_len] = tiles[idx]
|
|
compact_len++
|
|
}
|
|
}
|
|
mut merged := [4]u8{}
|
|
mut read_idx := 0
|
|
mut write_idx := 0
|
|
for read_idx < compact_len {
|
|
value := compact[read_idx]
|
|
if read_idx + 1 < compact_len && compact[read_idx + 1] == value {
|
|
merged[write_idx] = value + 1
|
|
read_idx += 2
|
|
} else {
|
|
merged[write_idx] = value
|
|
read_idx++
|
|
}
|
|
write_idx++
|
|
}
|
|
mut res := u16(0)
|
|
mut changed := false
|
|
for idx in 0 .. 4 {
|
|
res |= u16(merged[idx]) << (idx << 2)
|
|
if merged[idx] != tiles[idx] {
|
|
changed = true
|
|
}
|
|
}
|
|
return res, changed
|
|
}
|
|
|
|
fn ai_row_heuristic(row u16) f64 {
|
|
mut tiles := [4]int{}
|
|
mut empty_tiles := 0
|
|
mut mergeable_pairs := 0
|
|
mut smoothness := 0.0
|
|
mut monotonicity := 0.0
|
|
for idx in 0 .. 4 {
|
|
tiles[idx] = int((row >> (idx << 2)) & 0xf)
|
|
if tiles[idx] == 0 {
|
|
empty_tiles++
|
|
}
|
|
}
|
|
for idx in 0 .. 3 {
|
|
curr := tiles[idx]
|
|
next := tiles[idx + 1]
|
|
if curr != 0 && next != 0 {
|
|
smoothness -= math.abs(curr - next)
|
|
if curr == next {
|
|
mergeable_pairs++
|
|
}
|
|
}
|
|
}
|
|
mut left_penalty := 0.0
|
|
mut right_penalty := 0.0
|
|
for idx in 0 .. 3 {
|
|
left_penalty += math.max(0, tiles[idx + 1] - tiles[idx])
|
|
right_penalty += math.max(0, tiles[idx] - tiles[idx + 1])
|
|
}
|
|
monotonicity = -math.min(left_penalty, right_penalty)
|
|
return f64(empty_tiles) * 240.0 + f64(mergeable_pairs) * 500.0 + smoothness * 20.0 +
|
|
monotonicity * 70.0
|
|
}
|
|
|
|
fn (mut ai AiEngine) ensure_ready() {
|
|
if ai.initialized {
|
|
return
|
|
}
|
|
ai.row_left = []u16{len: ai_row_states}
|
|
ai.row_right = []u16{len: ai_row_states}
|
|
ai.row_heuristic = []f64{len: ai_row_states}
|
|
ai.tt = []AiTtEntry{len: ai_tt_size}
|
|
for i in 0 .. ai_row_states {
|
|
row := u16(i)
|
|
left, _ := build_ai_row_left(row)
|
|
reversed := reverse_row(row)
|
|
right_reversed, _ := build_ai_row_left(reversed)
|
|
ai.row_left[i] = left
|
|
ai.row_right[i] = reverse_row(right_reversed)
|
|
ai.row_heuristic[i] = ai_row_heuristic(row)
|
|
}
|
|
ai.initialized = true
|
|
}
|
|
|
|
@[inline]
|
|
fn ai_row(board AiBoard, row_idx int) u16 {
|
|
return u16((u64(board) >> (row_idx * 16)) & 0xffff)
|
|
}
|
|
|
|
@[inline]
|
|
fn ai_tile(board AiBoard, idx int) u8 {
|
|
return u8((u64(board) >> (idx * 4)) & 0xf)
|
|
}
|
|
|
|
fn ai_transpose(board AiBoard) AiBoard {
|
|
mut res := AiBoard(0)
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
src_idx := y << 2 + x
|
|
dst_idx := x << 2 + y
|
|
value := AiBoard(ai_tile(board, src_idx))
|
|
res |= value << (dst_idx * 4)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
@[inline]
|
|
fn ai_pack_exponent(exponent int) AiBoard {
|
|
// AiBoard stores 4-bit exponents, so clamp larger freeplay tiles to avoid corrupting
|
|
// adjacent cells in the packed representation.
|
|
return AiBoard(u64(math.min(exponent, 15)))
|
|
}
|
|
|
|
fn board_to_ai(board Board) AiBoard {
|
|
mut res := AiBoard(0)
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
res |= ai_pack_exponent(board.field[y][x]) << ((y << 2 + x) << 2)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
@[inline]
|
|
fn ai_empty_count(board AiBoard) int {
|
|
mut empty_tiles := 0
|
|
for idx in 0 .. 16 {
|
|
if ai_tile(board, idx) == 0 {
|
|
empty_tiles++
|
|
}
|
|
}
|
|
return empty_tiles
|
|
}
|
|
|
|
fn (ai &AiEngine) move_left(board AiBoard) (AiBoard, bool) {
|
|
mut res := AiBoard(0)
|
|
mut changed := false
|
|
for row_idx in 0 .. 4 {
|
|
row := ai_row(board, row_idx)
|
|
next := ai.row_left[int(row)]
|
|
res |= AiBoard(next) << (row_idx << 4)
|
|
changed = changed || row != next
|
|
}
|
|
return res, changed
|
|
}
|
|
|
|
fn (ai &AiEngine) move_right(board AiBoard) (AiBoard, bool) {
|
|
mut res := AiBoard(0)
|
|
mut changed := false
|
|
for row_idx in 0 .. 4 {
|
|
row := ai_row(board, row_idx)
|
|
next := ai.row_right[int(row)]
|
|
res |= AiBoard(next) << (row_idx << 4)
|
|
changed = changed || row != next
|
|
}
|
|
return res, changed
|
|
}
|
|
|
|
fn (ai &AiEngine) move_up(board AiBoard) (AiBoard, bool) {
|
|
transposed := ai_transpose(board)
|
|
moved, changed := ai.move_left(transposed)
|
|
return ai_transpose(moved), changed
|
|
}
|
|
|
|
fn (ai &AiEngine) move_down(board AiBoard) (AiBoard, bool) {
|
|
transposed := ai_transpose(board)
|
|
moved, changed := ai.move_right(transposed)
|
|
return ai_transpose(moved), changed
|
|
}
|
|
|
|
fn (ai &AiEngine) move_board(board AiBoard, move Direction) (AiBoard, bool) {
|
|
return match move {
|
|
.left { ai.move_left(board) }
|
|
.right { ai.move_right(board) }
|
|
.up { ai.move_up(board) }
|
|
.down { ai.move_down(board) }
|
|
}
|
|
}
|
|
|
|
@[direct_array_access]
|
|
fn (ai &AiEngine) evaluate(board AiBoard) f64 {
|
|
mut score := 0.0
|
|
transposed := ai_transpose(board)
|
|
for row_idx in 0 .. 4 {
|
|
score += ai.row_heuristic[int(ai_row(board, row_idx))]
|
|
score += ai.row_heuristic[int(ai_row(transposed, row_idx))]
|
|
}
|
|
mut max_tile := u8(0)
|
|
mut max_in_corner := false
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
value := ai_tile(board, y << 2 + x)
|
|
score += f64(value) * ai_eval_weights[y][x]
|
|
if value > max_tile {
|
|
max_tile = value
|
|
max_in_corner = (x == 0 && y == 0)
|
|
}
|
|
}
|
|
}
|
|
if max_in_corner {
|
|
score += 1500.0 + f64(max_tile) * 180.0
|
|
} else {
|
|
score -= 300.0 + f64(max_tile) * 80.0
|
|
}
|
|
score += f64(ai_empty_count(board)) * 500.0
|
|
score += ai_snake_score(board, ai_snake_path_row)
|
|
score += ai_snake_score(board, ai_snake_path_col)
|
|
return score
|
|
}
|
|
|
|
fn ai_snake_score(board AiBoard, path [16]int) f64 {
|
|
mut score := 0.0
|
|
mut weight := 1.0
|
|
for idx in path {
|
|
value := f64(ai_tile(board, idx))
|
|
score += value * value * weight
|
|
weight *= 0.5
|
|
}
|
|
return score * 220.0
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut ctx AiSearchCtx) should_abort() bool {
|
|
if ctx.aborted {
|
|
return true
|
|
}
|
|
if ctx.nodes & 1023 == 0 && ctx.watch.elapsed().microseconds() >= ctx.deadline_us {
|
|
ctx.aborted = true
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
@[inline]
|
|
fn ai_tt_index(board AiBoard, depth int, kind u8) int {
|
|
mut h := u64(board)
|
|
h ^= u64(depth) * 0x9e3779b97f4a7c15
|
|
h ^= u64(kind) * 0xbf58476d1ce4e5b9
|
|
h ^= h >> 30
|
|
h *= 0xbf58476d1ce4e5b9
|
|
h ^= h >> 27
|
|
h *= 0x94d049bb133111eb
|
|
h ^= h >> 31
|
|
return int(h & u64(ai_tt_size - 1))
|
|
}
|
|
|
|
fn (mut ai AiEngine) tt_lookup(board AiBoard, depth int, kind u8, mut ctx AiSearchCtx) ?f64 {
|
|
start_idx := ai_tt_index(board, depth, kind)
|
|
for probe in 0 .. 4 {
|
|
idx := (start_idx + probe) & (ai_tt_size - 1)
|
|
entry := ai.tt[idx]
|
|
if entry.generation != ctx.generation {
|
|
continue
|
|
}
|
|
if entry.board == board && entry.kind == kind && int(entry.depth) >= depth {
|
|
ctx.cache_hits++
|
|
return entry.score
|
|
}
|
|
}
|
|
return none
|
|
}
|
|
|
|
fn (mut ai AiEngine) tt_store(board AiBoard, depth int, kind u8, score f64, ctx AiSearchCtx) {
|
|
start_idx := ai_tt_index(board, depth, kind)
|
|
mut best_idx := start_idx
|
|
mut found_slot := false
|
|
for probe in 0 .. 4 {
|
|
idx := (start_idx + probe) & (ai_tt_size - 1)
|
|
entry := ai.tt[idx]
|
|
if entry.generation != ctx.generation || entry.board == board || int(entry.depth) <= depth {
|
|
best_idx = idx
|
|
found_slot = true
|
|
break
|
|
}
|
|
}
|
|
if !found_slot {
|
|
best_idx = start_idx
|
|
}
|
|
ai.tt[best_idx] = AiTtEntry{
|
|
board: board
|
|
score: score
|
|
generation: ctx.generation
|
|
depth: u8(depth)
|
|
kind: kind
|
|
}
|
|
}
|
|
|
|
fn (mut ai AiEngine) expectimax_max(board AiBoard, depth int, mut ctx AiSearchCtx) f64 {
|
|
ctx.nodes++
|
|
if ctx.should_abort() {
|
|
return ai_abort_score
|
|
}
|
|
if cached := ai.tt_lookup(board, depth, 0, mut ctx) {
|
|
return cached
|
|
}
|
|
if depth == 0 {
|
|
score := ai.evaluate(board)
|
|
ai.tt_store(board, depth, 0, score, ctx)
|
|
return score
|
|
}
|
|
mut best_score := ai_terminal_loss
|
|
mut has_move := false
|
|
for move in possible_moves {
|
|
next_board, is_valid := ai.move_board(board, move)
|
|
if !is_valid {
|
|
continue
|
|
}
|
|
has_move = true
|
|
score := ai.expectimax_chance(next_board, depth - 1, mut ctx)
|
|
if ctx.aborted {
|
|
return ai_abort_score
|
|
}
|
|
if score > best_score {
|
|
best_score = score
|
|
}
|
|
}
|
|
if !has_move {
|
|
return ai_terminal_loss
|
|
}
|
|
ai.tt_store(board, depth, 0, best_score, ctx)
|
|
return best_score
|
|
}
|
|
|
|
fn (mut ai AiEngine) expectimax_chance(board AiBoard, depth int, mut ctx AiSearchCtx) f64 {
|
|
ctx.nodes++
|
|
if ctx.should_abort() {
|
|
return ai_abort_score
|
|
}
|
|
if cached := ai.tt_lookup(board, depth, 1, mut ctx) {
|
|
return cached
|
|
}
|
|
empty_tiles := ai_empty_count(board)
|
|
if empty_tiles == 0 {
|
|
score := ai.expectimax_max(board, depth, mut ctx)
|
|
if !ctx.aborted {
|
|
ai.tt_store(board, depth, 1, score, ctx)
|
|
}
|
|
return score
|
|
}
|
|
cell_weight := 1.0 / f64(empty_tiles)
|
|
mut total_score := 0.0
|
|
for idx in 0 .. 16 {
|
|
if ai_tile(board, idx) != 0 {
|
|
continue
|
|
}
|
|
shift := idx << 2
|
|
two_board := board | (AiBoard(1) << shift)
|
|
two_score := ai.expectimax_max(two_board, depth, mut ctx)
|
|
if ctx.aborted {
|
|
return ai_abort_score
|
|
}
|
|
four_board := board | (AiBoard(2) << shift)
|
|
four_score := ai.expectimax_max(four_board, depth, mut ctx)
|
|
if ctx.aborted {
|
|
return ai_abort_score
|
|
}
|
|
total_score += cell_weight * (ai_spawn_two_prob * two_score +
|
|
ai_spawn_four_prob * four_score)
|
|
}
|
|
ai.tt_store(board, depth, 1, total_score, ctx)
|
|
return total_score
|
|
}
|
|
|
|
fn (mut ai AiEngine) first_valid_move(board AiBoard) ?Direction {
|
|
for move in possible_moves {
|
|
_, is_valid := ai.move_board(board, move)
|
|
if is_valid {
|
|
return move
|
|
}
|
|
}
|
|
return none
|
|
}
|
|
|
|
fn (mut ai AiEngine) best_move(board AiBoard) AiMoveResult {
|
|
ai.ensure_ready()
|
|
ai.generation++
|
|
mut ctx := AiSearchCtx{
|
|
watch: time.new_stopwatch()
|
|
deadline_us: ai_time_budget_us
|
|
generation: ai.generation
|
|
}
|
|
mut best := AiMoveResult{}
|
|
if fallback := ai.first_valid_move(board) {
|
|
best = AiMoveResult{
|
|
move: fallback
|
|
score: ai_terminal_loss
|
|
valid: true
|
|
}
|
|
} else {
|
|
return best
|
|
}
|
|
empty_tiles := ai_empty_count(board)
|
|
mut depth_limit := ai_max_search_depth
|
|
if empty_tiles >= 6 {
|
|
depth_limit = 6
|
|
}
|
|
for depth := ai_min_search_depth; depth <= depth_limit; depth++ {
|
|
mut iter_best := AiMoveResult{
|
|
score: ai_terminal_loss
|
|
}
|
|
mut iter_valid := false
|
|
for move in possible_moves {
|
|
next_board, is_valid := ai.move_board(board, move)
|
|
if !is_valid {
|
|
continue
|
|
}
|
|
score := ai.expectimax_chance(next_board, depth - 1, mut ctx)
|
|
if ctx.aborted {
|
|
break
|
|
}
|
|
if !iter_valid || score > iter_best.score {
|
|
iter_best = AiMoveResult{
|
|
move: move
|
|
score: score
|
|
depth: depth
|
|
valid: true
|
|
}
|
|
iter_valid = true
|
|
}
|
|
}
|
|
if ctx.aborted {
|
|
break
|
|
}
|
|
if iter_valid {
|
|
best = iter_best
|
|
best.nodes = ctx.nodes
|
|
best.cache_hits = ctx.cache_hits
|
|
}
|
|
}
|
|
best.nodes = ctx.nodes
|
|
best.cache_hits = ctx.cache_hits
|
|
return best
|
|
}
|
|
|
|
@[inline]
|
|
fn (b Board) has_moves() bool {
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
value := b.field[y][x]
|
|
if value == 0 {
|
|
return true
|
|
}
|
|
if (x < 3 && value == b.field[y][x + 1]) || (y < 3 && value == b.field[y + 1][x]) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
fn (mut b Board) move(d Direction) (Board, bool) {
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
b.oidxs[y][x] = yx2i(y, x)
|
|
}
|
|
}
|
|
new := match d {
|
|
.left { b.to_left() }
|
|
.right { b.hmirror().to_left().hmirror() }
|
|
.up { b.transpose().to_left().transpose() }
|
|
.down { b.transpose().hmirror().to_left().hmirror().transpose() }
|
|
}
|
|
|
|
// If the board hasn't changed, it's an illegal move, don't allow it.
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
if b.field[y][x] != new.field[y][x] {
|
|
return new, true
|
|
}
|
|
}
|
|
}
|
|
return new, false
|
|
}
|
|
|
|
fn (mut b Board) is_game_over() bool {
|
|
return !b.has_moves()
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) request_redraw() {
|
|
app.needs_redraw = true
|
|
}
|
|
|
|
fn (app &App) has_pending_animation() bool {
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
if app.atickers[y][x] > 0.0 || app.mtickers[y][x] > 0.0 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
fn (mut app App) update_tickers() {
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
app.atickers[y][x] = math.clip(app.atickers[y][x] - f64(zooming_percent_per_frame) / 100.0,
|
|
0.0, 1.0)
|
|
app.mtickers[y][x] = math.clip(app.mtickers[y][x] - f64(movement_percent_per_frame) / 100.0,
|
|
0.0, 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut app App) new_game() {
|
|
app.board = Board{}
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
app.board.field[y][x] = 0
|
|
app.atickers[y][x] = 0
|
|
app.mtickers[y][x] = 0
|
|
}
|
|
}
|
|
app.state = .play
|
|
app.undo = []Undo{cap: 4096}
|
|
app.moves = 0
|
|
app.new_random_tile()
|
|
app.new_random_tile()
|
|
app.request_redraw()
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) check_for_victory() {
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
fidx := app.board.field[y][x]
|
|
if fidx == 11 {
|
|
app.state = .victory
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) check_for_game_over() {
|
|
if app.board.is_game_over() {
|
|
app.state = .over
|
|
}
|
|
}
|
|
|
|
fn (mut b Board) place_random_tile() (Pos, int) {
|
|
mut etiles := [16]Pos{}
|
|
mut empty_tiles_max := 0
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
fidx := b.field[y][x]
|
|
if fidx == 0 {
|
|
etiles[empty_tiles_max] = Pos{x, y}
|
|
empty_tiles_max++
|
|
}
|
|
}
|
|
}
|
|
if empty_tiles_max > 0 {
|
|
new_random_tile_index := rand.intn(empty_tiles_max) or { 0 }
|
|
empty_pos := etiles[new_random_tile_index]
|
|
// 10% chance of getting a `4` tile
|
|
value := rand.f64n(1.0) or { 0.0 }
|
|
random_value := if value < 0.9 { 1 } else { 2 }
|
|
b.field[empty_pos.y][empty_pos.x] = random_value
|
|
b.oidxs[empty_pos.y][empty_pos.x] = yx2i(empty_pos.y, empty_pos.x)
|
|
return empty_pos, random_value
|
|
}
|
|
return Pos{}, 0
|
|
}
|
|
|
|
fn (mut app App) new_random_tile() {
|
|
// do not animate empty fields:
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
fidx := app.board.field[y][x]
|
|
if fidx == 0 {
|
|
app.atickers[y][x] = 0
|
|
app.board.oidxs[y][x] = 0xFFFF_FFFF
|
|
}
|
|
}
|
|
}
|
|
empty_pos, random_value := app.board.place_random_tile()
|
|
if random_value > 0 {
|
|
app.atickers[empty_pos.y][empty_pos.x] = 1.0
|
|
}
|
|
if app.state != .freeplay {
|
|
app.check_for_victory()
|
|
}
|
|
app.check_for_game_over()
|
|
}
|
|
|
|
fn (mut app App) apply_new_board(new Board) {
|
|
old := app.board
|
|
app.moves++
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
if old.oidxs[y][x] != new.oidxs[y][x] {
|
|
app.mtickers[y][x] = 1.0
|
|
}
|
|
}
|
|
}
|
|
app.board = new
|
|
app.undo << Undo{old, app.state}
|
|
app.new_random_tile()
|
|
app.request_redraw()
|
|
}
|
|
|
|
fn (mut app App) move(d Direction) {
|
|
new, is_valid := app.board.move(d)
|
|
if !is_valid {
|
|
return
|
|
}
|
|
app.apply_new_board(new)
|
|
}
|
|
|
|
fn (mut app App) ai_move() {
|
|
think_watch := time.new_stopwatch()
|
|
search_result := app.ai_engine.best_move(board_to_ai(app.board))
|
|
if !search_result.valid {
|
|
return
|
|
}
|
|
elapsed_us := think_watch.elapsed().microseconds()
|
|
cache_rate := if search_result.nodes > 0 {
|
|
100.0 * f64(search_result.cache_hits) / f64(search_result.nodes)
|
|
} else {
|
|
0.0
|
|
}
|
|
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}')
|
|
app.move(search_result.move)
|
|
}
|
|
|
|
fn (app &App) label_format(kind LabelKind) gg.TextCfg {
|
|
match kind {
|
|
.keys {
|
|
return gg.TextCfg{
|
|
color: gg.Color{150, 150, 255, 200}
|
|
align: .center
|
|
vertical_align: .bottom
|
|
size: app.ui.font_size / 4
|
|
}
|
|
}
|
|
.points {
|
|
return gg.TextCfg{
|
|
color: if app.state in [.over, .victory] { gg.white } else { app.theme.text_color }
|
|
align: .left
|
|
size: app.ui.font_size / 2
|
|
}
|
|
}
|
|
.moves {
|
|
return gg.TextCfg{
|
|
color: if app.state in [.over, .victory] { gg.white } else { app.theme.text_color }
|
|
align: .right
|
|
size: app.ui.font_size / 2
|
|
}
|
|
}
|
|
.tile {
|
|
return gg.TextCfg{
|
|
color: app.theme.text_color
|
|
align: .center
|
|
vertical_align: .middle
|
|
size: app.ui.font_size
|
|
}
|
|
}
|
|
.victory {
|
|
return gg.TextCfg{
|
|
color: app.theme.victory_color
|
|
align: .center
|
|
vertical_align: .middle
|
|
size: app.ui.font_size * 2
|
|
}
|
|
}
|
|
.game_over {
|
|
return gg.TextCfg{
|
|
color: app.theme.game_over_color
|
|
align: .center
|
|
vertical_align: .middle
|
|
size: app.ui.font_size * 2
|
|
}
|
|
}
|
|
.score_end {
|
|
return gg.TextCfg{
|
|
color: gg.white
|
|
align: .center
|
|
vertical_align: .middle
|
|
size: app.ui.font_size * 3 / 4
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) set_theme(idx int) {
|
|
theme := themes[idx]
|
|
app.theme_idx = idx
|
|
app.theme = theme
|
|
app.gg.set_bg_color(theme.bg_color)
|
|
app.request_redraw()
|
|
}
|
|
|
|
fn (mut app App) resize() {
|
|
mut s := app.gg.scale
|
|
if s == 0.0 {
|
|
s = 1.0
|
|
}
|
|
window_size := app.gg.window_size()
|
|
w := window_size.width
|
|
h := window_size.height
|
|
m := f32(math.min(w, h))
|
|
app.ui.dpi_scale = s
|
|
app.ui.window_width = w
|
|
app.ui.window_height = h
|
|
app.ui.padding_size = int(m / 38)
|
|
app.ui.header_size = app.ui.padding_size
|
|
app.ui.border_size = app.ui.padding_size * 2
|
|
app.ui.tile_size = int((m - app.ui.padding_size * 5 - app.ui.border_size * 2) / 4)
|
|
app.ui.font_size = int(m / 10)
|
|
// If the window's height is greater than its width, center the board vertically.
|
|
// If not, center it horizontally
|
|
if w > h {
|
|
app.ui.y_padding = 0
|
|
app.ui.x_padding = (app.ui.window_width - app.ui.window_height) / 2
|
|
} else {
|
|
app.ui.y_padding = (app.ui.window_height - app.ui.window_width - app.ui.header_size) / 2
|
|
app.ui.x_padding = 0
|
|
}
|
|
app.request_redraw()
|
|
}
|
|
|
|
fn (app &App) draw() {
|
|
xpad, ypad := app.ui.x_padding, app.ui.y_padding
|
|
ww := app.ui.window_width
|
|
wh := app.ui.window_height
|
|
m := math.min(ww, wh)
|
|
labelx := xpad + app.ui.border_size
|
|
labely := ypad + app.ui.border_size / 2
|
|
app.draw_tiles()
|
|
// TODO: Make transparency work in `gg`
|
|
if app.state == .over {
|
|
app.gg.draw_rect_filled(0, 0, ww, wh, gg.rgba(10, 0, 0, 180))
|
|
app.gg.draw_text(ww / 2, (m * 4 / 10) + ypad, 'Game Over', app.label_format(.game_over))
|
|
f := app.label_format(.tile)
|
|
msg := $if android { 'Tap to restart' } $else { 'Press `r` to restart' }
|
|
app.gg.draw_text(ww / 2, (m * 6 / 10) + ypad, msg, gg.TextCfg{
|
|
...f
|
|
color: gg.white
|
|
size: f.size * 3 / 4
|
|
})
|
|
}
|
|
if app.state == .victory {
|
|
app.gg.draw_rect_filled(0, 0, ww, wh, gg.rgba(0, 10, 0, 180))
|
|
app.gg.draw_text(ww / 2, (m * 4 / 10) + ypad, 'Victory!', app.label_format(.victory))
|
|
// f := app.label_format(.tile)
|
|
msg1 := $if android { 'Tap to continue' } $else { 'Press `space` to continue' }
|
|
msg2 := $if android { 'Tap to restart' } $else { 'Press `r` to restart' }
|
|
app.gg.draw_text(ww / 2, (m * 6 / 10) + ypad, msg1, app.label_format(.score_end))
|
|
app.gg.draw_text(ww / 2, (m * 8 / 10) + ypad, msg2, app.label_format(.score_end))
|
|
}
|
|
// Draw at the end, so that it's on top of the victory / game over overlays
|
|
app.gg.draw_text(labelx, labely, 'Points: ${app.board.points}', app.label_format(.points))
|
|
app.gg.draw_text(ww - labelx, labely, 'Moves: ${app.moves}', app.label_format(.moves))
|
|
app.gg.draw_text(ww / 2, wh, 'Controls: WASD,V,<=,T,Enter,ESC', app.label_format(.keys))
|
|
}
|
|
|
|
fn (app &App) draw_tiles() {
|
|
xstart := app.ui.x_padding + app.ui.border_size
|
|
ystart := app.ui.y_padding + app.ui.border_size + app.ui.header_size
|
|
toffset := app.ui.tile_size + app.ui.padding_size
|
|
tiles_size := math.min(app.ui.window_width, app.ui.window_height) - app.ui.border_size * 2
|
|
// Draw the padding around the tiles
|
|
app.gg.draw_rounded_rect_filled(xstart, ystart, tiles_size, tiles_size, tiles_size / 24,
|
|
app.theme.padding_color)
|
|
|
|
// Draw empty tiles:
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
tw := app.ui.tile_size
|
|
th := tw // square tiles, w == h
|
|
xoffset := xstart + app.ui.padding_size + x * toffset
|
|
yoffset := ystart + app.ui.padding_size + y * toffset
|
|
app.gg.draw_rounded_rect_filled(xoffset, yoffset, tw, th, tw / 8,
|
|
app.theme.tile_colors[0])
|
|
}
|
|
}
|
|
|
|
// Draw the already placed and potentially moving tiles:
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
tidx := app.board.field[y][x]
|
|
oidx := app.board.oidxs[y][x]
|
|
if tidx == 0 || oidx == 0xFFFF_FFFF {
|
|
continue
|
|
}
|
|
app.draw_one_tile(x, y, tidx)
|
|
}
|
|
}
|
|
|
|
// Draw the newly placed random tiles on top of everything else:
|
|
for y in 0 .. 4 {
|
|
for x in 0 .. 4 {
|
|
tidx := app.board.field[y][x]
|
|
oidx := app.board.oidxs[y][x]
|
|
if oidx == 0xFFFF_FFFF && tidx != 0 {
|
|
app.draw_one_tile(x, y, tidx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (app &App) draw_one_tile(x int, y int, tidx int) {
|
|
xstart := app.ui.x_padding + app.ui.border_size
|
|
ystart := app.ui.y_padding + app.ui.border_size + app.ui.header_size
|
|
toffset := app.ui.tile_size + app.ui.padding_size
|
|
oidx := app.board.oidxs[y][x]
|
|
oy := oidx >> 16
|
|
ox := oidx & 0xFFFF
|
|
mut dx := 0
|
|
mut dy := 0
|
|
if oidx != 0xFFFF_FFFF {
|
|
scaling := app.ui.tile_size * easing.in_out_quint(app.mtickers[y][x])
|
|
if ox != x {
|
|
dx = math.clip(int(scaling * (f64(ox) - f64(x))), -4 * app.ui.tile_size,
|
|
4 * app.ui.tile_size)
|
|
}
|
|
if oy != y {
|
|
dy = math.clip(int(scaling * (f64(oy) - f64(y))), -4 * app.ui.tile_size,
|
|
4 * app.ui.tile_size)
|
|
}
|
|
}
|
|
tile_color := if tidx < app.theme.tile_colors.len {
|
|
app.theme.tile_colors[tidx]
|
|
} else {
|
|
// If there isn't a specific color for this tile, reuse the last color available
|
|
app.theme.tile_colors.last()
|
|
}
|
|
anim_size := 1.0 - app.atickers[y][x]
|
|
tw := int(f64(anim_size * app.ui.tile_size))
|
|
th := tw // square tiles, w == h
|
|
xoffset := dx + xstart + app.ui.padding_size + x * toffset + (app.ui.tile_size - tw) / 2
|
|
yoffset := dy + ystart + app.ui.padding_size + y * toffset + (app.ui.tile_size - th) / 2
|
|
app.gg.draw_rounded_rect_filled(xoffset, yoffset, tw, th, tw / 8, tile_color)
|
|
if tidx != 0 { // 0 == blank spot
|
|
xpos := xoffset + tw / 2
|
|
ypos := yoffset + th / 2
|
|
mut fmt := app.label_format(.tile)
|
|
text_size := animated_tile_text_size(fmt.size, anim_size)
|
|
if text_size == 0 {
|
|
return
|
|
}
|
|
fmt = gg.TextCfg{
|
|
...fmt
|
|
size: text_size
|
|
}
|
|
match app.tile_format {
|
|
.normal {
|
|
app.gg.draw_text(xpos, ypos, '${1 << tidx}', fmt)
|
|
}
|
|
.log {
|
|
app.gg.draw_text(xpos, ypos, '${tidx}', fmt)
|
|
}
|
|
.exponent {
|
|
app.gg.draw_text(xpos, ypos, '2', fmt)
|
|
fs2 := quantized_tile_text_size(int(f32(fmt.size) * 0.67))
|
|
if fs2 > 0 {
|
|
app.gg.draw_text(xpos + app.ui.tile_size / 10, ypos - app.ui.tile_size / 8,
|
|
'${tidx}', gg.TextCfg{
|
|
...fmt
|
|
size: fs2
|
|
align: gg.HorizontalAlign.left
|
|
})
|
|
}
|
|
}
|
|
.shifts {
|
|
fs2 := quantized_tile_text_size(int(f32(fmt.size) * 0.6))
|
|
if fs2 > 0 {
|
|
app.gg.draw_text(xpos, ypos, '2<<${tidx - 1}', gg.TextCfg{
|
|
...fmt
|
|
size: fs2
|
|
})
|
|
}
|
|
}
|
|
.none {} // Don't draw any text here, colors only
|
|
.end {} // Should never get here
|
|
}
|
|
|
|
// oidx_fmt := gg.TextCfg{...fmt,size: 14}
|
|
// app.gg.draw_text(xoffset + 50, yoffset + 15, 'y:${oidx >> 16}|x:${oidx & 0xFFFF}|m:${app.mtickers[y][x]:5.3f}', oidx_fmt)
|
|
// app.gg.draw_text(xoffset + 52, yoffset + 30, 'ox:${ox}|oy:${oy}', oidx_fmt)
|
|
// app.gg.draw_text(xoffset + 52, yoffset + 85, 'dx:${dx}|dy:${dy}', oidx_fmt)
|
|
}
|
|
}
|
|
|
|
fn (mut app App) handle_touches() {
|
|
s, e := app.touch.start, app.touch.end
|
|
adx, ady := math.abs(e.pos.x - s.pos.x), math.abs(e.pos.y - s.pos.y)
|
|
if math.max(adx, ady) < 10 {
|
|
app.handle_tap()
|
|
} else {
|
|
app.handle_swipe()
|
|
}
|
|
}
|
|
|
|
fn (mut app App) handle_tap() {
|
|
_, ypad := app.ui.x_padding, app.ui.y_padding
|
|
w, h := app.ui.window_width, app.ui.window_height
|
|
m := math.min(w, h)
|
|
s, e := app.touch.start, app.touch.end
|
|
avgx, avgy := avg(s.pos.x, e.pos.x), avg(s.pos.y, e.pos.y)
|
|
// TODO: Replace "touch spots" with actual buttons
|
|
// bottom left -> change theme
|
|
if avgx < 50 && h - avgy < 50 {
|
|
app.next_theme()
|
|
}
|
|
// bottom right -> change tile format
|
|
if w - avgx < 50 && h - avgy < 50 {
|
|
app.next_tile_format()
|
|
}
|
|
if app.state == .victory {
|
|
if avgy > (m / 2) + ypad {
|
|
if avgy < (m * 7 / 10) + ypad {
|
|
app.state = .freeplay
|
|
} else if avgy < (m * 9 / 10) + ypad {
|
|
app.new_game()
|
|
} else {
|
|
// TODO: remove and implement an actual way to toggle themes on mobile
|
|
}
|
|
}
|
|
} else if app.state == .over {
|
|
if avgy > (m / 2) + ypad && avgy < (m * 7 / 10) + ypad {
|
|
app.new_game()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut app App) handle_swipe() {
|
|
// Currently, swipes are only used to move the tiles.
|
|
// If the user's not playing, exit early to avoid all the unnecessary calculations
|
|
if app.state !in [.play, .freeplay] {
|
|
return
|
|
}
|
|
s, e := app.touch.start, app.touch.end
|
|
w, h := app.ui.window_width, app.ui.window_height
|
|
dx, dy := e.pos.x - s.pos.x, e.pos.y - s.pos.y
|
|
adx, ady := math.abs(dx), math.abs(dy)
|
|
dmin := if math.min(adx, ady) > 0 { math.min(adx, ady) } else { 1 }
|
|
dmax := if math.max(adx, ady) > 0 { math.max(adx, ady) } else { 1 }
|
|
tdiff := (e.time - s.time).milliseconds()
|
|
// TODO: make this calculation more accurate (don't use arbitrary numbers)
|
|
distance_factor := f64(math.min(w, h)) * f64(tdiff) / 100.0
|
|
min_swipe_distance := int(math.sqrt(distance_factor)) + 20
|
|
if dmax < min_swipe_distance {
|
|
return
|
|
}
|
|
// Swipe was too short
|
|
if dmax / dmin < 2 {
|
|
return
|
|
}
|
|
// Swiped diagonally
|
|
if adx > ady {
|
|
if dx < 0 {
|
|
app.move(.left)
|
|
} else {
|
|
app.move(.right)
|
|
}
|
|
} else {
|
|
if dy < 0 {
|
|
app.move(.up)
|
|
} else {
|
|
app.move(.down)
|
|
}
|
|
}
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) next_theme() {
|
|
app.set_theme(if app.theme_idx == themes.len - 1 { 0 } else { app.theme_idx + 1 })
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) next_tile_format() {
|
|
app.tile_format = unsafe { TileFormat(int(app.tile_format) + 1) }
|
|
if app.tile_format == .end {
|
|
app.tile_format = .normal
|
|
}
|
|
app.request_redraw()
|
|
}
|
|
|
|
@[inline]
|
|
fn (mut app App) undo() {
|
|
if app.undo.len > 0 {
|
|
undo := app.undo.pop()
|
|
app.board = undo.board
|
|
app.state = undo.state
|
|
app.moves--
|
|
app.request_redraw()
|
|
}
|
|
}
|
|
|
|
fn (mut app App) on_key_down(key gg.KeyCode) {
|
|
// these keys are independent from the game state:
|
|
match key {
|
|
.v {
|
|
app.is_ai_mode = !app.is_ai_mode
|
|
app.request_redraw()
|
|
}
|
|
.page_up {
|
|
app.ai_fpm = dump(math.min(app.ai_fpm + 1, 60))
|
|
app.request_redraw()
|
|
}
|
|
.page_down {
|
|
app.ai_fpm = dump(math.max(app.ai_fpm - 1, 1))
|
|
app.request_redraw()
|
|
}
|
|
//
|
|
.escape {
|
|
app.gg.quit()
|
|
}
|
|
.n, .r {
|
|
app.new_game()
|
|
}
|
|
.backspace {
|
|
app.undo()
|
|
}
|
|
.enter {
|
|
app.next_tile_format()
|
|
}
|
|
.j {
|
|
app.state = .over
|
|
app.request_redraw()
|
|
}
|
|
.t {
|
|
app.next_theme()
|
|
}
|
|
else {}
|
|
}
|
|
|
|
if app.state in [.play, .freeplay] {
|
|
if !app.is_ai_mode {
|
|
match key {
|
|
.w, .up { app.move(.up) }
|
|
.a, .left { app.move(.left) }
|
|
.s, .down { app.move(.down) }
|
|
.d, .right { app.move(.right) }
|
|
else {}
|
|
}
|
|
}
|
|
}
|
|
if app.state == .victory {
|
|
if key == .space {
|
|
app.state = .freeplay
|
|
app.request_redraw()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_event(e &gg.Event, mut app App) {
|
|
match e.typ {
|
|
.key_down {
|
|
app.on_key_down(e.key_code)
|
|
}
|
|
.resized, .restored, .resumed {
|
|
app.resize()
|
|
}
|
|
.touches_began {
|
|
if e.num_touches > 0 {
|
|
t := e.touches[0]
|
|
app.touch.start = Touch{
|
|
pos: Pos{
|
|
x: int(t.pos_x / app.ui.dpi_scale)
|
|
y: int(t.pos_y / app.ui.dpi_scale)
|
|
}
|
|
time: time.now()
|
|
}
|
|
}
|
|
}
|
|
.touches_ended {
|
|
if e.num_touches > 0 {
|
|
t := e.touches[0]
|
|
app.touch.end = Touch{
|
|
pos: Pos{
|
|
x: int(t.pos_x / app.ui.dpi_scale)
|
|
y: int(t.pos_y / app.ui.dpi_scale)
|
|
}
|
|
time: time.now()
|
|
}
|
|
app.handle_touches()
|
|
}
|
|
}
|
|
.mouse_down {
|
|
app.touch.start = Touch{
|
|
pos: Pos{
|
|
x: int(e.mouse_x / app.ui.dpi_scale)
|
|
y: int(e.mouse_y / app.ui.dpi_scale)
|
|
}
|
|
time: time.now()
|
|
}
|
|
}
|
|
.mouse_up {
|
|
app.touch.end = Touch{
|
|
pos: Pos{
|
|
x: int(e.mouse_x / app.ui.dpi_scale)
|
|
y: int(e.mouse_y / app.ui.dpi_scale)
|
|
}
|
|
time: time.now()
|
|
}
|
|
app.handle_touches()
|
|
}
|
|
else {}
|
|
}
|
|
|
|
if e.typ in [.key_down, .touches_began, .touches_ended, .mouse_down, .mouse_up, .resized,
|
|
.restored, .focused, .resumed] {
|
|
app.request_redraw()
|
|
}
|
|
}
|
|
|
|
fn frame(mut app App) {
|
|
is_ai_running := app.is_ai_mode && app.state in [.play, .freeplay]
|
|
mut has_pending_animation := app.has_pending_animation()
|
|
mut do_update := false
|
|
if (app.needs_redraw || has_pending_animation || is_ai_running)
|
|
&& app.gg.timer.elapsed().milliseconds() > 15 {
|
|
app.gg.timer.restart()
|
|
do_update = true
|
|
app.updates++
|
|
}
|
|
if do_update {
|
|
app.update_tickers()
|
|
has_pending_animation = app.has_pending_animation()
|
|
}
|
|
if app.needs_redraw || (do_update && (has_pending_animation || is_ai_running)) {
|
|
app.gg.begin()
|
|
app.draw()
|
|
app.gg.end()
|
|
app.needs_redraw = false
|
|
}
|
|
if do_update && is_ai_running && app.updates % app.ai_fpm == 0 {
|
|
app.ai_move()
|
|
}
|
|
if has_pending_animation || is_ai_running {
|
|
app.request_redraw()
|
|
}
|
|
if do_update && app.updates % 120 == 0 {
|
|
// do GC once per 2 seconds
|
|
// eprintln('> gc_memory_use: ${gc_memory_use()}')
|
|
if gc_is_enabled() {
|
|
// Avoid assert error when built with `-cg` on some systems
|
|
gc_disable()
|
|
}
|
|
gc_enable()
|
|
gc_collect()
|
|
gc_disable()
|
|
}
|
|
}
|
|
|
|
fn init(mut app App) {
|
|
app.resize()
|
|
}
|
|
|
|
fn main() {
|
|
mut app := &App{}
|
|
app.new_game()
|
|
app.gg = gg.new_context(
|
|
bg_color: app.theme.bg_color
|
|
width: default_window_width
|
|
height: default_window_height
|
|
sample_count: 2 // higher quality curves
|
|
window_title: 'V 2048'
|
|
frame_fn: frame
|
|
event_fn: on_event
|
|
init_fn: init
|
|
user_data: app
|
|
font_path: asset.get_path('../assets', 'fonts/RobotoMono-Regular.ttf')
|
|
)
|
|
app.gg.run()
|
|
}
|