diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a6f38..f1100a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ 0.1.1: +- Add game browser and support custom game creation +- Add website and version to title screen - Kick inactive players 0.1.0: diff --git a/NETWORKING.md b/NETWORKING.md new file mode 100644 index 0000000..47b09ed --- /dev/null +++ b/NETWORKING.md @@ -0,0 +1,8 @@ +*This is a draft specification of a future networking protocol.* + +# Overview + +Gameplay is recorded and buffered for 1-2 seconds before being sent to the server, then distributed among all players. + +Each + diff --git a/README.md b/README.md index 88ace17..f289433 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,16 @@ Multiplayer Tetris clone -![](https://netris.rocketnine.space/static/screenshot4.png) +## Play Without Installing -## Demo - -To play netris without installing: +To play netris without installing, connect via [SSH](https://en.wikipedia.org/wiki/Secure_Shell): ```ssh netris.rocketnine.space``` +## Screenshot + +![](https://netris.rocketnine.space/static/screenshot4.png) + ## Install Choose one of the following methods: diff --git a/cmd/netris/gui.go b/cmd/netris/gui.go index 96ad5b1..537831a 100644 --- a/cmd/netris/gui.go +++ b/cmd/netris/gui.go @@ -33,8 +33,8 @@ var ( joinedGame bool - draw = make(chan event.DrawObject, game.CommandQueueSize) - selectMode = make(chan event.GameMode, game.CommandQueueSize) + draw = make(chan event.DrawObject, game.CommandQueueSize) + joinGame = make(chan int, game.CommandQueueSize) renderLock = new(sync.Mutex) renderBuffer bytes.Buffer @@ -60,6 +60,9 @@ var ( buttonKeybindHardDrop *tview.Button buttonKeybindCancel *tview.Button buttonKeybindSave *tview.Button + + buttonCancel *tview.Button + buttonStart *tview.Button ) const DefaultStatusText = "Press Enter to chat, Z/X to rotate, arrow keys or HJKL to move/drop" @@ -87,284 +90,14 @@ var renderBlock = map[mino.Block][]byte{ var ( renderHLine = []byte(string(tcell.RuneHLine)) renderVLine = []byte(string(tcell.RuneVLine)) + renderLTee = []byte(string(tcell.RuneLTee)) + renderRTee = []byte(string(tcell.RuneRTee)) renderULCorner = []byte(string(tcell.RuneULCorner)) renderURCorner = []byte(string(tcell.RuneURCorner)) renderLLCorner = []byte(string(tcell.RuneLLCorner)) renderLRCorner = []byte(string(tcell.RuneLRCorner)) ) -func initGUI(skipTitle bool) (*tview.Application, error) { - app = tview.NewApplication() - - app.SetAfterResizeFunc(handleResize) - - inputView = tview.NewInputField(). - SetText(DefaultStatusText). - SetLabel("> "). - SetFieldWidth(0). - SetFieldBackgroundColor(tcell.ColorDefault). - SetFieldTextColor(tcell.ColorWhite) - - inputView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if !inputActive { - return nil - } - - return event - }) - - gameGrid = tview.NewGrid(). - SetBorders(false). - SetRows(2+(20*blockSize)+extraScreenPadding, -1) - - mtx = tview.NewTextView(). - SetScrollable(false). - SetTextAlign(tview.AlignLeft). - SetWrap(false). - SetWordWrap(false) - - mtx.SetDynamicColors(true) - - side = tview.NewTextView(). - SetScrollable(false). - SetTextAlign(tview.AlignLeft). - SetWrap(false). - SetWordWrap(false) - - side.SetDynamicColors(true) - - buffer = tview.NewTextView(). - SetScrollable(false). - SetTextAlign(tview.AlignLeft). - SetWrap(false). - SetWordWrap(false) - - buffer.SetDynamicColors(true) - - spacer := tview.NewBox() - - recent = tview.NewTextView(). - SetScrollable(true). - SetTextAlign(tview.AlignLeft). - SetWrap(true). - SetWordWrap(true) - - gameGrid.SetColumns(1, 4+(10*blockSize), 10, -1). - AddItem(spacer, 0, 0, 2, 1, 0, 0, false). - AddItem(mtx, 0, 1, 1, 1, 0, 0, false). - AddItem(side, 0, 2, 1, 1, 0, 0, false). - AddItem(buffer, 0, 3, 1, 1, 0, 0, false). - AddItem(inputView, 1, 1, 1, 3, 0, 0, true). - AddItem(recent, 2, 1, 1, 3, 0, 0, true) - - // Set up title screen - - titleVisible = !skipTitle - - minos, err := mino.Generate(4) - if err != nil { - log.Fatalf("failed to render title: failed to generate minos: %s", err) - } - - var ( - piece *mino.Piece - addToRight bool - i int - ) - for y := 0; y < 6; y++ { - for x := 0; x < 4; x++ { - piece = mino.NewPiece(minos[i], mino.Point{x * 5, (y * 5)}) - - i++ - if i == len(minos) { - i = 0 - } - - if addToRight { - titlePiecesR = append(titlePiecesR, piece) - } else { - titlePiecesL = append(titlePiecesL, piece) - } - - addToRight = !addToRight - } - } - - titleName = tview.NewTextView(). - SetScrollable(false). - SetTextAlign(tview.AlignLeft). - SetWrap(false). - SetWordWrap(false).SetDynamicColors(true) - - titleL = tview.NewTextView(). - SetScrollable(false). - SetTextAlign(tview.AlignLeft). - SetWrap(false). - SetWordWrap(false).SetDynamicColors(true) - - titleR = tview.NewTextView(). - SetScrollable(false). - SetTextAlign(tview.AlignLeft). - SetWrap(false). - SetWordWrap(false).SetDynamicColors(true) - - go handleTitle() - - buttonA = tview.NewButton("A") - buttonLabelA = tview.NewTextView().SetTextAlign(tview.AlignCenter) - - buttonB = tview.NewButton("B") - buttonLabelB = tview.NewTextView().SetTextAlign(tview.AlignCenter) - - buttonC = tview.NewButton("C") - buttonLabelC = tview.NewTextView().SetTextAlign(tview.AlignCenter) - - titleNameGrid := tview.NewGrid().SetRows(5). - AddItem(titleName, 0, 0, 1, 1, 0, 0, false). - AddItem(tview.NewTextView().SetText(SubTitle+game.Version), 1, 0, 1, 1, 0, 0, false) - - titleGrid = tview.NewGrid(). - SetRows(7, 3, 3, 3, 3, 3, 2). - SetColumns(-1, 38, -1). - AddItem(titleL, 0, 0, 7, 1, 0, 0, false). - AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). - AddItem(titleR, 0, 2, 7, 1, 0, 0, false). - AddItem(buttonA, 1, 1, 1, 1, 0, 0, false). - AddItem(buttonLabelA, 2, 1, 1, 1, 0, 0, false). - AddItem(buttonB, 3, 1, 1, 1, 0, 0, false). - AddItem(buttonLabelB, 4, 1, 1, 1, 0, 0, false). - AddItem(buttonC, 5, 1, 1, 1, 0, 0, false). - AddItem(buttonLabelC, 6, 1, 1, 1, 0, 0, false) - - playerSettingsTitle := tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetWrap(false). - SetWordWrap(false).SetText("Player Settings") - - playerSettingsForm = tview.NewForm().SetButtonsAlign(tview.AlignCenter) - - playerSettingsGrid = tview.NewGrid(). - SetRows(7, 2, -1, 1). - SetColumns(-1, 38, -1). - AddItem(titleL, 0, 0, 3, 1, 0, 0, false). - AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). - AddItem(titleR, 0, 2, 3, 1, 0, 0, false). - AddItem(playerSettingsTitle, 1, 1, 1, 1, 0, 0, true). - AddItem(playerSettingsForm, 2, 1, 1, 1, 0, 0, true). - AddItem(tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetWrap(false). - SetWordWrap(false).SetText("Press Tab to move between fields"), 3, 1, 1, 1, 0, 0, true) - - gameSettingsTitle := tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetWrap(false). - SetWordWrap(false).SetText("Game Settings") - - buttonKeybindRotateCCW = tview.NewButton("Set") - buttonKeybindRotateCW = tview.NewButton("Set") - buttonKeybindMoveLeft = tview.NewButton("Set") - buttonKeybindMoveRight = tview.NewButton("Set") - buttonKeybindSoftDrop = tview.NewButton("Set") - buttonKeybindHardDrop = tview.NewButton("Set") - buttonKeybindCancel = tview.NewButton("Cancel") - buttonKeybindSave = tview.NewButton("Save") - - rotateCCWGrid := tview.NewGrid(). - AddItem(tview.NewTextView().SetText("Rotate CCW"), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindRotateCCW, 0, 1, 1, 1, 0, 0, false) - - rotateCWGrid := tview.NewGrid(). - AddItem(tview.NewTextView().SetText("Rotate CW"), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindRotateCW, 0, 1, 1, 1, 0, 0, false) - - moveLeftGrid := tview.NewGrid(). - AddItem(tview.NewTextView().SetText("Move Left"), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindMoveLeft, 0, 1, 1, 1, 0, 0, false) - - moveRightGrid := tview.NewGrid(). - AddItem(tview.NewTextView().SetText("Move Right"), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindMoveRight, 0, 1, 1, 1, 0, 0, false) - - softDropGrid := tview.NewGrid(). - AddItem(tview.NewTextView().SetText("Soft Drop"), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindSoftDrop, 0, 1, 1, 1, 0, 0, false) - - hardDropGrid := tview.NewGrid(). - AddItem(tview.NewTextView().SetText("Hard Drop"), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindHardDrop, 0, 1, 1, 1, 0, 0, false) - - gameSettingsSubmitGrid := tview.NewGrid(). - SetColumns(-1, 10, 1, 10, -1). - AddItem(tview.NewTextView(), 0, 0, 1, 1, 0, 0, false). - AddItem(buttonKeybindCancel, 0, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 0, 2, 1, 1, 0, 0, false). - AddItem(buttonKeybindSave, 0, 3, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 0, 4, 1, 1, 0, 0, false) - - gameSettingsGrid = tview.NewGrid(). - SetRows(7, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1). - SetColumns(-1, 38, -1). - AddItem(titleL, 0, 0, 16, 1, 0, 0, false). - AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). - AddItem(titleR, 0, 2, 16, 1, 0, 0, false). - AddItem(gameSettingsTitle, 1, 1, 1, 1, 0, 0, false). - AddItem(rotateCCWGrid, 2, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 3, 1, 1, 1, 0, 0, false). - AddItem(rotateCWGrid, 4, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 5, 1, 1, 1, 0, 0, false). - AddItem(moveLeftGrid, 6, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 7, 1, 1, 1, 0, 0, false). - AddItem(moveRightGrid, 8, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 9, 1, 1, 1, 0, 0, false). - AddItem(softDropGrid, 10, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 11, 1, 1, 1, 0, 0, false). - AddItem(hardDropGrid, 12, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 13, 1, 1, 1, 0, 0, false). - AddItem(gameSettingsSubmitGrid, 14, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetWrap(false). - SetWordWrap(false).SetText("\nPress Tab to move between fields"), 15, 1, 1, 1, 0, 0, false) - - titleContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). - AddItem(tview.NewTextView(), 0, 0, 1, 3, 0, 0, false). - AddItem(tview.NewTextView(), 1, 0, 1, 1, 0, 0, false). - AddItem(titleGrid, 1, 1, 1, 1, 0, 0, true). - AddItem(tview.NewTextView(), 1, 2, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 0, 0, 1, 3, 0, 0, false) - - playerSettingsContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). - AddItem(tview.NewTextView(), 0, 0, 1, 3, 0, 0, false). - AddItem(tview.NewTextView(), 1, 0, 1, 1, 0, 0, false). - AddItem(playerSettingsGrid, 1, 1, 1, 1, 0, 0, true). - AddItem(tview.NewTextView(), 1, 2, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 0, 0, 1, 3, 0, 0, false) - - gameSettingsContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). - AddItem(tview.NewTextView(), 0, 0, 1, 3, 0, 0, false). - AddItem(tview.NewTextView(), 1, 0, 1, 1, 0, 0, false). - AddItem(gameSettingsGrid, 1, 1, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 1, 2, 1, 1, 0, 0, false). - AddItem(tview.NewTextView(), 0, 0, 1, 3, 0, 0, false) - - app = app.SetInputCapture(handleKeypress) - - if !skipTitle { - app.SetRoot(titleContainerGrid, true) - - updateTitle() - } else { - app.SetRoot(gameGrid, true) - - app.SetFocus(nil) - } - - go handleDraw() - - return app, nil -} - func resetPlayerSettingsForm() { playerSettingsForm.Clear(true).AddInputField("Name", nickname, 0, nil, func(text string) { nicknameDraft = text @@ -498,14 +231,14 @@ func setInputStatus(active bool) { inputActive = active - inputView.SetText("") - if inputActive { - app.SetFocus(inputView) - } else { - app.SetFocus(nil) - } - - app.Draw() + app.QueueUpdateDraw(func() { + inputView.SetText("") + if inputActive { + app.SetFocus(inputView) + } else { + app.SetFocus(nil) + } + }) } func setShowDetails(active bool) { diff --git a/cmd/netris/gui_init.go b/cmd/netris/gui_init.go new file mode 100644 index 0000000..0683ed4 --- /dev/null +++ b/cmd/netris/gui_init.go @@ -0,0 +1,503 @@ +package main + +import ( + "log" + "unicode" + + "git.sr.ht/~tslocum/netris/pkg/event" + "git.sr.ht/~tslocum/netris/pkg/game" + "git.sr.ht/~tslocum/netris/pkg/mino" + "github.com/gdamore/tcell" + "github.com/tslocum/tview" +) + +func initGUI(skipTitle bool) (*tview.Application, error) { + app = tview.NewApplication() + + app.SetAfterResizeFunc(handleResize) + + inputView = tview.NewInputField(). + SetText(DefaultStatusText). + SetLabel("> "). + SetFieldWidth(0). + SetFieldBackgroundColor(tcell.ColorDefault). + SetFieldTextColor(tcell.ColorWhite) + + inputView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if !inputActive { + return nil + } + + return event + }) + + gameGrid = tview.NewGrid(). + SetBorders(false). + SetRows(2+(20*blockSize)+extraScreenPadding, -1) + + mtx = tview.NewTextView(). + SetScrollable(false). + SetTextAlign(tview.AlignLeft). + SetWrap(false). + SetWordWrap(false) + + mtx.SetDynamicColors(true) + + side = tview.NewTextView(). + SetScrollable(false). + SetTextAlign(tview.AlignLeft). + SetWrap(false). + SetWordWrap(false) + + side.SetDynamicColors(true) + + buffer = tview.NewTextView(). + SetScrollable(false). + SetTextAlign(tview.AlignLeft). + SetWrap(false). + SetWordWrap(false) + + buffer.SetDynamicColors(true) + + pad := tview.NewBox() + + recent = tview.NewTextView(). + SetScrollable(true). + SetTextAlign(tview.AlignLeft). + SetWrap(true). + SetWordWrap(true) + + gameGrid.SetColumns(1, 4+(10*blockSize), 10, -1). + AddItem(pad, 0, 0, 2, 1, 0, 0, false). + AddItem(mtx, 0, 1, 1, 1, 0, 0, false). + AddItem(side, 0, 2, 1, 1, 0, 0, false). + AddItem(buffer, 0, 3, 1, 1, 0, 0, false). + AddItem(inputView, 1, 1, 1, 3, 0, 0, true). + AddItem(recent, 2, 1, 1, 3, 0, 0, true) + + // Set up title screen + + titleVisible = !skipTitle + + minos, err := mino.Generate(4) + if err != nil { + log.Fatalf("failed to render title: failed to generate minos: %s", err) + } + + var ( + piece *mino.Piece + addToRight bool + i int + ) + for y := 0; y < 6; y++ { + for x := 0; x < 4; x++ { + piece = mino.NewPiece(minos[i], mino.Point{x * 5, (y * 5)}) + + i++ + if i == len(minos) { + i = 0 + } + + if addToRight { + titlePiecesR = append(titlePiecesR, piece) + } else { + titlePiecesL = append(titlePiecesL, piece) + } + + addToRight = !addToRight + } + } + + titleName = tview.NewTextView(). + SetScrollable(false). + SetTextAlign(tview.AlignLeft). + SetWrap(false). + SetWordWrap(false).SetDynamicColors(true) + + titleL = tview.NewTextView(). + SetScrollable(false). + SetTextAlign(tview.AlignLeft). + SetWrap(false). + SetWordWrap(false).SetDynamicColors(true) + + titleR = tview.NewTextView(). + SetScrollable(false). + SetTextAlign(tview.AlignLeft). + SetWrap(false). + SetWordWrap(false).SetDynamicColors(true) + + go handleTitle() + + buttonA = tview.NewButton("A") + buttonLabelA = tview.NewTextView().SetTextAlign(tview.AlignCenter) + + buttonB = tview.NewButton("B") + buttonLabelB = tview.NewTextView().SetTextAlign(tview.AlignCenter) + + buttonC = tview.NewButton("C") + buttonLabelC = tview.NewTextView().SetTextAlign(tview.AlignCenter) + + titleNameGrid := tview.NewGrid().SetRows(5). + AddItem(titleName, 0, 0, 1, 1, 0, 0, false). + AddItem(tview.NewTextView().SetText(SubTitle+game.Version), 1, 0, 1, 1, 0, 0, false) + + titleGrid = tview.NewGrid(). + SetRows(7, 3, 3, 3, 3, 3, 2). + SetColumns(-1, 38, -1). + AddItem(titleL, 0, 0, 7, 1, 0, 0, false). + AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). + AddItem(titleR, 0, 2, 7, 1, 0, 0, false). + AddItem(buttonA, 1, 1, 1, 1, 0, 0, false). + AddItem(buttonLabelA, 2, 1, 1, 1, 0, 0, false). + AddItem(buttonB, 3, 1, 1, 1, 0, 0, false). + AddItem(buttonLabelB, 4, 1, 1, 1, 0, 0, false). + AddItem(buttonC, 5, 1, 1, 1, 0, 0, false). + AddItem(buttonLabelC, 6, 1, 1, 1, 0, 0, false) + + gameListView = tview.NewTextView().SetDynamicColors(true) + + gameListButtonsGrid := tview.NewGrid(). + SetColumns(-1, 1, -1, 1, -1). + AddItem(buttonA, 0, 0, 1, 1, 0, 0, false). + AddItem(pad, 0, 1, 1, 1, 0, 0, false). + AddItem(buttonB, 0, 2, 1, 1, 0, 0, false). + AddItem(pad, 0, 3, 1, 1, 0, 0, false). + AddItem(buttonC, 0, 4, 1, 1, 0, 0, false) + + gameListHeader = tview.NewTextView().SetTextAlign(tview.AlignCenter) + + gameListGrid = tview.NewGrid(). + SetRows(7, 1, -1, 1, 3). + SetColumns(-1, 38, -1). + AddItem(titleL, 0, 0, 5, 1, 0, 0, false). + AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). + AddItem(titleR, 0, 2, 5, 1, 0, 0, false). + AddItem(gameListHeader, 1, 1, 1, 1, 0, 0, true). + AddItem(gameListView, 2, 1, 1, 1, 0, 0, true). + AddItem(gameListButtonsGrid, 3, 1, 1, 1, 0, 0, true). + AddItem(tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("\nRefresh: R\nPrevious: Shift+Tab - Next: Tab"), 4, 1, 1, 1, 0, 0, true) + + buttonCancel = tview.NewButton("Cancel") + buttonStart = tview.NewButton("Start") + + newGameSubmitGrid := tview.NewGrid(). + SetColumns(-1, 10, 1, 10, -1). + AddItem(pad, 0, 0, 1, 1, 0, 0, false). + AddItem(buttonCancel, 0, 1, 1, 1, 0, 0, false). + AddItem(pad, 0, 2, 1, 1, 0, 0, false). + AddItem(buttonStart, 0, 3, 1, 1, 0, 0, false). + AddItem(pad, 0, 4, 1, 1, 0, 0, false) + + newGameNameInput = tview.NewInputField().SetText("netris") + newGameMaxPlayersInput = tview.NewInputField().SetFieldWidth(3).SetAcceptanceFunc(func(textToCheck string, lastChar rune) bool { + return unicode.IsDigit(lastChar) && len(textToCheck) <= 3 + }) + newGameSpeedLimitInput = tview.NewInputField().SetFieldWidth(3).SetAcceptanceFunc(func(textToCheck string, lastChar rune) bool { + return unicode.IsDigit(lastChar) && len(textToCheck) <= 3 + }) + + resetNewGameInputs() + + newGameNameGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Name"), 0, 0, 1, 1, 0, 0, false). + AddItem(newGameNameInput, 0, 1, 1, 1, 0, 0, false) + + newGameMaxPlayersGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Player Limit"), 0, 0, 1, 1, 0, 0, false). + AddItem(newGameMaxPlayersInput, 0, 1, 1, 1, 0, 0, false) + + newGameSpeedLimitGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Speed Limit"), 0, 0, 1, 1, 0, 0, false). + AddItem(newGameSpeedLimitInput, 0, 1, 1, 1, 0, 0, false) + + newGameHeader := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("New Game") + + newGameGrid = tview.NewGrid(). + SetRows(7, 2, 1, 1, 1, 1, 1, 1, 1, -1, 3). + SetColumns(-1, 38, -1). + AddItem(titleL, 0, 0, 11, 1, 0, 0, false). + AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). + AddItem(titleR, 0, 2, 11, 1, 0, 0, false). + AddItem(newGameHeader, 1, 1, 1, 1, 0, 0, false). + AddItem(newGameNameGrid, 2, 1, 1, 1, 0, 0, false). + AddItem(pad, 3, 1, 1, 1, 0, 0, false). + AddItem(newGameMaxPlayersGrid, 4, 1, 1, 1, 0, 0, false). + AddItem(pad, 5, 1, 1, 1, 0, 0, false). + AddItem(newGameSpeedLimitGrid, 6, 1, 1, 1, 0, 0, false). + AddItem(pad, 7, 1, 1, 1, 0, 0, false). + AddItem(newGameSubmitGrid, 8, 1, 1, 1, 0, 0, false). + AddItem(pad, 9, 1, 1, 1, 0, 0, false). + AddItem(tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("\nLimits set to zero are disabled\nPrevious: Shift+Tab - Next: Tab"), 10, 1, 1, 1, 0, 0, false) + + playerSettingsTitle := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("Player Settings") + + playerSettingsForm = tview.NewForm().SetButtonsAlign(tview.AlignCenter) + + playerSettingsGrid = tview.NewGrid(). + SetRows(7, 2, -1, 1). + SetColumns(-1, 38, -1). + AddItem(titleL, 0, 0, 4, 1, 0, 0, false). + AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). + AddItem(titleR, 0, 2, 4, 1, 0, 0, false). + AddItem(playerSettingsTitle, 1, 1, 1, 1, 0, 0, true). + AddItem(playerSettingsForm, 2, 1, 1, 1, 0, 0, true). + AddItem(tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("Previous: Shift+Tab - Next: Tab"), 3, 1, 1, 1, 0, 0, true) + + gameSettingsTitle := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("Game Settings") + + buttonKeybindRotateCCW = tview.NewButton("Set") + buttonKeybindRotateCW = tview.NewButton("Set") + buttonKeybindMoveLeft = tview.NewButton("Set") + buttonKeybindMoveRight = tview.NewButton("Set") + buttonKeybindSoftDrop = tview.NewButton("Set") + buttonKeybindHardDrop = tview.NewButton("Set") + buttonKeybindCancel = tview.NewButton("Cancel") + buttonKeybindSave = tview.NewButton("Save") + + rotateCCWGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Rotate CCW"), 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindRotateCCW, 0, 1, 1, 1, 0, 0, false) + + rotateCWGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Rotate CW"), 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindRotateCW, 0, 1, 1, 1, 0, 0, false) + + moveLeftGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Move Left"), 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindMoveLeft, 0, 1, 1, 1, 0, 0, false) + + moveRightGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Move Right"), 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindMoveRight, 0, 1, 1, 1, 0, 0, false) + + softDropGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Soft Drop"), 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindSoftDrop, 0, 1, 1, 1, 0, 0, false) + + hardDropGrid := tview.NewGrid(). + AddItem(tview.NewTextView().SetText("Hard Drop"), 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindHardDrop, 0, 1, 1, 1, 0, 0, false) + + gameSettingsSubmitGrid := tview.NewGrid(). + SetColumns(-1, 10, 1, 10, -1). + AddItem(pad, 0, 0, 1, 1, 0, 0, false). + AddItem(buttonKeybindCancel, 0, 1, 1, 1, 0, 0, false). + AddItem(pad, 0, 2, 1, 1, 0, 0, false). + AddItem(buttonKeybindSave, 0, 3, 1, 1, 0, 0, false). + AddItem(pad, 0, 4, 1, 1, 0, 0, false) + + gameSettingsGrid = tview.NewGrid(). + SetRows(7, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1). + SetColumns(-1, 38, -1). + AddItem(titleL, 0, 0, 16, 1, 0, 0, false). + AddItem(titleNameGrid, 0, 1, 1, 1, 0, 0, false). + AddItem(titleR, 0, 2, 16, 1, 0, 0, false). + AddItem(gameSettingsTitle, 1, 1, 1, 1, 0, 0, false). + AddItem(rotateCCWGrid, 2, 1, 1, 1, 0, 0, false). + AddItem(pad, 3, 1, 1, 1, 0, 0, false). + AddItem(rotateCWGrid, 4, 1, 1, 1, 0, 0, false). + AddItem(pad, 5, 1, 1, 1, 0, 0, false). + AddItem(moveLeftGrid, 6, 1, 1, 1, 0, 0, false). + AddItem(pad, 7, 1, 1, 1, 0, 0, false). + AddItem(moveRightGrid, 8, 1, 1, 1, 0, 0, false). + AddItem(pad, 9, 1, 1, 1, 0, 0, false). + AddItem(softDropGrid, 10, 1, 1, 1, 0, 0, false). + AddItem(pad, 11, 1, 1, 1, 0, 0, false). + AddItem(hardDropGrid, 12, 1, 1, 1, 0, 0, false). + AddItem(pad, 13, 1, 1, 1, 0, 0, false). + AddItem(gameSettingsSubmitGrid, 14, 1, 1, 1, 0, 0, false). + AddItem(tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetWordWrap(false).SetText("\nPrevious: Shift+Tab - Next: Tab"), 15, 1, 1, 1, 0, 0, false) + + titleContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). + AddItem(pad, 0, 0, 1, 3, 0, 0, false). + AddItem(pad, 1, 0, 1, 1, 0, 0, false). + AddItem(titleGrid, 1, 1, 1, 1, 0, 0, true). + AddItem(pad, 1, 2, 1, 1, 0, 0, false). + AddItem(pad, 0, 0, 1, 3, 0, 0, false) + + gameListContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). + AddItem(pad, 0, 0, 1, 3, 0, 0, false). + AddItem(pad, 1, 0, 1, 1, 0, 0, false). + AddItem(gameListGrid, 1, 1, 1, 1, 0, 0, true). + AddItem(pad, 1, 2, 1, 1, 0, 0, false). + AddItem(pad, 0, 0, 1, 3, 0, 0, false) + + newGameContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). + AddItem(pad, 0, 0, 1, 3, 0, 0, false). + AddItem(pad, 1, 0, 1, 1, 0, 0, false). + AddItem(newGameGrid, 1, 1, 1, 1, 0, 0, false). + AddItem(pad, 1, 2, 1, 1, 0, 0, false). + AddItem(pad, 0, 0, 1, 3, 0, 0, false) + + playerSettingsContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). + AddItem(pad, 0, 0, 1, 3, 0, 0, false). + AddItem(pad, 1, 0, 1, 1, 0, 0, false). + AddItem(playerSettingsGrid, 1, 1, 1, 1, 0, 0, true). + AddItem(pad, 1, 2, 1, 1, 0, 0, false). + AddItem(pad, 0, 0, 1, 3, 0, 0, false) + + gameSettingsContainerGrid = tview.NewGrid().SetColumns(-1, 80, -1).SetRows(-1, 24, -1). + AddItem(pad, 0, 0, 1, 3, 0, 0, false). + AddItem(pad, 1, 0, 1, 1, 0, 0, false). + AddItem(gameSettingsGrid, 1, 1, 1, 1, 0, 0, false). + AddItem(pad, 1, 2, 1, 1, 0, 0, false). + AddItem(pad, 0, 0, 1, 3, 0, 0, false) + + app = app.SetInputCapture(handleKeypress) + + if !skipTitle { + app.SetRoot(titleContainerGrid, true) + + updateTitle() + } else { + app.SetRoot(gameGrid, true) + + app.SetFocus(nil) + } + + go handleDraw() + + return app, nil +} + +func newTitleMatrixSide() *mino.Matrix { + ev := make(chan interface{}) + go func() { + for range ev { + } + }() + + draw := make(chan event.DrawObject) + go func() { + for range draw { + } + }() + + m := mino.NewMatrix(21, 24, 0, 1, ev, draw, mino.MatrixCustom) + + return m +} + +func newTitleMatrixName() *mino.Matrix { + ev := make(chan interface{}) + go func() { + for range ev { + } + }() + + draw := make(chan event.DrawObject) + go func() { + for range draw { + } + }() + + m := mino.NewMatrix(36, 7, 0, 1, ev, draw, mino.MatrixCustom) + + centerStart := (m.W / 2) - 17 + + var titleBlocks = []struct { + mino.Point + mino.Block + }{ + // N + {mino.Point{0, 0}, mino.BlockSolidRed}, + {mino.Point{0, 1}, mino.BlockSolidRed}, + {mino.Point{0, 2}, mino.BlockSolidRed}, + {mino.Point{0, 3}, mino.BlockSolidRed}, + {mino.Point{0, 4}, mino.BlockSolidRed}, + {mino.Point{1, 3}, mino.BlockSolidRed}, + {mino.Point{2, 2}, mino.BlockSolidRed}, + {mino.Point{3, 1}, mino.BlockSolidRed}, + {mino.Point{4, 0}, mino.BlockSolidRed}, + {mino.Point{4, 1}, mino.BlockSolidRed}, + {mino.Point{4, 2}, mino.BlockSolidRed}, + {mino.Point{4, 3}, mino.BlockSolidRed}, + {mino.Point{4, 4}, mino.BlockSolidRed}, + + // E + {mino.Point{7, 0}, mino.BlockSolidYellow}, + {mino.Point{7, 1}, mino.BlockSolidYellow}, + {mino.Point{7, 2}, mino.BlockSolidYellow}, + {mino.Point{7, 3}, mino.BlockSolidYellow}, + {mino.Point{7, 4}, mino.BlockSolidYellow}, + {mino.Point{8, 0}, mino.BlockSolidYellow}, + {mino.Point{9, 0}, mino.BlockSolidYellow}, + {mino.Point{8, 2}, mino.BlockSolidYellow}, + {mino.Point{9, 2}, mino.BlockSolidYellow}, + {mino.Point{8, 4}, mino.BlockSolidYellow}, + {mino.Point{9, 4}, mino.BlockSolidYellow}, + + // T + {mino.Point{12, 4}, mino.BlockSolidGreen}, + {mino.Point{13, 4}, mino.BlockSolidGreen}, + {mino.Point{14, 0}, mino.BlockSolidGreen}, + {mino.Point{14, 1}, mino.BlockSolidGreen}, + {mino.Point{14, 2}, mino.BlockSolidGreen}, + {mino.Point{14, 3}, mino.BlockSolidGreen}, + {mino.Point{14, 4}, mino.BlockSolidGreen}, + {mino.Point{15, 4}, mino.BlockSolidGreen}, + {mino.Point{16, 4}, mino.BlockSolidGreen}, + + // R + {mino.Point{19, 0}, mino.BlockSolidCyan}, + {mino.Point{19, 1}, mino.BlockSolidCyan}, + {mino.Point{19, 2}, mino.BlockSolidCyan}, + {mino.Point{19, 3}, mino.BlockSolidCyan}, + {mino.Point{19, 4}, mino.BlockSolidCyan}, + {mino.Point{20, 2}, mino.BlockSolidCyan}, + {mino.Point{20, 4}, mino.BlockSolidCyan}, + {mino.Point{21, 2}, mino.BlockSolidCyan}, + {mino.Point{21, 4}, mino.BlockSolidCyan}, + {mino.Point{22, 0}, mino.BlockSolidCyan}, + {mino.Point{22, 1}, mino.BlockSolidCyan}, + {mino.Point{22, 3}, mino.BlockSolidCyan}, + + // I + {mino.Point{25, 0}, mino.BlockSolidBlue}, + {mino.Point{25, 1}, mino.BlockSolidBlue}, + {mino.Point{25, 2}, mino.BlockSolidBlue}, + {mino.Point{25, 3}, mino.BlockSolidBlue}, + {mino.Point{25, 4}, mino.BlockSolidBlue}, + + // S + {mino.Point{28, 0}, mino.BlockSolidMagenta}, + {mino.Point{29, 0}, mino.BlockSolidMagenta}, + {mino.Point{30, 0}, mino.BlockSolidMagenta}, + {mino.Point{31, 1}, mino.BlockSolidMagenta}, + {mino.Point{29, 2}, mino.BlockSolidMagenta}, + {mino.Point{30, 2}, mino.BlockSolidMagenta}, + {mino.Point{28, 3}, mino.BlockSolidMagenta}, + {mino.Point{29, 4}, mino.BlockSolidMagenta}, + {mino.Point{30, 4}, mino.BlockSolidMagenta}, + {mino.Point{31, 4}, mino.BlockSolidMagenta}, + } + + for _, titleBlock := range titleBlocks { + if !m.SetBlock(centerStart+titleBlock.X, titleBlock.Y, titleBlock.Block, false) { + log.Fatalf("failed to set title block %s", titleBlock.Point) + } + } + + return m +} diff --git a/cmd/netris/gui_input.go b/cmd/netris/gui_input.go index 2837c9f..925ed98 100644 --- a/cmd/netris/gui_input.go +++ b/cmd/netris/gui_input.go @@ -60,6 +60,7 @@ func scrollMessages(direction int) { draw <- event.DrawAll } +// Render functions called here don't need to be queued (Draw is called when nil is returned) func handleKeypress(ev *tcell.EventKey) *tcell.EventKey { k := ev.Key() r := ev.Rune() @@ -111,7 +112,19 @@ func handleKeypress(ev *tcell.EventKey) *tcell.EventKey { if titleScreen > 1 { switch k { case tcell.KeyEscape: - titleScreen = 1 + if titleScreen == 5 { + titleScreen = 4 + gameListSelected = 0 + titleSelectedButton = 0 + app.SetRoot(gameListContainerGrid, true) + renderGameList() + updateTitle() + return nil + } else if titleScreen == 4 { + titleScreen = 0 + } else { + titleScreen = 1 + } titleSelectedButton = 0 app.SetRoot(titleContainerGrid, true) @@ -157,6 +170,106 @@ func handleKeypress(ev *tcell.EventKey) *tcell.EventKey { app.SetRoot(modal, true) capturingKeybind = true + return nil + } + } else if titleScreen == 4 { + switch k { + case tcell.KeyUp: + if titleSelectedButton == 0 { + if gameListSelected > 0 { + gameListSelected-- + } + renderGameList() + } + return nil + case tcell.KeyBacktab: + previousTitleButton() + updateTitle() + renderGameList() + return nil + case tcell.KeyDown: + if titleSelectedButton == 0 { + if gameListSelected < len(gameList)-1 { + gameListSelected++ + } + renderGameList() + } + return nil + case tcell.KeyTab: + nextTitleButton() + updateTitle() + renderGameList() + return nil + case tcell.KeyEnter: + if titleSelectedButton == 0 { + if gameListSelected >= 0 && gameListSelected < len(gameList) { + joinGame <- gameList[gameListSelected].ID + } + } else if titleSelectedButton == 1 { + titleScreen = 5 + titleSelectedButton = 0 + + resetNewGameInputs() + app.SetRoot(newGameContainerGrid, true).SetFocus(nil) + updateTitle() + } else if titleSelectedButton == 2 { + titleScreen = 5 + titleSelectedButton = 0 + + modal := tview.NewModal().SetText("Joining another server by IP via GUI is not yet implemented.\nPlease re-launch netris with the --connect argument instead.\n\nPress Escape to go back").ClearButtons() + app.SetRoot(modal, true) + } else if titleSelectedButton == 3 { + titleScreen = 0 + titleSelectedButton = 0 + + app.SetRoot(titleContainerGrid, true) + updateTitle() + } + + return nil + default: + if titleSelectedButton == 0 { + switch r { + case 'j', 'J': + if gameListSelected < len(gameList)-1 { + gameListSelected++ + } + renderGameList() + return nil + case 'k', 'K': + if gameListSelected > 0 { + gameListSelected-- + } + renderGameList() + return nil + case 'r', 'R': + refreshGameList() + return nil + } + } + } + } else if titleScreen == 5 { + switch k { + case tcell.KeyBacktab: + previousTitleButton() + updateTitle() + return nil + case tcell.KeyTab: + nextTitleButton() + updateTitle() + return nil + case tcell.KeyEnter: + if titleSelectedButton == 3 { + titleScreen = 4 + gameListSelected = 0 + titleSelectedButton = 0 + app.SetRoot(gameListContainerGrid, true) + renderGameList() + updateTitle() + } else if titleSelectedButton == 4 { + joinGame <- event.GameIDNewCustom + } + return nil } } @@ -174,9 +287,7 @@ func handleKeypress(ev *tcell.EventKey) *tcell.EventKey { titleScreen = 2 titleSelectedButton = 0 - app.SetRoot(playerSettingsContainerGrid, true) - app.SetFocus(playerSettingsForm) - app.Draw() + app.SetRoot(playerSettingsContainerGrid, true).SetFocus(playerSettingsForm) return nil case 1: titleScreen = 3 @@ -186,9 +297,7 @@ func handleKeypress(ev *tcell.EventKey) *tcell.EventKey { draftKeybindings = make([]*Keybinding, len(keybindings)) copy(draftKeybindings, keybindings) - app.SetRoot(gameSettingsContainerGrid, true) - app.SetFocus(buttonKeybindRotateCCW) - app.Draw() + app.SetRoot(gameSettingsContainerGrid, true).SetFocus(buttonKeybindRotateCCW) return nil case 2: titleScreen = 0 @@ -216,10 +325,18 @@ func handleKeypress(ev *tcell.EventKey) *tcell.EventKey { } else { switch titleSelectedButton { case 0: - selectMode <- event.ModePlayOnline + titleScreen = 4 + titleSelectedButton = 0 + gameListSelected = 0 + + refreshGameList() + renderGameList() + + app.SetRoot(gameListContainerGrid, true).SetFocus(nil) + updateTitle() return nil case 1: - selectMode <- event.ModePractice + joinGame <- event.GameIDNewLocal return nil case 2: titleScreen = 1 diff --git a/cmd/netris/gui_title.go b/cmd/netris/gui_title.go index 9ad60ec..e99d4d3 100644 --- a/cmd/netris/gui_title.go +++ b/cmd/netris/gui_title.go @@ -1,11 +1,11 @@ package main import ( - "log" + "fmt" "math/rand" + "strconv" "time" - "git.sr.ht/~tslocum/netris/pkg/event" "git.sr.ht/~tslocum/netris/pkg/game" "git.sr.ht/~tslocum/netris/pkg/mino" "github.com/tslocum/tview" @@ -25,10 +25,24 @@ var ( titleGrid *tview.Grid titleContainerGrid *tview.Grid + gameListSelected int + + newGameGrid *tview.Grid + newGameNameInput *tview.InputField + newGameMaxPlayersInput *tview.InputField + newGameSpeedLimitInput *tview.InputField + playerSettingsForm *tview.Form playerSettingsGrid *tview.Grid playerSettingsContainerGrid *tview.Grid + gameList []*game.ListedGame + gameListHeader *tview.TextView + gameListView *tview.TextView + gameListGrid *tview.Grid + gameListContainerGrid *tview.Grid + newGameContainerGrid *tview.Grid + gameSettingsGrid *tview.Grid gameSettingsContainerGrid *tview.Grid gameGrid *tview.Grid @@ -61,7 +75,13 @@ func previousTitleButton() { } func nextTitleButton() { - if titleSelectedButton == 2 { + maxButton := 2 + if titleScreen == 4 { + maxButton = 3 + } else if titleScreen == 5 { + maxButton = 4 + } + if titleSelectedButton >= maxButton { return } @@ -122,6 +142,12 @@ func updateTitle() { buttonC.SetLabel("Return") buttonLabelC.SetText("\nReturn to the last screen") + } else if titleScreen == 4 { + buttonA.SetLabel("New Game") + + buttonB.SetLabel("Join by IP") + + buttonC.SetLabel("Return") } else { if joinedGame { buttonA.SetLabel("Resume") @@ -144,7 +170,35 @@ func updateTitle() { } } - if titleScreen > 1 { + if titleScreen == 4 { + switch titleSelectedButton { + case 2: + app.SetFocus(buttonB) + case 3: + app.SetFocus(buttonC) + case 1: + app.SetFocus(buttonA) + default: + app.SetFocus(nil) + } + + return + } else if titleScreen == 5 { + switch titleSelectedButton { + case 1: + app.SetFocus(newGameMaxPlayersInput) + case 2: + app.SetFocus(newGameSpeedLimitInput) + case 3: + app.SetFocus(buttonCancel) + case 4: + app.SetFocus(buttonStart) + default: + app.SetFocus(newGameNameInput) + } + + return + } else if titleScreen > 1 { return } @@ -261,123 +315,129 @@ func renderTitle() { renderLock.Unlock() } -func newTitleMatrixSide() *mino.Matrix { - ev := make(chan interface{}) - go func() { - for range ev { +func renderGameList() { + w := 36 + + gameListView.Clear() + gameListView.Write(renderULCorner) + for i := 0; i < w; i++ { + gameListView.Write(renderHLine) + } + gameListView.Write(renderURCorner) + gameListView.Write([]byte("\n")) + + gameListView.Write(renderVLine) + gameListView.Write([]byte(fmt.Sprintf("%-29s%s", "Game", "Players"))) + gameListView.Write(renderVLine) + gameListView.Write([]byte("\n")) + + gameListView.Write(renderLTee) + for i := 0; i < w; i++ { + gameListView.Write(renderHLine) + } + gameListView.Write(renderRTee) + gameListView.Write([]byte("\n")) + + h := 8 + + for i, g := range gameList { + p := strconv.Itoa(g.Players) + if g.MaxPlayers > 0 { + p += "/" + strconv.Itoa(g.MaxPlayers) } - }() - draw := make(chan event.DrawObject) - go func() { - for range draw { + gameListView.Write(renderVLine) + if titleSelectedButton == 0 && gameListSelected == i { + gameListView.Write([]byte("[#000000:#FFFFFF]")) } - }() - - m := mino.NewMatrix(21, 24, 0, 1, ev, draw, mino.MatrixCustom) - - return m -} - -func newTitleMatrixName() *mino.Matrix { - ev := make(chan interface{}) - go func() { - for range ev { + gameListView.Write([]byte(fmt.Sprintf("%-29s%7s", g.Name, p))) + if titleSelectedButton == 0 && gameListSelected == i { + gameListView.Write([]byte("[-:-]")) } - }() + gameListView.Write(renderVLine) + gameListView.Write([]byte("\n")) - draw := make(chan event.DrawObject) - go func() { - for range draw { - } - }() - - m := mino.NewMatrix(36, 7, 0, 1, ev, draw, mino.MatrixCustom) - - centerStart := (m.W / 2) - 17 - - var titleBlocks = []struct { - mino.Point - mino.Block - }{ - // N - {mino.Point{0, 0}, mino.BlockSolidRed}, - {mino.Point{0, 1}, mino.BlockSolidRed}, - {mino.Point{0, 2}, mino.BlockSolidRed}, - {mino.Point{0, 3}, mino.BlockSolidRed}, - {mino.Point{0, 4}, mino.BlockSolidRed}, - {mino.Point{1, 3}, mino.BlockSolidRed}, - {mino.Point{2, 2}, mino.BlockSolidRed}, - {mino.Point{3, 1}, mino.BlockSolidRed}, - {mino.Point{4, 0}, mino.BlockSolidRed}, - {mino.Point{4, 1}, mino.BlockSolidRed}, - {mino.Point{4, 2}, mino.BlockSolidRed}, - {mino.Point{4, 3}, mino.BlockSolidRed}, - {mino.Point{4, 4}, mino.BlockSolidRed}, - - // E - {mino.Point{7, 0}, mino.BlockSolidYellow}, - {mino.Point{7, 1}, mino.BlockSolidYellow}, - {mino.Point{7, 2}, mino.BlockSolidYellow}, - {mino.Point{7, 3}, mino.BlockSolidYellow}, - {mino.Point{7, 4}, mino.BlockSolidYellow}, - {mino.Point{8, 0}, mino.BlockSolidYellow}, - {mino.Point{9, 0}, mino.BlockSolidYellow}, - {mino.Point{8, 2}, mino.BlockSolidYellow}, - {mino.Point{9, 2}, mino.BlockSolidYellow}, - {mino.Point{8, 4}, mino.BlockSolidYellow}, - {mino.Point{9, 4}, mino.BlockSolidYellow}, - - // T - {mino.Point{12, 4}, mino.BlockSolidGreen}, - {mino.Point{13, 4}, mino.BlockSolidGreen}, - {mino.Point{14, 0}, mino.BlockSolidGreen}, - {mino.Point{14, 1}, mino.BlockSolidGreen}, - {mino.Point{14, 2}, mino.BlockSolidGreen}, - {mino.Point{14, 3}, mino.BlockSolidGreen}, - {mino.Point{14, 4}, mino.BlockSolidGreen}, - {mino.Point{15, 4}, mino.BlockSolidGreen}, - {mino.Point{16, 4}, mino.BlockSolidGreen}, - - // R - {mino.Point{19, 0}, mino.BlockSolidCyan}, - {mino.Point{19, 1}, mino.BlockSolidCyan}, - {mino.Point{19, 2}, mino.BlockSolidCyan}, - {mino.Point{19, 3}, mino.BlockSolidCyan}, - {mino.Point{19, 4}, mino.BlockSolidCyan}, - {mino.Point{20, 2}, mino.BlockSolidCyan}, - {mino.Point{20, 4}, mino.BlockSolidCyan}, - {mino.Point{21, 2}, mino.BlockSolidCyan}, - {mino.Point{21, 4}, mino.BlockSolidCyan}, - {mino.Point{22, 0}, mino.BlockSolidCyan}, - {mino.Point{22, 1}, mino.BlockSolidCyan}, - {mino.Point{22, 3}, mino.BlockSolidCyan}, - - // I - {mino.Point{25, 0}, mino.BlockSolidBlue}, - {mino.Point{25, 1}, mino.BlockSolidBlue}, - {mino.Point{25, 2}, mino.BlockSolidBlue}, - {mino.Point{25, 3}, mino.BlockSolidBlue}, - {mino.Point{25, 4}, mino.BlockSolidBlue}, - - // S - {mino.Point{28, 0}, mino.BlockSolidMagenta}, - {mino.Point{29, 0}, mino.BlockSolidMagenta}, - {mino.Point{30, 0}, mino.BlockSolidMagenta}, - {mino.Point{31, 1}, mino.BlockSolidMagenta}, - {mino.Point{29, 2}, mino.BlockSolidMagenta}, - {mino.Point{30, 2}, mino.BlockSolidMagenta}, - {mino.Point{28, 3}, mino.BlockSolidMagenta}, - {mino.Point{29, 4}, mino.BlockSolidMagenta}, - {mino.Point{30, 4}, mino.BlockSolidMagenta}, - {mino.Point{31, 4}, mino.BlockSolidMagenta}, + h-- } - for _, titleBlock := range titleBlocks { - if !m.SetBlock(centerStart+titleBlock.X, titleBlock.Y, titleBlock.Block, false) { - log.Fatalf("failed to set title block %s", titleBlock.Point) + if h > 0 { + for i := 0; i < h; i++ { + gameListView.Write(renderVLine) + for i := 0; i < w; i++ { + gameListView.Write([]byte(" ")) + } + gameListView.Write(renderVLine) } } - return m + gameListView.Write(renderLLCorner) + for i := 0; i < w; i++ { + gameListView.Write(renderHLine) + } + gameListView.Write(renderLRCorner) +} + +func refreshGameList() { + app.QueueUpdateDraw(func() { + gameListHeader.SetText("Finding games...") + }) + + go func() { + ok := fetchGameList() + app.QueueUpdateDraw(func() { + if !ok { + gameListHeader.SetText("Failed to connect to game server") + return + } + + var plural string + if len(gameList) != 1 { + plural = "s" + } + + gameListHeader.SetText(fmt.Sprintf("Found %d game%s", len(gameList), plural)) + }) + }() +} + +func fetchGameList() bool { + s, err := game.Connect(connectAddress) + if err != nil { + return false + } + + s.Write(&game.GameCommandListGames{}) + + t := time.NewTimer(10 * time.Second) + for { + select { + case <-t.C: + return false + case e := <-s.In: + if e.Command() == game.CommandListGames { + if p, ok := e.(*game.GameCommandListGames); ok { + gameList = p.Games + if gameListSelected >= len(gameList) { + gameListSelected = len(gameList) - 1 + } + + app.QueueUpdateDraw(renderGameList) + + s.Close() + + if !t.Stop() { + <-t.C + } + + return true + } + } + } + } +} + +func resetNewGameInputs() { + newGameNameInput.SetText("netris") + newGameMaxPlayersInput.SetText("0") + newGameSpeedLimitInput.SetText("0") } diff --git a/cmd/netris/main.go b/cmd/netris/main.go index cccd64a..7c0bf81 100644 --- a/cmd/netris/main.go +++ b/cmd/netris/main.go @@ -136,9 +136,11 @@ func main() { done <- true }() - // Connect automatically when an address or path is supplied + // TODO Connect automatically when an address or path is supplied if connectAddress != "" { - selectMode <- event.ModePlayOnline + serverAddress = connectAddress + } else { + connectAddress = serverAddress } var ( @@ -161,25 +163,50 @@ func main() { }(server) for { - mode := <-selectMode - switch mode { - case event.ModePlayOnline: + gameID := <-joinGame + + if server != nil { + server.StopListening() + server = nil + } + if localListenDir != "" { + os.RemoveAll(localListenDir) + localListenDir = "" + } + + if gameID == event.GameIDNewCustom || gameID >= 0 { joinedGame = true setTitleVisible(false) - if connectAddress == "" { - connectAddress = serverAddress - } - connectNetwork, _ := game.NetworkAndAddress(connectAddress) if connectNetwork != "unix" { logMessage(fmt.Sprintf("* Connecting to %s...", connectAddress)) } - s := game.Connect(connectAddress) + s, err := game.Connect(connectAddress) + if err != nil { + log.Fatal(err) + } - activeGame, err = s.JoinGame(nickname, 0, logger, draw) + var newGame *game.ListedGame + if gameID == event.GameIDNewCustom { + gameID = 0 + + maxPlayers, err := strconv.Atoi(newGameMaxPlayersInput.GetText()) + if err != nil { + maxPlayers = 0 + } + + speedLimit, err := strconv.Atoi(newGameSpeedLimitInput.GetText()) + if err != nil { + speedLimit = 0 + } + + newGame = &game.ListedGame{Name: game.GameName(newGameNameInput.GetText()), MaxPlayers: maxPlayers, SpeedLimit: speedLimit} + } + + activeGame, err = s.JoinGame(nickname, gameID, newGame, logger, draw) if err != nil { log.Fatalf("failed to connect to %s: %s", connectAddress, err) } @@ -189,66 +216,70 @@ func main() { } activeGame.LogLevel = logLevel - case event.ModePractice: - joinedGame = true - setTitleVisible(false) + continue + } - server = game.NewServer(nil) + joinedGame = true + setTitleVisible(false) - server.Logger = make(chan string, game.LogQueueSize) - if logDebug || logVerbose { - go func() { - for msg := range server.Logger { - logMessage("Local server: " + msg) - } - }() - } else { - go func() { - for range server.Logger { - } - }() - } + server = game.NewServer(nil) - localListenDir, err = ioutil.TempDir("", "netris") - if err != nil { - log.Fatal(err) - } - - localListenAddress := path.Join(localListenDir, "netris.sock") - - go server.Listen(localListenAddress) - - localServerConn := game.Connect(localListenAddress) - - activeGame, err = localServerConn.JoinGame(nickname, -1, logger, draw) - if err != nil { - panic(err) - } - - activeGame.LogLevel = logLevel - - if startMatrix != "" { - activeGame.Players[activeGame.LocalPlayer].Matrix.Lock() - startMatrixSplit := strings.Split(startMatrix, ",") - startMatrix = "" - var ( - token int - x int - err error - ) - for i := range startMatrixSplit { - token, err = strconv.Atoi(startMatrixSplit[i]) - if err != nil { - panic(fmt.Sprintf("failed to parse initial matrix on token #%d", i)) - } - if i%2 == 1 { - activeGame.Players[activeGame.LocalPlayer].Matrix.SetBlock(x, token, mino.BlockGarbage, false) - } else { - x = token - } + server.Logger = make(chan string, game.LogQueueSize) + if logDebug || logVerbose { + go func() { + for msg := range server.Logger { + logMessage("Local server: " + msg) + } + }() + } else { + go func() { + for range server.Logger { + } + }() + } + + localListenDir, err = ioutil.TempDir("", "netris") + if err != nil { + log.Fatal(err) + } + + localListenAddress := path.Join(localListenDir, "netris.sock") + + go server.Listen(localListenAddress) + + localServerConn, err := game.Connect(localListenAddress) + if err != nil { + log.Fatal(err) + } + + activeGame, err = localServerConn.JoinGame(nickname, event.GameIDNewLocal, nil, logger, draw) + if err != nil { + panic(err) + } + + activeGame.LogLevel = logLevel + + if startMatrix != "" { + activeGame.Players[activeGame.LocalPlayer].Matrix.Lock() + startMatrixSplit := strings.Split(startMatrix, ",") + startMatrix = "" + var ( + token int + x int + err error + ) + for i := range startMatrixSplit { + token, err = strconv.Atoi(startMatrixSplit[i]) + if err != nil { + panic(fmt.Sprintf("failed to parse initial matrix on token #%d", i)) + } + if i%2 == 1 { + activeGame.Players[activeGame.LocalPlayer].Matrix.SetBlock(x, token, mino.BlockGarbage, false) + } else { + x = token } - activeGame.Players[activeGame.LocalPlayer].Matrix.Unlock() } + activeGame.Players[activeGame.LocalPlayer].Matrix.Unlock() } } } diff --git a/go.mod b/go.mod index 4fef812..01f075b 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,8 @@ require ( github.com/gdamore/tcell v1.3.0 github.com/gliderlabs/ssh v0.2.2 github.com/mattn/go-isatty v0.0.10 + github.com/mattn/go-runewidth v0.0.5 // indirect github.com/tslocum/tview v0.0.0-20191018041445-09b275a4b660 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 - golang.org/x/sys v0.0.0-20191020212454-3e7259c5e7c2 // indirect + golang.org/x/sys v0.0.0-20191025090151-53bf42e6b339 // indirect ) diff --git a/go.sum b/go.sum index f4c30e4..2f63557 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW1 github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.5 h1:jrGtp51JOKTWgvLFzfG6OtZOJcK2sEnzc/U+zw7TtbA= +github.com/mattn/go-runewidth v0.0.5/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/tslocum/tview v0.0.0-20191018041445-09b275a4b660 h1:f/g7DFokEN2PRyGN4vIadgCDTTOoBoxiIxf4q1Re9PI= @@ -33,8 +35,8 @@ golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY= golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191020212454-3e7259c5e7c2 h1:nq114VpM8lsSlP+lyUbANecYHYiFcSNFtqcBlxRV+gA= -golang.org/x/sys v0.0.0-20191020212454-3e7259c5e7c2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191025090151-53bf42e6b339 h1:zSqWKgm/o7HAnlAzBQ+aetp9fpuyytsXnKA8eiLHYQM= +golang.org/x/sys v0.0.0-20191025090151-53bf42e6b339/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= diff --git a/pkg/event/game.go b/pkg/event/game.go index a109061..a81d793 100644 --- a/pkg/event/game.go +++ b/pkg/event/game.go @@ -1,9 +1,6 @@ package event -type GameMode int - const ( - ModeUnknown = iota - ModePractice - ModePlayOnline + GameIDNewCustom = -2 + GameIDNewLocal = -1 ) diff --git a/pkg/game/command.go b/pkg/game/command.go index 2d5f192..3a97baa 100644 --- a/pkg/game/command.go +++ b/pkg/game/command.go @@ -27,6 +27,7 @@ const ( CommandSendGarbage CommandReceiveGarbage CommandStats + CommandListGames ) func (c Command) String() string { @@ -63,6 +64,8 @@ func (c Command) String() string { return "Garbage-IN" case CommandStats: return "Stats" + case CommandListGames: + return "ListGames" default: return strconv.Itoa(int(c)) } @@ -75,7 +78,7 @@ type GameCommandInterface interface { } type GameCommand struct { - SourcePlayer int + SourcePlayer int `json:"sp,omitempty"` } func (gc *GameCommand) Source() int { @@ -96,8 +99,8 @@ func (gc *GameCommand) SetSource(source int) { type GameCommandDisconnect struct { GameCommand - Player int - Message string + Player int `json:"p,omitempty"` + Message string `json:"m,omitempty"` } func (gc GameCommandDisconnect) Command() Command { @@ -106,7 +109,7 @@ func (gc GameCommandDisconnect) Command() Command { type GameCommandPing struct { GameCommand - Message string + Message string `json:"m,omitempty"` } func (gc GameCommandPing) Command() Command { @@ -115,7 +118,7 @@ func (gc GameCommandPing) Command() Command { type GameCommandPong struct { GameCommand - Message string + Message string `json:"m,omitempty"` } func (gc GameCommandPong) Command() Command { @@ -124,8 +127,8 @@ func (gc GameCommandPong) Command() Command { type GameCommandNickname struct { GameCommand - Player int - Nickname string + Player int `json:"p,omitempty"` + Nickname string `json:"n,omitempty"` } func (gc GameCommandNickname) Command() Command { @@ -134,8 +137,8 @@ func (gc GameCommandNickname) Command() Command { type GameCommandMessage struct { GameCommand - Player int - Message string + Player int `json:"p,omitempty"` + Message string `json:"m,omitempty"` } func (gc GameCommandMessage) Command() Command { @@ -144,10 +147,12 @@ func (gc GameCommandMessage) Command() Command { type GameCommandJoinGame struct { GameCommand - Version int - Name string - GameID int - PlayerID int + Version int `json:"v,omitempty"` + Name string `json:"n,omitempty"` + GameID int `json:"g,omitempty"` + PlayerID int `json:"p,omitempty"` + + Listing ListedGame `json:"l,omitempty"` } func (gc GameCommandJoinGame) Command() Command { @@ -156,7 +161,7 @@ func (gc GameCommandJoinGame) Command() Command { type GameCommandQuitGame struct { GameCommand - Player int + Player int `json:"p,omitempty"` } func (gc GameCommandQuitGame) Command() Command { @@ -165,7 +170,7 @@ func (gc GameCommandQuitGame) Command() Command { type GameCommandUpdateGame struct { GameCommand - Players map[int]string + Players map[int]string `json:"p,omitempty"` } func (gc GameCommandUpdateGame) Command() Command { @@ -174,8 +179,8 @@ func (gc GameCommandUpdateGame) Command() Command { type GameCommandStartGame struct { GameCommand - Seed int64 - Started bool + Seed int64 `json:"s,omitempty"` + Started bool `json:"st,omitempty"` } func (gc GameCommandStartGame) Command() Command { @@ -184,7 +189,7 @@ func (gc GameCommandStartGame) Command() Command { type GameCommandUpdateMatrix struct { GameCommand - Matrixes map[int]*mino.Matrix + Matrixes map[int]*mino.Matrix `json:"m,omitempty"` } func (gc GameCommandUpdateMatrix) Command() Command { @@ -193,8 +198,8 @@ func (gc GameCommandUpdateMatrix) Command() Command { type GameCommandGameOver struct { GameCommand - Player int - Winner string + Player int `json:"p,omitempty"` + Winner string `json:"w,omitempty"` } func (gc GameCommandGameOver) Command() Command { @@ -203,7 +208,7 @@ func (gc GameCommandGameOver) Command() Command { type GameCommandSendGarbage struct { GameCommand - Lines int + Lines int `json:"l,omitempty"` } func (gc GameCommandSendGarbage) Command() Command { @@ -212,7 +217,7 @@ func (gc GameCommandSendGarbage) Command() Command { type GameCommandReceiveGarbage struct { GameCommand - Lines int + Lines int `json:"l,omitempty"` } func (gc GameCommandReceiveGarbage) Command() Command { @@ -221,11 +226,28 @@ func (gc GameCommandReceiveGarbage) Command() Command { type GameCommandStats struct { GameCommand - Created time.Time - Players int - Games int + Created time.Time `json:"c,omitempty"` + Players int `json:"p,omitempty"` + Games int `json:"g,omitempty"` } func (gc GameCommandStats) Command() Command { return CommandStats } + +type ListedGame struct { + ID int + Name string `json:"n,omitempty"` + Players int `json:"p,omitempty"` + MaxPlayers int `json:"pl,omitempty"` + SpeedLimit int `json:"sl,omitempty"` +} +type GameCommandListGames struct { + GameCommand + + Games []*ListedGame `json:"g,omitempty"` +} + +func (gc GameCommandListGames) Command() Command { + return CommandListGames +} diff --git a/pkg/game/conn.go b/pkg/game/conn.go index 4db2694..8f638e5 100644 --- a/pkg/game/conn.go +++ b/pkg/game/conn.go @@ -54,7 +54,7 @@ func NewServerConn(conn net.Conn, forwardOut chan GameCommandInterface) *Conn { return &c } -func Connect(address string) *Conn { +func Connect(address string) (*Conn, error) { var ( network string conn net.Conn @@ -67,7 +67,7 @@ func Connect(address string) *Conn { conn, err = net.DialTimeout(network, address, ConnTimeout) if err != nil { if tries > 25 { - log.Fatalf("failed to connect to %s: %s", address, err) + return nil, fmt.Errorf("failed to connect to %s: %s", address, err) } else { time.Sleep(250 * time.Millisecond) @@ -76,7 +76,7 @@ func Connect(address string) *Conn { } } - return NewServerConn(conn, nil) + return NewServerConn(conn, nil), nil } } @@ -211,7 +211,12 @@ func (s *Conn) handleRead() { var mgc GameCommandStats um(&mgc) gc = &mgc + case CommandListGames: + var mgc GameCommandListGames + um(&mgc) + gc = &mgc default: + // TODO Place beind debug log level log.Println("unknown serverconn command", scanner.Text()) continue } @@ -295,8 +300,16 @@ func (s *Conn) Close() { }() } -func (s *Conn) JoinGame(name string, gameID int, logger chan string, draw chan event.DrawObject) (*Game, error) { - s.Write(&GameCommandJoinGame{Name: name, GameID: gameID}) +// When newGame is set to a ListedGame and gameID is 0, a new custom game is created +func (s *Conn) JoinGame(name string, gameID int, newGame *ListedGame, logger chan string, draw chan event.DrawObject) (*Game, error) { + joinGameCommand := GameCommandJoinGame{Name: name, GameID: gameID} + if newGame != nil { + joinGameCommand.Listing.Name = newGame.Name + joinGameCommand.Listing.MaxPlayers = newGame.MaxPlayers + joinGameCommand.Listing.SpeedLimit = newGame.SpeedLimit + } + s.Write(&joinGameCommand) + var ( g *Game err error diff --git a/pkg/game/game.go b/pkg/game/game.go index 6a2d820..785dc49 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -3,7 +3,9 @@ package game import ( "fmt" "log" + "regexp" "strconv" + "strings" "sync" "time" @@ -26,20 +28,26 @@ const ( var Version = "0.0.0" +var gameNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9_\-!@#$%^&*+=,./?()\[\]{};:<>'" ]+`) + type Game struct { - ID int + ID int + Name string Starting bool Started bool TimeStarted time.Time gameOver bool sentGameOverMatrix bool - Terminated bool + + Eternal bool + Terminated bool Local bool LocalPlayer int nextPlayer int Players map[int]*Player + MaxPlayers int Event chan interface{} out func(GameCommandInterface) @@ -48,10 +56,12 @@ type Game struct { logger chan string LogLevel int - Rank int - Minos []mino.Mino - Seed int64 - FallTime time.Duration + Rank int + Minos []mino.Mino + Seed int64 + + FallTime time.Duration + SpeedLimit int sentPing time.Time *sync.Mutex @@ -77,6 +87,7 @@ func NewGame(rank int, out func(GameCommandInterface), logger chan string, draw } g := &Game{ + Name: "netris", Rank: rank, Minos: minos, nextPlayer: 1, @@ -97,6 +108,8 @@ func NewGame(rank int, out func(GameCommandInterface), logger chan string, draw g.FallTime = 850 * time.Millisecond + go g.handleDropTerminatedPlayers() + return g, nil } @@ -333,7 +346,7 @@ func (g *Game) ResetL() { } func (g *Game) StopL() { - if g.Terminated { + if g.Terminated || g.Eternal { return } @@ -389,11 +402,6 @@ func (g *Game) handleDistributeMatrixes() { remainingPlayers := 0 for playerID, p := range g.Players { - if p.Terminated { - g.RemovePlayerL(playerID) - continue - } - if !g.gameOver && !p.Matrix.GameOver && !g.Local && time.Since(p.Moved) >= IdleStart && time.Since(g.TimeStarted) >= IdleStart { p.Idle += UpdateDuration if p.Idle >= IdleTimeout { @@ -759,3 +767,30 @@ func (g *Game) ProcessAction(a event.GameAction) { } } } + +func (g *Game) handleDropTerminatedPlayers() { + for { + time.Sleep(15 * time.Second) + + if g.Terminated { + return + } + + for playerID, p := range g.Players { + if p.Terminated { + g.RemovePlayerL(playerID) + } + } + } +} + +func GameName(name string) string { + name = gameNameRegexp.ReplaceAllString(strings.TrimSpace(name), "") + if len(name) > 28 { + name = name[:28] + } else if name == "" { + name = "netris" + } + + return name +} diff --git a/pkg/game/player.go b/pkg/game/player.go index 6754287..22fe590 100644 --- a/pkg/game/player.go +++ b/pkg/game/player.go @@ -14,7 +14,7 @@ const ( PlayerUnknown = 0 ) -var nickRegexp = regexp.MustCompile(`[^a-zA-Z0-9_\-!@#$%^&*+=,./]+`) +var nickRegexp = regexp.MustCompile(`[^a-zA-Z0-9_\-!@#$%^&*+=,./?]+`) type ConnectingPlayer struct { Name string diff --git a/pkg/game/server.go b/pkg/game/server.go index 8408785..9bbb054 100644 --- a/pkg/game/server.go +++ b/pkg/game/server.go @@ -1,9 +1,11 @@ package game import ( + "encoding/json" "fmt" "log" "net" + "sort" "strings" "sync" "time" @@ -51,6 +53,36 @@ func NewServer(si []ServerInterface) *Server { s := &Server{I: si, In: in, Out: out, Games: make(map[int]*Game), created: time.Now()} + var ( + g *Game + err error + ) + g, err = s.NewGame() + if err != nil { + log.Fatal(err) + } + + g.Eternal = true + g.Name = "No speed limit" + + g, err = s.NewGame() + if err != nil { + log.Fatal(err) + } + + g.Eternal = true + g.Name = "Speed limit 100" + g.SpeedLimit = 100 + + g, err = s.NewGame() + if err != nil { + log.Fatal(err) + } + + g.Eternal = true + g.Name = "Speed limit 40" + g.SpeedLimit = 40 + s.NewPlayers = make(chan *IncomingPlayer, CommandQueueSize) go s.accept() @@ -118,7 +150,7 @@ func (s *Server) removeTerminatedGames() { } } -func (s *Server) FindGame(p *Player, gameID int) *Game { +func (s *Server) FindGame(p *Player, gameID int, newGame ListedGame) *Game { s.Lock() defer s.Unlock() @@ -127,40 +159,68 @@ func (s *Server) FindGame(p *Player, gameID int) *Game { err error ) - // Join a game by its ID - if gameID > 0 { - if gm, ok := s.Games[gameID]; ok && !gm.Terminated { - g = gm - } - } - - // Join any game - if g == nil { - for _, gm := range s.Games { - if gm != nil && !gm.Terminated { - g = gm - break - } - } - } - - // Create a new game - if g == nil { + if newGame.Name != "" { + // Create a custom game g, err = s.NewGame() if err != nil { panic(err) } - if gameID == -1 { - g.Local = true + g.Lock() + + g.Name = GameName(newGame.Name) + + g.MaxPlayers = newGame.MaxPlayers + if g.MaxPlayers < 0 { + g.MaxPlayers = 0 + } else if g.MaxPlayers > 999 { + g.MaxPlayers = 999 } + + g.SpeedLimit = newGame.SpeedLimit + if g.SpeedLimit < 0 { + g.SpeedLimit = 0 + } else if g.SpeedLimit > 999 { + g.SpeedLimit = 999 + } + + g.Unlock() + } else if gameID > 0 { + // Join a game by its ID + if gm, ok := s.Games[gameID]; ok && !gm.Terminated && (gm.MaxPlayers == 0 || len(gm.Players) < gm.MaxPlayers) { + g = gm + } else { + p.Write(&GameCommandMessage{Message: "Failed to join game - Player limit reached"}) + return nil + } + } else if gameID == 0 { + // Join any game + for _, gm := range s.Games { + if gm != nil && !gm.Terminated && (gm.MaxPlayers == 0 || len(gm.Players) < gm.MaxPlayers) { + g = gm + break + } + } + } else { + // Create a local game + g, err = s.NewGame() + if err != nil { + panic(err) + } + + g.Local = true + } + + if g == nil { + p.Write(&GameCommandMessage{Message: "Failed to join game"}) + return nil } g.Lock() g.AddPlayerL(p) - if gameID == -1 { + if gameID == event.GameIDNewLocal { go g.Start(0) } else if len(g.Players) > 1 { go s.initiateAutoStart(g) @@ -179,23 +239,61 @@ func (s *Server) accept() { p := NewPlayer(np.Name, np.Conn) - s.Log("Incoming connection from ", np.Name) - - go s.handleJoinGame(p) + go s.handleNewPlayer(p) } } -func (s *Server) handleJoinGame(pl *Player) { +func (s *Server) handleNewPlayer(pl *Player) { + handled := false + go func() { + time.Sleep(10 * time.Second) + if !handled { + pl.Close() + } + }() + for e := range pl.In { - if e.Command() == CommandJoinGame { + switch e.Command() { + case CommandListGames: + if _, ok := e.(*GameCommandListGames); ok { + var gl []*ListedGame + + for _, g := range s.Games { + if g.Terminated { + continue + } + + gl = append(gl, &ListedGame{ID: g.ID, Name: g.Name, Players: len(g.Players), MaxPlayers: g.MaxPlayers, SpeedLimit: g.SpeedLimit}) + } + + sort.Slice(gl, func(i, j int) bool { + if gl[i].Players == gl[j].Players { + return gl[i].Name < gl[j].Name + } + + return gl[i].Players > gl[j].Players + }) + + pl.Write(&GameCommandListGames{Games: gl}) + } + case CommandJoinGame: if p, ok := e.(*GameCommandJoinGame); ok { pl.Name = Nickname(p.Name) - g := s.FindGame(pl, p.GameID) + g := s.FindGame(pl, p.GameID, p.Listing) + if g == nil { + return + } - s.Logf("Adding %s to game %d", pl.Name, g.ID) + if p.Listing.Name == "" { + g.Logf(LogStandard, "Player %s joined %s", pl.Name, g.Name) + } else { + g.Logf(LogStandard, "Player %s created new game %s", pl.Name, g.Name) + } go s.handleGameCommands(pl, g) + + handled = true return } } @@ -220,10 +318,19 @@ func (s *Server) initiateAutoStart(g *Game) { } func (s *Server) handleGameCommands(pl *Player, g *Game) { + var ( + msgJSON []byte + err error + ) for e := range pl.In { c := e.Command() if (c != CommandPing && c != CommandPong && c != CommandUpdateMatrix) || g.LogLevel >= LogVerbose { - s.Log("REMOTE handle game command ", e.Command(), " from ", e.Source(), e) + msgJSON, err = json.Marshal(e) + if err != nil { + log.Fatal(err) + } + + g.Logf(LogStandard, "%d -> %s %s", e.Source(), e.Command(), msgJSON) } g.Lock() @@ -232,7 +339,7 @@ func (s *Server) handleGameCommands(pl *Player, g *Game) { case CommandMessage: if p, ok := e.(*GameCommandMessage); ok { if player, ok := g.Players[p.SourcePlayer]; ok { - s.Log("<" + player.Name + "> " + p.Message) + s.Logf("<%s> %s", player.Name, p.Message) msg := strings.ReplaceAll(strings.TrimSpace(p.Message), "\n", "") if msg != "" { @@ -258,6 +365,13 @@ func (s *Server) handleGameCommands(pl *Player, g *Game) { if pl, ok := g.Players[p.SourcePlayer]; ok { for _, m := range p.Matrixes { pl.Matrix.Replace(m) + + if g.SpeedLimit > 0 && m.Speed > g.SpeedLimit+5 && time.Since(g.TimeStarted) > 7*time.Second { + pl.Matrix.SetGameOver() + + g.WriteMessage(fmt.Sprintf("%s went too fast and crashed", pl.Name)) + g.WriteAllL(&GameCommandGameOver{Player: p.SourcePlayer}) + } } m := pl.Matrix diff --git a/pkg/mino/matrix.go b/pkg/mino/matrix.go index 9560cb0..0732567 100644 --- a/pkg/mino/matrix.go +++ b/pkg/mino/matrix.go @@ -35,26 +35,26 @@ type Matrix struct { Bag *Bag `json:"-"` P *Piece - PlayerName string + PlayerName string `json:"pn,omitempty"` - Type MatrixType + Type MatrixType `json:"ty,omitempty"` Event chan<- interface{} `json:"-"` Move chan int `json:"-"` draw chan event.DrawObject - Combo int + Combo int `json:"mc,omitempty"` ComboStart time.Time `json:"-"` ComboEnd time.Time `json:"-"` PendingGarbage int `json:"-"` PendingGarbageTime time.Time `json:"-"` - LinesCleared int - GarbageSent int - GarbageReceived int - Speed int + LinesCleared int `json:"lc,omitempty"` + GarbageSent int `json:"gs,omitempty"` + GarbageReceived int `json:"gr,omitempty"` + Speed int `json:"sp,omitempty"` - GameOver bool + GameOver bool `json:"go,omitempty"` lands []time.Time diff --git a/pkg/mino/piece.go b/pkg/mino/piece.go index 9d7ba32..6ed5800 100644 --- a/pkg/mino/piece.go +++ b/pkg/mino/piece.go @@ -73,11 +73,11 @@ var AllRotationOffsets = map[PieceType][]RotationOffsets{ {{0, 0}, {1, 2}, {0, 0}, {-1, 2}}}} */ type Piece struct { - Point - Mino - Ghost Block - Solid Block - Rotation int + Point `json:"pp,omitempty"` + Mino `json:"pm,omitempty"` + Ghost Block `json:"pg,omitempty"` + Solid Block `json:"ps,omitempty"` + Rotation int `json:"pr,omitempty"` original Mino pivotsCW []Point