From 0b78a5a4d6d3c65afd5923a1873d9fa5ba002534 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Wed, 21 Aug 2024 17:20:21 +1000 Subject: [PATCH] feat: implement ultra game mode (#10) * refactor: use KVPs for horizontal picker component; add ultra to mode picker * refactor: move subcommands * refactor: move shared code * refactor: rename marathon package to single * docs(readme): amend docs to use new play subcommand * fix: update play subcommand game modes * chore: duplicate marathon tui code for ultra * refactor: rename to gameStopwatch * feat: implement ultra game mode * feat: combine ultra & marathon models into 'single' model * chore: fix lint * fix: scoring * refactor: move from uint to int I was using uint to enforce values being positive, but this is not a good use case for the type. It also overcomplicates things due to so much casting between uint and int. Instead I will validate the Scoring type, which is the container of the strictly positive integers. * feat: validate scoring type on creation * chore: fix lint * test: fix scoring test --- README.md | 2 +- cmd/tetrigo/main.go | 62 ------- cmd/tetrigo/subcommands.go | 67 +++++++ internal/config/config.go | 2 +- .../{ => common}/components/hpicker/keymap.go | 0 .../{ => common}/components/hpicker/model.go | 18 +- .../{ => common}/components/hpicker/styles.go | 0 .../components/textinput/textinput.go | 0 internal/tui/common/input.go | 20 ++- internal/tui/common/keys.go | 18 -- internal/tui/common/mode.go | 28 ++- internal/tui/common/overlay.go | 33 +++- internal/tui/game/keymap.go | 76 ++++++++ internal/tui/{marathon => game}/styles.go | 2 +- internal/tui/marathon/keymap.go | 62 ------- internal/tui/menu/model.go | 71 ++++---- internal/tui/{marathon => single}/model.go | 165 +++++++++++------- internal/tui/starter/model.go | 8 +- pkg/tetris/fall.go | 4 +- pkg/tetris/matrix.go | 2 +- pkg/tetris/matrix_test.go | 4 +- .../modes/{marathon => single}/getters.go | 8 +- .../marathon.go => single/single.go} | 128 ++++++++------ pkg/tetris/scoring.go | 97 +++++++--- pkg/tetris/scoring_test.go | 91 +++++++--- 25 files changed, 598 insertions(+), 370 deletions(-) create mode 100644 cmd/tetrigo/subcommands.go rename internal/tui/{ => common}/components/hpicker/keymap.go (100%) rename internal/tui/{ => common}/components/hpicker/model.go (85%) rename internal/tui/{ => common}/components/hpicker/styles.go (100%) rename internal/tui/{ => common}/components/textinput/textinput.go (100%) delete mode 100644 internal/tui/common/keys.go create mode 100644 internal/tui/game/keymap.go rename internal/tui/{marathon => game}/styles.go (99%) delete mode 100644 internal/tui/marathon/keymap.go rename internal/tui/{marathon => single}/model.go (73%) rename pkg/tetris/modes/{marathon => single}/getters.go (86%) rename pkg/tetris/modes/{marathon/marathon.go => single/single.go} (82%) diff --git a/README.md b/README.md index 9a9306c..c2036fc 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ You're also able to start the game directly in a game mode (eg. Marathon), skipp ```bash # Start the game in Marathon mode with a level of 5 and the player name "Brodie" -./tetrigo marathon --level=5 --name=Brodie +./tetrigo play marathon --level=5 --name=Brodie ``` To see more options for starting the game you can run: diff --git a/cmd/tetrigo/main.go b/cmd/tetrigo/main.go index f4c7fe7..3d84656 100644 --- a/cmd/tetrigo/main.go +++ b/cmd/tetrigo/main.go @@ -1,15 +1,7 @@ package main import ( - "fmt" - - "github.com/Broderick-Westrope/tetrigo/internal/config" - "github.com/Broderick-Westrope/tetrigo/internal/data" - "github.com/Broderick-Westrope/tetrigo/internal/tui/common" - "github.com/Broderick-Westrope/tetrigo/internal/tui/starter" "github.com/alecthomas/kong" - - tea "github.com/charmbracelet/bubbletea" ) type CLI struct { @@ -25,35 +17,6 @@ type GlobalVars struct { DB string `help:"Path to database file" default:"tetrigo.db"` } -type MenuCmd struct{} - -func (c *MenuCmd) Run(globals *GlobalVars) error { - return launchStarter(globals, common.ModeMenu, common.NewMenuInput()) -} - -type PlayCmd struct { - GameMode string `arg:"" help:"Game mode to play" default:"marathon"` - Level uint `help:"Level to start at" short:"l" default:"1"` - Name string `help:"Name of the player" short:"n" default:"Anonymous"` -} - -func (c *PlayCmd) Run(globals *GlobalVars) error { - switch c.GameMode { - case "marathon": - return launchStarter(globals, common.ModeMarathon, common.NewMarathonInput(c.Level, c.Name)) - default: - return fmt.Errorf("invalid game mode: %s", c.GameMode) - } -} - -type LeaderboardCmd struct { - GameMode string `arg:"" help:"Game mode to display" default:"marathon"` -} - -func (c *LeaderboardCmd) Run(globals *GlobalVars) error { - return launchStarter(globals, common.ModeLeaderboard, common.NewLeaderboardInput(c.GameMode)) -} - func main() { cli := CLI{} ctx := kong.Parse(&cli, @@ -65,28 +28,3 @@ func main() { err := ctx.Run(&cli.GlobalVars) ctx.FatalIfErrorf(err) } - -func launchStarter(globals *GlobalVars, starterMode common.Mode, switchIn common.SwitchModeInput) error { - db, err := data.NewDB(globals.DB) - if err != nil { - return fmt.Errorf("error opening database: %w", err) - } - - cfg, err := config.GetConfig(globals.Config) - if err != nil { - return fmt.Errorf("error getting config: %w", err) - } - - model, err := starter.NewModel( - starter.NewInput(starterMode, switchIn, db, cfg), - ) - if err != nil { - return fmt.Errorf("error creating starter model: %w", err) - } - - if _, err = tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil { - return fmt.Errorf("error running tea program: %w", err) - } - - return nil -} diff --git a/cmd/tetrigo/subcommands.go b/cmd/tetrigo/subcommands.go new file mode 100644 index 0000000..975049f --- /dev/null +++ b/cmd/tetrigo/subcommands.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + + "github.com/Broderick-Westrope/tetrigo/internal/config" + "github.com/Broderick-Westrope/tetrigo/internal/data" + "github.com/Broderick-Westrope/tetrigo/internal/tui/common" + "github.com/Broderick-Westrope/tetrigo/internal/tui/starter" + tea "github.com/charmbracelet/bubbletea" +) + +type MenuCmd struct{} + +func (c *MenuCmd) Run(globals *GlobalVars) error { + return launchStarter(globals, common.ModeMenu, common.NewMenuInput()) +} + +type PlayCmd struct { + GameMode string `arg:"" help:"Game mode to play" default:"marathon"` + Level int `help:"Level to start at" short:"l" default:"1"` + Name string `help:"Name of the player" short:"n" default:"Anonymous"` +} + +func (c *PlayCmd) Run(globals *GlobalVars) error { + switch c.GameMode { + case "marathon": + return launchStarter(globals, common.ModeUltra, common.NewSingleInput(common.ModeMarathon, c.Level, c.Name)) + case "ultra": + return launchStarter(globals, common.ModeUltra, common.NewSingleInput(common.ModeUltra, c.Level, c.Name)) + default: + return fmt.Errorf("invalid game mode: %s", c.GameMode) + } +} + +type LeaderboardCmd struct { + GameMode string `arg:"" help:"Game mode to display" default:"marathon"` +} + +func (c *LeaderboardCmd) Run(globals *GlobalVars) error { + return launchStarter(globals, common.ModeLeaderboard, common.NewLeaderboardInput(c.GameMode)) +} + +func launchStarter(globals *GlobalVars, starterMode common.Mode, switchIn common.SwitchModeInput) error { + db, err := data.NewDB(globals.DB) + if err != nil { + return fmt.Errorf("error opening database: %w", err) + } + + cfg, err := config.GetConfig(globals.Config) + if err != nil { + return fmt.Errorf("error getting config: %w", err) + } + + model, err := starter.NewModel( + starter.NewInput(starterMode, switchIn, db, cfg), + ) + if err != nil { + return fmt.Errorf("error creating starter model: %w", err) + } + + if _, err = tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("error running tea program: %w", err) + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 3f23c5c..b90907f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,7 @@ type Config struct { LockDownMode string `toml:"lock_down_mode"` // The maximum level to reach before the game ends or the level stops increasing. - MaxLevel uint `toml:"max_level"` + MaxLevel int `toml:"max_level"` // Whether the game ends when the max level is reached. EndOnMaxLevel bool `toml:"end_on_max_level"` diff --git a/internal/tui/components/hpicker/keymap.go b/internal/tui/common/components/hpicker/keymap.go similarity index 100% rename from internal/tui/components/hpicker/keymap.go rename to internal/tui/common/components/hpicker/keymap.go diff --git a/internal/tui/components/hpicker/model.go b/internal/tui/common/components/hpicker/model.go similarity index 85% rename from internal/tui/components/hpicker/model.go rename to internal/tui/common/components/hpicker/model.go index 2c4ae13..b663ec9 100644 --- a/internal/tui/components/hpicker/model.go +++ b/internal/tui/common/components/hpicker/model.go @@ -15,15 +15,20 @@ type Model struct { // cursor is the index of the currently selected option. selected int // options is a list of the possible options for this component. - options []string + options []KeyValuePair // keymap encodes the keybindings recognized by the component. keymap *KeyMap styles Styles } +type KeyValuePair struct { + Key string + Value any +} + type Option func(*Model) -func NewModel(options []string, opts ...Option) *Model { +func NewModel(options []KeyValuePair, opts ...Option) *Model { m := &Model{ options: options, keymap: defaultKeyMap(), @@ -39,9 +44,10 @@ func NewModel(options []string, opts ...Option) *Model { func WithRange(minValue, maxValue int) Option { return func(m *Model) { - m.options = make([]string, (maxValue-minValue)+1) + m.options = make([]KeyValuePair, (maxValue-minValue)+1) for i := minValue - 1; i < maxValue; i++ { - m.options[i] = strconv.Itoa(i + 1) + m.options[i].Key = strconv.Itoa(i + 1) + m.options[i].Value = i + 1 } } } @@ -82,7 +88,7 @@ func (m *Model) View() string { return lipgloss.JoinHorizontal(lipgloss.Center, prev.Render(m.styles.PrevIndicator), - m.styles.SelectionStyle.Render(m.options[m.selected]), + m.styles.SelectionStyle.Render(m.options[m.selected].Key), next.Render(m.styles.NextIndicator), ) } @@ -111,6 +117,6 @@ func (m *Model) isLast() bool { return m.selected == len(m.options)-1 } -func (m *Model) GetSelection() string { +func (m *Model) GetSelection() KeyValuePair { return m.options[m.selected] } diff --git a/internal/tui/components/hpicker/styles.go b/internal/tui/common/components/hpicker/styles.go similarity index 100% rename from internal/tui/components/hpicker/styles.go rename to internal/tui/common/components/hpicker/styles.go diff --git a/internal/tui/components/textinput/textinput.go b/internal/tui/common/components/textinput/textinput.go similarity index 100% rename from internal/tui/components/textinput/textinput.go rename to internal/tui/common/components/textinput/textinput.go diff --git a/internal/tui/common/input.go b/internal/tui/common/input.go index 2c91c00..c45542a 100644 --- a/internal/tui/common/input.go +++ b/internal/tui/common/input.go @@ -3,11 +3,11 @@ package common import "github.com/Broderick-Westrope/tetrigo/internal/data" type MarathonInput struct { - Level uint + Level int PlayerName string } -func NewMarathonInput(level uint, playerName string) *MarathonInput { +func NewMarathonInput(level int, playerName string) *MarathonInput { return &MarathonInput{ Level: level, PlayerName: playerName, @@ -16,6 +16,22 @@ func NewMarathonInput(level uint, playerName string) *MarathonInput { func (in *MarathonInput) isSwitchModeInput() {} +type SingleInput struct { + Mode Mode + Level int + PlayerName string +} + +func NewSingleInput(mode Mode, level int, playerName string) *SingleInput { + return &SingleInput{ + Mode: mode, + Level: level, + PlayerName: playerName, + } +} + +func (in *SingleInput) isSwitchModeInput() {} + type MenuInput struct { } diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go deleted file mode 100644 index 86bbd06..0000000 --- a/internal/tui/common/keys.go +++ /dev/null @@ -1,18 +0,0 @@ -package common - -import "github.com/charmbracelet/bubbles/key" - -func ConstructKeyBinding(keys []string, desc string) key.Binding { - buildHelpKeys := func(keys []string) string { - helpKeys := "" - for _, key := range keys { - if key == " " { - key = "space" - } - helpKeys += key + ", " - } - return helpKeys[:len(helpKeys)-2] - } - - return key.NewBinding(key.WithKeys(keys...), key.WithHelp(buildHelpKeys(keys), desc)) -} diff --git a/internal/tui/common/mode.go b/internal/tui/common/mode.go index 098be9d..c505578 100644 --- a/internal/tui/common/mode.go +++ b/internal/tui/common/mode.go @@ -4,14 +4,6 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -type Mode int - -const ( - ModeMenu = Mode(iota) - ModeMarathon - ModeLeaderboard -) - type SwitchModeMsg struct { Target Mode Input SwitchModeInput @@ -29,3 +21,23 @@ func SwitchModeCmd(target Mode, in SwitchModeInput) tea.Cmd { } } } + +type Mode int + +const ( + ModeMenu = Mode(iota) + ModeMarathon + ModeUltra + ModeLeaderboard +) + +var modeToStrMap = map[Mode]string{ + ModeMenu: "Menu", + ModeMarathon: "Marathon", + ModeUltra: "Ultra", + ModeLeaderboard: "Leaderboard", +} + +func (m Mode) String() string { + return modeToStrMap[m] +} diff --git a/internal/tui/common/overlay.go b/internal/tui/common/overlay.go index 549145b..3670bab 100644 --- a/internal/tui/common/overlay.go +++ b/internal/tui/common/overlay.go @@ -11,10 +11,35 @@ import ( "github.com/muesli/termenv" ) +const ( + pausedMsg = ` ____ __ + / __ \____ ___ __________ ____/ / + / /_/ / __ ^/ / / / ___/ _ \/ __ / +/ ____/ /_/ / /_/ (__ ) __/ /_/ / +/_/ \__,_/\__,_/____/\___/\__,_/ +Press PAUSE to continue or HOLD to exit.` + + gameOverMsg = ` ______ ____ + / ____/___ _____ ___ ___ / __ \_ _____ _____ + / / __/ __ ^/ __ ^__ \/ _ \ / / / / | / / _ \/ ___/ +/ /_/ / /_/ / / / / / / __/ / /_/ /| |/ / __/ / +\____/\__,_/_/ /_/ /_/\___/ \____/ |___/\___/_/ + + Press EXIT or HOLD to continue.` +) + // Most of this code is borrowed from // https://github.com/charmbracelet/lipgloss/pull/102 // as well as the lipgloss library. +func OverlayPausedMessage(bg string) string { + return placeOverlayCenter(pausedMsg, bg) +} + +func OverlayGameOverMessage(bg string) string { + return placeOverlayCenter(gameOverMsg, bg) +} + // Split a string into lines, additionally returning the size of the widest // line. func getLines(s string) ([]string, int) { @@ -31,14 +56,14 @@ func getLines(s string) ([]string, int) { return lines, widest } -func PlaceOverlayCenter(fg, bg string, opts ...WhitespaceOption) string { +func placeOverlayCenter(fg, bg string, opts ...WhitespaceOption) string { x := lipgloss.Width(bg) / 2 y := lipgloss.Height(bg) / 2 - return PlaceOverlay(x, y, fg, bg, opts...) + return placeOverlay(x, y, fg, bg, opts...) } -// PlaceOverlay places fg on top of bg. -func PlaceOverlay(x, y int, fg, bg string, opts ...WhitespaceOption) string { +// placeOverlay places fg on top of bg. +func placeOverlay(x, y int, fg, bg string, opts ...WhitespaceOption) string { fgLines, fgWidth := getLines(fg) bgLines, bgWidth := getLines(bg) bgHeight := len(bgLines) diff --git a/internal/tui/game/keymap.go b/internal/tui/game/keymap.go new file mode 100644 index 0000000..0fa280f --- /dev/null +++ b/internal/tui/game/keymap.go @@ -0,0 +1,76 @@ +package game + +import ( + "github.com/Broderick-Westrope/tetrigo/internal/config" + "github.com/charmbracelet/bubbles/key" +) + +type KeyMap struct { + ForceQuit key.Binding + Exit key.Binding + Help key.Binding + Left key.Binding + Right key.Binding + Clockwise key.Binding + CounterClockwise key.Binding + SoftDrop key.Binding + HardDrop key.Binding + Hold key.Binding +} + +func constructKeyBinding(keys []string, desc string) key.Binding { + buildHelpKeys := func(keys []string) string { + helpKeys := "" + for _, key := range keys { + if key == " " { + key = "space" + } + helpKeys += key + ", " + } + return helpKeys[:len(helpKeys)-2] + } + + return key.NewBinding(key.WithKeys(keys...), key.WithHelp(buildHelpKeys(keys), desc)) +} + +func ConstructKeyMap(keys *config.Keys) *KeyMap { + return &KeyMap{ + ForceQuit: constructKeyBinding(keys.ForceQuit, "force quit"), + Exit: constructKeyBinding(keys.Exit, "exit"), + Help: constructKeyBinding(keys.Help, "help"), + Left: constructKeyBinding(keys.Left, "move left"), + Right: constructKeyBinding(keys.Right, "move right"), + Clockwise: constructKeyBinding(keys.RotateClockwise, "rotate clockwise"), + CounterClockwise: constructKeyBinding(keys.RotateCounterClockwise, "rotate counter-clockwise"), + SoftDrop: constructKeyBinding(keys.Down, "toggle soft drop"), + HardDrop: constructKeyBinding(keys.Up, "hard drop"), + Hold: constructKeyBinding(keys.Submit, "hold"), + } +} + +func (k *KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Exit, + k.Help, + } +} + +func (k *KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + k.Exit, + k.Help, + k.Left, + }, + { + k.Right, + k.Clockwise, + k.CounterClockwise, + }, + { + k.SoftDrop, + k.HardDrop, + k.Hold, + }, + } +} diff --git a/internal/tui/marathon/styles.go b/internal/tui/game/styles.go similarity index 99% rename from internal/tui/marathon/styles.go rename to internal/tui/game/styles.go index 251725c..e15801d 100644 --- a/internal/tui/marathon/styles.go +++ b/internal/tui/game/styles.go @@ -1,4 +1,4 @@ -package marathon +package game import ( "github.com/Broderick-Westrope/tetrigo/internal/config" diff --git a/internal/tui/marathon/keymap.go b/internal/tui/marathon/keymap.go deleted file mode 100644 index 0a808b2..0000000 --- a/internal/tui/marathon/keymap.go +++ /dev/null @@ -1,62 +0,0 @@ -package marathon - -import ( - "github.com/Broderick-Westrope/tetrigo/internal/config" - "github.com/Broderick-Westrope/tetrigo/internal/tui/common" - "github.com/charmbracelet/bubbles/key" -) - -type keyMap struct { - ForceQuit key.Binding - Exit key.Binding - Help key.Binding - Left key.Binding - Right key.Binding - Clockwise key.Binding - CounterClockwise key.Binding - SoftDrop key.Binding - HardDrop key.Binding - Hold key.Binding -} - -func constructKeyMap(keys *config.Keys) *keyMap { - return &keyMap{ - ForceQuit: common.ConstructKeyBinding(keys.ForceQuit, "force quit"), - Exit: common.ConstructKeyBinding(keys.Exit, "exit"), - Help: common.ConstructKeyBinding(keys.Help, "help"), - Left: common.ConstructKeyBinding(keys.Left, "move left"), - Right: common.ConstructKeyBinding(keys.Right, "move right"), - Clockwise: common.ConstructKeyBinding(keys.RotateClockwise, "rotate clockwise"), - CounterClockwise: common.ConstructKeyBinding(keys.RotateCounterClockwise, "rotate counter-clockwise"), - SoftDrop: common.ConstructKeyBinding(keys.Down, "toggle soft drop"), - HardDrop: common.ConstructKeyBinding(keys.Up, "hard drop"), - Hold: common.ConstructKeyBinding(keys.Submit, "hold"), - } -} - -func (k *keyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Exit, - k.Help, - } -} - -func (k *keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - { - k.Exit, - k.Help, - k.Left, - }, - { - k.Right, - k.Clockwise, - k.CounterClockwise, - }, - { - k.SoftDrop, - k.HardDrop, - k.Hold, - }, - } -} diff --git a/internal/tui/menu/model.go b/internal/tui/menu/model.go index 98eead1..d85c929 100644 --- a/internal/tui/menu/model.go +++ b/internal/tui/menu/model.go @@ -2,11 +2,10 @@ package menu import ( "fmt" - "strconv" "github.com/Broderick-Westrope/tetrigo/internal/tui/common" - "github.com/Broderick-Westrope/tetrigo/internal/tui/components/hpicker" - "github.com/Broderick-Westrope/tetrigo/internal/tui/components/textinput" + "github.com/Broderick-Westrope/tetrigo/internal/tui/common/components/hpicker" + "github.com/Broderick-Westrope/tetrigo/internal/tui/common/components/textinput" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -40,16 +39,18 @@ type item struct { func NewModel(_ *common.MenuInput) *Model { nameInput := textinput.NewModel("Enter your name", 20, 20) - modePicker := hpicker.NewModel([]string{"Marathon"}) - playersPicker := hpicker.NewModel(nil, hpicker.WithRange(1, 1)) + modePicker := hpicker.NewModel([]hpicker.KeyValuePair{ + {Key: "Marathon", Value: common.ModeMarathon}, + // {Key: "Sprint (40 Lines)", Value: "sprint"}, + {Key: "Ultra (Time Trial)", Value: common.ModeUltra}, + }) levelPicker := hpicker.NewModel(nil, hpicker.WithRange(1, 15)) return &Model{ items: []item{ {label: "Name", model: nameInput, hideLabel: true}, {label: "Mode", model: modePicker}, - {label: "Players", model: playersPicker}, - {label: "Level", model: levelPicker}, + {label: "Starting Level", model: levelPicker}, }, selected: 0, @@ -118,8 +119,8 @@ func (m Model) renderItem(index int) string { i := m.items[index] output := i.model.View() if !i.hideLabel { - label := lipgloss.NewStyle().Width(12).AlignHorizontal(lipgloss.Left).Render(i.label + ":") - output = lipgloss.NewStyle().Width(12).AlignHorizontal(lipgloss.Right).Render(output) + label := lipgloss.NewStyle().Width(15).AlignHorizontal(lipgloss.Left).Render(i.label + ":") + output = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Right).Render(output) output = lipgloss.JoinHorizontal(lipgloss.Left, label, output) } @@ -131,29 +132,33 @@ func (m Model) renderItem(index int) string { } func (m Model) startGame() (tea.Cmd, error) { - var level uint - var players uint - var mode string + var level int + var mode common.Mode var playerName string + errInvalidModel := fmt.Errorf("invalid model for item %q", m.items[m.selected].label) + errInvalidValue := fmt.Errorf("invalid value for model of item %q", m.items[m.selected].label) + for _, i := range m.items { switch i.label { - case "Level": - value := i.model.(*hpicker.Model).GetSelection() - intLevel, err := strconv.Atoi(value) - if err != nil { - return nil, fmt.Errorf("failed to convert level string %q to int", value) + case "Starting Level": + picker, ok := i.model.(*hpicker.Model) + if !ok { + return nil, errInvalidModel } - level = uint(intLevel) - case "Players": - value := i.model.(*hpicker.Model).GetSelection() - intPlayers, err := strconv.Atoi(value) - if err != nil { - return nil, fmt.Errorf("failed to convert players string %q to int", value) + level, ok = picker.GetSelection().Value.(int) + if !ok { + return nil, errInvalidValue } - players = uint(intPlayers) case "Mode": - mode = i.model.(*hpicker.Model).GetSelection() + picker, ok := i.model.(*hpicker.Model) + if !ok { + return nil, errInvalidModel + } + mode, ok = picker.GetSelection().Value.(common.Mode) + if !ok { + return nil, errInvalidValue + } case "Name": playerName = i.model.(textinput.Model).Child.Value() default: @@ -161,14 +166,16 @@ func (m Model) startGame() (tea.Cmd, error) { } } - // TODO: use players - _ = players - switch mode { - case "Marathon": - in := common.NewMarathonInput(level, playerName) - return common.SwitchModeCmd(common.ModeMarathon, in), nil + case common.ModeMarathon: + in := common.NewSingleInput(mode, level, playerName) + return common.SwitchModeCmd(mode, in), nil + case common.ModeUltra: + in := common.NewSingleInput(mode, level, playerName) + return common.SwitchModeCmd(mode, in), nil + case common.ModeMenu, common.ModeLeaderboard: + return nil, fmt.Errorf("invalid mode for starting game: %q", mode) default: - return nil, fmt.Errorf("invalid mode: %q", mode) + return nil, fmt.Errorf("invalid mode from menu: %q", mode) } } diff --git a/internal/tui/marathon/model.go b/internal/tui/single/model.go similarity index 73% rename from internal/tui/marathon/model.go rename to internal/tui/single/model.go index 8defb32..c0aa1db 100644 --- a/internal/tui/marathon/model.go +++ b/internal/tui/single/model.go @@ -1,4 +1,4 @@ -package marathon +package single import ( "fmt" @@ -8,71 +8,96 @@ import ( "github.com/Broderick-Westrope/tetrigo/internal/config" "github.com/Broderick-Westrope/tetrigo/internal/data" "github.com/Broderick-Westrope/tetrigo/internal/tui/common" + "github.com/Broderick-Westrope/tetrigo/internal/tui/game" "github.com/Broderick-Westrope/tetrigo/pkg/tetris" - "github.com/Broderick-Westrope/tetrigo/pkg/tetris/modes/marathon" + "github.com/Broderick-Westrope/tetrigo/pkg/tetris/modes/single" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/stopwatch" + "github.com/charmbracelet/bubbles/timer" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -const ( - pausedMsg = ` ____ __ - / __ \____ ___ __________ ____/ / - / /_/ / __ ^/ / / / ___/ _ \/ __ / - / ____/ /_/ / /_/ (__ ) __/ /_/ / -/_/ \__,_/\__,_/____/\___/\__,_/ -Press PAUSE to continue or HOLD to exit.` - - gameOverMsg = ` ______ ____ - / ____/___ _____ ___ ___ / __ \_ _____ _____ - / / __/ __ ^/ __ ^__ \/ _ \ / / / / | / / _ \/ ___/ -/ /_/ / /_/ / / / / / / __/ / /_/ /| |/ / __/ / -\____/\__,_/_/ /_/ /_/\___/ \____/ |___/\___/_/ - Press EXIT or HOLD to continue.` -) - var _ tea.Model = &Model{} type Model struct { playerName string - styles *Styles - help help.Model - keys *keyMap - timerStopwatch stopwatch.Model - isPaused bool - fallStopwatch stopwatch.Model - game *marathon.Game - isGameOver bool + game *single.Game nextQueueLength int -} + fallStopwatch stopwatch.Model + mode common.Mode -func NewModel(in *common.MarathonInput, cfg *config.Config) (*Model, error) { - game, err := marathon.NewGame(in.Level, cfg.MaxLevel, cfg.EndOnMaxLevel, cfg.GhostEnabled) - if err != nil { - return nil, fmt.Errorf("failed to create marathon game: %w", err) - } + useTimer bool + gameTimer timer.Model + gameStopwatch stopwatch.Model + + styles *game.Styles + help help.Model + keys *game.KeyMap + isPaused bool +} +func NewModel(in *common.SingleInput, cfg *config.Config) (*Model, error) { + // Setup initial model m := &Model{ playerName: in.PlayerName, - styles: CreateStyles(cfg.Theme), + styles: game.CreateStyles(cfg.Theme), help: help.New(), - keys: constructKeyMap(cfg.Keys), - timerStopwatch: stopwatch.NewWithInterval(time.Millisecond * 3), + keys: game.ConstructKeyMap(cfg.Keys), isPaused: false, - game: game, nextQueueLength: cfg.NextQueueLength, + mode: in.Mode, + } + + // Get game input + var gameIn *single.Input + switch in.Mode { + case common.ModeMarathon: + gameIn = &single.Input{ + Level: in.Level, + MaxLevel: cfg.MaxLevel, + IncreaseLevel: true, + EndOnMaxLevel: cfg.EndOnMaxLevel, + GhostEnabled: cfg.GhostEnabled, + } + m.gameStopwatch = stopwatch.NewWithInterval(time.Millisecond * 13) + case common.ModeUltra: + gameIn = &single.Input{ + Level: in.Level, + GhostEnabled: cfg.GhostEnabled, + } + m.useTimer = true + m.gameTimer = timer.NewWithInterval(time.Minute*2, time.Millisecond*13) + case common.ModeMenu, common.ModeLeaderboard: + return nil, fmt.Errorf("invalid single player game mode: %v", in.Mode) + default: + return nil, fmt.Errorf("invalid single player game mode: %v", in.Mode) + } + + // Create game + var err error + m.game, err = single.NewGame(gameIn) + if err != nil { + return nil, fmt.Errorf("failed to create single player game: %w", err) } - m.fallStopwatch = stopwatch.NewWithInterval(m.game.GetDefaultFallInterval()) - m.styles = CreateStyles(cfg.Theme) + // Setup game dependents + m.fallStopwatch = stopwatch.NewWithInterval(m.game.GetDefaultFallInterval()) return m, nil } func (m *Model) Init() tea.Cmd { - return tea.Batch(m.fallStopwatch.Init(), m.timerStopwatch.Init()) + var cmd tea.Cmd + switch m.useTimer { + case true: + cmd = m.gameTimer.Init() + default: + cmd = m.gameStopwatch.Init() + } + + return tea.Batch(m.fallStopwatch.Init(), cmd) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -121,7 +146,12 @@ func (m *Model) dependenciesUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd - m.timerStopwatch, cmd = m.timerStopwatch.Update(msg) + switch m.useTimer { + case true: + m.gameTimer, cmd = m.gameTimer.Update(msg) + default: + m.gameStopwatch, cmd = m.gameStopwatch.Update(msg) + } cmds = append(cmds, cmd) m.fallStopwatch, cmd = m.fallStopwatch.Update(msg) @@ -133,17 +163,17 @@ func (m *Model) dependenciesUpdate(msg tea.Msg) (*Model, tea.Cmd) { func (m *Model) gameOverUpdate(msg tea.Msg) (*Model, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { if key.Matches(msg, m.keys.Exit, m.keys.Hold) { + modeStr := m.mode.String() newEntry := &data.Score{ - GameMode: "marathon", + GameMode: modeStr, Name: m.playerName, - Time: m.timerStopwatch.Elapsed(), - Score: int(m.game.GetTotalScore()), - Lines: int(m.game.GetLinesCleared()), - Level: int(m.game.GetLevel()), + Score: m.game.GetTotalScore(), + Lines: m.game.GetLinesCleared(), + Level: m.game.GetLevel(), } return m, common.SwitchModeCmd(common.ModeLeaderboard, - common.NewLeaderboardInput("marathon", common.WithNewEntry(newEntry)), + common.NewLeaderboardInput(modeStr, common.WithNewEntry(newEntry)), ) } } @@ -182,6 +212,11 @@ func (m *Model) playingUpdate(msg tea.Msg) (*Model, tea.Cmd) { cmds = append(cmds, m.triggerGameOver()) } return m, tea.Batch(cmds...) + case timer.TimeoutMsg: + if msg.ID != m.gameTimer.ID() { + break + } + return m, m.triggerGameOver() } return m, nil @@ -248,11 +283,9 @@ func (m *Model) View() string { ) if m.game.IsGameOver() { - output = common.PlaceOverlayCenter(gameOverMsg, output) - } - - if m.isPaused { - output = common.PlaceOverlayCenter(pausedMsg, output) + output = common.OverlayGameOverMessage(output) + } else if m.isPaused { + output = common.OverlayPausedMessage(output) } output = lipgloss.JoinVertical(lipgloss.Left, output, m.help.View(m.keys)) @@ -305,15 +338,22 @@ func (m *Model) informationView() string { return fmt.Sprintf("%s%*s\n", title, width-(1+len(title)), value) } - elapsed := m.timerStopwatch.Elapsed().Seconds() - minutes := int(elapsed) / 60 + var gameTime float64 + switch m.useTimer { + case true: + gameTime = m.gameTimer.Timeout.Seconds() + default: + gameTime = m.gameStopwatch.Elapsed().Seconds() + } + + minutes := int(gameTime) / 60 var timeStr string if minutes > 0 { - seconds := int(elapsed) % 60 + seconds := int(gameTime) % 60 timeStr += fmt.Sprintf("%02d:%02d", minutes, seconds) } else { - timeStr += fmt.Sprintf("%06.3f", elapsed) + timeStr += fmt.Sprintf("%06.3f", gameTime) } var output string @@ -321,8 +361,8 @@ func (m *Model) informationView() string { output += fmt.Sprintf("%*d\n", width-1, m.game.GetTotalScore()) output += fmt.Sprintln("Time:") output += fmt.Sprintf("%*s\n", width-1, timeStr) - output += toFixedWidth("Lines:", strconv.Itoa(int(m.game.GetLinesCleared()))) - output += toFixedWidth("Level:", strconv.Itoa(int(m.game.GetLevel()))) + output += toFixedWidth("Lines:", strconv.Itoa(m.game.GetLinesCleared())) + output += toFixedWidth("Level:", strconv.Itoa(m.game.GetLevel())) return m.styles.Information.Render(lipgloss.JoinVertical(lipgloss.Left, header, output)) } @@ -378,10 +418,15 @@ func (m *Model) renderCell(cell byte) string { } func (m *Model) triggerGameOver() tea.Cmd { - m.isGameOver = true + m.game.EndGame() m.isPaused = false + + if m.useTimer { + m.gameTimer.Timeout = 0 + } + var cmds []tea.Cmd - cmds = append(cmds, m.timerStopwatch.Stop()) + cmds = append(cmds, m.gameTimer.Stop()) cmds = append(cmds, m.fallStopwatch.Stop()) return tea.Batch(cmds...) } @@ -390,6 +435,6 @@ func (m *Model) togglePause() tea.Cmd { m.isPaused = !m.isPaused return tea.Batch( m.fallStopwatch.Toggle(), - m.timerStopwatch.Toggle(), + m.gameTimer.Toggle(), ) } diff --git a/internal/tui/starter/model.go b/internal/tui/starter/model.go index 6656471..1843320 100644 --- a/internal/tui/starter/model.go +++ b/internal/tui/starter/model.go @@ -8,8 +8,8 @@ import ( "github.com/Broderick-Westrope/tetrigo/internal/config" "github.com/Broderick-Westrope/tetrigo/internal/tui/common" "github.com/Broderick-Westrope/tetrigo/internal/tui/leaderboard" - "github.com/Broderick-Westrope/tetrigo/internal/tui/marathon" "github.com/Broderick-Westrope/tetrigo/internal/tui/menu" + "github.com/Broderick-Westrope/tetrigo/internal/tui/single" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) @@ -107,12 +107,12 @@ func (m *Model) setChild(mode common.Mode, switchIn common.SwitchModeInput) erro return ErrInvalidSwitchModeInput } m.child = menu.NewModel(menuIn) - case common.ModeMarathon: - marathonIn, ok := switchIn.(*common.MarathonInput) + case common.ModeMarathon, common.ModeUltra: + ultraIn, ok := switchIn.(*common.SingleInput) if !ok { return ErrInvalidSwitchModeInput } - child, err := marathon.NewModel(marathonIn, m.cfg) + child, err := single.NewModel(ultraIn, m.cfg) if err != nil { return err } diff --git a/pkg/tetris/fall.go b/pkg/tetris/fall.go index 44310d8..75d4f61 100644 --- a/pkg/tetris/fall.go +++ b/pkg/tetris/fall.go @@ -11,13 +11,13 @@ type Fall struct { IsSoftDrop bool } -func NewFall(level uint) *Fall { +func NewFall(level int) *Fall { f := Fall{} f.CalculateFallSpeeds(level) return &f } -func (f *Fall) CalculateFallSpeeds(level uint) { +func (f *Fall) CalculateFallSpeeds(level int) { decrementedLevel := float64(level - 1) speed := math.Pow(0.8-(decrementedLevel*0.007), decrementedLevel) speed *= float64(time.Second) diff --git a/pkg/tetris/matrix.go b/pkg/tetris/matrix.go index 0b03ed4..072171b 100644 --- a/pkg/tetris/matrix.go +++ b/pkg/tetris/matrix.go @@ -17,7 +17,7 @@ func DefaultMatrix() Matrix { } // NewMatrix creates a new Matrix with the given height and width. -func NewMatrix(height, width uint) (Matrix, error) { +func NewMatrix(height, width int) (Matrix, error) { if height <= 20 { return nil, errors.New("matrix height must be greater than 20 to allow for a buffer zone of 20 lines") } diff --git a/pkg/tetris/matrix_test.go b/pkg/tetris/matrix_test.go index 87f53df..240ee2d 100644 --- a/pkg/tetris/matrix_test.go +++ b/pkg/tetris/matrix_test.go @@ -12,8 +12,8 @@ import ( func Test_NewMatrix(t *testing.T) { tt := map[string]struct { - width uint - height uint + width int + height int want Matrix wantErr error }{ diff --git a/pkg/tetris/modes/marathon/getters.go b/pkg/tetris/modes/single/getters.go similarity index 86% rename from pkg/tetris/modes/marathon/getters.go rename to pkg/tetris/modes/single/getters.go index f3efb05..acd8dd6 100644 --- a/pkg/tetris/modes/marathon/getters.go +++ b/pkg/tetris/modes/single/getters.go @@ -1,4 +1,4 @@ -package marathon +package single import ( "time" @@ -35,15 +35,15 @@ func (g *Game) GetHoldTetrimino() *tetris.Tetrimino { return g.holdQueue } -func (g *Game) GetTotalScore() uint { +func (g *Game) GetTotalScore() int { return g.scoring.Total() } -func (g *Game) GetLevel() uint { +func (g *Game) GetLevel() int { return g.scoring.Level() } -func (g *Game) GetLinesCleared() uint { +func (g *Game) GetLinesCleared() int { return g.scoring.Lines() } diff --git a/pkg/tetris/modes/marathon/marathon.go b/pkg/tetris/modes/single/single.go similarity index 82% rename from pkg/tetris/modes/marathon/marathon.go rename to pkg/tetris/modes/single/single.go index 2212a54..4e1026d 100644 --- a/pkg/tetris/modes/marathon/marathon.go +++ b/pkg/tetris/modes/single/single.go @@ -1,4 +1,4 @@ -package marathon +package single import ( "errors" @@ -8,6 +8,8 @@ import ( "github.com/Broderick-Westrope/tetrigo/pkg/tetris" ) +// Game represents a single player game of Tetris. +// This can be used for Marathon, Sprint, Ultra and other single player modes. type Game struct { matrix tetris.Matrix // The Matrix of cells on which the game is played nextQueue *tetris.NextQueue // The queue of upcoming Tetriminos @@ -21,13 +23,32 @@ type Game struct { fall *tetris.Fall // The system for calculating the fall speed } -func NewGame(level, maxLevel uint, endOnMaxLevel bool, ghostEnabled bool) (*Game, error) { +type Input struct { + Level int // The starting level of the game. + MaxLevel int // The maximum level the game can reach. 0 means no limit. + IncreaseLevel bool // Whether the level should increase as the game progresses. + EndOnMaxLevel bool // Whether the game should end when the maximum level is reached. + + MaxLines int // The maximum number of lines to clear before the game ends. 0 means no limit. + EndOnMaxLines bool // Whether the game should end when the maximum number of lines is cleared. + + GhostEnabled bool // Whether the ghost Tetrimino should be displayed. +} + +func NewGame(in *Input) (*Game, error) { matrix, err := tetris.NewMatrix(40, 10) if err != nil { return nil, err } nq := tetris.NewNextQueue(matrix.GetSkyline()) + scoring, err := tetris.NewScoring( + in.Level, in.MaxLevel, in.IncreaseLevel, in.EndOnMaxLevel, in.MaxLines, in.EndOnMaxLines, + ) + if err != nil { + return nil, fmt.Errorf("failed to create scoring system: %w", err) + } + g := &Game{ matrix: matrix, nextQueue: nq, @@ -35,11 +56,11 @@ func NewGame(level, maxLevel uint, endOnMaxLevel bool, ghostEnabled bool) (*Game holdQueue: tetris.GetEmptyTetrimino(), gameOver: false, softDropStartRow: matrix.GetHeight(), - scoring: tetris.NewScoring(level, maxLevel, endOnMaxLevel), - fall: tetris.NewFall(level), + scoring: scoring, + fall: tetris.NewFall(in.Level), } - if ghostEnabled { + if in.GhostEnabled { g.ghostTet = g.tetInPlay } @@ -126,9 +147,37 @@ func (g *Game) TickLower() (bool, error) { if g.fall.IsSoftDrop { linesCleared := g.tetInPlay.Pos.Y - g.softDropStartRow if linesCleared > 0 { - g.scoring.AddSoftDrop(uint(linesCleared)) + g.scoring.AddSoftDrop(linesCleared) + } + } + + g.tetInPlay = g.nextQueue.Next() + gameOver := g.setupNewTetInPlay() + if gameOver { + g.gameOver = gameOver + } + + return gameOver, nil +} + +func (g *Game) HardDrop() (bool, error) { + startRow := g.tetInPlay.Pos.Y + + for { + lockedDown, err := g.lowerTetInPlay() + if err != nil { + return false, fmt.Errorf("failed to lower tetrimino (hard drop): %w", err) + } + if lockedDown { + break } } + if g.gameOver { + return true, nil + } + + linesCleared := g.tetInPlay.Pos.Y - startRow + g.scoring.AddHardDrop(linesCleared) g.tetInPlay = g.nextQueue.Next() gameOver := g.setupNewTetInPlay() @@ -139,6 +188,28 @@ func (g *Game) TickLower() (bool, error) { return gameOver, nil } +// ToggleSoftDrop toggles the Soft Drop state of the game. +// If Soft Drop is enabled, the game will calculate the number of lines cleared and add them to the score. +// The time interval for the Fall system is returned. +func (g *Game) ToggleSoftDrop() time.Duration { + g.fall.ToggleSoftDrop() + if g.fall.IsSoftDrop { + g.softDropStartRow = g.tetInPlay.Pos.Y + return g.fall.SoftDropInterval + } + linesCleared := g.tetInPlay.Pos.Y - g.softDropStartRow + if linesCleared > 0 { + g.scoring.AddSoftDrop(linesCleared) + } + g.softDropStartRow = g.matrix.GetSkyline() + return g.fall.DefaultInterval +} + +// EndGame sets Game.gameOver to true. +func (g *Game) EndGame() { + g.gameOver = true +} + // lowerTetInPlay moves the current Tetrimino down one row if possible. // If it cannot be moved down this will instead remove completed lines, calculate scores and // fall speed and return true (representing a Lock Down). @@ -204,51 +275,6 @@ func (g *Game) setupNewTetInPlay() bool { return false } -func (g *Game) HardDrop() (bool, error) { - startRow := g.tetInPlay.Pos.Y - - for { - lockedDown, err := g.lowerTetInPlay() - if err != nil { - return false, fmt.Errorf("failed to lower tetrimino (hard drop): %w", err) - } - if lockedDown { - break - } - } - if g.gameOver { - return true, nil - } - - linesCleared := g.tetInPlay.Pos.Y - startRow - g.scoring.AddHardDrop(uint(linesCleared)) - - g.tetInPlay = g.nextQueue.Next() - gameOver := g.setupNewTetInPlay() - if gameOver { - g.gameOver = gameOver - } - - return gameOver, nil -} - -// ToggleSoftDrop toggles the Soft Drop state of the game. -// If Soft Drop is enabled, the game will calculate the number of lines cleared and add them to the score. -// The time interval for the Fall system is returned. -func (g *Game) ToggleSoftDrop() time.Duration { - g.fall.ToggleSoftDrop() - if g.fall.IsSoftDrop { - g.softDropStartRow = g.tetInPlay.Pos.Y - return g.fall.SoftDropInterval - } - linesCleared := g.tetInPlay.Pos.Y - g.softDropStartRow - if linesCleared > 0 { - g.scoring.AddSoftDrop(uint(linesCleared)) - } - g.softDropStartRow = g.matrix.GetSkyline() - return g.fall.DefaultInterval -} - func (g *Game) updateGhost() { if g.ghostTet == nil { return diff --git a/pkg/tetris/scoring.go b/pkg/tetris/scoring.go index ed24aca..8e381dd 100644 --- a/pkg/tetris/scoring.go +++ b/pkg/tetris/scoring.go @@ -1,45 +1,79 @@ package tetris +import ( + "fmt" +) + type Scoring struct { - level uint - maxLevel uint + level int + maxLevel int + increaseLevel bool endOnMaxLevel bool - total uint - lines uint - backToBack bool + + lines int + maxLines int + endOnMaxLines bool + + total int + backToBack bool } -func NewScoring(level, maxLevel uint, endOnMaxLevel bool) *Scoring { - return &Scoring{ +func NewScoring( + level, maxLevel int, + increaseLevel, endOnMaxLevel bool, + maxLines int, + endOnMaxLines bool) (*Scoring, error) { + s := &Scoring{ level: level, maxLevel: maxLevel, + increaseLevel: increaseLevel, endOnMaxLevel: endOnMaxLevel, + + maxLines: maxLines, + endOnMaxLines: endOnMaxLines, + } + return s, s.validate() +} + +func (s *Scoring) validate() error { + if s.level <= 0 { + return fmt.Errorf("invalid level '%d'", s.level) + } + if s.maxLevel < 0 { + return fmt.Errorf("invalid max level '%d'", s.maxLevel) + } + if s.maxLines < 0 { + return fmt.Errorf("invalid max lines '%d'", s.maxLines) + } + if s.total < 0 { + return fmt.Errorf("invalid total '%d'", s.total) } + return nil } -func (s *Scoring) Level() uint { +func (s *Scoring) Level() int { return s.level } -func (s *Scoring) Total() uint { +func (s *Scoring) Total() int { return s.total } -func (s *Scoring) Lines() uint { +func (s *Scoring) Lines() int { return s.lines } -func (s *Scoring) AddSoftDrop(lines uint) { +func (s *Scoring) AddSoftDrop(lines int) { s.total += lines } -func (s *Scoring) AddHardDrop(lines uint) { +func (s *Scoring) AddHardDrop(lines int) { s.total += lines * 2 } func (s *Scoring) ProcessAction(a Action) (bool, error) { if a == Actions.None { - return s.isGameOver(), nil + return false, nil } points := float64(a.GetPoints()) @@ -56,23 +90,36 @@ func (s *Scoring) ProcessAction(a Action) (bool, error) { s.backToBack = true } if err != nil { - return s.isGameOver(), err + return false, err } - s.total += uint(points+backToBack) * s.level - s.lines += uint((points + backToBack) / 100) + s.total += int(points+backToBack) * s.level - for s.lines >= s.level*5 { - s.level++ - if s.maxLevel > 0 && s.level >= s.maxLevel { - s.level = s.maxLevel - return s.isGameOver(), nil + // increase lines + s.lines += int((points + backToBack) / 100) + + // if max lines enabled, and max lines reached + if s.maxLines > 0 && s.lines >= s.maxLines { + s.lines = s.maxLines + + if s.endOnMaxLines { + return true, nil } } - return s.isGameOver(), nil -} + // increase level + for s.increaseLevel && s.lines >= s.level*5 { + s.level++ + + // if no max level, or max level not reached + if s.maxLevel <= 0 || s.level < s.maxLevel { + continue + } + + // if max level reached + s.level = s.maxLevel + return s.endOnMaxLevel, nil + } -func (s *Scoring) isGameOver() bool { - return s.level >= s.maxLevel && s.endOnMaxLevel + return false, nil } diff --git a/pkg/tetris/scoring_test.go b/pkg/tetris/scoring_test.go index e4cc1ce..494a872 100644 --- a/pkg/tetris/scoring_test.go +++ b/pkg/tetris/scoring_test.go @@ -1,6 +1,7 @@ package tetris import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -9,36 +10,72 @@ import ( func TestNewScoring(t *testing.T) { tt := map[string]struct { - level uint - maxLevel uint + level int + maxLevel int + increaseLevel bool + endOnMaxLevel bool + maxLines int + endOnMaxLines bool + + wantErr error }{ - "level 1": { - level: 1, - maxLevel: 15, + "invalid level": { + level: 0, + maxLevel: 0, + increaseLevel: false, + endOnMaxLevel: false, + maxLines: 0, + endOnMaxLines: false, + + wantErr: errors.New("invalid level '0'"), }, - "level 15": { - level: 15, - maxLevel: 15, + + "0; false": { + level: 1, + maxLevel: 0, + increaseLevel: false, + endOnMaxLevel: false, + maxLines: 0, + endOnMaxLines: false, + }, + "10; true": { + level: 10, + maxLevel: 10, + increaseLevel: true, + endOnMaxLevel: true, + maxLines: 10, + endOnMaxLines: true, }, } for name, tc := range tt { t.Run(name, func(t *testing.T) { - s := NewScoring(tc.level, tc.maxLevel, true) + s, err := NewScoring(tc.level, tc.maxLevel, tc.increaseLevel, tc.endOnMaxLevel, tc.maxLines, tc.endOnMaxLines) + + if tc.wantErr != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + + require.NoError(t, err) assert.Equal(t, tc.level, s.level) assert.Equal(t, tc.maxLevel, s.maxLevel) - assert.Equal(t, uint(0), s.total) - assert.Equal(t, uint(0), s.lines) + assert.Equal(t, tc.increaseLevel, s.increaseLevel) + assert.Equal(t, tc.endOnMaxLevel, s.endOnMaxLevel) + assert.Equal(t, tc.maxLines, s.maxLines) + assert.Equal(t, tc.endOnMaxLines, s.endOnMaxLines) + + assert.Equal(t, 0, s.total) + assert.Equal(t, 0, s.lines) assert.False(t, s.backToBack) - assert.True(t, s.endOnMaxLevel) }) } } func TestScoring_Level(t *testing.T) { tt := map[string]struct { - level uint + level int }{ "level 1": { level: 1, @@ -61,7 +98,7 @@ func TestScoring_Level(t *testing.T) { func TestScoring_Total(t *testing.T) { tt := map[string]struct { - total uint + total int }{ "total 0": { total: 0, @@ -84,7 +121,7 @@ func TestScoring_Total(t *testing.T) { func TestScoring_Lines(t *testing.T) { tt := map[string]struct { - lines uint + lines int }{ "lines 0": { lines: 0, @@ -107,7 +144,7 @@ func TestScoring_Lines(t *testing.T) { func TestScoring_AddSoftDrop(t *testing.T) { tt := map[string]struct { - lines uint + lines int }{ "0 lines": { lines: 0, @@ -135,7 +172,7 @@ func TestScoring_AddSoftDrop(t *testing.T) { func TestScoring_AddHardDrop(t *testing.T) { tt := map[string]struct { - lines uint + lines int }{ "0 lines": { lines: 0, @@ -166,8 +203,8 @@ func TestScoring_ProcessAction(t *testing.T) { tt := map[string]struct { a Action isBackToBack bool - maxLevel uint - expectedTotal uint + maxLevel int + expectedTotal int expectedBackToBack bool }{ // Back-to-back disabled @@ -338,11 +375,17 @@ func TestScoring_ProcessAction(t *testing.T) { for name, tc := range tt { t.Run(name, func(t *testing.T) { s := &Scoring{ - backToBack: tc.isBackToBack, + level: 1, + maxLevel: tc.maxLevel, + increaseLevel: true, + endOnMaxLevel: false, + + lines: 0, + maxLines: 0, + endOnMaxLines: false, + total: 0, - lines: 0, - level: 1, - maxLevel: tc.maxLevel, + backToBack: tc.isBackToBack, } // TODO: check gameOver (from endsOnMaxLevel) @@ -356,7 +399,7 @@ func TestScoring_ProcessAction(t *testing.T) { expectedLines := tc.expectedTotal / 100 assert.Equal(t, expectedLines, s.lines) - var expectedLevel uint + var expectedLevel int if tc.maxLevel == 0 { expectedLevel = 1 + (expectedLines / 5) } else {