- commit
- 21eba3b80aa86887ac4e804166619a6830450408
- parent
- ad89329d2ea22ce05bb19605189e51b4d8fe1a30
- author
- Dami <dami@Damis-MacBook-Air.local>
| M | core.go | 4 | + |
| M | go.sum | 2 | + |
| M | model.go | 5 | + |
| A | wordsearch/ | 52 | ++++++ |
| A | wordsearch/ | 41 | +++++ |
| A | wordsearch/ | 250 | ++++++++++++++++++++++++++++++ |
| A | wordsearch/ | 135 | ++++++++++++++++ |
| A | wordsearch/ | 66 | ++++++++ |
| A | wordsearch/ | 62 | +++++++ |
| A | wordsearch/ | 167 | ++++++++++++++++++++ |
| A | wordsearch/ | 82 | ++++++++++ |
@@ -8,6 +8,7 @@ import (
88 99 // Implementations of the Gamer interface should interface here.1010 nonogram "github.com/FelineStateMachine/puzzletea/nonogram"11+ "github.com/FelineStateMachine/puzzletea/wordsearch"1112 )1213 1314 func (m model) SpawnGame(mode game.Mode) (game.Gamer, error) {@@ -17,6 +18,9 @@ func (m model) SpawnGame(mode game.Mode) (game.Gamer, error) {
1718 return nonogram.New(mode, hints)1819 case sudoku.SudokuMode:1920 return sudoku.New(mode, sudoku.GenerateProvidedCells(mode))21+ case wordsearch.WordSearchMode:22+ grid, words := wordsearch.GenerateWordSearch(mode.Width, mode.Height, mode.WordCount, mode.MinWordLen, mode.MaxWordLen, mode.AllowedDirs)23+ return wordsearch.New(mode, grid, words), nil2024 default:2125 return nil, fmt.Errorf("Faled to create game.\nUnimplemented game type `%v`.", mode)2226 }@@ -40,6 +40,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
4040 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=4141 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=4242 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=43+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=44+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=4345 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=4446 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=4547 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=@@ -5,6 +5,7 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/game"77 "github.com/FelineStateMachine/puzzletea/nonogram"8+ "github.com/FelineStateMachine/puzzletea/wordsearch"89 // "github.com/FelineStateMachine/puzzletea/sudoku"910 "github.com/charmbracelet/bubbles/list"1011 tea "github.com/charmbracelet/bubbletea"@@ -22,6 +23,10 @@ var (
2223 nonogram.NewMode("Hard - 15x15", "A random nonogram on a fifteen by fifteen board.", 15, 15),2324 nonogram.NewMode("Extra - 5x10", "A random nonogram on a five by ten board.", 5, 10),2425 26+ wordsearch.NewMode("Word Search Easy - 10x10", "Find 6 words in a 10x10 grid.", 10, 10, 6, 3, 5, []wordsearch.Direction{wordsearch.Right, wordsearch.Down, wordsearch.DownRight}),27+ wordsearch.NewMode("Word Search Medium - 15x15", "Find 10 words in a 15x15 grid.", 15, 15, 10, 4, 7, []wordsearch.Direction{wordsearch.Right, wordsearch.Down, wordsearch.DownRight, wordsearch.DownLeft, wordsearch.Left, wordsearch.Up}),28+ wordsearch.NewMode("Word Search Hard - 20x20", "Find 15 words in a 20x20 grid.", 20, 20, 15, 5, 10, []wordsearch.Direction{wordsearch.Right, wordsearch.Down, wordsearch.DownRight, wordsearch.DownLeft, wordsearch.Left, wordsearch.Up, wordsearch.UpRight, wordsearch.UpLeft}),29+2530 // sudoku.NewMode("Easy - 38 Provided Cells", "A random sudoku with at least 38 cells provided to start.", 38),2631 // sudoku.NewMode("Hard - 26 Provided Cells", "A random sudoku with at least 26 cells provided to start.", 26)2732 }@@ -0,0 +1,52 @@
1+package wordsearch2+3+import "encoding/json"4+5+type exportData struct {6+ Width int `json:"width"`7+ Height int `json:"height"`8+ Grid string `json:"grid"`9+ Words []Word `json:"words"`10+ CursorX int `json:"cursor_x"`11+ CursorY int `json:"cursor_y"`12+ Selection int `json:"selection"`13+ SelectionX int `json:"selection_x"`14+ SelectionY int `json:"selection_y"`15+ Won bool `json:"won"`16+}17+18+func exportModel(m Model) ([]byte, error) {19+ data := exportData{20+ Width: m.width,21+ Height: m.height,22+ Grid: m.grid.String(),23+ Words: m.words,24+ CursorX: m.cursor.x,25+ CursorY: m.cursor.y,26+ Selection: int(m.selection),27+ SelectionX: m.selectionStart.x,28+ SelectionY: m.selectionStart.y,29+ Won: m.won,30+ }31+32+ return json.Marshal(data)33+}34+35+func ImportModel(data []byte) (*Model, error) {36+ var exported exportData37+ if err := json.Unmarshal(data, &exported); err != nil {38+ return nil, err39+ }40+41+ return &Model{42+ width: exported.Width,43+ height: exported.Height,44+ grid: newGrid(state(exported.Grid)),45+ words: exported.Words,46+ cursor: cursor{x: exported.CursorX, y: exported.CursorY},47+ selection: selectionState(exported.Selection),48+ selectionStart: cursor{x: exported.SelectionX, y: exported.SelectionY},49+ keys: newKeyMap(),50+ won: exported.Won,51+ }, nil52+}@@ -0,0 +1,41 @@
1+package wordsearch2+3+import "github.com/FelineStateMachine/puzzletea/game"4+5+// WordSearchMode implements game.Mode for word search puzzles6+type WordSearchMode struct {7+ title string8+ description string9+ Width int10+ Height int11+ WordCount int12+ MinWordLen int13+ MaxWordLen int14+ AllowedDirs []Direction15+}16+17+// NewMode creates a new WordSearchMode with the given parameters18+func NewMode(title, description string, width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) game.Mode {19+ return WordSearchMode{20+ title: title,21+ description: description,22+ Width: width,23+ Height: height,24+ WordCount: wordCount,25+ MinWordLen: minLen,26+ MaxWordLen: maxLen,27+ AllowedDirs: allowedDirs,28+ }29+}30+31+func (m WordSearchMode) Title() string {32+ return m.title33+}34+35+func (m WordSearchMode) Description() string {36+ return m.description37+}38+39+func (m WordSearchMode) FilterValue() string {40+ return m.title41+}@@ -0,0 +1,250 @@
1+package wordsearch2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ tea "github.com/charmbracelet/bubbletea"9+ "github.com/charmbracelet/bubbles/key"10+)11+12+type selectionState int13+14+const (15+ noSelection selectionState = iota16+ startSelected17+)18+19+type cursor struct {20+ x, y int21+}22+23+type Model struct {24+ width, height int25+ grid grid26+ words []Word27+ cursor cursor28+ selection selectionState29+ selectionStart cursor30+ keys KeyMap31+ won bool32+}33+34+// New creates a new word search game35+func New(mode game.Mode, g grid, words []Word) *Model {36+ wsMode, ok := mode.(WordSearchMode)37+ if !ok {38+ return nil39+ }40+41+ return &Model{42+ width: wsMode.Width,43+ height: wsMode.Height,44+ grid: g,45+ words: words,46+ cursor: cursor{x: 0, y: 0},47+ selection: noSelection,48+ keys: newKeyMap(),49+ won: false,50+ }51+}52+53+func (m Model) Init() tea.Cmd {54+ return nil55+}56+57+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {58+ switch msg := msg.(type) {59+ case tea.KeyMsg:60+ return m.handleKeyPress(msg)61+ }62+ return m, nil63+}64+65+func (m Model) handleKeyPress(msg tea.KeyMsg) (game.Gamer, tea.Cmd) {66+ switch {67+ case key.Matches(msg, m.keys.Up):68+ if m.cursor.y > 0 {69+ m.cursor.y--70+ }71+ case key.Matches(msg, m.keys.Down):72+ if m.cursor.y < m.height-1 {73+ m.cursor.y++74+ }75+ case key.Matches(msg, m.keys.Left):76+ if m.cursor.x > 0 {77+ m.cursor.x--78+ }79+ case key.Matches(msg, m.keys.Right):80+ if m.cursor.x < m.width-1 {81+ m.cursor.x++82+ }83+ case key.Matches(msg, m.keys.Select):84+ m.handleSelect()85+ case key.Matches(msg, m.keys.Cancel):86+ m.selection = noSelection87+ }88+89+ return m, nil90+}91+92+func (m *Model) handleSelect() {93+ if m.selection == noSelection {94+ // Mark selection start95+ m.selectionStart = m.cursor96+ m.selection = startSelected97+ } else {98+ // Try to validate selection99+ m.validateSelection()100+ m.selection = noSelection101+ }102+}103+104+func (m *Model) validateSelection() {105+ start := m.selectionStart106+ end := m.cursor107+108+ // Calculate direction109+ dx := 0110+ dy := 0111+112+ if end.x > start.x {113+ dx = 1114+ } else if end.x < start.x {115+ dx = -1116+ }117+118+ if end.y > start.y {119+ dy = 1120+ } else if end.y < start.y {121+ dy = -1122+ }123+124+ // Must be a valid line (horizontal, vertical, or diagonal)125+ if dx == 0 && dy == 0 {126+ return // Same position127+ }128+129+ // Verify it's a straight line130+ distX := abs(end.x - start.x)131+ distY := abs(end.y - start.y)132+133+ if dx != 0 && dy != 0 && distX != distY {134+ return // Not a valid diagonal135+ }136+137+ // Extract letters along the selection138+ letters := m.extractLetters(start, end, dx, dy)139+ lettersReverse := reverseString(letters)140+141+ // Check against all unfound words142+ for i := range m.words {143+ if m.words[i].Found {144+ continue145+ }146+147+ if m.words[i].Text == letters || m.words[i].Text == lettersReverse {148+ m.words[i].Found = true149+ m.checkWin()150+ return151+ }152+ }153+}154+155+func (m *Model) extractLetters(start, end cursor, dx, dy int) string {156+ var letters strings.Builder157+ x, y := start.x, start.y158+159+ for {160+ letters.WriteRune(m.grid.Get(x, y))161+162+ if x == end.x && y == end.y {163+ break164+ }165+166+ x += dx167+ y += dy168+ }169+170+ return letters.String()171+}172+173+func (m *Model) checkWin() {174+ allFound := true175+ for _, word := range m.words {176+ if !word.Found {177+ allFound = false178+ break179+ }180+ }181+ m.won = allFound182+}183+184+func (m Model) View() string {185+ return renderView(m)186+}187+188+func (m Model) GetDebugInfo() string {189+ var sb strings.Builder190+191+ sb.WriteString("# Word Search Debug\n\n")192+ sb.WriteString(fmt.Sprintf("**Grid Size:** %dx%d\n\n", m.width, m.height))193+ sb.WriteString(fmt.Sprintf("**Cursor:** (%d, %d)\n\n", m.cursor.x, m.cursor.y))194+ sb.WriteString(fmt.Sprintf("**Selection State:** %v\n\n", m.selection))195+196+ if m.selection == startSelected {197+ sb.WriteString(fmt.Sprintf("**Selection Start:** (%d, %d)\n\n", m.selectionStart.x, m.selectionStart.y))198+ }199+200+ sb.WriteString(fmt.Sprintf("**Words Found:** %d/%d\n\n", m.countFoundWords(), len(m.words)))201+ sb.WriteString(fmt.Sprintf("**Won:** %v\n\n", m.won))202+203+ sb.WriteString("## Words\n\n")204+ sb.WriteString("| Word | Found | Start | End | Direction |\n")205+ sb.WriteString("|------|-------|-------|-----|----------|\n")206+207+ for _, word := range m.words {208+ found := "❌"209+ if word.Found {210+ found = "✅"211+ }212+ sb.WriteString(fmt.Sprintf("| %s | %s | (%d,%d) | (%d,%d) | %d |\n",213+ word.Text, found, word.Start.X, word.Start.Y, word.End.X, word.End.Y, word.Direction))214+ }215+216+ return sb.String()217+}218+219+func (m Model) GetFullHelp() [][]key.Binding {220+ return m.keys.FullHelp()221+}222+223+func (m Model) GetSave() ([]byte, error) {224+ return exportModel(m)225+}226+227+func (m Model) countFoundWords() int {228+ count := 0229+ for _, word := range m.words {230+ if word.Found {231+ count++232+ }233+ }234+ return count235+}236+237+func abs(x int) int {238+ if x < 0 {239+ return -x240+ }241+ return x242+}243+244+func reverseString(s string) string {245+ runes := []rune(s)246+ for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {247+ runes[i], runes[j] = runes[j], runes[i]248+ }249+ return string(runes)250+}@@ -0,0 +1,135 @@
1+package wordsearch2+3+import (4+ "math/rand"5+ "strings"6+ "time"7+)8+9+// Word list organized by length for difficulty filtering10+var wordsByLength = map[int][]string{11+ 3: {"CAT", "DOG", "SUN", "HAT", "BAT", "RUN", "FUN", "HOT", "TOP", "BOX"},12+ 4: {"TREE", "BIRD", "FISH", "MOON", "STAR", "WIND", "RAIN", "SNOW", "BLUE", "JUMP", "PLAY", "SWIM", "WALK", "TALK", "BOOK", "DOOR", "FIRE", "GOLD", "HAND", "KING"},13+ 5: {"HAPPY", "WATER", "LIGHT", "BROWN", "GREEN", "HOUSE", "MUSIC", "OCEAN", "PLANT", "QUICK", "RIVER", "SMILE", "TIGER", "UNDER", "WATCH", "YOUTH", "ZEBRA", "APPLE", "BEACH", "CLOUD"},14+ 6: {"FRIEND", "GARDEN", "JUNGLE", "KITTEN", "LAPTOP", "MONKEY", "ORANGE", "PENCIL", "PUZZLE", "QUEEN", "RABBIT", "SCHOOL", "TRAVEL", "WINTER", "YELLOW", "ANIMAL", "BASKET", "CASTLE", "DRAGON", "FOREST"},15+ 7: {"ABILITY", "BALANCE", "CAPTAIN", "DIAMOND", "ELECTRIC", "FREEDOM", "GIRAFFE", "HARMONY", "JOURNEY", "KITCHEN", "LIBRARY", "MONSTER", "MYSTERY", "PICTURE", "QUALITY", "RAINBOW", "SUCCESS", "THUNDER", "VICTORY", "WEATHER"},16+ 8: {"ADVENTURE", "BIRTHDAY", "CALENDAR", "DAUGHTER", "ELEPHANT", "FOOTBALL", "GIGANTIC", "HOSPITAL", "INFINITY", "KANGAROO", "LANGUAGE", "MOUNTAIN", "NEIGHBOR", "PARADISE", "QUESTION", "SANDWICH", "TREASURE", "UNIVERSE", "VACATION", "WILDLIFE"},17+ 9: {"AMBITIOUS", "BEAUTIFUL", "CHALLENGE", "DANGEROUS", "EDUCATION", "FANTASTIC", "GENERATOR", "HALLOWEEN", "IMPORTANT", "KNOWLEDGE", "LANDSCAPE", "MEMORABLE", "NECESSARY", "CHOCOLATE", "PINEAPPLE", "RASPBERRY", "SUBMARINE", "TELEPHONE", "CROCODILE", "BUTTERFLY"},18+ 10: {"ACCOMPLISH", "BACKGROUND", "BASKETBALL", "CELEBRATION", "DELIGHTFUL", "EVERYTHING", "GENERATION", "JAVASCRIPT", "MOTIVATION", "WATERMELON", "STRAWBERRY", "TRAMPOLINE", "SKATEBOARD", "GREENHOUSE", "UNDERSTAND", "California", "GIRLFRIEND", "TOURNAMENT", "PLAYGROUND", "REVOLUTION"},19+}20+21+// GenerateWordSearch creates a new word search grid with the specified parameters22+func GenerateWordSearch(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) (grid, []Word) {23+ rand.Seed(time.Now().UnixNano())24+25+ g := createEmptyGrid(height, width)26+ words := selectWords(wordCount, minLen, maxLen)27+ placedWords := make([]Word, 0, len(words))28+29+ // Try to place each word30+ for _, wordText := range words {31+ word := tryPlaceWord(g, wordText, allowedDirs, 100)32+ if word != nil {33+ placedWords = append(placedWords, *word)34+ }35+ }36+37+ // Fill remaining empty cells with random letters38+ fillEmptyCells(g)39+40+ return g, placedWords41+}42+43+// selectWords randomly selects words from the word list44+func selectWords(count, minLen, maxLen int) []string {45+ // Collect all valid words46+ var candidates []string47+ for length := minLen; length <= maxLen; length++ {48+ if words, ok := wordsByLength[length]; ok {49+ candidates = append(candidates, words...)50+ }51+ }52+53+ // Shuffle and take first 'count' words54+ rand.Shuffle(len(candidates), func(i, j int) {55+ candidates[i], candidates[j] = candidates[j], candidates[i]56+ })57+58+ if count > len(candidates) {59+ count = len(candidates)60+ }61+62+ return candidates[:count]63+}64+65+// tryPlaceWord attempts to place a word in the grid66+func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int) *Word {67+ text = strings.ToUpper(text)68+ height := len(g)69+ width := len(g[0])70+71+ for attempt := 0; attempt < maxAttempts; attempt++ {72+ // Random starting position73+ x := rand.Intn(width)74+ y := rand.Intn(height)75+76+ // Random direction77+ dir := allowedDirs[rand.Intn(len(allowedDirs))]78+ dx, dy := dir.Vector()79+80+ // Check if word fits in this direction81+ endX := x + dx*(len(text)-1)82+ endY := y + dy*(len(text)-1)83+84+ if endX < 0 || endX >= width || endY < 0 || endY >= height {85+ continue86+ }87+88+ // Check if placement is valid (no conflicts, allow letter overlap)89+ valid := true90+ for i := 0; i < len(text); i++ {91+ cx := x + dx*i92+ cy := y + dy*i93+ existing := g.Get(cx, cy)94+95+ if existing != ' ' && existing != rune(text[i]) {96+ valid = false97+ break98+ }99+ }100+101+ if !valid {102+ continue103+ }104+105+ // Place the word106+ for i := 0; i < len(text); i++ {107+ cx := x + dx*i108+ cy := y + dy*i109+ g.Set(cx, cy, rune(text[i]))110+ }111+112+ return &Word{113+ Text: text,114+ Start: Position{X: x, Y: y},115+ End: Position{X: endX, Y: endY},116+ Direction: dir,117+ Found: false,118+ }119+ }120+121+ return nil122+}123+124+// fillEmptyCells fills all empty cells with random letters125+func fillEmptyCells(g grid) {126+ letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"127+128+ for y := range g {129+ for x := range g[y] {130+ if g[y][x] == ' ' {131+ g[y][x] = rune(letters[rand.Intn(len(letters))])132+ }133+ }134+ }135+}@@ -0,0 +1,66 @@
1+package wordsearch2+3+import "strings"4+5+// grid is a 2D array of runes representing the letter grid6+type grid [][]rune7+8+// state is the string serialization of the grid9+type state string10+11+// newGrid creates a grid from a serialized state12+func newGrid(s state) grid {13+ if s == "" {14+ return grid{}15+ }16+17+ lines := strings.Split(string(s), "\n")18+ g := make(grid, len(lines))19+20+ for i, line := range lines {21+ g[i] = []rune(line)22+ }23+24+ return g25+}26+27+// String serializes the grid to a state string28+func (g grid) String() string {29+ if len(g) == 0 {30+ return ""31+ }32+33+ lines := make([]string, len(g))34+ for i, row := range g {35+ lines[i] = string(row)36+ }37+38+ return strings.Join(lines, "\n")39+}40+41+// createEmptyGrid creates a grid of the given dimensions filled with spaces42+func createEmptyGrid(height, width int) grid {43+ g := make(grid, height)44+ for i := range g {45+ g[i] = make([]rune, width)46+ for j := range g[i] {47+ g[i][j] = ' '48+ }49+ }50+ return g51+}52+53+// Get returns the rune at the given position, or 0 if out of bounds54+func (g grid) Get(x, y int) rune {55+ if y < 0 || y >= len(g) || x < 0 || x >= len(g[y]) {56+ return 057+ }58+ return g[y][x]59+}60+61+// Set sets the rune at the given position if in bounds62+func (g grid) Set(x, y int, r rune) {63+ if y >= 0 && y < len(g) && x >= 0 && x < len(g[y]) {64+ g[y][x] = r65+ }66+}@@ -0,0 +1,62 @@
1+package wordsearch2+3+import "github.com/charmbracelet/bubbles/key"4+5+type KeyMap struct {6+ Up key.Binding7+ Down key.Binding8+ Left key.Binding9+ Right key.Binding10+ Select key.Binding11+ Cancel key.Binding12+ Quit key.Binding13+ Help key.Binding14+}15+16+func newKeyMap() KeyMap {17+ return KeyMap{18+ Up: key.NewBinding(19+ key.WithKeys("up", "k", "w"),20+ key.WithHelp("↑/k/w", "move up"),21+ ),22+ Down: key.NewBinding(23+ key.WithKeys("down", "j", "s"),24+ key.WithHelp("↓/j/s", "move down"),25+ ),26+ Left: key.NewBinding(27+ key.WithKeys("left", "h", "a"),28+ key.WithHelp("←/h/a", "move left"),29+ ),30+ Right: key.NewBinding(31+ key.WithKeys("right", "l", "d"),32+ key.WithHelp("→/l/d", "move right"),33+ ),34+ Select: key.NewBinding(35+ key.WithKeys("enter", " "),36+ key.WithHelp("enter/space", "select start/end"),37+ ),38+ Cancel: key.NewBinding(39+ key.WithKeys("esc"),40+ key.WithHelp("esc", "cancel selection"),41+ ),42+ Quit: key.NewBinding(43+ key.WithKeys("ctrl+c"),44+ key.WithHelp("ctrl+c", "quit"),45+ ),46+ Help: key.NewBinding(47+ key.WithKeys("?"),48+ key.WithHelp("?", "toggle help"),49+ ),50+ }51+}52+53+func (k KeyMap) ShortHelp() []key.Binding {54+ return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Select, k.Cancel}55+}56+57+func (k KeyMap) FullHelp() [][]key.Binding {58+ return [][]key.Binding{59+ {k.Up, k.Down, k.Left, k.Right},60+ {k.Select, k.Cancel, k.Help},61+ }62+}@@ -0,0 +1,167 @@
1+package wordsearch2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/charmbracelet/lipgloss"8+)9+10+var (11+ normalStyle = lipgloss.NewStyle().12+ Foreground(lipgloss.Color("#1a1a1a")).13+ Background(lipgloss.Color("#ffffff"))14+15+ cursorStyle = lipgloss.NewStyle().16+ Foreground(lipgloss.Color("#ffffff")).17+ Background(lipgloss.Color("#ff00ff"))18+19+ selectionStyle = lipgloss.NewStyle().20+ Foreground(lipgloss.Color("#1a1a1a")).21+ Background(lipgloss.Color("#ffff00"))22+23+ foundStyle = lipgloss.NewStyle().24+ Foreground(lipgloss.Color("#ffffff")).25+ Background(lipgloss.Color("#00ff00"))26+27+ wordListHeaderStyle = lipgloss.NewStyle().28+ Foreground(lipgloss.Color("#00ffff")).29+ Bold(true).30+ Underline(true)31+32+ foundWordStyle = lipgloss.NewStyle().33+ Foreground(lipgloss.Color("#00ff00")).34+ Strikethrough(true)35+36+ unfoundWordStyle = lipgloss.NewStyle().37+ Foreground(lipgloss.Color("#888888"))38+39+ winMessageStyle = lipgloss.NewStyle().40+ Foreground(lipgloss.Color("#00ff00")).41+ Bold(true).42+ Padding(1, 2).43+ Border(lipgloss.RoundedBorder()).44+ BorderForeground(lipgloss.Color("#00ff00"))45+)46+47+func renderView(m Model) string {48+ gridView := renderGrid(m)49+ wordListView := renderWordList(m)50+51+ // Join grid and word list horizontally with spacing52+ spacer := strings.Repeat(" ", 4)53+ mainView := lipgloss.JoinHorizontal(lipgloss.Top, gridView, spacer, wordListView)54+55+ // Add win message if won56+ if m.won {57+ winMsg := winMessageStyle.Render("🎉 Congratulations! You found all the words! 🎉")58+ return lipgloss.JoinVertical(lipgloss.Left, mainView, "\n", winMsg)59+ }60+61+ return mainView62+}63+64+func renderGrid(m Model) string {65+ var sb strings.Builder66+67+ for y := 0; y < m.height; y++ {68+ for x := 0; x < m.width; x++ {69+ letter := m.grid.Get(x, y)70+ style := getCellStyle(m, x, y)71+ sb.WriteString(style.Render(fmt.Sprintf(" %c ", letter)))72+ }73+ sb.WriteString("\n")74+ }75+76+ return sb.String()77+}78+79+func getCellStyle(m Model, x, y int) lipgloss.Style {80+ // Priority order: cursor > selection > found word > normal81+82+ // Check if cursor position83+ if m.cursor.x == x && m.cursor.y == y {84+ return cursorStyle85+ }86+87+ // Check if in current selection88+ if m.selection == startSelected && isInSelection(m, x, y) {89+ return selectionStyle90+ }91+92+ // Check if part of found word93+ for _, word := range m.words {94+ if word.Found && word.Contains(Position{X: x, Y: y}) {95+ return foundStyle96+ }97+ }98+99+ return normalStyle100+}101+102+func isInSelection(m Model, x, y int) bool {103+ // Check if (x, y) is on the line from selectionStart to cursor104+ start := m.selectionStart105+ end := m.cursor106+107+ // Calculate direction108+ dx := 0109+ dy := 0110+111+ if end.x > start.x {112+ dx = 1113+ } else if end.x < start.x {114+ dx = -1115+ }116+117+ if end.y > start.y {118+ dy = 1119+ } else if end.y < start.y {120+ dy = -1121+ }122+123+ // Verify it's a valid straight line before walking124+ distX := abs(end.x - start.x)125+ distY := abs(end.y - start.y)126+ if dx != 0 && dy != 0 && distX != distY {127+ return false // Not a valid diagonal, skip to avoid infinite loop128+ }129+130+ // Check all positions along the line131+ cx, cy := start.x, start.y132+ for {133+ if cx == x && cy == y {134+ return true135+ }136+137+ if cx == end.x && cy == end.y {138+ break139+ }140+141+ cx += dx142+ cy += dy143+ }144+145+ return false146+}147+148+func renderWordList(m Model) string {149+ var sb strings.Builder150+151+ sb.WriteString(wordListHeaderStyle.Render("Words to Find:"))152+ sb.WriteString("\n\n")153+154+ for _, word := range m.words {155+ if word.Found {156+ sb.WriteString(foundWordStyle.Render(fmt.Sprintf("✓ %s", word.Text)))157+ } else {158+ sb.WriteString(unfoundWordStyle.Render(fmt.Sprintf("○ %s", word.Text)))159+ }160+ sb.WriteString("\n")161+ }162+163+ sb.WriteString("\n")164+ sb.WriteString(fmt.Sprintf("Found: %d/%d", m.countFoundWords(), len(m.words)))165+166+ return sb.String()167+}@@ -0,0 +1,82 @@
1+package wordsearch2+3+// Direction represents one of 8 possible directions a word can be placed4+type Direction int5+6+const (7+ Right Direction = iota8+ Down9+ DownRight10+ DownLeft11+ Left12+ Up13+ UpRight14+ UpLeft15+)16+17+// Vector returns the dx, dy unit vector for a direction18+func (d Direction) Vector() (int, int) {19+ switch d {20+ case Right:21+ return 1, 022+ case Down:23+ return 0, 124+ case DownRight:25+ return 1, 126+ case DownLeft:27+ return -1, 128+ case Left:29+ return -1, 030+ case Up:31+ return 0, -132+ case UpRight:33+ return 1, -134+ case UpLeft:35+ return -1, -136+ default:37+ return 0, 038+ }39+}40+41+// Position represents a coordinate in the grid42+type Position struct {43+ X, Y int44+}45+46+// Word represents a hidden word in the puzzle47+type Word struct {48+ Text string49+ Start Position50+ End Position51+ Direction Direction52+ Found bool53+}54+55+// Contains checks if the given position is part of this word56+func (w *Word) Contains(pos Position) bool {57+ dx, dy := w.Direction.Vector()58+ x, y := w.Start.X, w.Start.Y59+60+ for i := 0; i < len(w.Text); i++ {61+ if x == pos.X && y == pos.Y {62+ return true63+ }64+ x += dx65+ y += dy66+ }67+ return false68+}69+70+// Positions returns all positions occupied by this word71+func (w *Word) Positions() []Position {72+ positions := make([]Position, 0, len(w.Text))73+ dx, dy := w.Direction.Vector()74+ x, y := w.Start.X, w.Start.Y75+76+ for i := 0; i < len(w.Text); i++ {77+ positions = append(positions, Position{X: x, Y: y})78+ x += dx79+ y += dy80+ }81+ return positions82+}