From 4109ee4b86f15b335432d3c15c5bbd1d191b9b96 Mon Sep 17 00:00:00 2001 From: Grzegorz Dziedzic Date: Thu, 30 May 2024 23:08:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(Multiplayer):=20=E2=9C=A8Add=20Multiplayer?= =?UTF-8?q?=20functionality=20and=20End=20Game=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Multiplayer.go | 234 +++++++++++++++++++++++++++++++++++++++++++++++++ endgame.go | 58 ++++++++++++ go.mod | 6 +- go.sum | 4 + main.go | 24 ++--- 5 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 Multiplayer.go create mode 100644 endgame.go diff --git a/Multiplayer.go b/Multiplayer.go new file mode 100644 index 0000000..5a936f1 --- /dev/null +++ b/Multiplayer.go @@ -0,0 +1,234 @@ +package main + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + cellStyle = lipgloss.NewStyle().Align(lipgloss.Center, lipgloss.Center).Width(5).Height(3).Border(lipgloss.NormalBorder()) + boardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) + headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFD700")).Align(lipgloss.Center).Margin(0, 0, 2, 0) + errorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF0000")).Align(lipgloss.Center) + blinkingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Blink(true) +) + +type GameModel struct { + width int + height int + cursor int + board [3][3]int + current int + winner int + blink bool + turnCount int + errorMessage string +} + +func NewGameModel(width, height int) *GameModel { + return &GameModel{ + width: width, + height: height, + cursor: 0, + current: 1, // X starts + board: [3][3]int{}, + turnCount: 9, + } +} + +func (m *GameModel) Init() tea.Cmd { + return tea.Batch(tea.EnterAltScreen) +} + +func (m *GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + m.moveCursor(-3) + case "down", "j": + m.moveCursor(3) + case "left", "h": + m.moveCursor(-1) + case "right", "l": + m.moveCursor(1) + case "enter": + if !m.placeMarker() { + m.errorMessage = "Cannot overwrite existing marker!" + } else { + m.errorMessage = "" + m.winner = m.checkWinner() + if m.winner != 0 { + endMessage := fmt.Sprintf("Player %s wins!", m.currentMarker()) + endGameModel := NewEndGameModel(m.width, m.height, endMessage, false) + //sleep for 500 ms for better UX + time.Sleep(500 * time.Millisecond) + return endGameModel, endGameModel.Init() + } + m.turnCount-- + if m.turnCount == 0 { + endMessage := "It's a draw!" + endGameModel := NewEndGameModel(m.width, m.height, endMessage, true) + //sleep for 500 ms for better UX + time.Sleep(500 * time.Millisecond) + return endGameModel, endGameModel.Init() + } + m.switchPlayer() + } + case "ctrl+c", "q", "esc": + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + return m, nil +} + +func (m *GameModel) View() string { + return m.renderBoard() +} + +func (m *GameModel) moveCursor(delta int) { + // Clear the error message when move is made + m.errorMessage = "" + + newCursor := m.cursor + delta + // Get the column + col := m.cursor % 3 + + // Calculate the new row and column + newRow, newCol := newCursor/3, newCursor%3 + + // Check for boundary conditions + if newRow < 0 || newRow >= 3 || newCol < 0 || newCol >= 3 { + newCursor = m.cursor + } + + // Handle wrapping around the edges + if (col == 2 && delta == 1) || (col == 0 && delta == -1) { + newCursor = m.cursor + } + + m.cursor = newCursor +} + +func (m *GameModel) placeMarker() bool { + row, col := m.cursor/3, m.cursor%3 + // Check if the cell is empty + if m.board[row][col] != 0 { + return false + } + // Place the marker + m.board[row][col] = m.current + return true +} + +func (m *GameModel) switchPlayer() { + // Switch player if 1 (X) or -1 (O) + m.current = -m.current +} + +func (m *GameModel) currentMarker() string { + if m.current == 1 { + return "X" + } + return "O" +} + +func (m *GameModel) checkWinner() int { + winningLines := [][3][2]int{ + {{0, 0}, {0, 1}, {0, 2}}, // first row + {{1, 0}, {1, 1}, {1, 2}}, // second row + {{2, 0}, {2, 1}, {2, 2}}, // third row + {{0, 0}, {1, 0}, {2, 0}}, // first column + {{0, 1}, {1, 1}, {2, 1}}, // second column + {{0, 2}, {1, 2}, {2, 2}}, // third column + {{0, 0}, {1, 1}, {2, 2}}, // diagonal top-left to bottom-right + {{0, 2}, {1, 1}, {2, 0}}, // diagonal top-right to bottom-left + } + + for _, line := range winningLines { + a, b, c := line[0], line[1], line[2] + // Check if all cells in the line are the same and not empty + if m.board[a[0]][a[1]] != 0 && + m.board[a[0]][a[1]] == m.board[b[0]][b[1]] && + m.board[a[0]][a[1]] == m.board[c[0]][c[1]] { + // Return the winning marker + return m.board[a[0]][a[1]] + } + } + + return 0 +} + +func (m *GameModel) renderBoard() string { + var cells []string + for i := 0; i < 9; i++ { + row, col := i/3, i%3 + cell := m.board[row][col] + cellStr := " " + + style := cellStyle + + if i == m.cursor { + cellStr = blinkingStyle.Render(m.currentMarker()) + } else if cell == 1 { + cellStr = "X" + } else if cell == -1 { + cellStr = "O" + } + + // Ustawianie granic + if col == 0 || col == 2 { + style = style.UnsetBorderLeft().UnsetBorderRight().UnsetBorderBottom() + if row == 0 { + style = style.UnsetBorderStyle() + } + } + if col == 1 { + style = style.UnsetBorderBottom() + if row == 0 { + style = style.UnsetBorderTop() + } + } + + cells = append(cells, style.Render(cellStr)) + } + + // Informacja o aktualnym graczu + currentPlayer := fmt.Sprintf("Current player: %s\n", m.currentMarker()) + + header := headerStyle.Render(currentPlayer) + + // Informacja o grze + footer := subtleStyle.Render("j/k, up/down: move | h/l, left/right: move | enter: select | ctrl+c: quit") + + // Joining all cells horizontally + board := lipgloss.JoinVertical(lipgloss.Left, + lipgloss.JoinHorizontal(lipgloss.Center, cells[0], cells[1], cells[2]), + lipgloss.JoinHorizontal(lipgloss.Left, cells[3], cells[4], cells[5]), + lipgloss.JoinHorizontal(lipgloss.Left, cells[6], cells[7], cells[8])) + + // Wyświetlanie błędów, jeśli istnieją + errorMsg := "" + if m.errorMessage != "" { + errorMsg = errorStyle.Render(m.errorMessage) + } + + // Joining all elements vertically + view := lipgloss.JoinVertical(lipgloss.Center, + header, + boardStyle.Render(board), + errorMsg, + footer, + ) + + centeredFullView := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, view) + + return centeredFullView +} diff --git a/endgame.go b/endgame.go new file mode 100644 index 0000000..3f5fc01 --- /dev/null +++ b/endgame.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2ECC71")).Align(lipgloss.Center) + drawStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F4D03F")).Align(lipgloss.Center) +) + +type EndGameModel struct { + width int + height int + message string + isDraw bool +} + +func NewEndGameModel(width, height int, endGameMessage string, isDraw bool) *EndGameModel { + return &EndGameModel{ + width: width, + height: height, + message: endGameMessage, + isDraw: isDraw, + } +} + +func (m *EndGameModel) Init() tea.Cmd { + return nil +} + +func (m *EndGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "r": + return NewGameModel(m.width, m.height), nil + case "m": + return initialModel(m.width, m.height), nil + case "ctrl+c": + return m, tea.Quit + } + } + return m, nil +} + +func (m *EndGameModel) View() string { + message := fmt.Sprintf("%s\n\nPress 'r' to restart or 'm' to return to menu.", m.message) + styledMessage := highlightStyle.Render(message) + if m.isDraw { + styledMessage = drawStyle.Render(message) + } + centeredMessage := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styledMessage) + return centeredMessage +} diff --git a/go.mod b/go.mod index c856718..da45f04 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ -module Tic-Tac-Toe +module github.com/dziedzicgrzegorz/Tic-Tac-Toe go 1.22 require ( - github.com/charmbracelet/bubbletea v0.26.2 + github.com/charmbracelet/bubbletea v0.26.4 github.com/charmbracelet/lipgloss v0.11.0 github.com/spf13/cobra v1.8.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/charmbracelet/x/ansi v0.1.2 // indirect github.com/charmbracelet/x/input v0.1.0 // indirect github.com/charmbracelet/x/term v0.1.1 // indirect github.com/charmbracelet/x/windows v0.1.0 // indirect diff --git a/go.sum b/go.sum index d29b486..4393cdc 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,14 @@ github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwus github.com/charmbracelet/bubbletea v0.26.2/go.mod h1:6I0nZ3YHUrQj7YHIHlM8RySX4ZIthTliMY+W8X8b+Gs= github.com/charmbracelet/bubbletea v0.26.3 h1:iXyGvI+FfOWqkB2V07m1DF3xxQijxjY2j8PqiXYqasg= github.com/charmbracelet/bubbletea v0.26.3/go.mod h1:bpZHfDHTYJC5g+FBK+ptJRCQotRC+Dhh3AoMxa/2+3Q= +github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= +github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= +github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= diff --git a/main.go b/main.go index 6040d78..9213b65 100644 --- a/main.go +++ b/main.go @@ -43,10 +43,7 @@ var ( Bold(true). Foreground(lipgloss.Color("#FFD700")). // Gold Align(lipgloss.Center). - Background(lipgloss.Color("#0000FF")). - Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FFD700")) + Padding(1, 2) backgroundStyle = lipgloss.NewStyle(). Align(lipgloss.Center). @@ -73,7 +70,7 @@ func initialModel(width, height int) model { } func (m model) Init() tea.Cmd { - return tea.EnterAltScreen + return tea.SetWindowTitle("Tic-Tac-Toe Game") } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -82,16 +79,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.mode { case modeMenu: switch msg.String() { - case "up", "k": + case "up": if m.cursor > 0 { m.cursor-- } - case "down", "j": + case "down": if m.cursor < len(m.menuItems)-1 { m.cursor++ } case "enter": - m.mode = m.menuItems[m.cursor].mode + switch m.cursor { + case 0: + m.mode = modeSinglePlayer + case 1: + game := NewGameModel(m.width, m.height) + return game, nil + case 2: + m.mode = modeStats + } case "ctrl+c", "q", "esc": return m, tea.Quit } @@ -121,7 +126,7 @@ func choicesView(m model) string { menuSelect := lipgloss.JoinVertical(lipgloss.Left, choices...) - footer := subtleStyle.Render("j/k, up/down: select | enter: choose | q, esc: quit") + footer := subtleStyle.Render("up ↑ / down ↓ : select | enter: choose | q, esc: quit") view := lipgloss.JoinVertical(lipgloss.Center, titleStyle.Render(title), @@ -166,7 +171,6 @@ func main() { Long: "A simple Tic-Tac-Toe game written in Go using the Bubble Tea library and the Lip Gloss library.", Run: func(cmd *cobra.Command, args []string) { p := tea.NewProgram(initialModel(0, 0), tea.WithAltScreen()) - p.SetWindowTitle("Welcome to the game") if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1)