- commit
- dcef8c16f7fb425d77c8d3a9159cf65cf07a676f
- parent
- 7295a1ca8d0c7da863251996aa69654efaf4a778
- author
- Dami <dami@Damis-MacBook-Air.local>
| A | game/ | 39 | ++++++++++++++++ |
| M | game/ | 9 | ++++ |
| A | game/ | 31 | +++++++++++++ |
| A | game/ | 25 | ++++++++++ |
| M | hashiwokakero/ | 6 | ++ |
| M | hashiwokakero/ | 4 | ++ |
| M | hashiwokakero/ | 23 | +-------- |
| M | hashiwokakero/ | 18 | +------- |
| M | model.go | 44 | +++--------------- |
| M | nonogram/ | 6 | ++ |
| M | nonogram/ | 37 | +++++---------- |
| M | nonogram/ | 41 | ++++------------- |
| M | nonogram/ | 26 | ++-------- |
| M | sudoku/ | 10 | ++-- |
| M | sudoku/ | 6 | ++ |
| M | sudoku/ | 46 | +++++++------------ |
| M | sudoku/ | 10 | ++-- |
| M | sudoku/ | 26 | +++++----- |
| M | sudoku/ | 41 | ++++------------- |
| M | sudoku/ | 36 | +++++---------- |
| M | wordsearch/ | 32 | +++++++------ |
| M | wordsearch/ | 6 | ++ |
| M | wordsearch/ | 73 | ++++++++++++------------------ |
| M | wordsearch/ | 59 | ++++++------------------ |
| M | wordsearch/ | 35 | +++++--------- |
@@ -0,0 +1,39 @@
1+package game2+3+import (4+ "github.com/charmbracelet/bubbles/key"5+ tea "github.com/charmbracelet/bubbletea"6+)7+8+// Cursor represents a 2D grid cursor position.9+type Cursor struct {10+ X, Y int11+}12+13+// Move updates the cursor position based on the key message and bounds.14+// Returns true if the cursor moved.15+func (c *Cursor) Move(keys CursorKeyMap, msg tea.KeyMsg, maxX, maxY int) bool {16+ switch {17+ case key.Matches(msg, keys.Up):18+ if c.Y > 0 {19+ c.Y--20+ return true21+ }22+ case key.Matches(msg, keys.Down):23+ if c.Y < maxY {24+ c.Y++25+ return true26+ }27+ case key.Matches(msg, keys.Left):28+ if c.X > 0 {29+ c.X--30+ return true31+ }32+ case key.Matches(msg, keys.Right):33+ if c.X < maxX {34+ c.X++35+ return true36+ }37+ }38+ return false39+}@@ -12,6 +12,7 @@ type Gamer interface {
1212 GetFullHelp() [][]key.Binding1313 1414 GetSave() ([]byte, error)15+ IsSolved() bool1516 1617 Init() tea.Cmd1718 View() string@@ -59,3 +60,11 @@ type Category struct {
5960 func (c Category) Title() string { return c.Name }6061 func (c Category) Description() string { return c.Desc }6162 func (c Category) FilterValue() string { return c.Name }63+64+// Registry maps game type names to their import functions.65+var Registry = map[string]func([]byte) (Gamer, error){}66+67+// Register adds an import function for a game type to the registry.68+func Register(name string, fn func([]byte) (Gamer, error)) {69+ Registry[name] = fn70+}@@ -0,0 +1,31 @@
1+package game2+3+import "github.com/charmbracelet/bubbles/key"4+5+// CursorKeyMap defines the shared cursor movement key bindings.6+type CursorKeyMap struct {7+ Up key.Binding8+ Down key.Binding9+ Left key.Binding10+ Right key.Binding11+}12+13+// DefaultCursorKeyMap provides the standard cursor movement bindings.14+var DefaultCursorKeyMap = CursorKeyMap{15+ Up: key.NewBinding(16+ key.WithKeys("k", "up", "w"),17+ key.WithHelp("↑/w/k", "Up"),18+ ),19+ Down: key.NewBinding(20+ key.WithKeys("j", "down", "s"),21+ key.WithHelp("↓/s/j", "Down"),22+ ),23+ Left: key.NewBinding(24+ key.WithKeys("h", "left", "a"),25+ key.WithHelp("←/a/h", "Left"),26+ ),27+ Right: key.NewBinding(28+ key.WithKeys("l", "right", "d"),29+ key.WithHelp("→/d/l", "Right"),30+ ),31+}@@ -0,0 +1,25 @@
1+package game2+3+import "github.com/charmbracelet/lipgloss"4+5+var (6+ titleStyle = lipgloss.NewStyle().7+ Bold(true).8+ Foreground(lipgloss.Color("#ffffff")).9+ Background(lipgloss.Color("#7B2FBE")).10+ Padding(0, 1)11+12+ solvedBadgeStyle = lipgloss.NewStyle().13+ Bold(true).14+ Foreground(lipgloss.Color("#00ff00"))15+)16+17+// TitleBarView renders a title bar with the game name, mode name, and optional solved badge.18+func TitleBarView(gameName, modeName string, solved bool) string {19+ title := titleStyle.Render(gameName + " " + modeName)20+ if solved {21+ badge := solvedBadgeStyle.Render(" SOLVED")22+ return title + badge23+ }24+ return title25+}@@ -5,6 +5,12 @@ import (
55 "github.com/charmbracelet/bubbles/list"66 )77 8+func init() {9+ game.Register("Hashiwokakero", func(data []byte) (game.Gamer, error) {10+ return ImportModel(data)11+ })12+}13+814 // HashiMode defines a hashiwokakero difficulty/configuration.915 type HashiMode struct {1016 game.BaseMode@@ -129,6 +129,10 @@ func (m Model) View() string {
129129 return lipgloss.JoinVertical(lipgloss.Left, title, grid, info, status)130130 }131131 132+func (m Model) IsSolved() bool {133+ return m.puzzle.IsSolved()134+}135+132136 func (m Model) GetDebugInfo() string {133137 solved := m.puzzle.IsSolved()134138 connected := m.puzzle.IsConnected()@@ -1,35 +1,18 @@
11 package hashiwokakero22 33 import (4+ "github.com/FelineStateMachine/puzzletea/game"45 "github.com/charmbracelet/bubbles/key"56 )67 78 type KeyMap struct {8- Up key.Binding9- Down key.Binding10- Left key.Binding11- Right key.Binding9+ game.CursorKeyMap1210 Select key.Binding1311 Cancel key.Binding1412 }1513 1614 var DefaultKeyMap = KeyMap{17- Up: key.NewBinding(18- key.WithKeys("k", "up", "w"),19- key.WithHelp("↑/w/k", "Up"),20- ),21- Down: key.NewBinding(22- key.WithKeys("j", "down", "s"),23- key.WithHelp("↓/s/j", "Down"),24- ),25- Left: key.NewBinding(26- key.WithKeys("h", "left", "a"),27- key.WithHelp("←/a/h", "Left"),28- ),29- Right: key.NewBinding(30- key.WithKeys("l", "right", "d"),31- key.WithHelp("→/d/l", "Right"),32- ),15+ CursorKeyMap: game.DefaultCursorKeyMap,3316 Select: key.NewBinding(3417 key.WithKeys("enter", " "),3518 key.WithHelp("enter/space", "Select"),@@ -4,6 +4,7 @@ import (
44 "fmt"55 "strings"66 7+ "github.com/FelineStateMachine/puzzletea/game"78 "github.com/charmbracelet/lipgloss"89 )910 @@ -46,16 +47,6 @@ var (
4647 emptyStyle = baseStyle.4748 Foreground(lipgloss.Color("#333333"))4849 49- titleStyle = lipgloss.NewStyle().50- Bold(true).51- Foreground(lipgloss.Color("#ffffff")).52- Background(lipgloss.Color("#7B2FBE")).53- Padding(0, 1)54-55- solvedBadgeStyle = lipgloss.NewStyle().56- Bold(true).57- Foreground(lipgloss.Color("#00ff00"))58-5950 statusBarStyle = lipgloss.NewStyle().6051 Foreground(lipgloss.Color("#888888")).6152 MarginTop(1)@@ -205,12 +196,7 @@ func bridgeVView(count int) string {
205196 }206197 207198 func titleBarView(modeName string, solved bool) string {208- title := titleStyle.Render("Hashiwokakero " + modeName)209- if solved {210- badge := solvedBadgeStyle.Render(" SOLVED")211- return title + badge212- }213- return title199+ return game.TitleBarView("Hashiwokakero", modeName, solved)214200 }215201 216202 func statusBarView(selected bool) string {@@ -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-}@@ -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 }@@ -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)@@ -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 {@@ -20,11 +20,11 @@ type Save struct {
2020 2121 func gridToString(g grid) string {2222 var b strings.Builder23- for y := range GRIDSIZE {24- for x := range GRIDSIZE {23+ for y := range gridSize {24+ for x := range gridSize {2525 b.WriteByte(byte('0' + g[y][x].v))2626 }27- if y < GRIDSIZE-1 {27+ if y < gridSize-1 {2828 b.WriteByte('\n')2929 }3030 }@@ -46,8 +46,8 @@ func ImportModel(data []byte) (*Model, error) {
4646 4747 // Parse save.Grid to restore user-entered values.4848 rows := strings.Split(save.Grid, "\n")49- for y := 0; y < GRIDSIZE && y < len(rows); y++ {50- for x := 0; x < GRIDSIZE && x < len(rows[y]); x++ {49+ for y := 0; y < gridSize && y < len(rows); y++ {50+ for x := 0; x < gridSize && x < len(rows[y]); x++ {5151 v := int(rows[y][x] - '0')5252 if v != 0 {5353 g[y][x].v = v@@ -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,@@ -11,10 +11,10 @@ type grid = [9][9]cell
1111 1212 func newGrid(provided []cell) grid {1313 var g grid14- for i := range GRIDSIZE {15- g[i] = [GRIDSIZE]cell{}14+ for i := range gridSize {15+ g[i] = [gridSize]cell{}1616 17- for j := range GRIDSIZE {17+ for j := range gridSize {1818 g[i][j].x = j1919 g[i][j].y = i2020 }@@ -41,8 +41,8 @@ func (c cell) String() string {
4141 }4242 4343 func (m Model) isSolved() bool {44- for y := range GRIDSIZE {45- for x := range GRIDSIZE {44+ for y := range gridSize {45+ for x := range gridSize {4646 c := m.grid[y][x]4747 if c.v == 0 {4848 return false@@ -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 }@@ -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 }@@ -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 {@@ -1,18 +1,22 @@
11 package wordsearch22 3-import "encoding/json"3+import (4+ "encoding/json"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+)48 59 type Save struct {6- Width int `json:"width"`7- Height int `json:"height"`8- Grid string `json:"grid"`9- Words []Word `json:"words"`10- CursorX int `json:"cursor_x"`11- CursorY int `json:"cursor_y"`12- Selection int `json:"selection"`13- SelectionX int `json:"selection_x"`14- SelectionY int `json:"selection_y"`15- Won bool `json:"won"`10+ Width int `json:"width"`11+ Height int `json:"height"`12+ Grid string `json:"grid"`13+ Words []Word `json:"words"`14+ CursorX int `json:"cursor_x"`15+ CursorY int `json:"cursor_y"`16+ Selection int `json:"selection"`17+ SelectionX int `json:"selection_x"`18+ SelectionY int `json:"selection_y"`19+ Solved bool `json:"solved"`1620 }1721 1822 func ImportModel(data []byte) (*Model, error) {@@ -26,10 +30,10 @@ func ImportModel(data []byte) (*Model, error) {
2630 height: exported.Height,2731 grid: newGrid(state(exported.Grid)),2832 words: exported.Words,29- cursor: cursor{x: exported.CursorX, y: exported.CursorY},33+ cursor: game.Cursor{X: exported.CursorX, Y: exported.CursorY},3034 selection: selectionState(exported.Selection),31- selectionStart: cursor{x: exported.SelectionX, y: exported.SelectionY},35+ selectionStart: game.Cursor{X: exported.SelectionX, Y: exported.SelectionY},3236 keys: DefaultKeyMap,33- won: exported.Won,37+ solved: exported.Solved,3438 }, nil3539 }@@ -5,6 +5,12 @@ import (
55 "github.com/charmbracelet/bubbles/list"66 )77 8+func init() {9+ game.Register("Word Search", func(data []byte) (game.Gamer, error) {10+ return ImportModel(data)11+ })12+}13+814 // WordSearchMode implements game.Mode for word search puzzles915 type WordSearchMode struct {1016 game.BaseMode@@ -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 {@@ -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 }@@ -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