diff options
author | Logan Hunt <loganhunt@simponic.xyz> | 2023-01-17 14:00:18 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-17 14:00:18 -0700 |
commit | bdf99b4ee989df1813745e1dfd2983689b09ca85 (patch) | |
tree | f2c1c52cc2d51019b742800c24d5761f32495b95 /lib/chessh/ssh/client/game | |
parent | 53be77e2c57bac6b861d7c3d1d2d6355816a823b (diff) | |
download | chessh-bdf99b4ee989df1813745e1dfd2983689b09ca85.tar.gz chessh-bdf99b4ee989df1813745e1dfd2983689b09ca85.zip |
Persistent game (#5)
* Remove unnecessary server in board
* Initial persistent games
* Remove done chessh README stuff, warning issue
* Show current players and move
* Add promotion
* Merge default changeset on all status
Diffstat (limited to 'lib/chessh/ssh/client/game')
-rw-r--r-- | lib/chessh/ssh/client/game/game.ex | 359 | ||||
-rw-r--r-- | lib/chessh/ssh/client/game/promotion.ex | 63 | ||||
-rw-r--r-- | lib/chessh/ssh/client/game/renderer.ex | 314 |
3 files changed, 736 insertions, 0 deletions
diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex new file mode 100644 index 0000000..2ee6dca --- /dev/null +++ b/lib/chessh/ssh/client/game/game.ex @@ -0,0 +1,359 @@ +defmodule Chessh.SSH.Client.Game do + require Logger + alias Chessh.{Game, Utils, Repo} + alias Chessh.SSH.Client.Game.Renderer + + @default_fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + defmodule State do + defstruct cursor: %{x: 7, y: 7}, + highlighted: %{}, + move_from: nil, + game: nil, + client_pid: nil, + binbo_pid: nil, + width: 0, + height: 0, + flipped: false, + color: nil, + player_session: nil + end + + use Chessh.SSH.Client.Screen + + defp initialize_game(game_id, fen) do + :syn.add_node_to_scopes([:games]) + :ok = :syn.join(:games, {:game, game_id}, self()) + + :binbo.start() + {:ok, binbo_pid} = :binbo.new_server() + :binbo.new_game(binbo_pid, fen) + + binbo_pid + end + + def init([ + %State{ + color: color, + game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id} + } = state + | tail + ]) + when is_nil(color) do + new_state = + case {is_nil(dark_player_id), is_nil(light_player_id)} do + {true, false} -> %State{state | color: :dark} + {false, true} -> %State{state | color: :light} + {_, _} -> %State{state | color: Enum.random([:light, :dark])} + end + + init([new_state | tail]) + end + + def init([ + %State{ + player_session: player_session, + color: color, + client_pid: client_pid, + game: + %Game{ + id: game_id, + fen: fen, + dark_player_id: dark_player_id, + light_player_id: light_player_id + } = game + } = state + | _ + ]) do + maybe_changeset = + case color do + :light -> + if !light_player_id, + do: Game.changeset(game, %{light_player_id: player_session.player_id}) + + :dark -> + if !dark_player_id, + do: Game.changeset(game, %{dark_player_id: player_session.player_id}) + end + + {status, maybe_joined_game} = + if maybe_changeset do + maybe_changeset + |> Repo.update() + else + {:undefined, nil} + end + + if status == :ok && maybe_joined_game do + :syn.publish(:games, {:game, game_id}, :player_joined) + end + + binbo_pid = initialize_game(game_id, fen) + send(client_pid, {:send_to_ssh, Utils.clear_codes()}) + + new_game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) + + new_state = %State{ + state + | binbo_pid: binbo_pid, + color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark), + game: new_game + } + + {:ok, new_state} + end + + def init([ + %State{player_session: player_session, color: color, client_pid: client_pid, game: nil} = + state + | _ + ]) do + {:ok, %Game{id: game_id, fen: fen}} = + Game.changeset( + %Game{}, + Map.merge( + if(color == :light, + do: %{light_player_id: player_session.player_id}, + else: %{dark_player_id: player_session.player_id} + ), + %{ + fen: @default_fen + } + ) + ) + |> Repo.insert() + + binbo_pid = initialize_game(game_id, fen) + send(client_pid, {:send_to_ssh, Utils.clear_codes()}) + + {:ok, + %State{ + state + | game: Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]), + binbo_pid: binbo_pid + }} + end + + def handle_info( + {:new_move, move}, + %State{game: %Game{id: game_id}, client_pid: client_pid, binbo_pid: binbo_pid} = state + ) do + :binbo.move(binbo_pid, move) + + new_state = %State{ + state + | game: Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) + } + + send(client_pid, {:send_to_ssh, render_state(new_state)}) + + {:noreply, new_state} + end + + def handle_info( + :player_joined, + %State{client_pid: client_pid, game: %Game{id: game_id}} = state + ) do + game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) + new_state = %State{state | game: game} + send(client_pid, {:send_to_ssh, render_state(new_state)}) + {:noreply, new_state} + end + + def input( + width, + height, + action, + %State{ + move_from: move_from, + cursor: %{x: cursor_x, y: cursor_y} = cursor, + client_pid: client_pid, + flipped: flipped, + binbo_pid: binbo_pid + } = state + ) do + new_cursor = + case action do + :left -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, -1, Renderer.chess_board_width())} + :right -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, 1, Renderer.chess_board_width())} + :down -> %{y: Utils.wrap_around(cursor_y, 1, Renderer.chess_board_height()), x: cursor_x} + :up -> %{y: Utils.wrap_around(cursor_y, -1, Renderer.chess_board_height()), x: cursor_x} + _ -> cursor + end + + {new_move_from, move_to} = + if action == :return do + coords = {new_cursor.y, new_cursor.x} + + case move_from do + nil -> {coords, nil} + _ -> {nil, coords} + end + else + {move_from, nil} + end + + new_state = %State{ + state + | cursor: new_cursor, + move_from: new_move_from, + highlighted: %{ + {new_cursor.y, new_cursor.x} => Renderer.to_select_background(), + new_move_from => Renderer.from_select_background() + }, + width: width, + height: height, + flipped: if(action == "f", do: !flipped, else: flipped) + } + + if move_from && move_to do + maybe_flipped_from = if flipped, do: flip(move_from), else: move_from + maybe_flipped_to = if flipped, do: flip(move_to), else: move_to + + piece_type = + :binbo_position.get_piece( + :binbo_board.notation_to_index(Renderer.to_chess_coord(maybe_flipped_from)), + :binbo.game_state(binbo_pid) + ) + + promotion_possible = + case piece_type do + 1 -> + # Light pawn + {y, _} = maybe_flipped_to + y == 0 + + 17 -> + # Dark pawn + {y, _} = maybe_flipped_to + y == Renderer.chess_board_height() - 1 + + _ -> + false + end + + if promotion_possible do + send( + client_pid, + {:set_screen_process, Chessh.SSH.Client.Game.PromotionScreen, + %Chessh.SSH.Client.Game.PromotionScreen.State{ + client_pid: client_pid, + game_pid: self(), + game_state: new_state + }} + ) + + receive do + {:promotion, promotion} -> + attempt_move(move_from, move_to, state, promotion) + end + else + attempt_move(move_from, move_to, state) + end + end + + send(client_pid, {:send_to_ssh, render_state(new_state)}) + new_state + end + + def render(width, height, %State{client_pid: client_pid} = state) do + new_state = %State{state | width: width, height: height} + send(client_pid, {:send_to_ssh, render_state(new_state)}) + new_state + end + + defp attempt_move( + from, + to, + %State{} = state + ), + do: attempt_move(from, to, state, nil) + + defp attempt_move( + from, + to, + %State{ + game: %Game{id: game_id, turn: turn}, + binbo_pid: binbo_pid, + flipped: flipped, + color: turn + }, + promotion + ) do + attempted_move = + if(flipped, + do: "#{Renderer.to_chess_coord(flip(from))}#{Renderer.to_chess_coord(flip(to))}", + else: "#{Renderer.to_chess_coord(from)}#{Renderer.to_chess_coord(to)}" + ) <> + if(promotion, do: promotion, else: "") + + game = Repo.get(Game, game_id) + + case :binbo.move( + binbo_pid, + attempted_move + ) do + {:ok, status} -> + {:ok, fen} = :binbo.get_fen(binbo_pid) + + default_changeset = %{ + fen: fen, + moves: game.moves + 1, + turn: if(game.turn == :dark, do: :light, else: :dark) + } + + case status do + :continue -> + {:ok, _new_game} = + Game.changeset( + game, + default_changeset + ) + |> Repo.update() + + {:draw, _} -> + Game.changeset( + game, + Map.merge(default_changeset, %{status: :draw}) + ) + |> Repo.update() + + {:checkmate, :white_wins} -> + Game.changeset( + game, + Map.merge(default_changeset, %{status: :winner, winner: :light}) + ) + |> Repo.update() + + {:checkmate, :black_wins} -> + Game.changeset( + game, + Map.merge(default_changeset, %{status: :winner, winner: :dark}) + ) + |> Repo.update() + end + + :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move}) + + x -> + Logger.debug(inspect(x)) + nil + end + end + + defp attempt_move(_, _, _, _) do + Logger.debug("No matching clause for move attempt - must be illegal?") + nil + end + + defp flip({y, x}), + do: {Renderer.chess_board_height() - 1 - y, Renderer.chess_board_width() - 1 - x} + + defp render_state( + %State{ + game: %Game{fen: fen} + } = state + ) do + Renderer.render_board_state(fen, state) + end +end diff --git a/lib/chessh/ssh/client/game/promotion.ex b/lib/chessh/ssh/client/game/promotion.ex new file mode 100644 index 0000000..c4cece6 --- /dev/null +++ b/lib/chessh/ssh/client/game/promotion.ex @@ -0,0 +1,63 @@ +defmodule Chessh.SSH.Client.Game.PromotionScreen do + alias Chessh.Utils + alias Chessh.SSH.Client.Game + alias IO.ANSI + + defmodule State do + defstruct game_pid: nil, + client_pid: nil, + game_state: nil + end + + use Chessh.SSH.Client.Screen + + @promotion_screen Utils.clear_codes() ++ + [ + "Press the key associated to the piece you'd like to promote", + " 'q' - queen", + " 'r' - rook", + " 'n' - knight", + " 'b' - bishop" + ] + + def init([%State{} = state | _]) do + {:ok, state} + end + + def render(_, _, %State{client_pid: client_pid} = state) do + rendered = + Enum.flat_map( + Enum.zip(0..(length(@promotion_screen) - 1), @promotion_screen), + fn {i, promotion} -> + [ + ANSI.cursor(i, 0), + promotion + ] + end + ) ++ [ANSI.home()] + + send( + client_pid, + {:send_to_ssh, rendered} + ) + + state + end + + def input( + _, + _, + action, + %State{client_pid: client_pid, game_pid: game_pid, game_state: %Game.State{} = game_state} = + state + ) do + promotion = if Enum.member?(["q", "b", "n", "r"], action), do: action, else: nil + + if promotion do + send(client_pid, {:go_back_one_screen, game_state}) + send(game_pid, {:promotion, promotion}) + end + + state + end +end diff --git a/lib/chessh/ssh/client/game/renderer.ex b/lib/chessh/ssh/client/game/renderer.ex new file mode 100644 index 0000000..f8cf689 --- /dev/null +++ b/lib/chessh/ssh/client/game/renderer.ex @@ -0,0 +1,314 @@ +defmodule Chessh.SSH.Client.Game.Renderer do + alias IO.ANSI + alias Chessh.{Utils, Player} + alias Chessh.SSH.Client.Game + require Logger + + @chess_board_height 8 + @chess_board_width 8 + + @tile_width 7 + @tile_height 4 + + @from_select_background ANSI.light_blue_background() + @to_select_background ANSI.blue_background() + @dark_piece_color ANSI.light_red() + @light_piece_color ANSI.light_magenta() + + def chess_board_height(), do: @chess_board_height + def chess_board_width(), do: @chess_board_width + def to_select_background(), do: @to_select_background + def from_select_background(), do: @from_select_background + + def to_chess_coord({y, x}) + when x >= 0 and x < @chess_board_width and y >= 0 and y < @chess_board_height do + "#{List.to_string([?a + x])}#{@chess_board_height - y}" + end + + def render_board_state( + fen, + %Game.State{ + game: + %Chessh.Game{ + light_player: light_player + } = game + } = state + ) + when is_nil(light_player) do + render_board_state(fen, %Game.State{ + state + | game: %Chessh.Game{game | light_player: %Player{username: "(no opponent)"}} + }) + end + + def render_board_state( + fen, + %Game.State{ + game: + %Chessh.Game{ + dark_player: dark_player + } = game + } = state + ) + when is_nil(dark_player) do + render_board_state(fen, %Game.State{ + state + | game: %Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}} + }) + end + + def render_board_state(fen, %Game.State{ + width: _width, + height: _height, + highlighted: highlighted, + flipped: flipped, + game: %Chessh.Game{ + id: game_id, + dark_player: %Player{username: dark_player}, + light_player: %Player{username: light_player}, + turn: turn, + status: status, + winner: winner + } + }) do + rendered = [ + Enum.join( + [ + ANSI.clear_line(), + "Game #{game_id}: ", + ANSI.format_fragment([@light_piece_color, light_player]), + "#{ANSI.default_color()} --vs-- ", + ANSI.format_fragment([@dark_piece_color, dark_player]), + ANSI.default_color(), + case status do + :continue -> + ", #{ANSI.format_fragment([if(turn == :light, do: @light_piece_color, else: @dark_piece_color), if(turn == :dark, do: dark_player, else: light_player)])} to move" + + :draw -> + "ended in a draw" + + :winner -> + ", #{ANSI.format_fragment([if(winner == :light, do: @light_piece_color, else: @dark_piece_color), if(winner == :dark, do: dark_player, else: light_player)])} won!" + end + ], + "" + ) + | draw_board( + fen, + {@tile_width, @tile_height}, + highlighted, + flipped + ) + ] + + [ANSI.home()] ++ + Enum.map( + Enum.zip(1..length(rendered), rendered), + fn {i, line} -> + [ANSI.cursor(i, 0), line] + end + ) + end + + defp tileIsLight(row, col) do + rem(row, 2) == rem(col, 2) + end + + defp piece_type(char) do + case String.capitalize(char) do + "P" -> "pawn" + "N" -> "knight" + "R" -> "rook" + "B" -> "bishop" + "K" -> "king" + "Q" -> "queen" + _ -> nil + end + end + + defp skip_cols_or_place_piece_reduce(char, {curr_column, data}, rowI) do + case Integer.parse(char) do + {skip, ""} -> + {curr_column + skip, data} + + _ -> + case piece_type(char) do + nil -> + {curr_column, data} + + type -> + shade = if(char == String.capitalize(char), do: "light", else: "dark") + + {curr_column + 1, + Map.put( + data, + "#{rowI}, #{curr_column}", + {shade, type} + )} + end + end + end + + defp make_coordinate_to_piece_art_map(fen) do + rows = + String.split(fen, " ") + |> List.first() + |> String.split("/") + + Enum.zip(rows, 0..(length(rows) - 1)) + |> Enum.map(fn {row, rowI} -> + {@chess_board_height, pieces_per_row} = + Enum.reduce( + String.split(row, ""), + {0, %{}}, + &skip_cols_or_place_piece_reduce(&1, &2, rowI) + ) + + pieces_per_row + end) + |> Enum.reduce(%{}, fn pieces_map_for_this_row, acc -> + Map.merge(acc, pieces_map_for_this_row) + end) + end + + defp draw_board( + fen, + {tile_width, tile_height} = tile_dims, + highlights, + flipped + ) do + coordinate_to_piece = make_coordinate_to_piece_art_map(fen) + board = make_board(tile_dims) + + (Enum.zip_with([board, 0..(length(board) - 1)], fn [row_str, row] -> + curr_y = div(row, tile_height) + + %{row_chars: row_chars} = + Enum.reduce( + Enum.zip(String.graphemes(row_str), 0..(String.length(row_str) - 1)), + %{current_color: ANSI.black(), row_chars: []}, + fn {char, col}, %{current_color: current_color, row_chars: row_chars} = row_state -> + curr_x = div(col, tile_width) + + key = + "#{if !flipped, do: curr_y, else: @chess_board_height - curr_y - 1}, #{if !flipped, do: curr_x, else: @chess_board_width - curr_x - 1}" + + relative_to_tile_col = col - curr_x * tile_width + + prefix = + if relative_to_tile_col == 0 do + case Map.fetch(highlights, {curr_y, curr_x}) do + {:ok, color} -> + color + + _ -> + ANSI.default_background() + end + end + + {color, row_chars} = + case Map.fetch(coordinate_to_piece, key) do + {:ok, {shade, type}} -> + piece = Utils.ascii_chars()["pieces"][shade][type] + piece_line = Enum.at(piece, row - curr_y * tile_height) + pad_left_right = div(tile_width - String.length(piece_line), 2) + + if relative_to_tile_col >= pad_left_right && + relative_to_tile_col < tile_width - pad_left_right do + piece_char = String.at(piece_line, relative_to_tile_col - pad_left_right) + new_char = if piece_char == " ", do: char, else: piece_char + + color = + if piece_char == " ", + do: ANSI.default_color(), + else: + if(shade == "dark", do: @dark_piece_color, else: @light_piece_color) + + if color != current_color do + {color, row_chars ++ [prefix, color, new_char]} + else + {current_color, row_chars ++ [prefix, new_char]} + end + else + {ANSI.default_color(), row_chars ++ [prefix, ANSI.default_color(), char]} + end + + _ -> + if ANSI.default_color() != current_color do + {ANSI.default_color(), row_chars ++ [prefix, ANSI.default_color(), char]} + else + {current_color, row_chars ++ [prefix, char]} + end + end + + %{ + row_state + | current_color: color, + row_chars: row_chars + } + end + ) + + curr_num = + Utils.ascii_chars()["numbers"][ + Integer.to_string(if flipped, do: curr_y + 1, else: @chess_board_height - curr_y) + ] + + curr_num_line_no = rem(row, @tile_height) + + Enum.join( + [ + String.pad_trailing( + if(curr_num_line_no < length(curr_num), + do: Enum.at(curr_num, curr_num_line_no), + else: "" + ), + @tile_width + ) + ] ++ row_chars, + "" + ) + end) ++ column_coords(flipped)) + |> Enum.map(fn row_line -> "#{ANSI.default_background()}#{row_line}" end) + end + + defp column_coords(flipped) do + Enum.map(0..(@tile_height - 1), fn row -> + String.duplicate(" ", @tile_width) <> + (Enum.map( + if(!flipped, do: 0..(@chess_board_width - 1), else: (@chess_board_width - 1)..0), + fn col -> + curr_letter = Utils.ascii_chars()["letters"][List.to_string([?a + col])] + curr_letter_line_no = rem(row, @tile_height) + + curr_line = + if(curr_letter_line_no < length(curr_letter), + do: Enum.at(curr_letter, curr_letter_line_no), + else: "" + ) + + center_prefix_len = div(@tile_width - String.length(curr_line), 2) + + String.pad_trailing( + String.duplicate(" ", center_prefix_len) <> curr_line, + @tile_width + ) + end + ) + |> Enum.join("")) + end) + end + + defp make_board({tile_width, tile_height}) do + rows = + Enum.map(0..(@chess_board_height - 1), fn row -> + Enum.map(0..(@chess_board_width - 1), fn col -> + if(tileIsLight(row, col), do: '▊', else: ' ') + |> List.duplicate(tile_width) + end) + |> Enum.join("") + end) + + Enum.flat_map(rows, fn row -> Enum.map(1..tile_height, fn _ -> row end) end) + end +end |