clone

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

chore/app-model-decomp
puzzletea / games / wordsearch / model.go
model.go 13.3K
1// Package wordsearch implements the word-finding grid puzzle.2package wordsearch34import (5 "fmt"6 "math"7 "strings"89 "charm.land/bubbles/v2/key"10 tea "charm.land/bubbletea/v2"11 "github.com/FelineStateMachine/puzzletea/game"12)1314type selectionState int1516const (17 noSelection selectionState = iota18 startSelected19)2021var selectionDirections = []Direction{22 Right,23 Down,24 DownRight,25 DownLeft,26 Left,27 Up,28 UpRight,29 UpLeft,30}3132var _ game.Gamer = Model{}3334type Model struct {35 width, height int36 grid grid37 words []Word38 cursor game.Cursor39 selection selectionState40 selectionStart game.Cursor41 keys KeyMap42 modeTitle string43 solved bool44 showFullHelp bool45 foundCells [][]bool4647 // Mouse drag support: true while left-button is held after clicking a cell.48 mouseDragging bool4950 // Screen geometry for mouse hit-testing.51 termWidth, termHeight int5253 // Cached grid origin for mouse hit-testing (recomputed on resize/solve).54 originX, originY int55 originValid bool5657 // Debug: last mouse event info.58 lastMouseX, lastMouseY int59 lastMouseBtn string60 lastMouseGridCol int61 lastMouseGridRow int62 lastMouseHit bool63}6465type selectionPreview struct {66 Letters string67 Direction string68 Length int69 Valid bool70 ExactWord string71 NearWord string72}7374func buildFoundCells(width, height int, words []Word) [][]bool {75 fc := make([][]bool, height)76 for y := range fc {77 fc[y] = make([]bool, width)78 }79 for i := range words {80 if words[i].Found {81 for _, pos := range words[i].Positions() {82 fc[pos.Y][pos.X] = true83 }84 }85 }86 return fc87}8889func New(mode WordSearchMode, g grid, words []Word) (game.Gamer, error) {90 return &Model{91 width: mode.Width,92 height: mode.Height,93 grid: g,94 words: words,95 selection: noSelection,96 keys: DefaultKeyMap,97 modeTitle: mode.Title(),98 solved: false,99 foundCells: buildFoundCells(mode.Width, mode.Height, words),100 }, nil101}102103func (m Model) Init() tea.Cmd {104 return nil105}106107func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {108 switch msg := msg.(type) {109 case game.HelpToggleMsg:110 m.showFullHelp = msg.Show111 m.originValid = false112113 case tea.WindowSizeMsg:114 m.termWidth = msg.Width115 m.termHeight = msg.Height116 m.originValid = false117118 case tea.KeyPressMsg:119 return m.handleKeyPress(msg)120121 case tea.MouseClickMsg:122 m.handleMouseClick(msg)123124 case tea.MouseMotionMsg:125 m.handleMouseMotion(msg)126127 case tea.MouseReleaseMsg:128 m.handleMouseRelease()129 }130 return m, nil131}132133func (m *Model) handleMouseClick(msg tea.MouseClickMsg) {134 m.lastMouseX, m.lastMouseY = msg.X, msg.Y135 m.lastMouseBtn = msg.String()136137 col, row, ok := m.screenToGrid(msg.X, msg.Y)138 m.lastMouseGridCol, m.lastMouseGridRow = col, row139 m.lastMouseHit = ok140141 if m.solved || !ok {142 return143 }144145 switch msg.Button {146 case tea.MouseLeft:147 // Click sets the selection start and begins a drag.148 m.cursor.X, m.cursor.Y = col, row149 m.selectionStart = m.cursor150 m.selection = startSelected151 m.mouseDragging = true152153 case tea.MouseRight:154 // Right-click cancels the current selection.155 m.selection = noSelection156 m.mouseDragging = false157 }158}159160func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) {161 if m.solved || !m.mouseDragging || m.selection != startSelected {162 return163 }164165 col, row, ok := m.screenToGrid(msg.X, msg.Y)166 if !ok {167 return168 }169170 m.setSelectionCursor(game.Cursor{X: col, Y: row})171}172173func (m *Model) handleMouseRelease() {174 if !m.mouseDragging {175 return176 }177 m.mouseDragging = false178179 if m.selection != startSelected {180 return181 }182183 // Same cell as start: keep selection active (start is set, waiting184 // for a second click/drag to define the end — mirrors keyboard flow).185 if m.cursor.X == m.selectionStart.X && m.cursor.Y == m.selectionStart.Y {186 return187 }188189 // Try to validate and submit the selection.190 m.validateSelection()191 m.selection = noSelection192}193194func (m Model) handleKeyPress(msg tea.KeyPressMsg) (game.Gamer, tea.Cmd) {195 switch {196 case key.Matches(msg, m.keys.Select):197 m.handleSelect()198 case key.Matches(msg, m.keys.Cancel):199 m.selection = noSelection200 default:201 m.handleCursorMove(msg)202 }203204 return m, nil205}206207func (m *Model) handleCursorMove(msg tea.KeyPressMsg) {208 if m.selection == startSelected {209 if m.moveSelectionCursor(msg) {210 return211 }212 }213214 target := m.cursor215 if !target.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1) {216 return217 }218219 if m.selection == startSelected {220 m.setSelectionCursor(target)221 return222 }223224 m.cursor = target225}226227func (m *Model) moveSelectionCursor(msg tea.KeyPressMsg) bool {228 dx, dy, valid := lineDirection(m.selectionStart, m.cursor)229 if !valid {230 return false231 }232233 moveX, moveY, ok := m.moveDelta(msg)234 if !ok {235 return false236 }237238 step := 0239 switch {240 case moveX != 0 && dx != 0 && moveX == dx:241 step = 1242 case moveY != 0 && dy != 0 && moveY == dy:243 step = 1244 case moveX != 0 && dx != 0 && moveX == -dx:245 step = -1246 case moveY != 0 && dy != 0 && moveY == -dy:247 step = -1248 default:249 return false250 }251252 next := game.Cursor{253 X: m.cursor.X + step*dx,254 Y: m.cursor.Y + step*dy,255 }256 if !cursorInBounds(next, m.width-1, m.height-1) {257 return true258 }259260 m.cursor = next261 return true262}263264func (m *Model) handleSelect() {265 if m.selection == noSelection {266 // Mark selection start267 m.selectionStart = m.cursor268 m.selection = startSelected269 } else {270 // Try to validate selection271 m.validateSelection()272 m.selection = noSelection273 }274}275276func (m *Model) validateSelection() {277 var letters strings.Builder278 valid := walkLine(m.selectionStart, m.cursor, func(x, y int) {279 letters.WriteRune(m.grid.Get(x, y))280 })281 if !valid {282 return283 }284285 word := letters.String()286 wordReverse := reverseString(word)287288 for i := range m.words {289 if m.words[i].Found {290 continue291 }292 if m.words[i].Text == word || m.words[i].Text == wordReverse {293 m.words[i].Found = true294 for _, pos := range m.words[i].Positions() {295 m.foundCells[pos.Y][pos.X] = true296 }297 m.checkWin()298 return299 }300 }301}302303func (m *Model) setSelectionCursor(target game.Cursor) {304 m.cursor = snapSelectionCursor(m.selectionStart, target, m.width-1, m.height-1)305}306307func (m *Model) checkWin() {308 allFound := true309 for _, word := range m.words {310 if !word.Found {311 allFound = false312 break313 }314 }315 if allFound != m.solved {316 m.originValid = false317 }318 m.solved = allFound319}320321func (m Model) View() string {322 return renderView(m)323}324325func (m Model) GetDebugInfo() string {326 ox, oy := m.gridOrigin()327 hitStr := "miss"328 if m.lastMouseHit {329 hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)330 }331332 rows := [][2]string{333 {"Grid Size", fmt.Sprintf("%dx%d", m.width, m.height)},334 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},335 {"Selection State", fmt.Sprintf("%v", m.selection)},336 }337 if m.selection == startSelected {338 rows = append(rows, [2]string{"Selection Start", fmt.Sprintf("(%d, %d)", m.selectionStart.X, m.selectionStart.Y)})339 preview := m.currentSelectionPreview()340 if preview.Valid {341 rows = append(rows, [2]string{342 "Selection Preview",343 fmt.Sprintf("%s (%s, len %d)", preview.Letters, preview.Direction, preview.Length),344 })345 }346 }347 rows = append(rows,348 [2]string{"Words Found", fmt.Sprintf("%d/%d", m.countFoundWords(), len(m.words))},349 [2]string{"Won", fmt.Sprintf("%v", m.solved)},350 [2]string{"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},351 [2]string{"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},352 [2]string{"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},353 [2]string{"Mouse Drag", fmt.Sprintf("%v", m.mouseDragging)},354 )355356 s := game.DebugHeader("Word Search", rows)357358 var tableRows [][]string359 for _, word := range m.words {360 found := "No"361 if word.Found {362 found = "Yes"363 }364 tableRows = append(tableRows, []string{365 word.Text, found,366 fmt.Sprintf("(%d,%d)", word.Start.X, word.Start.Y),367 fmt.Sprintf("(%d,%d)", word.End.X, word.End.Y),368 fmt.Sprintf("%d", word.Direction),369 })370 }371 s += game.DebugTable("Words", []string{"Word", "Found", "Start", "End", "Direction"}, tableRows)372373 return s374}375376func (m Model) SetTitle(t string) game.Gamer {377 m.modeTitle = t378 return m379}380381func (m Model) IsSolved() bool {382 return m.solved383}384385func (m Model) Reset() game.Gamer {386 words := make([]Word, len(m.words))387 for i, w := range m.words {388 words[i] = Word{389 Text: w.Text,390 Start: w.Start,391 End: w.End,392 Direction: w.Direction,393 Found: false,394 }395 }396 m.words = words397 m.foundCells = buildFoundCells(m.width, m.height, m.words)398 m.solved = false399 m.selection = noSelection400 m.selectionStart = game.Cursor{}401 m.cursor = game.Cursor{}402 m.mouseDragging = false403 m.originValid = false404 return m405}406407func (m Model) countFoundWords() int {408 count := 0409 for _, word := range m.words {410 if word.Found {411 count++412 }413 }414 return count415}416417// lineDirection returns the unit direction vector from start to end418// and whether the line is valid (horizontal, vertical, or diagonal).419func lineDirection(start, end game.Cursor) (dx, dy int, valid bool) {420 if start.X == end.X && start.Y == end.Y {421 return 0, 0, false422 }423424 if end.X > start.X {425 dx = 1426 } else if end.X < start.X {427 dx = -1428 }429430 if end.Y > start.Y {431 dy = 1432 } else if end.Y < start.Y {433 dy = -1434 }435436 distX := abs(end.X - start.X)437 distY := abs(end.Y - start.Y)438 if dx != 0 && dy != 0 && distX != distY {439 return 0, 0, false440 }441442 return dx, dy, true443}444445// walkLine calls fn for each cell on the line from start to end.446// Returns false if the line is not valid.447func walkLine(start, end game.Cursor, fn func(x, y int)) bool {448 dx, dy, valid := lineDirection(start, end)449 if !valid {450 return false451 }452453 x, y := start.X, start.Y454 for {455 fn(x, y)456 if x == end.X && y == end.Y {457 break458 }459 x += dx460 y += dy461 }462 return true463}464465func abs(x int) int {466 if x < 0 {467 return -x468 }469 return x470}471472func reverseString(s string) string {473 runes := []rune(s)474 for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {475 runes[i], runes[j] = runes[j], runes[i]476 }477 return string(runes)478}479480func (m Model) currentSelectionPreview() selectionPreview {481 if m.selection != startSelected || m.cursor == m.selectionStart {482 return selectionPreview{}483 }484485 var letters strings.Builder486 if !walkLine(m.selectionStart, m.cursor, func(x, y int) {487 letters.WriteRune(m.grid.Get(x, y))488 }) {489 return selectionPreview{}490 }491492 word := letters.String()493 preview := selectionPreview{494 Letters: word,495 Direction: directionName(m.selectionStart, m.cursor),496 Length: len(word),497 Valid: true,498 }499500 reversed := reverseString(word)501 bestNearDelta := -1502 for _, candidate := range m.words {503 if candidate.Found {504 continue505 }506 switch {507 case candidate.Text == word || candidate.Text == reversed:508 preview.ExactWord = candidate.Text509 return preview510 case strings.HasPrefix(candidate.Text, word) || strings.HasPrefix(candidate.Text, reversed):511 delta := len(candidate.Text) - len(word)512 if bestNearDelta == -1 || delta < bestNearDelta {513 bestNearDelta = delta514 preview.NearWord = candidate.Text515 }516 }517 }518519 return preview520}521522func directionName(start, end game.Cursor) string {523 dx, dy, valid := lineDirection(start, end)524 if !valid {525 return "pending"526 }527528 switch {529 case dx == 1 && dy == 0:530 return "right"531 case dx == 0 && dy == 1:532 return "down"533 case dx == 1 && dy == 1:534 return "down-right"535 case dx == -1 && dy == 1:536 return "down-left"537 case dx == -1 && dy == 0:538 return "left"539 case dx == 0 && dy == -1:540 return "up"541 case dx == 1 && dy == -1:542 return "up-right"543 case dx == -1 && dy == -1:544 return "up-left"545 default:546 return "pending"547 }548}549550func (m Model) moveDelta(msg tea.KeyPressMsg) (dx, dy int, ok bool) {551 switch {552 case key.Matches(msg, m.keys.Left):553 return -1, 0, true554 case key.Matches(msg, m.keys.Right):555 return 1, 0, true556 case key.Matches(msg, m.keys.Up):557 return 0, -1, true558 case key.Matches(msg, m.keys.Down):559 return 0, 1, true560 default:561 return 0, 0, false562 }563}564565func snapSelectionCursor(start, target game.Cursor, maxX, maxY int) game.Cursor {566 if start == target {567 return start568 }569570 best := start571 bestDistance := -1572 bestAlignment := math.Inf(-1)573 bestSteps := -1574 targetDX := target.X - start.X575 targetDY := target.Y - start.Y576577 for _, dir := range selectionDirections {578 dx, dy := dir.Vector()579 alignment := float64(targetDX*dx+targetDY*dy) / math.Sqrt(float64(dx*dx+dy*dy))580 for step := 1; step <= maxStepsToEdge(start, dx, dy, maxX, maxY); step++ {581 candidate := game.Cursor{582 X: start.X + step*dx,583 Y: start.Y + step*dy,584 }585 distance := squaredDistance(candidate, target)586 if bestDistance == -1 ||587 distance < bestDistance ||588 (distance == bestDistance && alignment > bestAlignment) ||589 (distance == bestDistance && alignment == bestAlignment && step > bestSteps) {590 best = candidate591 bestDistance = distance592 bestAlignment = alignment593 bestSteps = step594 }595 }596 }597598 return best599}600601func maxStepsToEdge(start game.Cursor, dx, dy, maxX, maxY int) int {602 steps := maxX + maxY + 2603 if dx > 0 {604 steps = min(steps, maxX-start.X)605 } else if dx < 0 {606 steps = min(steps, start.X)607 }608 if dy > 0 {609 steps = min(steps, maxY-start.Y)610 } else if dy < 0 {611 steps = min(steps, start.Y)612 }613 return steps614}615616func squaredDistance(a, b game.Cursor) int {617 dx := a.X - b.X618 dy := a.Y - b.Y619 return dx*dx + dy*dy620}621622func cursorInBounds(cursor game.Cursor, maxX, maxY int) bool {623 return cursor.X >= 0 && cursor.X <= maxX && cursor.Y >= 0 && cursor.Y <= maxY624}