clone

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

add wordsearch

Dami committed 21eba3b
commit
21eba3b80aa86887ac4e804166619a6830450408
parent
ad89329d2ea22ce05bb19605189e51b4d8fe1a30
author
Dami <dami@Damis-MacBook-Air.local>
11 files changed, +866 -0
Mcore.go4+
Mgo.sum2+
Mmodel.go5+
Awordsearch/Export.go52++++++
Awordsearch/Gamemode.go41+++++
Awordsearch/Model.go250++++++++++++++++++++++++++++++
Awordsearch/generator.go135++++++++++++++++
Awordsearch/grid.go66++++++++
Awordsearch/keys.go62+++++++
Awordsearch/style.go167++++++++++++++++++++
Awordsearch/word.go82++++++++++
M core.go +4 -0
M go.sum +2 -0
M model.go +5 -0
A wordsearch/Export.go +52 -0
A wordsearch/Gamemode.go +41 -0
A wordsearch/Model.go +250 -0
@@ -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+}
A wordsearch/generator.go +135 -0
@@ -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+}
A wordsearch/grid.go +66 -0
A wordsearch/keys.go +62 -0
A wordsearch/style.go +167 -0
@@ -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+}
A wordsearch/word.go +82 -0
@@ -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+}