diff options
author | Santo Cariotti <santo@dcariotti.me> | 2025-04-17 10:18:22 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2025-04-17 10:18:22 +0200 |
commit | 169796db6fa9a31a3ec24b745754bf342acc9173 (patch) | |
tree | 7be46b856592ae2230a2df60185f80379e2d1dc5 | |
parent | f60fadc54421c8e0aedb33e59270d4aa48e842d2 (diff) |
Choose a move
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 4 | ||||
-rw-r--r-- | pkg/ui/views/game.go | 233 |
3 files changed, 166 insertions, 72 deletions
@@ -44,6 +44,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.6.1 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/sync v0.12.0 // indirect @@ -45,6 +45,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -70,6 +72,8 @@ github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBO github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/pkg/ui/views/game.go b/pkg/ui/views/game.go index 4c5ed71..1087d5f 100644 --- a/pkg/ui/views/game.go +++ b/pkg/ui/views/game.go @@ -3,13 +3,13 @@ package views import ( "encoding/json" "fmt" - "math/rand" "os" "github.com/boozec/rahanna/internal/api/database" "github.com/boozec/rahanna/internal/network" "github.com/boozec/rahanna/pkg/ui/multiplayer" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -18,30 +18,36 @@ import ( // gameKeyMap defines the key bindings for the game view. type gameKeyMap struct { - GoLogout key.Binding - RandomMove key.Binding - Quit key.Binding + GoLogout key.Binding + Quit key.Binding } // defaultGameKeyMap provides the default key bindings for the game view. var defaultGameKeyMap = gameKeyMap{ - RandomMove: key.NewBinding( - key.WithKeys("R", "r"), - key.WithHelp("R", "Random Move"), - ), GoLogout: key.NewBinding( key.WithKeys("alt+Q", "alt+q"), key.WithHelp("Alt+Q", "Logout"), ), Quit: key.NewBinding( key.WithKeys("Q", "q"), - key.WithHelp(" Q", "Quit"), + key.WithHelp(" Q", "Quit"), ), } // ChessMoveMsg is a message containing a received chess move. type ChessMoveMsg string +// UpdateMovesListMsg is a message to update the moves list +type UpdateMovesListMsg struct{} + +type item struct { + title string +} + +func (i item) Title() string { return i.title } +func (i item) Description() string { return "" } +func (i item) FilterValue() string { return i.title } + // GameModel represents the state of the game view. type GameModel struct { // UI dimensions @@ -59,10 +65,25 @@ type GameModel struct { chessGame *chess.Game incomingMoves chan string turn int + movesList list.Model } // NewGameModel creates a new GameModel. func NewGameModel(width, height int, peer string, currentGameID int, network *multiplayer.GameNetwork) GameModel { + listDelegate := list.NewDefaultDelegate() + listDelegate.ShowDescription = false + listDelegate.Styles.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(highlightColor). + Foreground(highlightColor). + Padding(0, 0, 0, 1) + + moveList := list.New([]list.Item{}, listDelegate, width/4, height/2) + moveList.Styles.Title = lipgloss.NewStyle(). + Background(highlightColor). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + return GameModel{ width: width, height: height, @@ -73,13 +94,20 @@ func NewGameModel(width, height int, peer string, currentGameID int, network *mu chessGame: chess.NewGame(chess.UseNotation(chess.UCINotation{})), incomingMoves: make(chan string), turn: 0, + movesList: moveList, } } // Init initializes the GameModel. func (m GameModel) Init() tea.Cmd { ClearScreen() - return tea.Batch(textinput.Blink, m.getGame(), m.getMoves()) + return tea.Batch(textinput.Blink, m.getGame(), m.getMoves(), m.updateMovesListCmd()) +} + +func (m *GameModel) updateMovesListCmd() tea.Cmd { + return func() tea.Msg { + return UpdateMovesListMsg{} + } } // Update handles incoming messages and updates the GameModel. @@ -88,45 +116,142 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, exit } + var cmds []tea.Cmd + var cmd tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: - return m.handleWindowSize(msg) + m, cmd = m.handleWindowSizeMsg(msg) + cmds = append(cmds, cmd) + case UpdateMovesListMsg: + m = m.handleUpdateMovesListMsg() case tea.KeyMsg: - return m.handleKeyPress(msg) + m, cmd = m.handleKeyMsg(msg) + cmds = append(cmds, cmd) case ChessMoveMsg: - m.turn++ - err := m.chessGame.MoveStr(string(msg)) - if err != nil { - fmt.Println("Error applying move:", err) - } - return m, m.getMoves() + m, cmd = m.handleChessMoveMsg(msg) + cmds = append(cmds, cmd) case database.Game: - return m.handleGetGameResponse(msg) + m = m.handleDatabaseGameMsg(msg) + cmds = append(cmds, m.updateMovesListCmd()) + } + + if m.isMyTurn() { + m.movesList, cmd = m.movesList.Update(msg) + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyEnter { + selectedItem := m.movesList.SelectedItem() + if selectedItem != nil { + moveStr := selectedItem.(item).Title() + m.network.Server.Send(network.NetworkID(m.peer), []byte(moveStr)) + m.chessGame.MoveStr(moveStr) + m.turn++ + cmds = append(cmds, m.getMoves(), m.updateMovesListCmd()) + } + } + } + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m GameModel) handleWindowSizeMsg(msg tea.WindowSizeMsg) (GameModel, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + listWidth := m.width / 4 + m.movesList.SetSize(listWidth, m.height/2) + return m, m.updateMovesListCmd() +} + +func (m GameModel) handleUpdateMovesListMsg() GameModel { + if m.isMyTurn() && m.game != nil { + var items []list.Item + for _, move := range m.chessGame.ValidMoves() { + items = append(items, item{title: move.String()}) + } + m.movesList.SetItems(items) + m.movesList.Title = "Choose a move" + m.movesList.Select(0) + m.movesList.SetShowFilter(true) + m.movesList.SetFilteringEnabled(true) + } + return m +} + +func (m GameModel) handleKeyMsg(msg tea.KeyMsg) (GameModel, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.GoLogout): + return m, logout(m.width, m.height+1) + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit } return m, nil } +func (m GameModel) handleChessMoveMsg(msg ChessMoveMsg) (GameModel, tea.Cmd) { + m.turn++ + err := m.chessGame.MoveStr(string(msg)) + if err != nil { + fmt.Println("Error applying move:", err) + } + return m, tea.Batch(m.getMoves(), m.updateMovesListCmd()) +} + +func (m GameModel) handleDatabaseGameMsg(msg database.Game) GameModel { + m.game = &msg + if m.peer == "peer-2" { + m.network.Peer = msg.IP2 + } else { + m.network.Peer = msg.IP1 + } + return m +} + // View renders the GameModel. func (m GameModel) View() string { formWidth := getFormWidth(m.width) - var content string - if m.game != nil { - yourTurn := "" - if m.turn%2 == 0 && m.peer == "peer-2" || m.turn%2 == 1 && m.peer == "peer-1" { - yourTurn = "[YOUR TURN]" - } + if m.game == nil { + return "Loading game..." + } - content = fmt.Sprintf("%s vs %s\n%s\n\n%s\n%s", - m.game.Player1.Username, - m.game.Player2.Username, - lipgloss.NewStyle().Foreground(highlightColor).Render(yourTurn), - m.chessGame.Position().Board().Draw(), - m.chessGame.String(), - ) + listWidth := formWidth / 4 + boardWidth := formWidth / 2 + notationWidth := formWidth - listWidth - boardWidth - 2 + + listHeight := m.height / 3 + boardHeight := m.height / 3 + notationHeight := m.height - listHeight - boardHeight - 2 + + listStyle := lipgloss.NewStyle().Width(listWidth).Height(listHeight).Padding(0, 1) + boardStyle := lipgloss.NewStyle().Width(boardWidth).Height(boardHeight).Align(lipgloss.Center).Padding(0, 1) + notationStyle := lipgloss.NewStyle().Width(notationWidth).Height(notationHeight).Padding(0, 1) + + var movesListView string + + if m.isMyTurn() { + m.movesList.SetSize(listWidth, listHeight-2) + movesListView = listStyle.Render(m.movesList.View()) + } else { + movesListView = listStyle.Render(lipgloss.Place(listWidth, listHeight, lipgloss.Center, lipgloss.Center, "Wait your turn")) } + content := lipgloss.JoinVertical( + lipgloss.Center, + lipgloss.NewStyle().Foreground(lipgloss.Color("#f1c40f")).Render(fmt.Sprintf("%s vs %s", m.game.Player1.Username, m.game.Player2.Username)), + lipgloss.JoinHorizontal( + lipgloss.Top, + movesListView, + boardStyle.Render( + m.chessGame.Position().Board().Draw(), + ), + notationStyle.Render(fmt.Sprintf("Moves\n%s", m.chessGame.String())), + ), + ) + windowContent := m.buildWindowContent(content, formWidth) buttons := m.renderNavigationButtons() @@ -146,31 +271,6 @@ func (m GameModel) View() string { ) } -func (m GameModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - m.width = msg.Width - m.height = msg.Height - return m, nil -} - -func (m GameModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch { - case key.Matches(msg, m.keys.GoLogout): - return m, logout(m.width, m.height+1) - case key.Matches(msg, m.keys.RandomMove): - if m.turn%2 == 0 && m.peer == "peer-2" || m.turn%2 == 1 && m.peer == "peer-1" { - moves := m.chessGame.ValidMoves() - move := moves[rand.Intn(len(moves))] - m.network.Server.Send(network.NetworkID(m.peer), []byte(move.String())) - m.chessGame.MoveStr(move.String()) - m.turn++ - } - return m, nil - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - } - return m, nil -} - func (m GameModel) buildWindowContent(content string, formWidth int) string { return lipgloss.JoinVertical( lipgloss.Center, @@ -182,10 +282,6 @@ func (m GameModel) buildWindowContent(content string, formWidth int) string { } func (m GameModel) renderNavigationButtons() string { - randomMoveKey := fmt.Sprintf("%s %s", - altCodeStyle.Render(m.keys.RandomMove.Help().Key), - m.keys.RandomMove.Help().Desc) - logoutKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.GoLogout.Help().Key), m.keys.GoLogout.Help().Desc) @@ -196,22 +292,11 @@ func (m GameModel) renderNavigationButtons() string { return lipgloss.JoinVertical( lipgloss.Left, - randomMoveKey, logoutKey, quitKey, ) } -func (m *GameModel) handleGetGameResponse(msg database.Game) (tea.Model, tea.Cmd) { - m.game = &msg - if m.peer == "peer-2" { - m.network.Peer = msg.IP2 - } else { - m.network.Peer = msg.IP1 - } - return m, nil -} - func (m *GameModel) getGame() tea.Cmd { return func() tea.Msg { var game database.Game @@ -262,3 +347,7 @@ func (m *GameModel) getMoves() tea.Cmd { return ChessMoveMsg(move) } } + +func (m GameModel) isMyTurn() bool { + return m.turn%2 == 0 && m.peer == "peer-2" || m.turn%2 == 1 && m.peer == "peer-1" +} |