clone

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

feat/app-decomposition
puzzletea / games / rippleeffect / model.go
model.go 3.6K
1package rippleeffect23import (4 "fmt"5 "strconv"67 "charm.land/bubbles/v2/key"8 tea "charm.land/bubbletea/v2"9 "github.com/FelineStateMachine/puzzletea/game"10)1112var _ game.Gamer = Model{}1314type Model struct {15 width int16 height int17 geo *geometry18 grid grid19 initialGrid grid20 givens grid21 cursor game.Cursor22 conflicts [][]bool23 solved bool24 keys KeyMap25 modeTitle string26 showFullHelp bool27}2829func New(mode Mode, puzzle Puzzle) (game.Gamer, error) {30 if puzzle.Width != mode.Size || puzzle.Height != mode.Size {31 return nil, fmt.Errorf("ripple effect puzzle size %dx%d does not match mode %dx%d", puzzle.Width, puzzle.Height, mode.Size, mode.Size)32 }3334 geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)35 if err != nil {36 return nil, err37 }38 if !gridsMatchPuzzle(puzzle.Givens, puzzle.Width, puzzle.Height) {39 return nil, fmt.Errorf("ripple effect givens grid does not match puzzle size")40 }4142 m := Model{43 width: puzzle.Width,44 height: puzzle.Height,45 geo: geo,46 grid: cloneGrid(puzzle.Givens),47 initialGrid: cloneGrid(puzzle.Givens),48 givens: cloneGrid(puzzle.Givens),49 cursor: game.Cursor{},50 keys: DefaultKeyMap,51 modeTitle: mode.Title(),52 }53 m.recompute()54 return m, nil55}5657func gridsMatchPuzzle(g grid, width, height int) bool {58 if len(g) != height {59 return false60 }61 for y := range height {62 if len(g[y]) != width {63 return false64 }65 }66 return true67}6869func (m Model) Init() tea.Cmd {70 return nil71}7273func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {74 switch msg := msg.(type) {75 case game.HelpToggleMsg:76 m.showFullHelp = msg.Show77 case tea.KeyPressMsg:78 switch {79 case key.Matches(msg, m.keys.Clear):80 m.setCell(0)81 case key.Matches(msg, m.keys.FillValue):82 value, _ := strconv.Atoi(msg.String())83 m.setCell(value)84 default:85 m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)86 }87 }8889 m.updateKeyBindings()90 return m, nil91}9293func (m *Model) setCell(value int) {94 if m.solved || m.givens[m.cursor.Y][m.cursor.X] != 0 {95 return96 }97 if value != 0 && value > m.geo.cageSizes[m.geo.cageGrid[m.cursor.Y][m.cursor.X]] {98 return99 }100 m.grid[m.cursor.Y][m.cursor.X] = value101 m.recompute()102}103104func (m *Model) recompute() {105 result := validateGridState(m.grid, m.geo)106 m.conflicts = result.conflicts107 m.solved = result.solved108}109110func (m Model) View() string {111 title := game.TitleBarView("Ripple Effect", m.modeTitle, m.solved)112 body := gridView(m)113 if m.solved {114 return game.ComposeGameView(title, body)115 }116 status := statusBarView(m.showFullHelp)117 return game.ComposeGameViewRows(title, body, game.StableRow(status, statusBarView(false), statusBarView(true)))118}119120func (m Model) SetTitle(t string) game.Gamer {121 m.modeTitle = t122 return m123}124125func (m Model) IsSolved() bool {126 return m.solved127}128129func (m Model) Reset() game.Gamer {130 m.grid = cloneGrid(m.initialGrid)131 m.cursor = game.Cursor{}132 m.recompute()133 return m134}135136func (m Model) GetDebugInfo() string {137 status := "In Progress"138 if m.solved {139 status = "Solved"140 }141142 filled := 0143 conflictCount := 0144 for y := range m.height {145 for x := range m.width {146 if m.grid[y][x] != 0 {147 filled++148 }149 if m.conflicts[y][x] {150 conflictCount++151 }152 }153 }154155 cageIdx := m.geo.cageGrid[m.cursor.Y][m.cursor.X]156 return game.DebugHeader("Ripple Effect", [][2]string{157 {"Status", status},158 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},159 {"Filled Cells", fmt.Sprintf("%d / %d", filled, m.width*m.height)},160 {"Conflict Count", fmt.Sprintf("%d", conflictCount)},161 {"Current Cage", fmt.Sprintf("%d (size %d)", m.geo.cages[cageIdx].ID, m.geo.cageSizes[cageIdx])},162 })163}