From fcf23db3a31900ad862064071e2ee2d1895d5fbc Mon Sep 17 00:00:00 2001 From: Grzegorz Dziedzic Date: Fri, 31 May 2024 03:16:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(Multiplayer):=20=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add support for local multiplayer where two players can play on the same keyboard. - Implemented TCP multiplayer allowing two players to connect and play over a network. - Created a new input view to gather connection details for TCP multiplayer. --- constants/style.go | 37 ++++ endgame.go => end-view.go | 27 +-- go.mod | 4 +- go.sum | 16 +- input-tcp.go | 226 +++++++++++++++++++++++ main.go | 79 +++----- multiplayer-tcp.go | 306 +++++++++++++++++++++++++++++++ Multiplayer.go => multiplayer.go | 32 ++-- setup-tcp.go | 39 ++++ 9 files changed, 658 insertions(+), 108 deletions(-) create mode 100644 constants/style.go rename endgame.go => end-view.go (53%) create mode 100644 input-tcp.go create mode 100644 multiplayer-tcp.go rename Multiplayer.go => multiplayer.go (81%) create mode 100644 setup-tcp.go diff --git a/constants/style.go b/constants/style.go new file mode 100644 index 0000000..c94b6ad --- /dev/null +++ b/constants/style.go @@ -0,0 +1,37 @@ +package constants + +import ( + "github.com/charmbracelet/lipgloss" +) + +var ( + InfoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#1F618D")).Align(lipgloss.Center).Margin(0, 0, 1, 0) + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Margin(1, 0, 2, 0). + Padding(0, 1). + Align(lipgloss.Center) + + BackgroundStyle = lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(80). + Height(24) + + NormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + SelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true) + SubtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Margin(2, 0, 0, 0) + 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) + TCPErrStyle = ErrorStyle.Margin(2, 0, 0, 0) + BlinkingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Blink(true) + HighlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("241")).Align(lipgloss.Center) + DrawMsgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1C40F")).Align(lipgloss.Center) + WinMsgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ECC71 ")).Align(lipgloss.Center) + LoseMsgStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF0000")).Align(lipgloss.Center) + FocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + BlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + NoStyle = lipgloss.NewStyle() +) diff --git a/endgame.go b/end-view.go similarity index 53% rename from endgame.go rename to end-view.go index 3f5fc01..7ab0dbc 100644 --- a/endgame.go +++ b/end-view.go @@ -5,26 +5,20 @@ import ( 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) + "github.com/dziedzicgrzegorz/Tic-Tac-Toe/constants" ) type EndGameModel struct { width int height int message string - isDraw bool } -func NewEndGameModel(width, height int, endGameMessage string, isDraw bool) *EndGameModel { +func NewEndGameModel(width, height int, endGameMessage string) *EndGameModel { return &EndGameModel{ width: width, height: height, message: endGameMessage, - isDraw: isDraw, } } @@ -36,23 +30,22 @@ 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": + case constants.M: return initialModel(m.width, m.height), nil - case "ctrl+c": + case constants.Quit, constants.CtrlC, constants.Esc: return m, tea.Quit } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height } 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) - } + message := fmt.Sprintf("%s\n\nPress 'm' to return to menu.", m.message) + styledMessage := constants.HighlightStyle.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 da45f04..ac2e856 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/dziedzicgrzegorz/Tic-Tac-Toe go 1.22 require ( + github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.4 github.com/charmbracelet/lipgloss v0.11.0 github.com/spf13/cobra v1.8.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.2 // indirect github.com/charmbracelet/x/input v0.1.0 // indirect @@ -22,13 +24,11 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect - golang.org/x/term v0.20.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 4393cdc..48442a4 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwusQpwLiEgJQ= -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/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 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= @@ -29,18 +27,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -59,8 +53,6 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/input-tcp.go b/input-tcp.go new file mode 100644 index 0000000..e7f8af8 --- /dev/null +++ b/input-tcp.go @@ -0,0 +1,226 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/dziedzicgrzegorz/Tic-Tac-Toe/constants" +) + +const ( + waitPlaceholder = "Are you Client? Type C otherwise !C" + ipPlaceholder = "IP Address" + portPlaceholder = "Port" + submitButtonText = "Submit" + cursorModeHelp = "cursor mode is %s (ctrl+r to change style)" + defaultIPAddress = "localhost" + defaultPort = "8080" + maxErrorMessageLen = 60 // Maximum length of the error message +) + +var ( + focusedButton = constants.FocusedStyle.Render("[ " + submitButtonText + " ]") + blurredButton = fmt.Sprintf("[ %s ]", constants.BlurredStyle.Render(submitButtonText)) +) + +type TcpInputModel struct { + focusIndex int + inputs []textinput.Model + cursorMode cursor.Mode + height int + width int + errorMessage string +} + +func NewTCPInputModel(width, height int) TcpInputModel { + m := TcpInputModel{ + inputs: make([]textinput.Model, 3), + width: width, + height: height, + } + + var t textinput.Model + for i := range m.inputs { + t = textinput.New() + t.Cursor.Style = constants.FocusedStyle + t.CharLimit = 32 + + switch i { + case 0: + t.Placeholder = waitPlaceholder + t.Focus() + t.Width = len(waitPlaceholder) + t.PromptStyle = constants.FocusedStyle + t.TextStyle = constants.FocusedStyle + case 1: + t.Placeholder = ipPlaceholder + t.Width = len(ipPlaceholder) + t.CharLimit = 15 + case 2: + t.Placeholder = portPlaceholder + t.Width = len(portPlaceholder) + t.CharLimit = 5 + } + + m.inputs[i] = t + } + + return m +} + +func (m TcpInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m TcpInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + + case constants.CtrlC, constants.Esc: + return m, tea.Quit + + // Change cursor mode + case constants.CtrlR: + m.cursorMode++ + if m.cursorMode > cursor.CursorHide { + m.cursorMode = cursor.CursorBlink + } + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + cmds[i] = m.inputs[i].Cursor.SetMode(m.cursorMode) + } + return m, tea.Batch(cmds...) + + // Set focus to next input + case constants.Tab, constants.ShiftTab, constants.Enter, constants.Up, constants.Down: + s := msg.String() + + // Did the user press enter while the submit button was focused? + // If so, attempt to start the game. + if s == constants.Enter && m.focusIndex == len(m.inputs) { + wait := strings.ToUpper(m.inputs[0].Value()) != "C" + ip := m.inputs[1].Value() + port := m.inputs[2].Value() + + // Use default values if inputs are empty + if ip == "" { + ip = defaultIPAddress + } + if port == "" { + port = defaultPort + } + //after submit button freeze because waiting for connection + conn, player, err := setupConnection(wait, ip, port) + if err != nil { + m.errorMessage = formatErrorMessage(err.Error()) + return m, nil + } + game := newTCPModel(m.width, m.height, &conn, player) + return game, nil + } + + // Cycle indexes + if s == constants.Up || s == constants.ShiftTab { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex > len(m.inputs) { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i <= len(m.inputs)-1; i++ { + if i == m.focusIndex { + // Set focused state + cmds[i] = m.inputs[i].Focus() + m.inputs[i].PromptStyle = constants.FocusedStyle + m.inputs[i].TextStyle = constants.FocusedStyle + continue + } + // Remove focused state + m.inputs[i].Blur() + m.inputs[i].PromptStyle = constants.NoStyle + m.inputs[i].TextStyle = constants.NoStyle + } + + return m, tea.Batch(cmds...) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + // Handle character input and blinking + cmd := m.updateInputs(msg) + + return m, cmd +} + +func (m *TcpInputModel) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + + // Only text inputs with Focus() set will respond, so it's safe to simply + // update all of them here without any further logic. + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + + return tea.Batch(cmds...) +} + +func (m TcpInputModel) View() string { + // Collecting all inputs into a vertical layout + var inputs []string + for i := range m.inputs { + inputs = append(inputs, m.inputs[i].View()) + } + inputsView := lipgloss.JoinVertical(lipgloss.Left, inputs...) + + // Setting the button view + button := blurredButton + if m.focusIndex == len(m.inputs) { + button = focusedButton + } + buttonView := fmt.Sprintf("\n\n%s\n\n", button) + + // Combining inputs and button + mainView := lipgloss.JoinVertical(lipgloss.Center, inputsView, buttonView) + + // Adding the help text + helpText := fmt.Sprintf(cursorModeHelp, m.cursorMode.String()) + helpView := lipgloss.JoinVertical(lipgloss.Center, mainView, constants.BlurredStyle.Render(helpText)) + + // Error message view + errorMsg := "" + if m.errorMessage != "" { + errorMsg = constants.ErrorStyle.Margin(2, 0, 0, 0).Render(m.errorMessage) + } + + // Placing everything in the center of the screen + fullScreen := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.JoinVertical(lipgloss.Center, helpView, errorMsg), lipgloss.WithWhitespaceChars(" ")) + + return fullScreen +} + +func formatErrorMessage(msg string) string { + if len(msg) <= maxErrorMessageLen { + return msg + } + var formattedMsg strings.Builder + for i, r := range msg { + if i > 0 && i%maxErrorMessageLen == 0 { + formattedMsg.WriteString("\n") + } + formattedMsg.WriteRune(r) + } + return formattedMsg.String() +} diff --git a/main.go b/main.go index 9213b65..2e60fd2 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,12 @@ package main import ( "fmt" + "net" "os" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/dziedzicgrzegorz/Tic-Tac-Toe/constants" "github.com/spf13/cobra" ) @@ -13,9 +15,8 @@ type mode int const ( modeMenu mode = iota - modeSinglePlayer modeMultiPlayer - modeStats + modeMultiTCP ) type menuItem struct { @@ -29,32 +30,10 @@ type model struct { mode mode cursor int menuItems []menuItem + conn net.Conn + player string } -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Margin(1, 0, 2, 0). - Padding(0, 1). - Align(lipgloss.Center) - - messageStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFD700")). // Gold - Align(lipgloss.Center). - Padding(1, 2) - - backgroundStyle = lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(80). - Height(24) - - normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true) - subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Margin(2, 0, 0, 0) -) - func initialModel(width, height int) model { return model{ mode: modeMenu, @@ -62,9 +41,8 @@ func initialModel(width, height int) model { height: height, cursor: 0, menuItems: []menuItem{ - {mode: modeSinglePlayer, name: "Single Player"}, {mode: modeMultiPlayer, name: "Multiplayer"}, - {mode: modeStats, name: "View Stats"}, + {mode: modeMultiTCP, name: "Multiplayer TCP"}, }, } } @@ -79,32 +57,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.mode { case modeMenu: switch msg.String() { - case "up": + case constants.Up: if m.cursor > 0 { m.cursor-- } - case "down": + case constants.Down: if m.cursor < len(m.menuItems)-1 { m.cursor++ } - case "enter": + case constants.Enter: 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 1: + tcpInputModel := NewTCPInputModel(m.width, m.height) + return tcpInputModel, nil + } - case "ctrl+c", "q", "esc": + case constants.Quit, constants.CtrlC: return m, tea.Quit } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - backgroundStyle = backgroundStyle.Width(m.width).Height(m.height) + constants.BackgroundStyle = constants.BackgroundStyle.Width(m.width).Height(m.height) } return m, nil } @@ -114,27 +92,23 @@ func choicesView(m model) string { var choices []string for i, choice := range m.menuItems { - - choiceStr := normalStyle.Render("[ ] " + choice.name) - + choiceStr := constants.NormalStyle.Render("[ ] " + choice.name) if m.cursor == i { - choiceStr = selectedStyle.Render("[x] " + choice.name) + choiceStr = constants.SelectedStyle.Render("[x] " + choice.name) } - choices = append(choices, choiceStr) } menuSelect := lipgloss.JoinVertical(lipgloss.Left, choices...) - - footer := subtleStyle.Render("up ↑ / down ↓ : select | enter: choose | q, esc: quit") + footer := constants.SubtleStyle.Render("up ↑ / down ↓ : select | enter: choose | q, esc: quit") view := lipgloss.JoinVertical(lipgloss.Center, - titleStyle.Render(title), + constants.TitleStyle.Render(title), menuSelect, - subtleStyle.Render(footer), + constants.SubtleStyle.Render(footer), ) - fullScreen := backgroundStyle.Render( + fullScreen := constants.BackgroundStyle.Render( lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, view, lipgloss.WithWhitespaceChars(" ")), ) @@ -143,24 +117,15 @@ func choicesView(m model) string { func (m model) View() string { var view string - switch m.mode { case modeMenu: view = choicesView(m) - case modeSinglePlayer: - view = messageStyle.Render("Single Player Mode Selected") - case modeMultiPlayer: - view = messageStyle.Render("Multiplayer Mode Selected") - case modeStats: - view = messageStyle.Render("Game Stats: \n\n- Wins: 10\n- Losses: 5\n- Draws: 2") // Example stats } - fullScreen := lipgloss.NewStyle().Align(lipgloss.Center).Render( - backgroundStyle.Render( + constants.BackgroundStyle.Render( lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, view), ), ) - return fullScreen } diff --git a/multiplayer-tcp.go b/multiplayer-tcp.go new file mode 100644 index 0000000..9a2b90c --- /dev/null +++ b/multiplayer-tcp.go @@ -0,0 +1,306 @@ +package main + +import ( + "fmt" + "net" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/dziedzicgrzegorz/Tic-Tac-Toe/constants" +) + +type moveMessage struct{ command string } +type errMsg struct{ err error } + +func (e errMsg) Error() string { + return e.err.Error() +} + +type TCPmodel struct { + board [constants.BoardSize][constants.BoardSize]int + selectedRow int + selectedColumn int + winner string + conn *net.Conn + player int + playerTurn int + width int + height int + errorMessage string + infoMessage string +} + +func newTCPModel(width, height int, conn *net.Conn, player int) TCPmodel { + return TCPmodel{ + board: [constants.BoardSize][constants.BoardSize]int{}, + selectedRow: 0, + selectedColumn: 0, + winner: "", + conn: conn, + player: player, + playerTurn: constants.PlayerX, + width: width, + height: height, + } +} + +func (m TCPmodel) Init() tea.Cmd { + return createReceiveMove(*m.conn) +} + +func (m TCPmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var err error + + switch msg := msg.(type) { + + case tea.KeyMsg: + switch msg.String() { + case constants.CtrlC, constants.Quit: + return m, tea.Quit + + case constants.Up: + if m.selectedRow > 0 { + m.selectedRow-- + } + case constants.Down: + if m.selectedRow < constants.BoardSize-1 { + m.selectedRow++ + } + case constants.Left: + if m.selectedColumn > 0 { + m.selectedColumn-- + } + case constants.Right: + if m.selectedColumn < constants.BoardSize-1 { + m.selectedColumn++ + } + case constants.Enter: + m, err = m.HandleMyEnter() + if err != nil { + m.errorMessage = err.Error() + return NewEndGameModel(m.width, m.height, m.errorMessage), nil + } + if val := m.checkWinner(); val != 0 { + winMsg := fmt.Sprintf("Player %s wins!", mapValueToMarker(val)) + return NewEndGameModel(m.width, m.height, constants.WinMsgStyle.Render(winMsg)), nil + } + if m.isDraw() { + drawMsg := "It's a draw!" + return NewEndGameModel(m.width, m.height, constants.DrawMsgStyle.Render(drawMsg)), nil + } + } + + return m, createReceiveMove(*m.conn) + + case moveMessage: + commandParts := strings.Split(msg.command, ",") + + if commandParts[0] == constants.Enter { + m, err = m.HandleOpponentEnter(commandParts[0], commandParts[1], commandParts[2], commandParts[3]) + if err != nil { + m.errorMessage = err.Error() + return NewEndGameModel(m.width, m.height, m.errorMessage), nil + } + if val := m.checkWinner(); val != 0 { + loseMsg := fmt.Sprintf("Player %s wins!", mapValueToMarker(val)) + return NewEndGameModel(m.width, m.height, constants.LoseMsgStyle.Render(loseMsg)), nil + } + if m.isDraw() { + drawMsg := "It's a draw!" + return NewEndGameModel(m.width, m.height, constants.DrawMsgStyle.Render(drawMsg)), nil + } + } + + return m, nil + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + return m, nil +} + +func (m TCPmodel) View() string { + var cells []string + for i := 0; i < constants.BoardSize*constants.BoardSize; i++ { + row, col := i/constants.BoardSize, i%constants.BoardSize + cell := m.board[row][col] + cellStr := " " + + style := constants.CellStyle + + if row == m.selectedRow && col == m.selectedColumn { + cellStr = constants.BlinkingStyle.Render(m.getCurrentUser()) + } else if cell == constants.PlayerX { + cellStr = "X" + } else if cell == constants.PlayerO { + cellStr = "O" + } + + // Border styling for the cells because if we set a border for each side it has a margin + 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)) + } + + currentPlayer := fmt.Sprintf("I am a %s player: \n", m.getCurrentUser()) + + header := constants.HeaderStyle.Render(currentPlayer) + + // Instructions + footer := constants.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])) + + whoseTurn := fmt.Sprintf("It's %s's turn.\n", m.getCurrentMarker()) + + infoMsg := constants.InfoStyle.Render(m.infoMessage) + + // Joining all elements vertically + view := lipgloss.JoinVertical(lipgloss.Center, + header, + infoMsg, + whoseTurn, + constants.BoardStyle.Render(board), + m.errorMessage, + footer, + ) + + centeredFullView := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, view) + + return centeredFullView +} + +func (m TCPmodel) getCurrentMarker() string { + if m.playerTurn == constants.PlayerX { + return "X" + } + return "O" +} +func (m TCPmodel) getCurrentUser() string { + if m.player == constants.PlayerX { + return "X" + } + return "O" +} +func mapValueToMarker(value int) string { + if value == constants.PlayerX { + return "X" + } + if value == constants.PlayerO { + return "O" + } + return " " +} + +func (m TCPmodel) HandleMyEnter() (TCPmodel, error) { + m = m.handlePlayerEnter(m.player, m.selectedRow, m.selectedColumn) + err := m.sendMove(constants.Enter) + return m, err +} + +func (m TCPmodel) HandleOpponentEnter(key, opponentStr, selectedRowStr, selectedColStr string) (TCPmodel, error) { + selectedRow, err := strconv.Atoi(selectedRowStr) + if err != nil { + return m, err + } + + selectedCol, err := strconv.Atoi(selectedColStr) + if err != nil { + return m, err + } + + opponent, err := strconv.Atoi(opponentStr) + if err != nil { + return m, err + } + + return m.handlePlayerEnter(opponent, selectedRow, selectedCol), nil +} + +func (m TCPmodel) handlePlayerEnter(player, row, col int) TCPmodel { + if player != m.playerTurn { + m.infoMessage = fmt.Sprintf("Ignoring %d's move as it's %d's turn.", player, m.playerTurn) + return m + } + + if m.board[row][col] != constants.Empty { + m.infoMessage = fmt.Sprintf("Ignoring %s's move as cell [%d, %d] is not Empty.", m.getCurrentMarker(), row, col) + return m + } + + m.board[row][col] = player + m.infoMessage = fmt.Sprintf("%s marked cell [%d, %d]", m.getCurrentMarker(), row, col) + + m.switchPlayer() + + return m +} + +func (m *TCPmodel) 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]] != constants.Empty && + 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 *TCPmodel) isDraw() bool { + boardFull := true + for i := 0; i < constants.BoardSize; i++ { + for j := 0; j < constants.BoardSize; j++ { + if m.board[i][j] == constants.Empty { + boardFull = false + break + } + } + } + + return boardFull +} + +func (m *TCPmodel) switchPlayer() { + m.playerTurn = -m.playerTurn +} + +func (m *TCPmodel) sendMove(key string) error { + move := fmt.Sprintf("%s,%d,%d,%d", key, m.player, m.selectedRow, m.selectedColumn) + _, err := (*m.conn).Write([]byte((move))) + return err +} diff --git a/Multiplayer.go b/multiplayer.go similarity index 81% rename from Multiplayer.go rename to multiplayer.go index 5a936f1..d909c99 100644 --- a/Multiplayer.go +++ b/multiplayer.go @@ -6,14 +6,7 @@ import ( 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) + "github.com/dziedzicgrzegorz/Tic-Tac-Toe/constants" ) type GameModel struct { @@ -63,7 +56,7 @@ func (m *GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.winner = m.checkWinner() if m.winner != 0 { endMessage := fmt.Sprintf("Player %s wins!", m.currentMarker()) - endGameModel := NewEndGameModel(m.width, m.height, endMessage, false) + endGameModel := NewEndGameModel(m.width, m.height, constants.WinMsgStyle.Render(endMessage)) //sleep for 500 ms for better UX time.Sleep(500 * time.Millisecond) return endGameModel, endGameModel.Init() @@ -71,7 +64,8 @@ func (m *GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.turnCount-- if m.turnCount == 0 { endMessage := "It's a draw!" - endGameModel := NewEndGameModel(m.width, m.height, endMessage, true) + + endGameModel := NewEndGameModel(m.width, m.height, constants.DrawMsgStyle.Render(endMessage)) //sleep for 500 ms for better UX time.Sleep(500 * time.Millisecond) return endGameModel, endGameModel.Init() @@ -173,17 +167,17 @@ func (m *GameModel) renderBoard() string { cell := m.board[row][col] cellStr := " " - style := cellStyle + style := constants.CellStyle if i == m.cursor { - cellStr = blinkingStyle.Render(m.currentMarker()) + cellStr = constants.BlinkingStyle.Render(m.currentMarker()) } else if cell == 1 { cellStr = "X" } else if cell == -1 { cellStr = "O" } - // Ustawianie granic + // Border styling for the cells because if we set a border for each side it has a margin if col == 0 || col == 2 { style = style.UnsetBorderLeft().UnsetBorderRight().UnsetBorderBottom() if row == 0 { @@ -200,13 +194,12 @@ func (m *GameModel) renderBoard() string { cells = append(cells, style.Render(cellStr)) } - // Informacja o aktualnym graczu currentPlayer := fmt.Sprintf("Current player: %s\n", m.currentMarker()) - header := headerStyle.Render(currentPlayer) + header := constants.HeaderStyle.Render(currentPlayer) - // Informacja o grze - footer := subtleStyle.Render("j/k, up/down: move | h/l, left/right: move | enter: select | ctrl+c: quit") + // Quick help + footer := constants.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, @@ -214,16 +207,15 @@ func (m *GameModel) renderBoard() string { 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) + errorMsg = constants.ErrorStyle.Render(m.errorMessage) } // Joining all elements vertically view := lipgloss.JoinVertical(lipgloss.Center, header, - boardStyle.Render(board), + constants.BoardStyle.Render(board), errorMsg, footer, ) diff --git a/setup-tcp.go b/setup-tcp.go new file mode 100644 index 0000000..1045576 --- /dev/null +++ b/setup-tcp.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "net" + + tea "github.com/charmbracelet/bubbletea" + "github.com/dziedzicgrzegorz/Tic-Tac-Toe/constants" +) + +func setupConnection(wait bool, ip string, port string) (net.Conn, int, error) { + if wait { + ln, err := net.Listen("tcp", ":"+port) + if err != nil { + return nil, 0, fmt.Errorf("failed to listen on port %v: %w", port, err) + } + conn, err := ln.Accept() + if err != nil { + return nil, 0, fmt.Errorf("failed to accept a connection: %w", err) + } + return conn, constants.PlayerX, nil + } else { + conn, err := net.Dial("tcp", ip+":"+port) + if err != nil { + return nil, 0, fmt.Errorf("failed to connect to %v:%v: %w", ip, port, err) + } + return conn, constants.PlayerO, nil + } +} +func createReceiveMove(conn net.Conn) func() tea.Msg { + return func() tea.Msg { + buffer := make([]byte, 1024) + len, err := conn.Read(buffer) + if err != nil { + return errMsg{err: err} + } + return moveMessage{command: string(buffer[:len])} + } +}