clone

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

feat/elo-difficulty
puzzletea / games / hashiwokakero / model.go
model.go 5.4K
1// Package hashiwokakero implements the bridge-connecting puzzle game.2package hashiwokakero34import (5 "fmt"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 puzzle Puzzle16 cursorIsland int // ID of island the cursor is on17 selectedIsland *int // ID of island selected for bridge building, nil if none18 keys KeyMap19 modeTitle string20 showFullHelp bool21 termWidth int22 termHeight int23 originX int24 originY int25 originValid bool26}2728func New(mode HashiMode, puzzle Puzzle) game.Gamer {29 cursorID := 030 if len(puzzle.Islands) > 0 {31 cursorID = puzzle.Islands[0].ID32 }33 return Model{34 puzzle: puzzle,35 cursorIsland: cursorID,36 keys: DefaultKeyMap,37 modeTitle: mode.Title(),38 }39}4041func (m Model) Init() tea.Cmd {42 return nil43}4445func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {46 switch msg := msg.(type) {47 case game.HelpToggleMsg:48 m.showFullHelp = msg.Show49 m.originValid = false50 case tea.WindowSizeMsg:51 m.termWidth = msg.Width52 m.termHeight = msg.Height53 m.originValid = false54 case tea.MouseClickMsg:55 if msg.Button == tea.MouseLeft {56 if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {57 if isl := m.puzzle.FindIslandAt(col, row); isl != nil {58 m.cursorIsland = isl.ID59 }60 }61 }62 case tea.KeyPressMsg:63 if m.selectedIsland != nil {64 m = m.handleBridgeMode(msg)65 } else {66 m = m.handleNavMode(msg)67 }68 }69 return m, nil70}7172func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {73 switch {74 case key.Matches(msg, m.keys.Up):75 m = m.moveCursor(0, -1)76 case key.Matches(msg, m.keys.Down):77 m = m.moveCursor(0, 1)78 case key.Matches(msg, m.keys.Left):79 m = m.moveCursor(-1, 0)80 case key.Matches(msg, m.keys.Right):81 m = m.moveCursor(1, 0)82 case key.Matches(msg, m.keys.Select):83 id := m.cursorIsland84 m.selectedIsland = &id85 }86 return m87}8889func (m Model) handleBridgeMode(msg tea.KeyPressMsg) Model {90 switch {91 case key.Matches(msg, m.keys.Up):92 m = m.cycleBridge(0, -1)93 case key.Matches(msg, m.keys.Down):94 m = m.cycleBridge(0, 1)95 case key.Matches(msg, m.keys.Left):96 m = m.cycleBridge(-1, 0)97 case key.Matches(msg, m.keys.Right):98 m = m.cycleBridge(1, 0)99 case key.Matches(msg, m.keys.Select):100 m.selectedIsland = nil101 }102 return m103}104105// moveCursor moves the cursor to the nearest island in direction (dx, dy).106func (m Model) moveCursor(dx, dy int) Model {107 adj := m.puzzle.FindAdjacentIsland(m.cursorIsland, dx, dy)108 if adj != nil {109 m.cursorIsland = adj.ID110 }111 return m112}113114// cycleBridge cycles the bridge from the selected island toward (dx, dy): 0→1→2→0.115func (m Model) cycleBridge(dx, dy int) Model {116 if m.selectedIsland == nil {117 return m118 }119120 adj := m.puzzle.FindAdjacentIsland(*m.selectedIsland, dx, dy)121 if adj == nil {122 return m123 }124125 existing := m.puzzle.GetBridge(*m.selectedIsland, adj.ID)126 currentCount := 0127 if existing != nil {128 currentCount = existing.Count129 }130131 newCount := (currentCount + 1) % 3132133 if newCount > 0 && currentCount == 0 {134 // Adding a new bridge — check for crossings135 if m.puzzle.WouldCross(*m.selectedIsland, adj.ID) {136 return m137 }138 }139140 m.puzzle.SetBridge(*m.selectedIsland, adj.ID, newCount)141 if m.puzzle.IsSolved() {142 m.selectedIsland = nil143 m.originValid = false144 }145 return m146}147148func (m Model) View() string {149 solved := m.puzzle.IsSolved()150151 title := game.TitleBarView("Hashiwokakero", m.modeTitle, solved)152 grid := gridView(m, solved)153 info := infoView(&m.puzzle)154 if solved {155 return game.ComposeGameViewRows(title, grid, game.StaticRow(info))156 }157158 selected := m.selectedIsland != nil159 status := statusBarView(selected, m.showFullHelp)160 rows := []game.SecondaryRow{161 game.StaticRow(info),162 game.StableRow(status, statusBarVariants()...),163 }164165 return game.ComposeGameViewRows(title, grid, rows...)166}167168func (m Model) SetTitle(t string) game.Gamer {169 m.modeTitle = t170 return m171}172173func (m Model) IsSolved() bool {174 return m.puzzle.IsSolved()175}176177func (m Model) Reset() game.Gamer {178 m.puzzle.Bridges = nil179 m.puzzle.invalidateCache()180 m.cursorIsland = 0181 if len(m.puzzle.Islands) > 0 {182 m.cursorIsland = m.puzzle.Islands[0].ID183 }184 m.selectedIsland = nil185 m.originValid = false186 return m187}188189func (m Model) GetDebugInfo() string {190 solved := m.puzzle.IsSolved()191 connected := m.puzzle.IsConnected()192193 status := "In Progress"194 if solved {195 status = "Solved"196 }197198 connStr := "No"199 if connected {200 connStr = "Yes"201 }202203 selectedStr := "None"204 if m.selectedIsland != nil {205 selectedStr = fmt.Sprintf("%d", *m.selectedIsland)206 }207208 s := game.DebugHeader("Hashiwokakero", [][2]string{209 {"Status", status},210 {"Grid Size", fmt.Sprintf("%dx%d", m.puzzle.Width, m.puzzle.Height)},211 {"Islands", fmt.Sprintf("%d", len(m.puzzle.Islands))},212 {"Bridges", fmt.Sprintf("%d", len(m.puzzle.Bridges))},213 {"Connected", connStr},214 {"Cursor Island", fmt.Sprintf("%d", m.cursorIsland)},215 {"Selected Island", selectedStr},216 })217218 var rows [][]string219 for _, isl := range m.puzzle.Islands {220 current := m.puzzle.BridgeCount(isl.ID)221 islStatus := "Unsatisfied"222 if current == isl.Required {223 islStatus = "Satisfied"224 } else if current > isl.Required {225 islStatus = "Over"226 }227 rows = append(rows, []string{228 fmt.Sprintf("%d", isl.ID),229 fmt.Sprintf("(%d,%d)", isl.X, isl.Y),230 fmt.Sprintf("%d", isl.Required),231 fmt.Sprintf("%d", current),232 islStatus,233 })234 }235 s += game.DebugTable("Islands", []string{"ID", "Pos", "Required", "Current", "Status"}, rows)236237 return s238}