diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8151c0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +farm.data +TODO.md +vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..169cf42 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Terminal Tiller + +Terminal Tiller is a simple idle game to grow, harvest, and sell crops from your terminal! + +Built with tools from [charm.sh](https://charm.sh)! + + +## Install + +```shell +go install github.com/calvinmclean/terminal-tiller@latest +``` + +## Play +After installing, simply run: + +```shell +terminal-tiller +``` + +This will create a new save file in `~/.terminal-tiller` + +See the on-screen usage instructions and start planting! When you close the game, the state is saved and your crops will continue growing! diff --git a/farm/crop.go b/farm/crop.go new file mode 100644 index 0000000..535a022 --- /dev/null +++ b/farm/crop.go @@ -0,0 +1,193 @@ +package farm + +import "time" + +const ( + Broccoli CropType = iota + Carrot + Corn + Cucumber + Eggplant + Garlic + Ginger + Lettuce + Onion + Pea + Potato + Strawberry + Tomato + Watermelon + Yam +) + +var ( + CropTypes = map[CropType]CropTypeDetails{ + Broccoli: { + Name: "Broccoli", + Representation: "🥦", + HarvestTime: 10, + SeedCost: 80, + MarketPrice: 150, + }, + Carrot: { + Name: "Carrot", + Representation: "🥕", + HarvestTime: 4, + SeedCost: 20, + MarketPrice: 35, + }, + Corn: { + Name: "Corn", + Representation: "🌽", + HarvestTime: 6, + SeedCost: 30, + MarketPrice: 55, + }, + Cucumber: { + Name: "Cucumber", + Representation: "🥒", + HarvestTime: 5, + SeedCost: 150, + MarketPrice: 275, + }, + Eggplant: { + Name: "Eggplant", + Representation: "🍆", + HarvestTime: 5, + SeedCost: 125, + MarketPrice: 200, + }, + Garlic: { + Name: "Garlic", + Representation: "🧄", + HarvestTime: 15, + SeedCost: 100, + MarketPrice: 200, + }, + Ginger: { + Name: "Ginger", + Representation: "🫚 ", + HarvestTime: 11, + SeedCost: 90, + MarketPrice: 175, + }, + Lettuce: { + Name: "Lettuce", + Representation: "🥬", + HarvestTime: 3, + SeedCost: 9, + MarketPrice: 15, + }, + Onion: { + Name: "Onion", + Representation: "🧅", + HarvestTime: 12, + SeedCost: 80, + MarketPrice: 155, + }, + Pea: { + Name: "Pea", + Representation: "🫛 ", + HarvestTime: 3, + SeedCost: 25, + MarketPrice: 40, + }, + Potato: { + Name: "Potato", + Representation: "🥔", + HarvestTime: 10, + SeedCost: 50, + MarketPrice: 80, + }, + Strawberry: { + Name: "Strawberry", + Representation: "🍓", + HarvestTime: 6, + SeedCost: 40, + MarketPrice: 65, + }, + Tomato: { + Name: "Tomato", + Representation: "🍅", + HarvestTime: 5, + SeedCost: 70, + MarketPrice: 130, + }, + Watermelon: { + Name: "Watermelon", + Representation: "🍉", + HarvestTime: 8, + SeedCost: 60, + MarketPrice: 100, + }, + Yam: { + Name: "Yam", + Representation: "🍠", + HarvestTime: 10, + SeedCost: 75, + MarketPrice: 160, + }, + } +) + +type CropType int + +func (c CropType) Name() string { + return CropTypes[c].Name +} + +func (c CropType) Representation() string { + return CropTypes[c].Representation +} + +func (c CropType) HarvestTime(scale time.Duration) time.Duration { + return time.Duration(CropTypes[c].HarvestTime) * scale +} + +func (c CropType) SeedCost() int { + return CropTypes[c].SeedCost +} + +func (c CropType) MarketPrice() int { + return CropTypes[c].MarketPrice +} + +type CropTypeDetails struct { + Name string + Representation string + HarvestTime int + SeedCost int + MarketPrice int +} + +type Crop struct { + Type CropType + PlantedAt time.Time +} + +func NewCrop(cropType CropType) *Crop { + _, ok := CropTypes[cropType] + if !ok { + panic("invalid crop type") + } + + return &Crop{ + Type: cropType, + PlantedAt: time.Now(), + } +} + +func (c *Crop) String(scale time.Duration) string { + if !c.ReadyToHarvest(scale) { + return "🌱" + } + return c.Type.Representation() +} + +func (c *Crop) ReadyToHarvest(scale time.Duration) bool { + return time.Now().After(c.HarvestTime(scale)) +} + +func (c *Crop) HarvestTime(scale time.Duration) time.Time { + return c.PlantedAt.Add(c.Type.HarvestTime(scale)) +} diff --git a/farm/crop_test.go b/farm/crop_test.go new file mode 100644 index 0000000..092068c --- /dev/null +++ b/farm/crop_test.go @@ -0,0 +1,15 @@ +package farm + +import ( + "fmt" + "testing" +) + +// This isn't a real test but is useful for checking crop details and balancing +func TestDisplayCropStats(t *testing.T) { + for cropType := range CropTypes { + profit := cropType.MarketPrice() - cropType.SeedCost() + ppd := float64(profit) / float64(cropType.HarvestTime(1)) + fmt.Printf("%-12s: -%3dg / +%3dg = +%3d ppd: %.3f\n", cropType.Name(), cropType.SeedCost(), cropType.MarketPrice(), profit, ppd) + } +} diff --git a/farm/encode.go b/farm/encode.go new file mode 100644 index 0000000..b7d2228 --- /dev/null +++ b/farm/encode.go @@ -0,0 +1,92 @@ +package farm + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "time" +) + +const encryptionKey = "F6FFAF11E0BC4B27ADDA7E4F4B91B4BA" + +type encodeableFarm struct { + Name string + W, H int + TimeScale time.Duration + Field [][]*Crop + Money int + LastModified time.Time +} + +func (f *Farm) Marshal() ([]byte, error) { + jsonData, err := json.Marshal(encodeableFarm{ + f.name, + f.w, f.h, + f.timeScale, + f.field, + f.money, + time.Now(), + }) + if err != nil { + return nil, fmt.Errorf("error marshaling json: %w", err) + } + + encrypted, err := encrypt(jsonData) + if err != nil { + return nil, fmt.Errorf("error encrypting: %w", err) + } + + return encrypted, nil +} + +func createCipher() (cipher.AEAD, error) { + c, err := aes.NewCipher([]byte(encryptionKey)) + if err != nil { + return nil, fmt.Errorf("error creating cipher: %w", err) + } + + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, fmt.Errorf("error creating gcm: %w", err) + } + + return gcm, nil +} + +func decrypt(in []byte) ([]byte, error) { + gcm, err := createCipher() + if err != nil { + return nil, fmt.Errorf("error creating cipher: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(in) < nonceSize { + return nil, fmt.Errorf("wrong nonce size") + } + + nonce, in := in[:nonceSize], in[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, in, nil) + if err != nil { + return nil, fmt.Errorf("error decrypting: %w", err) + } + + return plaintext, nil +} + +func encrypt(in []byte) ([]byte, error) { + gcm, err := createCipher() + if err != nil { + return nil, fmt.Errorf("error creating cipher: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return nil, fmt.Errorf("error reading nonce: %w", err) + } + + return gcm.Seal(nonce, nonce, in, nil), nil +} diff --git a/farm/farm.go b/farm/farm.go new file mode 100644 index 0000000..232a21e --- /dev/null +++ b/farm/farm.go @@ -0,0 +1,132 @@ +package farm + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" +) + +const startingMoney = 50 + +type Farm struct { + name string + w, h int + timeScale time.Duration + field [][]*Crop + money int +} + +func New(name string, w, h int, scale time.Duration) *Farm { + field := make([][]*Crop, h) + for row := range field { + field[row] = make([]*Crop, w) + } + + return &Farm{ + name, + w, h, + scale, + field, + startingMoney, + } +} + +func Load(filename string) (*Farm, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error opening file: %w", err) + } + + decrypted, err := decrypt(data) + if err != nil { + return nil, fmt.Errorf("error decrypting: %w", err) + } + + var f encodeableFarm + err = json.Unmarshal(decrypted, &f) + if err != nil { + return nil, fmt.Errorf("error parsing farm data: %w", err) + } + + return &Farm{ + f.Name, + f.W, f.H, + f.TimeScale, + f.Field, + f.Money, + }, nil +} + +func (f *Farm) Data() [][]string { + tableData := make([][]string, f.h) + for row := range f.field { + tableData[row] = make([]string, f.w) + for col, crop := range f.field[row] { + switch { + case crop != nil: + tableData[row][col] = crop.String(f.timeScale) + default: + tableData[row][col] = " " + } + } + } + + return tableData +} + +func (f *Farm) Name() string { + return f.name +} + +func (f *Farm) TimeScale() time.Duration { + return f.timeScale +} + +func (f *Farm) Height() int { + return f.h +} + +func (f *Farm) Width() int { + return f.w +} + +func (f *Farm) Money() int { + return f.money +} + +func (f *Farm) Get(row, col int) *Crop { + return f.field[row][col] +} + +func (f *Farm) Plant(cropType CropType, row, col int) error { + cur := f.Get(row, col) + if cur != nil { + return errors.New("crop here already") + } + + if f.money < cropType.SeedCost() { + return errors.New("not enough money") + } + + f.money -= cropType.SeedCost() + f.field[row][col] = NewCrop(cropType) + + return nil +} + +func (f *Farm) Harvest(row, col int) error { + cur := f.Get(row, col) + if cur == nil { + return errors.New("no crop here") + } + if !cur.ReadyToHarvest(f.TimeScale()) { + return errors.New("not ready to harvest") + } + + f.money += cur.Type.MarketPrice() + f.field[row][col] = nil + + return nil +} diff --git a/game/coordinate.go b/game/coordinate.go new file mode 100644 index 0000000..2d76da6 --- /dev/null +++ b/game/coordinate.go @@ -0,0 +1,29 @@ +package game + +type coord struct { + col, row int +} + +func (c *coord) up() { + if c.row > 0 { + c.row-- + } +} + +func (c *coord) down(h int) { + if c.row < h-1 { + c.row++ + } +} + +func (c *coord) left() { + if c.col > 0 { + c.col-- + } +} + +func (c *coord) right(w int) { + if c.col < w-1 { + c.col++ + } +} diff --git a/game/filesystem.go b/game/filesystem.go new file mode 100644 index 0000000..ab8e0a1 --- /dev/null +++ b/game/filesystem.go @@ -0,0 +1,77 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func findSaveFiles() ([]string, error) { + dir, err := terminalTillerDir() + if err != nil { + return nil, err + } + + result := []string{} + files, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("error reading save files: %w", err) + } + + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".data" { + result = append(result, filepath.Join(dir, f.Name())) + } + } + + return result, nil +} + +func terminalTillerDir() (string, error) { + dir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("error getting home directory: %w", err) + } + + dir = filepath.Join(dir, ".terminal-tiller") + + err = os.MkdirAll(dir, 0750) + if err != nil { + return "", fmt.Errorf("error creating save file directory: %w", err) + } + + return dir, nil +} + +func (g *game) filePath() (string, error) { + dir, err := terminalTillerDir() + if err != nil { + return "", err + } + + fname := strings.ToLower(strings.ReplaceAll(g.farm.Name(), " ", "_")) + return filepath.Join(dir, fname+".data"), nil +} + +func (g *game) saveAndQuit() tea.Msg { + data, err := g.farm.Marshal() + if err != nil { + // TODO: how to handle errors in bubbletea??? + panic(fmt.Sprintf("MARSHAL Error: %v", err)) + } + + path, err := g.filePath() + if err != nil { + panic(fmt.Sprintf("GET DIR Error: %v", err)) + } + + err = os.WriteFile(path, data, 0644) + if err != nil { + panic(fmt.Sprintf("WRITE Error: %v", err)) + } + + return tea.Quit() +} diff --git a/game/game.go b/game/game.go new file mode 100644 index 0000000..6b49ead --- /dev/null +++ b/game/game.go @@ -0,0 +1,166 @@ +package game + +import ( + "time" + + "github.com/calvinmclean/terminal-tiller/farm" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + DEFAULT_WIDTH = 7 + DEFAULT_HEIGHT = 7 + DEFAULT_SCALE = time.Second + DEFAULT_FILENAME = "farm.data" + + helpStr = `h/j/k/l or ←↓↑→ to move +enter or space to start a selection +esc to cancel selection. +s to select seeds. +p/f to plant. +c/d to harvest. +q to quit. +` +) + +type game struct { + farm *farm.Farm + + actualWidth, actualHeight int + + curCoord coord + selectedCropType farm.CropType + + selectedCoord coord + selecting bool + + showSeedSelect bool + seedSelect list.Model +} + +func New() tea.Model { + saveFiles, err := findSaveFiles() + if err != nil { + panic("error finding save files " + err.Error()) + } + + // TODO: select from list of save files if len > 1 and ask for name + var f *farm.Farm + if len(saveFiles) == 0 { + f = farm.New("My Farm", DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_SCALE) + } else { + f, err = farm.Load(saveFiles[0]) + if err != nil { + panic("error loading from save file " + err.Error()) + } + } + + return &game{ + farm: f, + curCoord: coord{}, + selectedCoord: coord{-1, -1}, + seedSelect: newSeedSelectView(f.TimeScale()), + selectedCropType: farm.Lettuce, + } +} + +func (g *game) Init() tea.Cmd { + return tick() +} + +func (g *game) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + if g.showSeedSelect { + g.seedSelect, cmd = g.seedSelect.Update(msg) + } + + switch msg := msg.(type) { + case tickMsg: + cmd = tea.Batch(tick(), cmd) + case tea.WindowSizeMsg: + g.actualWidth = msg.Width + g.actualHeight = msg.Height + + g.seedSelect.SetSize(g.actualWidth, g.actualHeight) + case tea.KeyMsg: + cmd = tea.Batch(g.handleInput(msg), cmd) + } + + return g, cmd +} + +func (g *game) View() string { + lines := []string{} + width := g.actualWidth + + seedSelectViewPort := "" + if g.showSeedSelect { + vpWidth := g.actualWidth / 4 + vp := viewport.New(vpWidth, g.actualHeight-4) + vp.Style = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Margin(2, 0). + PaddingRight(2) + + vp.SetContent(g.seedSelect.View()) + + seedSelectViewPort = vp.View() + width -= lipgloss.Width(seedSelectViewPort) + } + + topView := lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + lipgloss.NewStyle(). + Margin(2, 2, 0). + Render( + lipgloss.JoinHorizontal( + lipgloss.Top, + g.selectedSeedView(), + g.selectedCellView(), + ), + ), + ) + lines = append(lines, topView) + + table := lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + lipgloss.NewStyle(). + Margin(2, 2). + Align(lipgloss.Center, lipgloss.Center). + Render(g.renderTable()), + ) + lines = append(lines, table) + + help := lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + lipgloss.NewStyle(). + Margin(2). + Align(lipgloss.Center, lipgloss.Center). + Render(helpStr), + ) + lines = append(lines, help) + + result := lipgloss.JoinVertical(lipgloss.Top, lines...) + if g.showSeedSelect { + seedSelectViewPort = lipgloss.PlaceHorizontal(lipgloss.Width(seedSelectViewPort), lipgloss.Left, seedSelectViewPort) + result = lipgloss.JoinHorizontal(lipgloss.Top, seedSelectViewPort, result) + } + + statusBar := g.statusBar() + statusBar = lipgloss.PlaceVertical( + g.actualHeight-lipgloss.Height(result), + lipgloss.Bottom, statusBar, + ) + + result = lipgloss.JoinVertical(lipgloss.Top, result, statusBar) + + return result +} diff --git a/game/input.go b/game/input.go new file mode 100644 index 0000000..43d57e4 --- /dev/null +++ b/game/input.go @@ -0,0 +1,101 @@ +package game + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (g *game) handleInput(msg tea.KeyMsg) tea.Cmd { + if g.showSeedSelect { + return g.handleListInput(msg) + } + + switch msg.String() { + case "ctrl+c", "q": + return g.saveAndQuit + case "up", "k", + "down", "j", + "left", "h", + "right", "l": + g.move(msg) + case "esc": + g.stopSelecting() + case "s": + g.showSeedSelect = true + case "p", "f": + g.plant() + case "c", "d": + g.harvest() + case "enter", " ": + g.handleSelection() + } + return nil +} + +func (g *game) handleListInput(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "s", "q": + g.showSeedSelect = false + case "enter", " ": + g.selectedCropType = g.seedSelect.SelectedItem().(seedListItem).CropType + g.showSeedSelect = false + } + return nil +} + +func (g *game) move(msg tea.KeyMsg) { + newCoord := coord{g.curCoord.col, g.curCoord.row} + switch msg.String() { + case "up", "k": + newCoord.up() + case "down", "j": + newCoord.down(g.farm.Height()) + case "left", "h": + newCoord.left() + case "right", "l": + newCoord.right(g.farm.Width()) + } + + if !g.selecting { + g.curCoord = newCoord + return + } + + // don't allow overlap of existing plants or outside of harvestable area + selected := g.farm.Get(g.selectedCoord.row, g.selectedCoord.col) + startCol, startRow, endCol, endRow := g.getCurrentSelectionsAtCoord(newCoord) + for row := startRow; row <= endRow; row++ { + for col := startCol; col <= endCol; col++ { + current := g.farm.Get(row, col) + + // Only select crops of the same type + if current != nil && selected != nil && current.Type != selected.Type { + return + } + + // All selections must be crop or no crop + if (current == nil) != (selected == nil) { + return + } + } + } + g.curCoord = newCoord +} + +func (g *game) plant() { + // can't plant if there's not enough money + if g.selectedCropType.SeedCost()*g.numSelected() > g.farm.Money() { + return + } + + g.doForCurrentSelection(func(row, col int) { + g.farm.Plant(g.selectedCropType, row, col) + }) + g.stopSelecting() +} + +func (g *game) harvest() { + g.doForCurrentSelection(func(row, col int) { + g.farm.Harvest(row, col) + }) + g.stopSelecting() +} diff --git a/game/seed_selection.go b/game/seed_selection.go new file mode 100644 index 0000000..84c65ff --- /dev/null +++ b/game/seed_selection.go @@ -0,0 +1,50 @@ +package game + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/calvinmclean/terminal-tiller/farm" + "github.com/calvinmclean/terminal-tiller/styles" + + "github.com/charmbracelet/bubbles/list" +) + +func newSeedSelectView(scale time.Duration) list.Model { + items := []list.Item{} + for cropType := range farm.CropTypes { + items = append(items, seedListItem{cropType, scale}) + } + + slices.SortFunc[[]list.Item](items, func(a, b list.Item) int { + return strings.Compare(a.FilterValue(), b.FilterValue()) + }) + + plantList := list.New(items, list.NewDefaultDelegate(), 0, 0) + plantList.Title = "Seeds" + + return plantList +} + +type seedListItem struct { + farm.CropType + scale time.Duration +} + +func (i seedListItem) Title() string { + return fmt.Sprintf("%s %s", i.CropType.Representation(), i.CropType.Name()) +} + +func (i seedListItem) Description() string { + return fmt.Sprintf( + "%s %s", + cropCost(i.CropType), + styles.AquaText(fmt.Sprintf("%v", i.CropType.HarvestTime(i.scale))), + ) +} + +func (i seedListItem) FilterValue() string { + return fmt.Sprintf("%03d", i.CropType.SeedCost()) +} diff --git a/game/selection.go b/game/selection.go new file mode 100644 index 0000000..0fee62a --- /dev/null +++ b/game/selection.go @@ -0,0 +1,67 @@ +package game + +func (g *game) stopSelecting() { + g.selecting = false + g.selectedCoord = coord{-1, -1} +} + +func (g *game) handleSelection() { + if g.selecting { + return + } + + g.selectedCoord = g.curCoord + g.selecting = true +} + +// doForCurrentSelection will modifRow each cell in the currentlRow-selected range +func (g *game) doForCurrentSelection(do func(int, int)) { + startCol, startRow, endCol, endRow := g.getCurrentSelections() + for row := startRow; row <= endRow; row++ { + for col := startCol; col <= endCol; col++ { + do(row, col) + } + } +} + +// getCurrentSelections returns the current Col/Row and the last selected Col/Row in order with top-left first +func (g *game) getCurrentSelections() (int, int, int, int) { + return g.getCurrentSelectionsAtCoord(g.curCoord) +} + +func (g *game) getCurrentSelectionsAtCoord(curCoord coord) (int, int, int, int) { + startCol, endCol := g.selectedCoord.col, curCoord.col + startRow, endRow := g.selectedCoord.row, curCoord.row + + if startRow > endRow { + startRow, endRow = endRow, startRow + } + if startCol > endCol { + startCol, endCol = endCol, startCol + } + + if startCol == -1 { + startCol = endCol + } + if startRow == -1 { + startRow = endRow + } + + return startCol, startRow, endCol, endRow +} + +func (g *game) isSelected(c coord) bool { + if !g.selecting { + return false + } + startCol, startRow, endCol, endRow := g.getCurrentSelections() + return c.col >= startCol && c.col <= endCol && c.row >= startRow && c.row <= endRow +} + +func (g *game) numSelected() int { + startCol, startRow, endCol, endRow := g.getCurrentSelections() + colSize := endCol - startCol + 1 + rowSize := endRow - startRow + 1 + + return colSize * rowSize +} diff --git a/game/tick.go b/game/tick.go new file mode 100644 index 0000000..03e9e20 --- /dev/null +++ b/game/tick.go @@ -0,0 +1,16 @@ +package game + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type tickMsg struct{} + +func tick() tea.Cmd { + return func() tea.Msg { + time.Sleep(100 * time.Millisecond) + return tickMsg{} + } +} diff --git a/game/views.go b/game/views.go new file mode 100644 index 0000000..ff22aa8 --- /dev/null +++ b/game/views.go @@ -0,0 +1,146 @@ +package game + +import ( + "fmt" + "time" + + "github.com/calvinmclean/terminal-tiller/farm" + "github.com/calvinmclean/terminal-tiller/styles" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +const readyToHarvestMsg = "Ready to harvest!" + +func (g *game) statusBar() string { + w := lipgloss.Width + + statusBarStyle := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#343433", Dark: "#C1C6B2"}). + Background(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#353533"}) + + moneyStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Padding(0, 1). + Background(lipgloss.Color("#A550DF")). + Align(lipgloss.Right) + + statusText := lipgloss.NewStyle().Inherit(statusBarStyle) + + statusKey := statusBarStyle.Render(g.farm.Name()) + money := moneyStyle.Render(fmt.Sprintf("%dg", g.farm.Money())) + statusVal := statusText.Copy(). + Width(g.actualWidth - w(statusKey) - w(money)). + Render("") + + bar := lipgloss.JoinHorizontal(lipgloss.Top, + statusKey, + statusVal, + money, + ) + + return statusBarStyle.Width(g.actualWidth).Render(bar) +} + +func (g *game) renderTable() string { + tableData := g.farm.Data() + + return table.New(). + Border(lipgloss.NormalBorder()). + BorderRow(true). + BorderColumn(true). + // Width(actualWidth / 2). + // Height(actualWidth). + Rows(tableData...). + StyleFunc(func(row, col int) lipgloss.Style { + var bg lipgloss.TerminalColor = lipgloss.NoColor{} + var fg lipgloss.TerminalColor = lipgloss.NoColor{} + + // background color of current selection + c := coord{col, row - 1} + if c == g.curCoord || g.isSelected(c) { + bg = lipgloss.Color("241") + + current := g.farm.Get(c.row, c.col) + if current == nil && // tile is NOT a plant + g.selectedCropType.SeedCost()*g.numSelected() <= g.farm.Money() { // can afford to plant all tiles + bg = styles.Green + } + if current != nil && current.ReadyToHarvest(g.farm.TimeScale()) { + bg = styles.Yellow + } + } + + return lipgloss.NewStyle(). + Foreground(fg). + Background(bg). + Padding(0, 1). + Align(lipgloss.Center, lipgloss.Center) + }). + Render() +} + +func (g *game) selectedSeedView() string { + return lipgloss.JoinVertical(lipgloss.Left, + styles.ListHeader("Current Seed"), + styles.ListItem(fmt.Sprintf("%s %s", g.selectedCropType.Name(), g.selectedCropType.Representation())), + styles.ListItem(cropCost(g.selectedCropType)), + styles.ListItem(styles.AquaText(fmt.Sprintf("%v", g.selectedCropType.HarvestTime(g.farm.TimeScale())))), + ) +} + +func cropCost(cropType farm.CropType) string { + return fmt.Sprintf( + "%s / %s", + styles.RedText(fmt.Sprintf("-%dg", cropType.SeedCost())), + styles.GreenText(fmt.Sprintf("+%dg", cropType.MarketPrice())), + ) +} + +func (g *game) selectedCellView() string { + return lipgloss.NewStyle(). + Width(len(readyToHarvestMsg) + 2). + Render(lipgloss.JoinVertical(lipgloss.Left, g.selectedCellDetails()...)) +} + +func (g *game) selectedCellDetails() []string { + header := styles.ListHeader("Selected Cell") + + crop := g.farm.Get(g.curCoord.row, g.curCoord.col) + if crop == nil { + return []string{ + header, + styles.ListItem("Soil"), + } + } + + harvestDate := crop.PlantedAt.Add(crop.Type.HarvestTime(g.farm.TimeScale())) + timeUntilHarvest := time.Since(harvestDate) * -1 + timeUntilHarvestDisplay := styles.YellowText(fmt.Sprintf("%v", timeUntilHarvest.Truncate(time.Millisecond))) + if timeUntilHarvest < 0 { + timeUntilHarvestDisplay = styles.GreenText(readyToHarvestMsg) + } + + return []string{ + header, + styles.ListItem(fmt.Sprintf("%s %s", crop.Type.Name(), crop.Type.Representation())), + styles.ListItem(progressBar(crop, g.farm.TimeScale())), + styles.ListItem(timeUntilHarvestDisplay), + } +} + +func progressBar(crop *farm.Crop, scale time.Duration) string { + progress := progress.New( + progress.WithGradient(string(styles.Yellow), string(styles.Green)), + progress.WithWidth(len(readyToHarvestMsg)), + progress.WithoutPercentage(), + ) + + harvestDate := crop.HarvestTime(scale) + until := time.Until(harvestDate) + percent := 1 - float64(until)/float64(crop.Type.HarvestTime(scale)) + + return progress.ViewAs(percent) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d6eb47 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/calvinmclean/terminal-tiller + +go 1.21.3 + +require ( + github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..573ad68 --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= +github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..766e97d --- /dev/null +++ b/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/calvinmclean/terminal-tiller/game" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + g := game.New() + p := tea.NewProgram(g, tea.WithAltScreen()) + + _, err := p.Run() + if err != nil { + panic(err) + } +} diff --git a/styles/styles.go b/styles/styles.go new file mode 100644 index 0000000..4e162ab --- /dev/null +++ b/styles/styles.go @@ -0,0 +1,27 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +const ( + Yellow = lipgloss.Color("#F0E68C") + Green = lipgloss.Color("#73F59F") +) + +var ( + GreenText = lipgloss.NewStyle().Foreground(lipgloss.Color(Green)).Render + RedText = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render + AquaText = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Render + YellowText = lipgloss.NewStyle().Foreground(lipgloss.Color(Yellow)).Render + + Subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + + ListHeader = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(Subtle). + MarginRight(2). + MarginLeft(2). + Render + + ListItem = lipgloss.NewStyle().PaddingLeft(2).PaddingRight(2).Render +)