clone

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

feat/app-decomposition
puzzletea / games / nurikabe / model.go
model.go 6.1K
1package nurikabe23import (4 "fmt"56 "charm.land/bubbles/v2/key"7 tea "charm.land/bubbletea/v2"8 "github.com/FelineStateMachine/puzzletea/game"9)1011type Model struct {12 width, height int13 clues clueGrid14 marks grid15 initialMarks grid16 cursor game.Cursor17 solved bool18 conflicts [][]bool19 keys KeyMap20 modeTitle string21 showFullHelp bool2223 dragging bool24 dragTarget cellState2526 termWidth, termHeight int27 originX, originY int28 originValid bool2930 lastMouseX, lastMouseY int31 lastMouseBtn string32 lastMouseGridCol int33 lastMouseGridRow int34 lastMouseHit bool35}3637var _ game.Gamer = Model{}3839func New(mode NurikabeMode, puzzle Puzzle) (game.Gamer, error) {40 if puzzle.Width != mode.Width || puzzle.Height != mode.Height {41 return Model{}, fmt.Errorf("puzzle size %dx%d does not match mode %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)42 }43 if err := validateClues(puzzle.Clues, puzzle.Width, puzzle.Height); err != nil {44 return Model{}, err45 }4647 marks := newGrid(puzzle.Width, puzzle.Height, unknownCell)48 for y := range puzzle.Height {49 for x := range puzzle.Width {50 if puzzle.Clues[y][x] > 0 {51 marks[y][x] = islandCell52 }53 }54 }5556 m := Model{57 width: puzzle.Width,58 height: puzzle.Height,59 clues: cloneClues(puzzle.Clues),60 marks: marks,61 initialMarks: marks.clone(),62 cursor: game.Cursor{X: 0, Y: 0},63 keys: DefaultKeyMap,64 modeTitle: mode.Title(),65 dragTarget: unknownCell,66 }67 m.recomputeDerivedState()68 return m, nil69}7071func (m Model) Init() tea.Cmd {72 return nil73}7475func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {76 switch msg := msg.(type) {77 case game.HelpToggleMsg:78 m.showFullHelp = msg.Show79 m.originValid = false8081 case tea.WindowSizeMsg:82 m.termWidth = msg.Width83 m.termHeight = msg.Height84 m.originValid = false8586 case tea.KeyPressMsg:87 switch {88 case key.Matches(msg, m.keys.SetSea):89 if !m.solved {90 m.setCellAtCursor(seaCell)91 }92 case key.Matches(msg, m.keys.SetIsland):93 if !m.solved {94 current := m.marks[m.cursor.Y][m.cursor.X]95 m.setCellAtCursor(m.islandTarget(current))96 }97 case key.Matches(msg, m.keys.Clear):98 if !m.solved {99 m.setCellAtCursor(unknownCell)100 }101 default:102 m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)103 }104105 case tea.MouseClickMsg:106 m.lastMouseX, m.lastMouseY = msg.X, msg.Y107 m.lastMouseBtn = msg.String()108 col, row, ok := m.screenToGrid(msg.X, msg.Y)109 m.lastMouseGridCol, m.lastMouseGridRow = col, row110 m.lastMouseHit = ok111 if !ok {112 break113 }114 sameCell := col == m.cursor.X && row == m.cursor.Y115 m.cursor.X, m.cursor.Y = col, row116 if m.solved {117 break118 }119 switch msg.Button {120 case tea.MouseLeft:121 if !sameCell {122 break123 }124 target := m.leftClickTarget()125 m.dragging = true126 m.dragTarget = target127 m.setCellAtCursor(target)128 case tea.MouseRight:129 target := m.rightClickTarget(m.marks[row][col])130 m.dragging = true131 m.dragTarget = target132 m.setCellAtCursor(target)133 }134135 case tea.MouseMotionMsg:136 if m.dragging {137 if m.solved {138 break139 }140 col, row, ok := m.screenToGridDrag(msg.X, msg.Y)141 if !ok {142 break143 }144 if col == m.cursor.X && row == m.cursor.Y {145 break146 }147 m.cursor.X, m.cursor.Y = col, row148 m.setCellAtCursor(m.dragTarget)149 }150151 case tea.MouseReleaseMsg:152 m.dragging = false153 m.dragTarget = unknownCell154 }155156 m.updateKeyBindings()157 return m, nil158}159160func (m *Model) setCellAtCursor(state cellState) {161 x, y := m.cursor.X, m.cursor.Y162 if isClueCell(m.clues, x, y) {163 // Clue cells are immutable island cells.164 m.marks[y][x] = islandCell165 return166 }167 m.marks[y][x] = state168 m.afterBoardChange()169}170171func (m *Model) afterBoardChange() {172 wasSolved := m.solved173 m.recomputeDerivedState()174 if wasSolved != m.solved {175 m.originValid = false176 }177}178179func (m *Model) recomputeDerivedState() {180 m.solved = isSolvedGrid(m.marks, m.clues)181 m.conflicts = computeConflicts(m.marks, m.clues)182}183184func (m Model) leftClickTarget() cellState {185 return seaCell186}187188func (m Model) rightClickTarget(current cellState) cellState {189 return m.islandTarget(current)190}191192func (m Model) islandTarget(current cellState) cellState {193 if current == islandCell {194 return unknownCell195 }196 return islandCell197}198199func (m Model) View() string {200 title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)201 grid := gridView(m)202 if m.solved {203 return game.ComposeGameView(title, grid)204 }205206 status := statusBarView(m.showFullHelp)207 return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))208}209210func (m Model) SetTitle(t string) game.Gamer {211 m.modeTitle = t212 return m213}214215func (m Model) IsSolved() bool {216 return m.solved217}218219func (m Model) Reset() game.Gamer {220 m.marks = m.initialMarks.clone()221 m.cursor = game.Cursor{}222 m.dragging = false223 m.dragTarget = unknownCell224 m.recomputeDerivedState()225 m.originValid = false226 return m227}228229func (m Model) GetDebugInfo() string {230 status := "In Progress"231 if m.solved {232 status = "Solved"233 }234235 unknownCount := 0236 seaCount := 0237 islandCount := 0238 conflictCount := 0239 for y := range m.height {240 for x := range m.width {241 switch m.marks[y][x] {242 case unknownCell:243 unknownCount++244 case seaCell:245 seaCount++246 case islandCell:247 islandCount++248 }249 if m.conflicts[y][x] {250 conflictCount++251 }252 }253 }254255 ox, oy := m.gridOrigin()256 hit := "miss"257 if m.lastMouseHit {258 hit = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)259 }260261 return game.DebugHeader("Nurikabe", [][2]string{262 {"Status", status},263 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},264 {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},265 {"Clues", fmt.Sprintf("%d", countClues(m.clues))},266 {"Cells", fmt.Sprintf("unknown=%d sea=%d island=%d", unknownCount, seaCount, islandCount)},267 {"Conflicts", fmt.Sprintf("%d", conflictCount)},268 {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},269 {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},270 {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hit)},271 {"Drag", fmt.Sprintf("active=%v target=%q", m.dragging, rune(m.dragTarget))},272 })273}