clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

unify ux and fix bad exit handling.

Dami committed dcef8c1
commit
dcef8c16f7fb425d77c8d3a9159cf65cf07a676f
parent
7295a1ca8d0c7da863251996aa69654efaf4a778
author
Dami <dami@Damis-MacBook-Air.local>
25 files changed, +305 -384
Agame/cursor.go39++++++++++++++++
Mgame/gamer.go9++++
Agame/keys.go31+++++++++++++
Agame/style.go25++++++++++
Mhashiwokakero/Gamemode.go6++
Mhashiwokakero/Model.go4++
Mhashiwokakero/keys.go23+--------
Mhashiwokakero/style.go18+-------
Mmodel.go44+++---------------
Mnonogram/Gamemode.go6++
Mnonogram/Model.go37+++++----------
Mnonogram/keys.go41++++-------------
Mnonogram/style.go26++--------
Msudoku/Export.go10++--
Msudoku/Gamemode.go6++
Msudoku/Model.go46+++++++------------
Msudoku/cell.go10++--
Msudoku/generator.go26+++++-----
Msudoku/keys.go41++++-------------
Msudoku/style.go36+++++----------
Mwordsearch/Export.go32+++++++------
Mwordsearch/Gamemode.go6++
Mwordsearch/Model.go73++++++++++++------------------
Mwordsearch/keys.go59++++++------------------
Mwordsearch/style.go35+++++---------
A game/cursor.go +39 -0
M game/gamer.go +9 -0
A game/keys.go +31 -0
A game/style.go +25 -0
M hashiwokakero/Gamemode.go +6 -0
M hashiwokakero/Model.go +4 -0
M hashiwokakero/keys.go +3 -20
M hashiwokakero/style.go +2 -16
M model.go +7 -37
@@ -1,7 +1,6 @@
11 package main22 33 import (4- "encoding/json"54 "fmt"65 "log"76 "strconv"
@@ -183,20 +182,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183182 return m, nil184183 }185184 rec := m.continueGames[idx]186- var g game.Gamer187- var err error188- switch rec.GameType {189- case "Nonogram":190- g, err = nonogram.ImportModel([]byte(rec.SaveState))191- case "Word Search":192- g, err = wordsearch.ImportModel([]byte(rec.SaveState))193- case "Sudoku":194- g, err = sudoku.ImportModel([]byte(rec.SaveState))195- case "Hashiwokakero":196- g, err = hashiwokakero.ImportModel([]byte(rec.SaveState))197- default:185+ importFn, ok := game.Registry[rec.GameType]186+ if !ok {198187 return m, nil199188 }189+ g, err := importFn([]byte(rec.SaveState))200190 if err != nil {201191 log.Printf("failed to import game: %v", err)202192 return m, nil
@@ -232,7 +222,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
232222 if m.debug {233223 m.debuginfo = m.renderDebugInfo()234224 }235- if !m.completionSaved && isSolvedFromSave(m.game) {225+ if !m.completionSaved && m.game.IsSolved() {236226 m.completionSaved = true237227 saveData, err := m.game.GetSave()238228 if err == nil {
@@ -267,6 +257,9 @@ func (m model) View() string {
267257 }268258 return rootStyle.Render(m.continueTable.View())269259 case gameView:260+ if m.game == nil {261+ return ""262+ }270263 var debugInfo string271264 if m.debug {272265 debugInfo = debugStyle.Render(m.debuginfo)
@@ -412,26 +405,3 @@ func generateUniqueName(s *store.Store) string {
412405 }413406 }414407 }415-416-// isSolvedFromSave checks whether the game's save data indicates a solved/won state.417-func isSolvedFromSave(g game.Gamer) bool {418- data, err := g.GetSave()419- if err != nil {420- return false421- }422- var fields map[string]json.RawMessage423- if err := json.Unmarshal(data, &fields); err != nil {424- return false425- }426- for _, key := range []string{"solved", "won"} {427- raw, ok := fields[key]428- if !ok {429- continue430- }431- var val bool432- if json.Unmarshal(raw, &val) == nil && val {433- return true434- }435- }436- return false437-}
M nonogram/Gamemode.go +6 -0
M nonogram/Model.go +12 -25
@@ -3,6 +3,7 @@ package nonogram
33 import (44 "errors"55 "fmt"6+67 "github.com/FelineStateMachine/puzzletea/game"78 "github.com/charmbracelet/bubbles/key"89 tea "github.com/charmbracelet/bubbletea"
@@ -15,15 +16,11 @@ const (
1516 emptyTile = ' '1617 )1718 18-type cursor struct {19- x, y int20-}21-2219 type Model struct {2320 width, height int2421 rowHints TomographyDefinition2522 colHints TomographyDefinition26- cursor cursor23+ cursor game.Cursor2724 grid grid2825 keys KeyMap2926 modeName string
@@ -66,26 +63,11 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6663 m.updateTile(markedTile)6764 case key.Matches(msg, m.keys.ClearTile):6865 m.updateTile(emptyTile)69- case key.Matches(msg, m.keys.Up):70- if m.cursor.y > 0 {71- m.cursor.y--72- }73- case key.Matches(msg, m.keys.Down):74- if m.cursor.y < m.height-1 {75- m.cursor.y++76- }77- case key.Matches(msg, m.keys.Left):78- if m.cursor.x > 0 {79- m.cursor.x--80- }81- case key.Matches(msg, m.keys.Right):82- if m.cursor.x < m.width-1 {83- m.cursor.x++84- }66+ default:67+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)8568 }86-8769 }88- m.updateKeyBindinds()70+ m.updateKeyBindings()8971 return m, nil9072 }9173
@@ -129,7 +111,7 @@ func (m Model) GetDebugInfo() string {
129111 "| Grid Size | %d x %d |\n"+130112 "| Hint Widths | row: %d, col: %d |\n",131113 status,132- m.cursor.x, m.cursor.y,114+ m.cursor.X, m.cursor.Y,133115 m.width, m.height,134116 m.rowHints.RequiredLen()*cellWidth, m.colHints.RequiredLen(),135117 )
@@ -185,6 +167,11 @@ func intSliceStr(s []int) string {
185167 return result186168 }187169 170+func (m Model) IsSolved() bool {171+ curr := generateTomography(m.grid)172+ return curr.rows.equal(m.rowHints) && curr.cols.equal(m.colHints)173+}174+188175 func (m *Model) updateTile(r rune) {189- m.grid[m.cursor.y][m.cursor.x] = r176+ m.grid[m.cursor.Y][m.cursor.X] = r190177 }
M nonogram/keys.go +9 -32
@@ -1,42 +1,19 @@
11 package nonogram22 33 import (4+ "github.com/FelineStateMachine/puzzletea/game"45 "github.com/charmbracelet/bubbles/key"56 )67 7-type CursorKeyMap struct {8- Up key.Binding9- Down key.Binding10- Left key.Binding11- Right key.Binding12-}13-148 type KeyMap struct {15- CursorKeyMap9+ game.CursorKeyMap1610 FillTile key.Binding1711 MarkTile key.Binding1812 ClearTile key.Binding1913 }2014 2115 var DefaultKeyMap = KeyMap{22- CursorKeyMap: CursorKeyMap{23- Up: key.NewBinding(24- key.WithKeys("k", "up", "w"),25- key.WithHelp("↑/w/k", "Up"),26- ),27- Down: key.NewBinding(28- key.WithKeys("j", "down", "s"),29- key.WithHelp("↓/s/j", "Down"),30- ),31- Left: key.NewBinding(32- key.WithKeys("h", "left", "a"),33- key.WithHelp("←/a/h", "Left"),34- ),35- Right: key.NewBinding(36- key.WithKeys("l", "right", "d"),37- key.WithHelp("→/d/l", "Right"),38- ),39- },16+ CursorKeyMap: game.DefaultCursorKeyMap,4017 4118 FillTile: key.NewBinding(4219 key.WithKeys("z"),
@@ -52,13 +29,13 @@ var DefaultKeyMap = KeyMap{
5229 ),5330 }5431 55-func (m *Model) updateKeyBindinds() {56- m.keys.Up.SetEnabled(m.cursor.y > 0)57- m.keys.Down.SetEnabled(m.cursor.y < m.height-1)58- m.keys.Left.SetEnabled(m.cursor.x > 0)59- m.keys.Right.SetEnabled(m.cursor.x < m.width-1)32+func (m *Model) updateKeyBindings() {33+ m.keys.Up.SetEnabled(m.cursor.Y > 0)34+ m.keys.Down.SetEnabled(m.cursor.Y < m.height-1)35+ m.keys.Left.SetEnabled(m.cursor.X > 0)36+ m.keys.Right.SetEnabled(m.cursor.X < m.width-1)6037 61- cursorVal := m.grid[m.cursor.y][m.cursor.x]38+ cursorVal := m.grid[m.cursor.Y][m.cursor.X]6239 switch cursorVal {6340 case filledTile:6441 m.keys.FillTile.SetEnabled(false)
M nonogram/style.go +6 -20
@@ -4,6 +4,7 @@ import (
44 "fmt"55 "strings"66 7+ "github.com/FelineStateMachine/puzzletea/game"78 "github.com/charmbracelet/lipgloss"89 )910
@@ -35,16 +36,6 @@ var (
3536 hintSatisfiedStyle = baseStyle.3637 Foreground(lipgloss.Color("#00ff00"))3738 38- nonoTitleStyle = lipgloss.NewStyle().39- Bold(true).40- Foreground(lipgloss.Color("#ffffff")).41- Background(lipgloss.Color("#7B2FBE")).42- Padding(0, 1)43-44- nonoSolvedBadgeStyle = lipgloss.NewStyle().45- Bold(true).46- Foreground(lipgloss.Color("#00ff00"))47-4839 nonoStatusBarStyle = lipgloss.NewStyle().4940 Foreground(lipgloss.Color("#888888")).5041 MarginTop(1)
@@ -131,14 +122,14 @@ func rowHintView(r TomographyDefinition, width int, current ...TomographyDefinit
131122 return s132123 }133124 134-func gridView(g grid, c cursor, solved bool) string {125+func gridView(g grid, c game.Cursor, solved bool) string {135126 var rows []string136127 for y, row := range g {137128 var rowBuilder []string138129 for x, cell := range row {139- isCursor := x == c.x && y == c.y140- inCursorRow := y == c.y141- inCursorCol := x == c.x130+ isCursor := x == c.X && y == c.Y131+ inCursorRow := y == c.Y132+ inCursorCol := x == c.X142133 cell := tileView(cell, isCursor, inCursorRow, inCursorCol, solved)143134 rowBuilder = append(rowBuilder, cell)144135 }
@@ -175,12 +166,7 @@ func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string
175166 }176167 177168 func nonoTitleBarView(modeName string, solved bool) string {178- title := nonoTitleStyle.Render("Nonogram " + modeName)179- if solved {180- badge := nonoSolvedBadgeStyle.Render(" SOLVED")181- return title + badge182- }183- return title169+ return game.TitleBarView("Nonogram", modeName, solved)184170 }185171 186172 func nonoStatusBarView(_ KeyMap) string {
M sudoku/Export.go +5 -5
M sudoku/Gamemode.go +6 -0
M sudoku/Model.go +16 -30
@@ -11,14 +11,10 @@ import (
1111 "github.com/charmbracelet/lipgloss"1212 )1313 14-const GRIDSIZE = 915-16-type cursor struct {17- x, y int18-}14+const gridSize = 91915 2016 type Model struct {21- cursor cursor17+ cursor game.Cursor2218 grid grid2319 provided []cell2420 keys KeyMap
@@ -52,31 +48,21 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5248 m.updateCell(val)5349 case key.Matches(msg, m.keys.ClearCell):5450 m.updateCell(0)55- case key.Matches(msg, m.keys.Up):56- if m.cursor.y > 0 {57- m.cursor.y--58- }59- case key.Matches(msg, m.keys.Down):60- if m.cursor.y < GRIDSIZE-1 {61- m.cursor.y++62- }63- case key.Matches(msg, m.keys.Left):64- if m.cursor.x > 0 {65- m.cursor.x--66- }67- case key.Matches(msg, m.keys.Right):68- if m.cursor.x < GRIDSIZE-1 {69- m.cursor.x++70- }51+ default:52+ m.cursor.Move(m.keys.CursorKeyMap, msg, gridSize-1, gridSize-1)7153 }72-7354 }74- m.updateKeyBindinds()55+ m.updateKeyBindings()7556 return m, nil7657 }7758 59+// IsSolved implements game.Gamer.60+func (m Model) IsSolved() bool {61+ return m.isSolved()62+}63+7864 func (m *Model) updateCell(v int) {79- c := &m.grid[m.cursor.y][m.cursor.x]65+ c := &m.grid[m.cursor.Y][m.cursor.X]8066 if slices.Contains(m.provided, *c) {8167 return8268 }
@@ -94,15 +80,15 @@ func (m Model) View() string {
9480 9581 // GetDebugInfo implements game.Gamer.9682 func (m Model) GetDebugInfo() string {97- cursorCell := m.grid[m.cursor.y][m.cursor.x]83+ cursorCell := m.grid[m.cursor.Y][m.cursor.X]9884 isProvided := slices.Contains(m.provided, cursorCell)99- conflict := hasConflict(m, cursorCell, m.cursor.x, m.cursor.y)85+ conflict := hasConflict(m, cursorCell, m.cursor.X, m.cursor.Y)10086 solved := m.isSolved()10187 10288 filledCount := 010389 conflictCount := 0104- for y := range GRIDSIZE {105- for x := range GRIDSIZE {90+ for y := range gridSize {91+ for x := range gridSize {10692 c := m.grid[y][x]10793 if c.v != 0 {10894 filledCount++
@@ -132,7 +118,7 @@ func (m Model) GetDebugInfo() string {
132118 "| Conflict Count | %d |\n"+133119 "| Provided Count | %d |\n",134120 status,135- m.cursor.x, m.cursor.y,121+ m.cursor.X, m.cursor.Y,136122 cellContent(cursorCell),137123 isProvided,138124 conflict,
M sudoku/cell.go +5 -5
M sudoku/generator.go +13 -13
@@ -4,7 +4,7 @@ import "math/rand/v2"
44 55 // isValid checks whether placing val at (x, y) in the grid is valid.66 func isValid(g *grid, val, x, y int) bool {7- for i := range GRIDSIZE {7+ for i := range gridSize {88 if i != x && g[y][i].v == val {99 return false1010 }
@@ -25,10 +25,10 @@ func isValid(g *grid, val, x, y int) bool {
2525 2626 // fillGrid fills an empty grid with a random valid sudoku solution using backtracking.2727 func fillGrid(g *grid) bool {28- for y := range GRIDSIZE {29- for x := range GRIDSIZE {28+ for y := range gridSize {29+ for x := range gridSize {3030 if g[y][x].v == 0 {31- order := rand.Perm(GRIDSIZE)31+ order := rand.Perm(gridSize)3232 for _, i := range order {3333 val := i + 13434 if isValid(g, val, x, y) {
@@ -48,11 +48,11 @@ func fillGrid(g *grid) bool {
4848 4949 // countSolutions counts solutions of the grid up to limit using backtracking.5050 func countSolutions(g *grid, limit int) int {51- for y := range GRIDSIZE {52- for x := range GRIDSIZE {51+ for y := range gridSize {52+ for x := range gridSize {5353 if g[y][x].v == 0 {5454 count := 055- for val := 1; val <= GRIDSIZE; val++ {55+ for val := 1; val <= gridSize; val++ {5656 if isValid(g, val, x, y) {5757 g[y][x].v = val5858 count += countSolutions(g, limit-count)
@@ -77,9 +77,9 @@ func GenerateProvidedCells(m SudokuMode) []cell {
7777 7878 // Build shuffled list of all positions7979 type pos struct{ x, y int }80- positions := make([]pos, 0, GRIDSIZE*GRIDSIZE)81- for y := range GRIDSIZE {82- for x := range GRIDSIZE {80+ positions := make([]pos, 0, gridSize*gridSize)81+ for y := range gridSize {82+ for x := range gridSize {8383 positions = append(positions, pos{x, y})8484 }8585 }
@@ -88,7 +88,7 @@ func GenerateProvidedCells(m SudokuMode) []cell {
8888 })8989 9090 // Iteratively remove cells, ensuring unique solution91- filled := GRIDSIZE * GRIDSIZE91+ filled := gridSize * gridSize9292 for _, p := range positions {9393 if filled <= m.ProvidedCount {9494 break
@@ -104,8 +104,8 @@ func GenerateProvidedCells(m SudokuMode) []cell {
104104 105105 // Collect remaining filled cells as provided hints106106 var cells []cell107- for y := range GRIDSIZE {108- for x := range GRIDSIZE {107+ for y := range gridSize {108+ for x := range gridSize {109109 if g[y][x].v != 0 {110110 cells = append(cells, g[y][x])111111 }
M sudoku/keys.go +9 -32
@@ -1,41 +1,18 @@
11 package sudoku22 33 import (4+ "github.com/FelineStateMachine/puzzletea/game"45 "github.com/charmbracelet/bubbles/key"56 )67 7-type CursorKeyMap struct {8- Up key.Binding9- Down key.Binding10- Left key.Binding11- Right key.Binding12-}13-148 type KeyMap struct {15- CursorKeyMap9+ game.CursorKeyMap1610 FillValue key.Binding1711 ClearCell key.Binding1812 }1913 2014 var DefaultKeyMap = KeyMap{21- CursorKeyMap: CursorKeyMap{22- Up: key.NewBinding(23- key.WithKeys("k", "up", "w"),24- key.WithHelp("↑/w/k", "Up"),25- ),26- Down: key.NewBinding(27- key.WithKeys("j", "down", "s"),28- key.WithHelp("↓/s/j", "Down"),29- ),30- Left: key.NewBinding(31- key.WithKeys("h", "left", "a"),32- key.WithHelp("←/a/h", "Left"),33- ),34- Right: key.NewBinding(35- key.WithKeys("l", "right", "d"),36- key.WithHelp("→/d/l", "Right"),37- ),38- },15+ CursorKeyMap: game.DefaultCursorKeyMap,3916 FillValue: key.NewBinding(4017 key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"),4118 key.WithHelp("[1-9]", "Fill Cell"),
@@ -46,11 +23,11 @@ var DefaultKeyMap = KeyMap{
4623 ),4724 }4825 49-func (m *Model) updateKeyBindinds() {50- m.keys.Up.SetEnabled(m.cursor.y > 0)51- m.keys.Down.SetEnabled(m.cursor.y < 8)52- m.keys.Left.SetEnabled(m.cursor.x > 0)53- m.keys.Right.SetEnabled(m.cursor.x < 8)26+func (m *Model) updateKeyBindings() {27+ m.keys.Up.SetEnabled(m.cursor.Y > 0)28+ m.keys.Down.SetEnabled(m.cursor.Y < 8)29+ m.keys.Left.SetEnabled(m.cursor.X > 0)30+ m.keys.Right.SetEnabled(m.cursor.X < 8)5431 allowInteraction := !m.IsProvidedCell()5532 m.keys.FillValue.SetEnabled(allowInteraction)5633 m.keys.ClearCell.SetEnabled(allowInteraction)
@@ -59,7 +36,7 @@ func (m *Model) updateKeyBindinds() {
5936 func (m Model) IsProvidedCell() bool {6037 v := false6138 for _, hint := range m.provided {62- if m.cursor.x == hint.x && m.cursor.y == hint.y {39+ if m.cursor.X == hint.x && m.cursor.Y == hint.y {6340 v = true6441 }6542 }
M sudoku/style.go +11 -25
@@ -5,20 +5,11 @@ import (
55 "slices"66 "strings"77 8+ "github.com/FelineStateMachine/puzzletea/game"89 "github.com/charmbracelet/lipgloss"910 )1011 1112 var (12- titleStyle = lipgloss.NewStyle().13- Bold(true).14- Foreground(lipgloss.Color("#ffffff")).15- Background(lipgloss.Color("#2E86AB")).16- Padding(0, 1)17-18- solvedBadgeStyle = lipgloss.NewStyle().19- Bold(true).20- Foreground(lipgloss.Color("#00ff00"))21-2213 emptyCellStyle = lipgloss.NewStyle().2314 Foreground(lipgloss.Color("#555555")).2415 Background(lipgloss.Color("#1a1a1a"))
@@ -63,9 +54,9 @@ var (
6354 func renderGrid(m Model) string {6455 var rows []string6556 66- for y := range GRIDSIZE {57+ for y := range gridSize {6758 var cells []string68- for x := range GRIDSIZE {59+ for x := range gridSize {6960 c := m.grid[y][x]7061 style := getCellStyle(m, c, x, y)7162 content := cellContent(c)
@@ -85,7 +76,7 @@ func renderGrid(m Model) string {
8576 if y == 2 || y == 5 {8677 sepLine := strings.Repeat("─", sudokuCellWidth)8778 var sepParts []string88- for x := range GRIDSIZE {79+ for x := range gridSize {8980 sepParts = append(sepParts, sepLine)9081 if x == 2 || x == 5 {9182 sepParts = append(sepParts, "┼")
@@ -106,7 +97,7 @@ func renderGrid(m Model) string {
10697 10798 func getCellStyle(m Model, c cell, x, y int) lipgloss.Style {10899 // Priority: cursor > conflict > provided > user > empty109- if m.cursor.x == x && m.cursor.y == y {100+ if m.cursor.X == x && m.cursor.Y == y {110101 return cursorCellStyle111102 }112103
@@ -115,9 +106,9 @@ func getCellStyle(m Model, c cell, x, y int) lipgloss.Style {
115106 }116107 117108 isProvided := slices.Contains(m.provided, c)118- inCursorRow := m.cursor.y == y119- inCursorCol := m.cursor.x == x120- inCursorBox := (x/3 == m.cursor.x/3) && (y/3 == m.cursor.y/3)109+ inCursorRow := m.cursor.Y == y110+ inCursorCol := m.cursor.X == x111+ inCursorBox := (x/3 == m.cursor.X/3) && (y/3 == m.cursor.Y/3)121112 inCrosshair := inCursorRow || inCursorCol || inCursorBox122113 123114 if isProvided {
@@ -156,14 +147,14 @@ func hasConflict(m Model, c cell, x, y int) bool {
156147 }157148 158149 // Check row159- for cx := range GRIDSIZE {150+ for cx := range gridSize {160151 if cx != x && m.grid[y][cx].v == c.v {161152 return true162153 }163154 }164155 165156 // Check column166- for cy := range GRIDSIZE {157+ for cy := range gridSize {167158 if cy != y && m.grid[cy][x].v == c.v {168159 return true169160 }
@@ -184,12 +175,7 @@ func hasConflict(m Model, c cell, x, y int) bool {
184175 }185176 186177 func titleBarView(modeName string, solved bool) string {187- title := titleStyle.Render("Sudoku " + modeName)188- if solved {189- badge := solvedBadgeStyle.Render(" SOLVED")190- return title + badge191- }192- return title178+ return game.TitleBarView("Sudoku", modeName, solved)193179 }194180 195181 func statusBarView() string {
M wordsearch/Export.go +18 -14
M wordsearch/Gamemode.go +6 -0
M wordsearch/Model.go +30 -43
@@ -17,19 +17,16 @@ const (
1717 startSelected1818 )1919 20-type cursor struct {21- x, y int22-}23-2420 type Model struct {2521 width, height int2622 grid grid2723 words []Word28- cursor cursor24+ cursor game.Cursor2925 selection selectionState30- selectionStart cursor26+ selectionStart game.Cursor3127 keys KeyMap32- won bool28+ modeName string29+ solved bool3330 }3431 3532 // New creates a new word search game
@@ -39,10 +36,10 @@ func New(mode WordSearchMode, g grid, words []Word) *Model {
3936 height: mode.Height,4037 grid: g,4138 words: words,42- cursor: cursor{x: 0, y: 0},4339 selection: noSelection,4440 keys: DefaultKeyMap,45- won: false,41+ modeName: mode.Title(),42+ solved: false,4643 }4744 }4845
@@ -60,26 +57,12 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6057 6158 func (m Model) handleKeyPress(msg tea.KeyMsg) (game.Gamer, tea.Cmd) {6259 switch {63- case key.Matches(msg, m.keys.Up):64- if m.cursor.y > 0 {65- m.cursor.y--66- }67- case key.Matches(msg, m.keys.Down):68- if m.cursor.y < m.height-1 {69- m.cursor.y++70- }71- case key.Matches(msg, m.keys.Left):72- if m.cursor.x > 0 {73- m.cursor.x--74- }75- case key.Matches(msg, m.keys.Right):76- if m.cursor.x < m.width-1 {77- m.cursor.x++78- }7960 case key.Matches(msg, m.keys.Select):8061 m.handleSelect()8162 case key.Matches(msg, m.keys.Cancel):8263 m.selection = noSelection64+ default:65+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)8366 }8467 8568 return m, nil
@@ -105,15 +88,15 @@ func (m *Model) validateSelection() {
10588 dx := 010689 dy := 010790 108- if end.x > start.x {91+ if end.X > start.X {10992 dx = 1110- } else if end.x < start.x {93+ } else if end.X < start.X {11194 dx = -111295 }11396 114- if end.y > start.y {97+ if end.Y > start.Y {11598 dy = 1116- } else if end.y < start.y {99+ } else if end.Y < start.Y {117100 dy = -1118101 }119102
@@ -123,8 +106,8 @@ func (m *Model) validateSelection() {
123106 }124107 125108 // Verify it's a straight line126- distX := abs(end.x - start.x)127- distY := abs(end.y - start.y)109+ distX := abs(end.X - start.X)110+ distY := abs(end.Y - start.Y)128111 129112 if dx != 0 && dy != 0 && distX != distY {130113 return // Not a valid diagonal
@@ -148,14 +131,14 @@ func (m *Model) validateSelection() {
148131 }149132 }150133 151-func (m *Model) extractLetters(start, end cursor, dx, dy int) string {134+func (m *Model) extractLetters(start, end game.Cursor, dx, dy int) string {152135 var letters strings.Builder153- x, y := start.x, start.y136+ x, y := start.X, start.Y154137 155138 for {156139 letters.WriteRune(m.grid.Get(x, y))157140 158- if x == end.x && y == end.y {141+ if x == end.X && y == end.Y {159142 break160143 }161144
@@ -174,7 +157,7 @@ func (m *Model) checkWin() {
174157 break175158 }176159 }177- m.won = allFound160+ m.solved = allFound178161 }179162 180163 func (m Model) View() string {
@@ -186,15 +169,15 @@ func (m Model) GetDebugInfo() string {
186169 187170 sb.WriteString("# Word Search Debug\n\n")188171 sb.WriteString(fmt.Sprintf("**Grid Size:** %dx%d\n\n", m.width, m.height))189- sb.WriteString(fmt.Sprintf("**Cursor:** (%d, %d)\n\n", m.cursor.x, m.cursor.y))172+ sb.WriteString(fmt.Sprintf("**Cursor:** (%d, %d)\n\n", m.cursor.X, m.cursor.Y))190173 sb.WriteString(fmt.Sprintf("**Selection State:** %v\n\n", m.selection))191174 192175 if m.selection == startSelected {193- sb.WriteString(fmt.Sprintf("**Selection Start:** (%d, %d)\n\n", m.selectionStart.x, m.selectionStart.y))176+ sb.WriteString(fmt.Sprintf("**Selection Start:** (%d, %d)\n\n", m.selectionStart.X, m.selectionStart.Y))194177 }195178 196179 sb.WriteString(fmt.Sprintf("**Words Found:** %d/%d\n\n", m.countFoundWords(), len(m.words)))197- sb.WriteString(fmt.Sprintf("**Won:** %v\n\n", m.won))180+ sb.WriteString(fmt.Sprintf("**Won:** %v\n\n", m.solved))198181 199182 sb.WriteString("## Words\n\n")200183 sb.WriteString("| Word | Found | Start | End | Direction |\n")
@@ -222,16 +205,20 @@ func (m Model) GetSave() ([]byte, error) {
222205 Height: m.height,223206 Grid: m.grid.String(),224207 Words: m.words,225- CursorX: m.cursor.x,226- CursorY: m.cursor.y,208+ CursorX: m.cursor.X,209+ CursorY: m.cursor.Y,227210 Selection: int(m.selection),228- SelectionX: m.selectionStart.x,229- SelectionY: m.selectionStart.y,230- Won: m.won,211+ SelectionX: m.selectionStart.X,212+ SelectionY: m.selectionStart.Y,213+ Solved: m.solved,231214 }232215 return json.Marshal(data)233216 }234217 218+func (m Model) IsSolved() bool {219+ return m.solved220+}221+235222 func (m Model) countFoundWords() int {236223 count := 0237224 for _, word := range m.words {
M wordsearch/keys.go +15 -44
@@ -1,60 +1,31 @@
11 package wordsearch22 3-import "github.com/charmbracelet/bubbles/key"3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+ "github.com/charmbracelet/bubbles/key"6+)47 58 type KeyMap struct {6- Up key.Binding7- Down key.Binding8- Left key.Binding9- Right key.Binding9+ game.CursorKeyMap1010 Select key.Binding1111 Cancel key.Binding12- Quit key.Binding13- Help key.Binding1412 }1513 1614 var DefaultKeyMap = KeyMap{17- Up: key.NewBinding(18- key.WithKeys("up", "k", "w"),19- key.WithHelp("↑/k/w", "move up"),20- ),21- Down: key.NewBinding(22- key.WithKeys("down", "j", "s"),23- key.WithHelp("↓/j/s", "move down"),24- ),25- Left: key.NewBinding(26- key.WithKeys("left", "h", "a"),27- key.WithHelp("←/h/a", "move left"),28- ),29- Right: key.NewBinding(30- key.WithKeys("right", "l", "d"),31- key.WithHelp("→/l/d", "move right"),32- ),33- Select: key.NewBinding(34- key.WithKeys("enter", " "),35- key.WithHelp("enter/space", "select start/end"),36- ),37- Cancel: key.NewBinding(38- key.WithKeys("esc"),39- key.WithHelp("esc", "cancel selection"),40- ),41- Quit: key.NewBinding(42- key.WithKeys("ctrl+c"),43- key.WithHelp("ctrl+c", "quit"),44- ),45- Help: key.NewBinding(46- key.WithKeys("?"),47- key.WithHelp("?", "toggle help"),48- ),49-}50-51-func (k KeyMap) ShortHelp() []key.Binding {52- return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Select, k.Cancel}15+ CursorKeyMap: game.DefaultCursorKeyMap,16+ Select: key.NewBinding(17+ key.WithKeys("enter", " "),18+ key.WithHelp("enter/space", "select start/end"),19+ ),20+ Cancel: key.NewBinding(21+ key.WithKeys("esc"),22+ key.WithHelp("esc", "cancel selection"),23+ ),5324 }5425 5526 func (k KeyMap) FullHelp() [][]key.Binding {5627 return [][]key.Binding{5728 {k.Up, k.Down, k.Left, k.Right},58- {k.Select, k.Cancel, k.Help},29+ {k.Select, k.Cancel},5930 }6031 }
M wordsearch/style.go +12 -23
@@ -4,6 +4,7 @@ import (
44 "fmt"55 "strings"66 7+ "github.com/FelineStateMachine/puzzletea/game"78 "github.com/charmbracelet/lipgloss"89 )910
@@ -36,19 +37,13 @@ var (
3637 unfoundWordStyle = lipgloss.NewStyle().3738 Foreground(lipgloss.Color("#888888"))3839 39- winMessageStyle = lipgloss.NewStyle().40- Foreground(lipgloss.Color("#00ff00")).41- Bold(true).42- Padding(1, 2).43- Border(lipgloss.RoundedBorder()).44- BorderForeground(lipgloss.Color("#00ff00"))45-4640 statusBarStyle = lipgloss.NewStyle().4741 Foreground(lipgloss.Color("#888888")).4842 MarginTop(1)4943 )5044 5145 func renderView(m Model) string {46+ title := game.TitleBarView("Word Search", m.modeName, m.solved)5247 gridView := renderGrid(m)5348 wordListView := renderWordList(m)5449
@@ -58,13 +53,7 @@ func renderView(m Model) string {
5853 5954 status := statusBarView()6055 61- // Add win message if won62- if m.won {63- winMsg := winMessageStyle.Render("🎉 Congratulations! You found all the words! 🎉")64- return lipgloss.JoinVertical(lipgloss.Left, mainView, "\n", winMsg, status)65- }66-67- return lipgloss.JoinVertical(lipgloss.Left, mainView, status)56+ return lipgloss.JoinVertical(lipgloss.Left, title, mainView, status)6857 }6958 7059 func statusBarView() string {
@@ -90,7 +79,7 @@ func getCellStyle(m Model, x, y int) lipgloss.Style {
9079 // Priority order: cursor > selection > found word > normal9180 9281 // Check if cursor position93- if m.cursor.x == x && m.cursor.y == y {82+ if m.cursor.X == x && m.cursor.Y == y {9483 return cursorStyle9584 }9685
@@ -118,33 +107,33 @@ func isInSelection(m Model, x, y int) bool {
118107 dx := 0119108 dy := 0120109 121- if end.x > start.x {110+ if end.X > start.X {122111 dx = 1123- } else if end.x < start.x {112+ } else if end.X < start.X {124113 dx = -1125114 }126115 127- if end.y > start.y {116+ if end.Y > start.Y {128117 dy = 1129- } else if end.y < start.y {118+ } else if end.Y < start.Y {130119 dy = -1131120 }132121 133122 // Verify it's a valid straight line before walking134- distX := abs(end.x - start.x)135- distY := abs(end.y - start.y)123+ distX := abs(end.X - start.X)124+ distY := abs(end.Y - start.Y)136125 if dx != 0 && dy != 0 && distX != distY {137126 return false // Not a valid diagonal, skip to avoid infinite loop138127 }139128 140129 // Check all positions along the line141- cx, cy := start.x, start.y130+ cx, cy := start.X, start.Y142131 for {143132 if cx == x && cy == y {144133 return true145134 }146135 147- if cx == end.x && cy == end.y {136+ if cx == end.X && cy == end.Y {148137 break149138 }150139