From bdf99b4ee989df1813745e1dfd2983689b09ca85 Mon Sep 17 00:00:00 2001 From: Logan Hunt Date: Tue, 17 Jan 2023 14:00:18 -0700 Subject: 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 --- lib/chessh/ssh/client/board/board.ex | 123 ----------- lib/chessh/ssh/client/board/renderer.ex | 252 ---------------------- lib/chessh/ssh/client/board/server.ex | 19 -- lib/chessh/ssh/client/client.ex | 43 +++- lib/chessh/ssh/client/game/game.ex | 359 ++++++++++++++++++++++++++++++++ lib/chessh/ssh/client/game/promotion.ex | 63 ++++++ lib/chessh/ssh/client/game/renderer.ex | 314 ++++++++++++++++++++++++++++ lib/chessh/ssh/client/menu.ex | 74 +++++-- lib/chessh/ssh/tui.ex | 42 ---- 9 files changed, 829 insertions(+), 460 deletions(-) delete mode 100644 lib/chessh/ssh/client/board/board.ex delete mode 100644 lib/chessh/ssh/client/board/renderer.ex delete mode 100644 lib/chessh/ssh/client/board/server.ex create mode 100644 lib/chessh/ssh/client/game/game.ex create mode 100644 lib/chessh/ssh/client/game/promotion.ex create mode 100644 lib/chessh/ssh/client/game/renderer.ex (limited to 'lib/chessh/ssh') diff --git a/lib/chessh/ssh/client/board/board.ex b/lib/chessh/ssh/client/board/board.ex deleted file mode 100644 index cbbade5..0000000 --- a/lib/chessh/ssh/client/board/board.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule Chessh.SSH.Client.Board do - require Logger - alias Chessh.Utils - alias Chessh.SSH.Client.Board.Renderer - - defmodule State do - defstruct cursor: %{x: 7, y: 7}, - highlighted: %{}, - move_from: nil, - game_id: nil, - client_pid: nil, - binbo_pid: nil, - width: 0, - height: 0, - flipped: false - end - - use Chessh.SSH.Client.Screen - - def init([%State{client_pid: client_pid, game_id: game_id} = state | _]) 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) - - send(client_pid, {:send_to_ssh, Utils.clear_codes()}) - - {:ok, %State{state | binbo_pid: binbo_pid}} - end - - def handle_info({:new_move, move}, %State{binbo_pid: binbo_pid, client_pid: client_pid} = state) do - case :binbo.move(binbo_pid, move) do - {:ok, :continue} -> - send(client_pid, {:send_to_ssh, render_state(state)}) - - _ -> - nil - end - - {:noreply, state} - end - - def input( - width, - height, - action, - %State{ - move_from: move_from, - game_id: game_id, - cursor: %{x: cursor_x, y: cursor_y} = cursor, - client_pid: client_pid, - flipped: flipped - } = 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 - - # TODO: Check move here, then publish new move, subscribers get from DB instead - if move_from && move_to do - attempted_move = - if flipped, - do: - "#{Renderer.to_chess_coord(flip(move_from))}#{Renderer.to_chess_coord(flip(move_to))}", - else: "#{Renderer.to_chess_coord(move_from)}#{Renderer.to_chess_coord(move_to)}" - - :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move}) - 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) - } - - send(client_pid, {:send_to_ssh, render_state(new_state)}) - new_state - end - - def render(width, height, %State{client_pid: client_pid} = state) do - send(client_pid, {:send_to_ssh, render_state(state)}) - %State{state | width: width, height: height} - end - - def flip({y, x}), - do: {Renderer.chess_board_height() - 1 - y, Renderer.chess_board_width() - 1 - x} - - defp render_state( - %State{ - binbo_pid: binbo_pid - } = state - ) do - {:ok, fen} = :binbo.get_fen(binbo_pid) - - Renderer.render_board_state(fen, state) - end -end diff --git a/lib/chessh/ssh/client/board/renderer.ex b/lib/chessh/ssh/client/board/renderer.ex deleted file mode 100644 index fa8648f..0000000 --- a/lib/chessh/ssh/client/board/renderer.ex +++ /dev/null @@ -1,252 +0,0 @@ -defmodule Chessh.SSH.Client.Board.Renderer do - alias IO.ANSI - alias Chessh.Utils - alias Chessh.SSH.Client.Board - 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, %Board.State{ - width: _width, - height: _height, - highlighted: highlighted, - flipped: flipped - }) do - board = - draw_board( - fen, - {@tile_width, @tile_height}, - highlighted, - flipped - ) - - [ANSI.home()] ++ - Enum.map( - Enum.zip(1..length(board), board), - 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 diff --git a/lib/chessh/ssh/client/board/server.ex b/lib/chessh/ssh/client/board/server.ex deleted file mode 100644 index 2e480e7..0000000 --- a/lib/chessh/ssh/client/board/server.ex +++ /dev/null @@ -1,19 +0,0 @@ -# defmodule Chessh.SSH.Client.Board.Server do -# use GenServer -# -# defmodule State do -# defstruct game_id: nil, -# binbo_pid: nil -# end -# -# def init([%State{game_id: game_id} = state]) do -# {:ok, binbo_pid} = GenServer.start_link(:binbo, []) -# -# :syn.join(:games, {:game, game_id}) -# {:ok, state} -# end -# -# def handle_cast({:new_move, attempted_move}, %State{game_id: game_id} = state) do -# {:no_reply, state} -# end -# end diff --git a/lib/chessh/ssh/client/client.ex b/lib/chessh/ssh/client/client.ex index da9779b..72bbb66 100644 --- a/lib/chessh/ssh/client/client.ex +++ b/lib/chessh/ssh/client/client.ex @@ -24,8 +24,13 @@ defmodule Chessh.SSH.Client do end @impl true - def init([%State{} = state]) do - send(self(), {:set_screen_process, Chessh.SSH.Client.Menu, %Chessh.SSH.Client.Menu.State{}}) + def init([%State{player_session: player_session} = state]) do + send( + self(), + {:set_screen_process, Chessh.SSH.Client.Menu, + %Chessh.SSH.Client.Menu.State{player_session: player_session}} + ) + {:ok, state} end @@ -56,6 +61,11 @@ defmodule Chessh.SSH.Client do }} end + @impl true + def handle_info({:go_back_one_screen, previous_state}, %State{} = state) do + {:noreply, go_back_one_screen(state, previous_state)} + end + @impl true def handle_info(:quit, %State{} = state) do {:stop, :normal, state} @@ -87,8 +97,7 @@ defmodule Chessh.SSH.Client do %State{ width: width, height: height, - screen_pid: screen_pid, - screen_state_initials: [_ | rest_initial] + screen_pid: screen_pid } = state ) do case keymap(data) do @@ -96,10 +105,7 @@ defmodule Chessh.SSH.Client do {:stop, :normal, state} :previous_screen -> - [{prev_module, prev_state_initial} | _] = rest_initial - send(self(), {:set_screen_process, prev_module, prev_state_initial}) - - {:noreply, %State{state | screen_state_initials: rest_initial}} + {:noreply, go_back_one_screen(state)} action -> send(screen_pid, {:input, width, height, action}) @@ -181,4 +187,25 @@ defmodule Chessh.SSH.Client do send(screen_pid, {:render, width, height}) end end + + defp go_back_one_screen( + %State{ + screen_state_initials: [_ | rest_initial] + } = state, + previous_state + ) do + [{prev_module, prev_state_initial} | _] = rest_initial + + send( + self(), + {:set_screen_process, prev_module, + if(is_nil(previous_state), do: prev_state_initial, else: previous_state)} + ) + + %State{state | screen_state_initials: rest_initial} + end + + defp go_back_one_screen(%State{} = state) do + go_back_one_screen(state, nil) + end end 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 diff --git a/lib/chessh/ssh/client/menu.ex b/lib/chessh/ssh/client/menu.ex index 946928e..6c96cc2 100644 --- a/lib/chessh/ssh/client/menu.ex +++ b/lib/chessh/ssh/client/menu.ex @@ -1,12 +1,15 @@ defmodule Chessh.SSH.Client.Menu do - alias Chessh.Utils alias IO.ANSI + alias Chessh.{Utils, Repo, Game} + import Ecto.Query require Logger defmodule State do defstruct client_pid: nil, - selected: 0 + selected: 0, + player_session: nil, + options: [] end @logo " Simponic's @@ -20,31 +23,70 @@ defmodule Chessh.SSH.Client.Menu do use Chessh.SSH.Client.Screen def init([%State{} = state | _]) do - {:ok, state} + {:ok, %State{state | options: options(state)}} end - @options [ - {"Start A Game", {Chessh.SSH.Client.Board, %Chessh.SSH.Client.Board.State{}}}, - {"Join A Game", {}}, - {"My Games", {}}, - {"Settings", {}}, - {"Help", {}} - ] + def options(%State{player_session: player_session}) do + current_games = + Repo.all( + from(g in Game, + where: g.light_player_id == ^player_session.player_id, + or_where: g.dark_player_id == ^player_session.player_id, + where: g.status == :continue + ) + ) - def input(width, height, action, %State{client_pid: client_pid, selected: selected} = state) do + joinable_games = + Repo.all( + from(g in Game, + where: is_nil(g.light_player_id), + or_where: is_nil(g.dark_player_id) + ) + ) + + [ + {"Start A Game As Light", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, color: :light}}}, + {"Start A Game As Dark", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, color: :dark}}} + ] ++ + Enum.map(current_games, fn game -> + {"Current Game - #{game.id}", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}} + end) ++ + Enum.map(joinable_games, fn game -> + {"Joinable Game - #{game.id}", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}} + end) ++ + [ + {"Settings", {}}, + {"Help", {}} + ] + end + + def input( + width, + height, + action, + %State{options: options, client_pid: client_pid, selected: selected} = state + ) do new_state = case(action) do :up -> %State{ state - | selected: Utils.wrap_around(selected, -1, length(@options)) + | selected: Utils.wrap_around(selected, -1, length(options)) } :down -> - %State{state | selected: Utils.wrap_around(selected, 1, length(@options))} + %State{state | selected: Utils.wrap_around(selected, 1, length(options))} :return -> - {_option, {module, state}} = Enum.at(@options, selected) + {_option, {module, state}} = Enum.at(options, selected) send(client_pid, {:set_screen_process, module, state}) state @@ -67,7 +109,7 @@ defmodule Chessh.SSH.Client.Menu do defp render_state( width, height, - %State{selected: selected} + %State{options: options, selected: selected} ) do logo_lines = String.split(@logo, "\n") {logo_width, logo_height} = Utils.text_dim(@logo) @@ -84,7 +126,7 @@ defmodule Chessh.SSH.Client.Menu do end ) ++ Enum.flat_map( - Enum.zip(0..(length(@options) - 1), @options), + Enum.zip(0..(length(options) - 1), options), fn {i, {option, _}} -> [ ANSI.cursor(y + length(logo_lines) + i + 1, x), diff --git a/lib/chessh/ssh/tui.ex b/lib/chessh/ssh/tui.ex index 1838fb8..483d882 100644 --- a/lib/chessh/ssh/tui.ex +++ b/lib/chessh/ssh/tui.ex @@ -144,7 +144,6 @@ defmodule Chessh.SSH.Tui do {:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}}, %{width: width, height: height, player_session: player_session} = state ) do - Logger.debug("Session #{player_session.id} requested shell") :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) {:ok, client_pid} = @@ -161,47 +160,6 @@ defmodule Chessh.SSH.Tui do {:ok, %State{state | client_pid: client_pid}} end - def handle_ssh_msg( - {:ssh_cm, connection_handler, {:exec, channel_id, want_reply?, cmd}}, - %State{} = state - ) do - :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) - Logger.debug("EXEC #{cmd}") - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:eof, _channel_id}}, - %State{} = state - ) do - Logger.debug("EOF") - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:signal, _channel_id, signal}}, - %State{} = state - ) do - Logger.debug("SIGNAL #{signal}") - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:exit_signal, channel_id, signal, err, lang}}, - %State{} = state - ) do - Logger.debug("EXIT SIGNAL #{signal} #{err} #{lang}") - {:stop, channel_id, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:exit_STATUS, channel_id, status}}, - %State{} = state - ) do - Logger.debug("EXIT STATUS #{status}") - {:stop, channel_id, state} - end - def handle_ssh_msg( msg, %State{channel_id: channel_id} = state -- cgit v1.2.3-70-g09d2