clone

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

feat/elo-difficulty
puzzletea / games / hitori / model.go
model.go 4.6K
1// Package hitori implements the Hitori number puzzle game.2package hitori34import (5 "fmt"67 "github.com/FelineStateMachine/puzzletea/game"89 "charm.land/bubbles/v2/key"10 tea "charm.land/bubbletea/v2"11)1213// Model implements game.Gamer for Hitori.14type Model struct {15 size int16 numbers grid17 userMarks [][]cellMark18 marks [][]cellMark19 initialUserMarks [][]cellMark20 conflicts [][]bool21 cursor game.Cursor22 solved bool23 keys KeyMap24 modeTitle string25 showFullHelp bool26 termWidth int27 termHeight int28 originX int29 originY int30 originValid bool31}3233var _ game.Gamer = Model{}3435// New creates a new Hitori game model.36func New(mode HitoriMode, numbers grid) (game.Gamer, error) {37 if mode.Size <= 0 {38 return Model{}, fmt.Errorf("invalid grid size: %d", mode.Size)39 }40 if len(numbers) != mode.Size {41 return Model{}, fmt.Errorf("numbers grid has %d rows, expected %d", len(numbers), mode.Size)42 }43 for y, row := range numbers {44 if len(row) != mode.Size {45 return Model{}, fmt.Errorf("numbers row %d has %d columns, expected %d", y, len(row), mode.Size)46 }47 }4849 userMarks := newMarks(mode.Size)50 m := Model{51 size: mode.Size,52 numbers: numbers,53 userMarks: userMarks,54 initialUserMarks: cloneMarks(userMarks),55 cursor: game.Cursor{X: 0, Y: 0},56 keys: DefaultKeyMap,57 modeTitle: mode.Title(),58 }59 m.recomputeState()60 return m, nil61}6263func (m Model) Init() tea.Cmd {64 return nil65}6667func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {68 switch msg := msg.(type) {69 case game.HelpToggleMsg:70 m.showFullHelp = msg.Show71 m.originValid = false72 case tea.WindowSizeMsg:73 m.termWidth = msg.Width74 m.termHeight = msg.Height75 m.originValid = false76 case tea.MouseClickMsg:77 if msg.Button == tea.MouseLeft {78 if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {79 m.cursor.X = col80 m.cursor.Y = row81 }82 }83 case tea.KeyPressMsg:84 switch {85 case key.Matches(msg, m.keys.ShadeCell):86 if !m.solved {87 m.toggleMark(shaded)88 }89 case key.Matches(msg, m.keys.CircleCell):90 if !m.solved {91 m.toggleMark(circled)92 }93 case key.Matches(msg, m.keys.ClearCell):94 if !m.solved {95 m.userMarks[m.cursor.Y][m.cursor.X] = unmarked96 m.recomputeState()97 }98 default:99 m.cursor.Move(m.keys.CursorKeyMap, msg, m.size-1, m.size-1)100 }101 }102 m.updateKeyBindings()103 return m, nil104}105106// toggleMark sets the cell to the given mark if it's currently different,107// otherwise clears it to unmarked.108func (m *Model) toggleMark(mark cellMark) {109 if m.marks[m.cursor.Y][m.cursor.X] == mark {110 m.userMarks[m.cursor.Y][m.cursor.X] = unmarked111 } else {112 m.userMarks[m.cursor.Y][m.cursor.X] = mark113 }114 m.recomputeState()115}116117func (m *Model) recomputeState() {118 wasSolved := m.solved119 m.marks = propagateRequiredMarks(m.numbers, m.userMarks, m.size)120 m.solved = m.checkSolved()121 m.conflicts = computeConflicts(m.numbers, m.marks, m.size)122 if m.solved != wasSolved {123 m.originValid = false124 }125}126127func (m Model) View() string {128 title := game.TitleBarView("Hitori", m.modeTitle, m.solved)129 grid := gridView(m.numbers, m.marks, m.cursor, m.solved, m.conflicts)130 if m.solved {131 return game.ComposeGameView(title, grid)132 }133134 status := statusBarView(m.showFullHelp)135 return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))136}137138func (m Model) SetTitle(t string) game.Gamer {139 m.modeTitle = t140 m.originValid = false141 return m142}143144func (m Model) IsSolved() bool {145 return m.solved146}147148func (m Model) Reset() game.Gamer {149 m.userMarks = cloneMarks(m.initialUserMarks)150 m.recomputeState()151 m.cursor = game.Cursor{}152 m.originValid = false153 return m154}155156func (m Model) checkSolved() bool {157 // At least one cell must be shaded for a valid solution.158 hasShaded := false159 for y := range m.size {160 for x := range m.size {161 if m.marks[y][x] == shaded {162 hasShaded = true163 break164 }165 }166 if hasShaded {167 break168 }169 }170 if !hasShaded {171 return false172 }173174 return isValidSolution(m.numbers, m.marks, m.size)175}176177func (m Model) GetDebugInfo() string {178 status := "In Progress"179 if m.solved {180 status = "Solved"181 }182183 shadedCount := 0184 circledCount := 0185 for y := range m.size {186 for x := range m.size {187 switch m.marks[y][x] {188 case shaded:189 shadedCount++190 case circled:191 circledCount++192 }193 }194 }195196 return game.DebugHeader("Hitori", [][2]string{197 {"Status", status},198 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},199 {"Grid Size", fmt.Sprintf("%d\u00d7%d", m.size, m.size)},200 {"Shaded Cells", fmt.Sprintf("%d", shadedCount)},201 {"Circled Cells", fmt.Sprintf("%d", circledCount)},202 })203}