clone

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

main
puzzletea / games / shikaku / model.go
model.go 11.3K
1package shikaku23import (4 "fmt"56 "github.com/FelineStateMachine/puzzletea/game"78 "charm.land/bubbles/v2/key"9 tea "charm.land/bubbletea/v2"10)1112// expansion tracks how far the preview rectangle extends from the clue cell.13type expansion struct {14 clueID int15 left, right, up, down int16}1718// rect converts the expansion to a Rectangle.19func (e expansion) rect(clue *Clue) Rectangle {20 return Rectangle{21 ClueID: e.clueID,22 X: clue.X - e.left,23 Y: clue.Y - e.up,24 W: e.left + 1 + e.right,25 H: e.up + 1 + e.down,26 }27}2829var _ game.Gamer = Model{}3031// Model implements game.Gamer for Shikaku.32type Model struct {33 puzzle Puzzle34 cursor game.Cursor35 selectedClue *int // nil = navigation mode, non-nil = expansion mode36 expansion expansion37 keys KeyMap38 modeTitle string39 showFullHelp bool4041 // Mouse drag support: anchor is the cell where the drag started.42 mouseDragAnchor *[2]int // nil = not dragging, [col, row] of click origin43 mousePreview *Rectangle // preview rect computed during drag (for rendering)4445 // Screen geometry for mouse hit-testing.46 termWidth, termHeight int4748 // Cached grid origin for mouse hit-testing (recomputed on resize/solve).49 originX, originY int50 originValid bool5152 // Debug: last mouse event info.53 lastMouseX, lastMouseY int54 lastMouseBtn string55 lastMouseGridCol int56 lastMouseGridRow int57 lastMouseHit bool58}5960// New creates a new Shikaku game model.61func New(mode ShikakuMode, puzzle Puzzle) game.Gamer {62 m := Model{63 puzzle: puzzle,64 cursor: game.Cursor{X: 0, Y: 0},65 keys: DefaultKeyMap,66 modeTitle: mode.Title(),67 }68 m.finalizePlacements()69 return m70}7172func (m Model) Init() tea.Cmd { return nil }7374func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {75 switch msg := msg.(type) {76 case game.HelpToggleMsg:77 m.showFullHelp = msg.Show78 m.originValid = false7980 case tea.WindowSizeMsg:81 m.termWidth = msg.Width82 m.termHeight = msg.Height83 m.originValid = false8485 case tea.KeyPressMsg:86 if m.puzzle.IsSolved() {87 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)88 } else if m.selectedClue != nil {89 m = m.handleExpansionMode(msg)90 } else {91 m = m.handleNavMode(msg)92 }9394 case tea.MouseClickMsg:95 m = m.handleMouseClick(msg)9697 case tea.MouseMotionMsg:98 m = m.handleMouseMotion(msg)99100 case tea.MouseReleaseMsg:101 m = m.handleMouseRelease(msg)102 }103 return m, nil104}105106func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {107 m.lastMouseX, m.lastMouseY = msg.X, msg.Y108 m.lastMouseBtn = msg.String()109110 col, row, ok := m.screenToGrid(msg.X, msg.Y)111 m.lastMouseGridCol, m.lastMouseGridRow = col, row112 m.lastMouseHit = ok113114 if m.puzzle.IsSolved() || !ok {115 return m116 }117118 m.cursor.X, m.cursor.Y = col, row119120 switch msg.Button {121 case tea.MouseLeft:122 // Start a drag from any cell.123 m.mouseDragAnchor = &[2]int{col, row}124 r := rectFromCorners(col, row, col, row)125 m.mousePreview = &r126 m.selectedClue = nil127128 // Sync expansion if the anchor is on a clue (for seamless129 // keyboard takeover if the user releases without moving).130 m.syncExpansionFromPreview()131132 case tea.MouseRight:133 // Right-click deletes rectangle at cursor.134 owner := m.puzzle.CellOwner(col, row)135 if owner >= 0 {136 m.deleteRectangle(owner)137 }138 }139 return m140}141142func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) Model {143 if m.puzzle.IsSolved() || m.mouseDragAnchor == nil {144 return m145 }146147 col, row, ok := m.screenToGridDrag(msg.X, msg.Y)148 if !ok {149 return m150 }151152 // Update the preview rectangle spanning anchor to current cell.153 r := rectFromCorners(m.mouseDragAnchor[0], m.mouseDragAnchor[1], col, row)154 m.mousePreview = &r155 m.syncExpansionFromPreview()156 return m157}158159func (m Model) handleMouseRelease(msg tea.MouseReleaseMsg) Model {160 if m.mouseDragAnchor == nil {161 return m162 }163164 if col, row, ok := m.screenToGridDrag(msg.X, msg.Y); ok {165 r := rectFromCorners(m.mouseDragAnchor[0], m.mouseDragAnchor[1], col, row)166 m.mousePreview = &r167 m.syncExpansionFromPreview()168 }169170 preview := m.mousePreview171 m.mouseDragAnchor = nil172 m.mousePreview = nil173174 if preview == nil {175 m.selectedClue = nil176 return m177 }178179 // Find clues inside the drawn rectangle.180 clues := m.puzzle.CluesInRect(*preview)181 if len(clues) != 1 {182 // Not exactly one clue: cancel the preview.183 m.selectedClue = nil184 return m185 }186187 clue := clues[0]188 rect := Rectangle{189 ClueID: clue.ID,190 X: preview.X,191 Y: preview.Y,192 W: preview.W,193 H: preview.H,194 }195196 if m.puzzle.ValidRectangleForClue(rect, clue.ID) {197 m.commitRectangle(rect)198 m.selectedClue = nil199 } else {200 // Invalid: enter keyboard expansion mode on this clue so the201 // player can fine-tune.202 m.selectedClue = &clue.ID203 m.expansion = expansion{clueID: clue.ID}204 m.expansion.left = clue.X - preview.X205 m.expansion.right = preview.X + preview.W - 1 - clue.X206 m.expansion.up = clue.Y - preview.Y207 m.expansion.down = preview.Y + preview.H - 1 - clue.Y208 m.cursor.X, m.cursor.Y = clue.X, clue.Y209 }210 return m211}212213// syncExpansionFromPreview updates selectedClue and expansion to match214// the current mousePreview so that the existing preview rendering works.215// If the preview contains exactly one clue, that clue is selected;216// otherwise selectedClue is cleared (the preview is still rendered via217// mousePreview in gridView).218func (m *Model) syncExpansionFromPreview() {219 if m.mousePreview == nil {220 m.selectedClue = nil221 return222 }223224 clues := m.puzzle.CluesInRect(*m.mousePreview)225 if len(clues) == 1 {226 clue := clues[0]227 m.selectedClue = &clue.ID228 m.expansion = expansion{clueID: clue.ID}229 m.expansion.left = clue.X - m.mousePreview.X230 m.expansion.right = m.mousePreview.X + m.mousePreview.W - 1 - clue.X231 m.expansion.up = clue.Y - m.mousePreview.Y232 m.expansion.down = m.mousePreview.Y + m.mousePreview.H - 1 - clue.Y233 } else {234 m.selectedClue = nil235 }236}237238// rectFromCorners builds a Rectangle spanning two arbitrary grid cells.239func rectFromCorners(x1, y1, x2, y2 int) Rectangle {240 minX, maxX := x1, x2241 if x2 < x1 {242 minX, maxX = x2, x1243 }244 minY, maxY := y1, y2245 if y2 < y1 {246 minY, maxY = y2, y1247 }248 return Rectangle{249 X: minX,250 Y: minY,251 W: maxX - minX + 1,252 H: maxY - minY + 1,253 }254}255256func (m Model) cancelPendingRectangle() Model {257 m.selectedClue = nil258 m.mouseDragAnchor = nil259 m.mousePreview = nil260 return m261}262263func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {264 switch {265 case key.Matches(msg, m.keys.Select):266 clue := m.puzzle.FindClueAt(m.cursor.X, m.cursor.Y)267 if clue != nil {268 m.selectedClue = &clue.ID269 m.expansion = expansion{clueID: clue.ID}270 // Initialize from existing rectangle if present.271 if r := m.puzzle.FindRectangleForClue(clue.ID); r != nil {272 m.expansion.left = clue.X - r.X273 m.expansion.right = r.X + r.W - 1 - clue.X274 m.expansion.up = clue.Y - r.Y275 m.expansion.down = r.Y + r.H - 1 - clue.Y276 }277 }278 case key.Matches(msg, m.keys.Delete):279 // Backspace prioritizes dismissing transient previews before deleting a committed rectangle.280 if m.mouseDragAnchor != nil || m.mousePreview != nil {281 m = m.cancelPendingRectangle()282 break283 }284 owner := m.puzzle.CellOwner(m.cursor.X, m.cursor.Y)285 if owner >= 0 {286 m.deleteRectangle(owner)287 }288 default:289 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)290 }291 return m292}293294func (m Model) handleExpansionMode(msg tea.KeyPressMsg) Model {295 clue := m.puzzle.FindClueByID(*m.selectedClue)296 if clue == nil {297 m.selectedClue = nil298 return m299 }300301 switch {302 // Expand each side independently.303 case key.Matches(msg, m.keys.Right):304 if m.expansion.right < m.puzzle.Width-1-clue.X {305 m.expansion.right++306 }307 case key.Matches(msg, m.keys.Left):308 if m.expansion.left < clue.X {309 m.expansion.left++310 }311 case key.Matches(msg, m.keys.Down):312 if m.expansion.down < m.puzzle.Height-1-clue.Y {313 m.expansion.down++314 }315 case key.Matches(msg, m.keys.Up):316 if m.expansion.up < clue.Y {317 m.expansion.up++318 }319 // Shrink each side independently.320 case key.Matches(msg, m.keys.ShrinkRight):321 if m.expansion.right > 0 {322 m.expansion.right--323 }324 case key.Matches(msg, m.keys.ShrinkLeft):325 if m.expansion.left > 0 {326 m.expansion.left--327 }328 case key.Matches(msg, m.keys.ShrinkDown):329 if m.expansion.down > 0 {330 m.expansion.down--331 }332 case key.Matches(msg, m.keys.ShrinkUp):333 if m.expansion.up > 0 {334 m.expansion.up--335 }336 case key.Matches(msg, m.keys.Select):337 rect := m.expansion.rect(clue)338 if m.puzzle.ValidRectangleForClue(rect, clue.ID) {339 m.commitRectangle(rect)340 m.selectedClue = nil341 }342 case key.Matches(msg, m.keys.Delete):343 m = m.cancelPendingRectangle()344 }345 return m346}347348func (m Model) View() string {349 solved := m.puzzle.IsSolved()350 title := game.TitleBarView("Shikaku", m.modeTitle, solved)351 grid := gridView(m, solved)352 if solved {353 return game.ComposeGameView(title, grid)354 }355 selected := m.selectedClue != nil356 info := infoView(&m.puzzle)357 status := statusBarView(selected, m.showFullHelp)358 return game.ComposeGameViewRows(359 title,360 grid,361 game.StaticRow(info),362 game.StableRow(status, statusBarVariants()...),363 )364}365366func (m Model) SetTitle(t string) game.Gamer {367 m.modeTitle = t368 return m369}370371func (m Model) IsSolved() bool {372 return m.puzzle.IsSolved()373}374375func (m Model) Reset() game.Gamer {376 m.puzzle.Rectangles = nil377 m.finalizePlacements()378 m.cursor = game.Cursor{}379 m.selectedClue = nil380 m.mouseDragAnchor = nil381 m.mousePreview = nil382 m.originValid = false383 return m384}385386func (m *Model) finalizePlacements() {387 m.puzzle.autoPlaceForcedRectangles()388 m.originValid = false389}390391func (m *Model) commitRectangle(rect Rectangle) {392 m.puzzle.SetRectangle(rect)393 m.finalizePlacements()394}395396func (m *Model) deleteRectangle(clueID int) {397 m.puzzle.RemoveRectangle(clueID)398 m.finalizePlacements()399}400401func (m Model) GetDebugInfo() string {402 status := "In Progress"403 if m.puzzle.IsSolved() {404 status = "Solved"405 }406407 selectedStr := "None"408 if m.selectedClue != nil {409 selectedStr = fmt.Sprintf("%d", *m.selectedClue)410 }411412 ox, oy := m.gridOrigin()413 hitStr := "miss"414 if m.lastMouseHit {415 hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)416 }417418 dragStr := "None"419 if m.mouseDragAnchor != nil {420 dragStr = fmt.Sprintf("anchor=(%d, %d)", m.mouseDragAnchor[0], m.mouseDragAnchor[1])421 }422423 s := game.DebugHeader("Shikaku", [][2]string{424 {"Status", status},425 {"Grid Size", fmt.Sprintf("%dx%d", m.puzzle.Width, m.puzzle.Height)},426 {"Clues", fmt.Sprintf("%d", len(m.puzzle.Clues))},427 {"Rectangles", fmt.Sprintf("%d", len(m.puzzle.Rectangles))},428 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},429 {"Selected Clue", selectedStr},430 {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},431 {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},432 {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},433 {"Mouse Drag", dragStr},434 })435436 var rows [][]string437 for _, c := range m.puzzle.Clues {438 r := m.puzzle.FindRectangleForClue(c.ID)439 rectStr := "-"440 rectStatus := "Empty"441 if r != nil {442 rectStr = fmt.Sprintf("(%d,%d) %dx%d", r.X, r.Y, r.W, r.H)443 if r.Area() == c.Value {444 rectStatus = "Correct"445 } else {446 rectStatus = fmt.Sprintf("Area %d", r.Area())447 }448 }449 rows = append(rows, []string{450 fmt.Sprintf("%d", c.ID),451 fmt.Sprintf("(%d,%d)", c.X, c.Y),452 fmt.Sprintf("%d", c.Value),453 rectStr,454 rectStatus,455 })456 }457 s += game.DebugTable("Clues", []string{"ID", "Pos", "Value", "Rectangle", "Status"}, rows)458459 return s460}