clone

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

Merge pull request #13 from FelineStateMachine/feat/takuzu-implementation

FelineStateMachine committed 8a606da
feat: add Takuzu (Binairo) puzzle game
commit
8a606da1d7003e63c754272e15decaed493b2c6e
parent
400a626648e61f80d33409ddfe966c12e26cc000
parent
26e10585d1463720d39b38a5170ad842c42b4a0d
author
FelineStateMachine <dami.etoile@gmail.com>
12 files changed, +963 -21
MCLAUDE.md7+-
Mmodel.go48+++--
Mresolve.go3+
Atakuzu/Export.go93+++++++++
Atakuzu/Gamemode.go55+++++
Atakuzu/Model.go123+++++++++++
Atakuzu/README.md50+++++
Atakuzu/generator.go322++++++++++++++++++++++++++++++
Atakuzu/grid.go56+++++
Atakuzu/keys.go52+++++
Atakuzu/style.go110++++++++++
Avhs/takuzu.tape65++++++
M CLAUDE.md +6 -1
@@ -91,7 +91,11 @@ Save/load uses `game.Registry` — each package registers its import function vi
9191 4. Export a `Modes` variable (`[]list.Item`) listing available difficulties9292 5. Implement `game.Gamer` on a `Model` struct9393 6. Register the import function in an `init()`: `game.Register("My Puzzle", func(data []byte) (game.Gamer, error) { ... })`94-7. Add the category to `GameCategories` in `model.go`94+7. Add the category to `GameCategories` in `model.go` (maintain alphabetical order)95+8. Add aliases to `categoryAliases` in `resolve.go`96+9. Create a `README.md` in the package directory (see existing games for format)97+10. Create a VHS tape file in `vhs/<game>.tape` (see VHS Tape Files section)98+11. Add a one-line entry to "Current Implementations" below9599 96100 Each package follows a consistent file structure:97101 - `Gamemode.go`: Mode struct, `NewMode()`, `Spawn()`, `Modes` var, `init()` with `game.Register()`
@@ -109,6 +113,7 @@ Each package follows a consistent file structure:
109113 - **Sudoku** (`sudoku/`): Classic 9x9 with configurable provided cells. Uses `cell` type with provided/user-entered distinction.110114 - **Hashiwokakero** (`hashiwokakero/`): Connect islands with bridges (1 or 2). Navigation mode and bridge mode. Victory requires all islands satisfied and connected.111115 - **Lights Out** (`lightsout/`): Toggle lights in a cross pattern to turn all off. Grid sizes from 3x3 to 9x9.116+- **Takuzu** (`takuzu/`): Fill grid with two symbols (● and ○). No triples, equal counts, unique lines. Grid sizes from 6x6 to 14x14.112117 113118 ### Supporting Packages114119
M model.go +28 -20
@@ -13,6 +13,7 @@ import (
1313 "github.com/FelineStateMachine/puzzletea/nonogram"1414 "github.com/FelineStateMachine/puzzletea/store"1515 "github.com/FelineStateMachine/puzzletea/sudoku"16+ "github.com/FelineStateMachine/puzzletea/takuzu"1617 "github.com/FelineStateMachine/puzzletea/wordsearch"1718 "github.com/charmbracelet/bubbles/list"1819 "github.com/charmbracelet/bubbles/table"
@@ -38,6 +39,7 @@ var (
3839 game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes},3940 game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes},4041 game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes},42+ game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes},4143 game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes},4244 }4345 )
@@ -79,6 +81,9 @@ type model struct {
7981 mode game.Mode8082 game game.Gamer8183 84+ width int // available content width (terminal - rootStyle frame)85+ height int // available content height (terminal - rootStyle frame)86+8287 debug bool8388 debugRenderer *glamour.TermRenderer8489 debuginfo string
@@ -131,10 +136,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131136 case tea.WindowSizeMsg:132137 h, v := rootStyle.GetFrameSize()133138 w, ht := msg.Width-h, msg.Height-v134- m.mainMenuList.SetSize(w, ht)135- m.gameSelectList.SetSize(w, ht)139+ m.width = w140+ m.height = ht141+ menuW, menuH := min(w, 64), min(ht, 24)142+ m.mainMenuList.SetSize(menuW, menuH)143+ m.gameSelectList.SetSize(menuW, menuH)136144 if m.state == modeSelectView {137- m.modeSelectList.SetSize(w, ht)145+ m.modeSelectList.SetSize(menuW, menuH)138146 }139147 if m.state == continueView {140148 m.continueTable.SetWidth(w)
@@ -302,26 +310,27 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
302310 }303311 304312 func (m model) View() string {305- var s string306-307313 switch m.state {308314 case mainMenuView:309- return rootStyle.Render(m.mainMenuList.View())315+ return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.mainMenuList.View()))310316 case gameSelectView:311- return rootStyle.Render(m.gameSelectList.View())317+ return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.gameSelectList.View()))312318 case modeSelectView:313- return rootStyle.Render(m.modeSelectList.View())319+ return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.modeSelectList.View()))314320 case continueView:321+ var s string315322 if len(m.continueGames) == 0 {316- return rootStyle.Render("No saved games yet.\n\nPress Escape to return.")323+ s = "No saved games yet.\n\nPress Escape to return."324+ } else {325+ title := lipgloss.NewStyle().326+ Bold(true).327+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).328+ Background(menuAccent).329+ Padding(0, 1).330+ Render("Saved Games")331+ s = lipgloss.JoinVertical(lipgloss.Left, title, "", m.continueTable.View())317332 }318- title := lipgloss.NewStyle().319- Bold(true).320- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).321- Background(menuAccent).322- Padding(0, 1).323- Render("Saved Games")324- return rootStyle.Render(lipgloss.JoinVertical(lipgloss.Left, title, "", m.continueTable.View()))333+ return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))325334 case gameView:326335 if m.game == nil {327336 return ""
@@ -330,16 +339,15 @@ func (m model) View() string {
330339 if m.debug {331340 debugInfo = debugStyle.Render(m.debuginfo)332341 }333- s = lipgloss.JoinVertical(lipgloss.Left,342+ s := lipgloss.JoinVertical(lipgloss.Left,334343 m.game.View(),335344 debugInfo,336345 )346+ return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))337347 default:338- s = fmt.Sprintf("unknown state: %d", m.state)348+ s := fmt.Sprintf("unknown state: %d", m.state)339349 panic(s)340350 }341-342- return rootStyle.Render(s)343351 }344352 345353 func initList(items []list.Item, title string) list.Model {
M resolve.go +3 -0
A takuzu/Export.go +93 -0
@@ -0,0 +1,93 @@
1+package takuzu2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+// Save represents the serialized state of a Takuzu game.12+type Save struct {13+ Size int `json:"size"`14+ State string `json:"state"`15+ Provided string `json:"provided"`16+ ModeTitle string `json:"mode_title"`17+ Solved bool `json:"solved"`18+}19+20+func (m Model) GetSave() ([]byte, error) {21+ save := Save{22+ Size: m.size,23+ State: m.grid.String(),24+ Provided: serializeProvided(m.provided),25+ ModeTitle: m.modeTitle,26+ Solved: m.solved,27+ }28+ data, err := json.Marshal(save)29+ if err != nil {30+ return nil, fmt.Errorf("failed to marshal takuzu save: %w", err)31+ }32+ return data, nil33+}34+35+// ImportModel reconstructs a Model from saved JSON data.36+func ImportModel(data []byte) (*Model, error) {37+ var s Save38+ if err := json.Unmarshal(data, &s); err != nil {39+ return nil, fmt.Errorf("failed to unmarshal takuzu save: %w", err)40+ }41+ if s.Size <= 0 {42+ return nil, fmt.Errorf("invalid grid size in save: %d", s.Size)43+ }44+45+ g := newGrid(state(s.State))46+ provided := deserializeProvided(s.Provided, s.Size)47+48+ m := &Model{49+ size: s.Size,50+ grid: g,51+ provided: provided,52+ cursor: game.Cursor{X: 0, Y: 0},53+ keys: DefaultKeyMap,54+ modeTitle: s.ModeTitle,55+ }56+ m.solved = m.checkSolved()57+ return m, nil58+}59+60+// serializeProvided encodes the provided matrix as a string of '.' (not provided) and '#' (provided).61+func serializeProvided(p [][]bool) string {62+ var b strings.Builder63+ for y, row := range p {64+ for _, v := range row {65+ if v {66+ b.WriteByte('#')67+ } else {68+ b.WriteByte('.')69+ }70+ }71+ if y < len(p)-1 {72+ b.WriteByte('\n')73+ }74+ }75+ return b.String()76+}77+78+// deserializeProvided decodes a provided-matrix string back into [][]bool.79+func deserializeProvided(s string, size int) [][]bool {80+ rows := strings.Split(s, "\n")81+ p := make([][]bool, size)82+ for y := range size {83+ p[y] = make([]bool, size)84+ if y < len(rows) {85+ for x := range size {86+ if x < len(rows[y]) && rows[y][x] == '#' {87+ p[y][x] = true88+ }89+ }90+ }91+ }92+ return p93+}
A takuzu/Gamemode.go +55 -0
@@ -0,0 +1,55 @@
1+package takuzu2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+ "github.com/charmbracelet/bubbles/list"6+)7+8+func init() {9+ game.Register("Takuzu", func(data []byte) (game.Gamer, error) {10+ return ImportModel(data)11+ })12+}13+14+// TakuzuMode represents a specific difficulty configuration for Takuzu.15+type TakuzuMode struct {16+ game.BaseMode17+ Size int18+ Prefilled float6419+}20+21+var (22+ _ game.Mode = TakuzuMode{}23+ _ game.Spawner = TakuzuMode{}24+)25+26+// NewMode creates a new Takuzu game mode.27+func NewMode(title, desc string, size int, prefilled float64) TakuzuMode {28+ return TakuzuMode{29+ BaseMode: game.NewBaseMode(title, desc),30+ Size: size,31+ Prefilled: prefilled,32+ }33+}34+35+// Spawn creates a new game instance for this mode.36+func (t TakuzuMode) Spawn() (game.Gamer, error) {37+ complete := generateComplete(t.Size)38+ puzzle, provided := generatePuzzle(complete, t.Size, t.Prefilled)39+ return New(t, puzzle, provided)40+}41+42+// Modes defines the available difficulty levels.43+// Difficulty is controlled by two axes: grid size and clue density.44+// Smaller grids with more clues need only basic pattern recognition (doubles,45+// sandwich patterns). Larger grids with fewer clues require counting, uniqueness46+// elimination, and deeper chains of deduction.47+var Modes = []list.Item{48+ NewMode("Beginner", "6×6 grid, ~50% clues. Doubles and sandwich patterns.", 6, 0.50),49+ NewMode("Easy", "6×6 grid, ~40% clues. Counting required.", 6, 0.40),50+ NewMode("Medium", "8×8 grid, ~40% clues. Larger grid, moderate deduction.", 8, 0.40),51+ NewMode("Tricky", "10×10 grid, ~38% clues. Uniqueness rule needed.", 10, 0.38),52+ NewMode("Hard", "10×10 grid, ~32% clues. Long deduction chains.", 10, 0.32),53+ NewMode("Very Hard", "12×12 grid, ~30% clues. Deep logic required.", 12, 0.30),54+ NewMode("Extreme", "14×14 grid, ~28% clues. Maximum challenge.", 14, 0.28),55+}
A takuzu/Model.go +123 -0
@@ -0,0 +1,123 @@
1+// Package takuzu implements the binary (Binairo) puzzle game.2+package takuzu3+4+import (5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/charmbracelet/bubbles/key"9+ tea "github.com/charmbracelet/bubbletea"10+ "github.com/charmbracelet/lipgloss"11+)12+13+// Model implements game.Gamer for Takuzu.14+type Model struct {15+ size int16+ grid grid17+ provided [][]bool18+ cursor game.Cursor19+ solved bool20+ keys KeyMap21+ modeTitle string22+ showFullHelp bool23+}24+25+var _ game.Gamer = Model{}26+27+// New creates a new Takuzu game model.28+func New(mode TakuzuMode, puzzle grid, provided [][]bool) (game.Gamer, error) {29+ m := Model{30+ size: mode.Size,31+ grid: puzzle,32+ provided: provided,33+ cursor: game.Cursor{X: 0, Y: 0},34+ keys: DefaultKeyMap,35+ modeTitle: mode.Title(),36+ }37+ m.solved = m.checkSolved()38+ return m, nil39+}40+41+func (m Model) Init() tea.Cmd {42+ return nil43+}44+45+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {46+ switch msg := msg.(type) {47+ case game.HelpToggleMsg:48+ m.showFullHelp = msg.Show49+ case tea.KeyMsg:50+ switch {51+ case key.Matches(msg, m.keys.PlaceZero):52+ if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {53+ m.grid[m.cursor.Y][m.cursor.X] = zeroCell54+ m.solved = m.checkSolved()55+ }56+ case key.Matches(msg, m.keys.PlaceOne):57+ if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {58+ m.grid[m.cursor.Y][m.cursor.X] = oneCell59+ m.solved = m.checkSolved()60+ }61+ case key.Matches(msg, m.keys.Clear):62+ if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {63+ m.grid[m.cursor.Y][m.cursor.X] = emptyCell64+ m.solved = m.checkSolved()65+ }66+ default:67+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.size-1, m.size-1)68+ }69+ }70+ m.updateKeyBindings()71+ return m, nil72+}73+74+func (m Model) View() string {75+ title := game.TitleBarView("Takuzu", m.modeTitle, m.solved)76+ grid := gridView(m.grid, m.provided, m.cursor, m.solved)77+ status := statusBarView(m.showFullHelp)78+ return lipgloss.JoinVertical(lipgloss.Left, title, grid, status)79+}80+81+func (m Model) SetTitle(t string) game.Gamer {82+ m.modeTitle = t83+ return m84+}85+86+func (m Model) IsSolved() bool {87+ return m.solved88+}89+90+func (m Model) checkSolved() bool {91+ // All cells must be filled.92+ for y := range m.size {93+ for x := range m.size {94+ if m.grid[y][x] == emptyCell {95+ return false96+ }97+ }98+ }99+ return checkConstraints(m.grid, m.size) && hasUniqueLines(m.grid, m.size)100+}101+102+func (m Model) GetDebugInfo() string {103+ status := "In Progress"104+ if m.solved {105+ status = "Solved"106+ }107+108+ filled := 0109+ for y := range m.size {110+ for x := range m.size {111+ if m.grid[y][x] != emptyCell {112+ filled++113+ }114+ }115+ }116+117+ return game.DebugHeader("Takuzu", [][2]string{118+ {"Status", status},119+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},120+ {"Grid Size", fmt.Sprintf("%d×%d", m.size, m.size)},121+ {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.size*m.size)},122+ })123+}
A takuzu/README.md +50 -0
@@ -0,0 +1,50 @@
1+# Takuzu2+3+Fill the grid with two symbols following three simple rules.4+5+![Takuzu gameplay](../vhs/takuzu.gif)6+7+## How to Play8+9+Fill every empty cell with either a filled circle (●) or an open circle (○)10+so that the grid satisfies three rules:11+12+1. **No triple**: No three consecutive identical symbols in any row or column.13+2. **Equal count**: Each row and column must contain exactly N/2 of each symbol.14+3. **Unique lines**: No two rows may be identical; no two columns may be identical.15+16+Pre-filled cells (shown in bold) cannot be changed. The puzzle is solved when17+every cell is filled and all three rules are satisfied.18+19+## Controls20+21+| Key | Action |22+|-----|--------|23+| Arrow keys / WASD / hjkl | Move cursor |24+| `z` / `0` | Place ● |25+| `x` / `1` | Place ○ |26+| `Backspace` | Clear cell |27+| `Ctrl+H` | Toggle full help |28+| `Ctrl+E` | Toggle debug overlay |29+| `Ctrl+N` | Return to main menu |30+31+## Modes32+33+| Mode | Grid | Clues | Techniques Required |34+|------|------|-------|---------------------|35+| Beginner | 6x6 | ~50% | Doubles and sandwich patterns |36+| Easy | 6x6 | ~40% | Counting required |37+| Medium | 8x8 | ~40% | Moderate deduction |38+| Tricky | 10x10 | ~38% | Uniqueness rule needed |39+| Hard | 10x10 | ~32% | Long deduction chains |40+| Very Hard | 12x12 | ~30% | Deep logic required |41+| Extreme | 14x14 | ~28% | Maximum challenge |42+43+## Quick Start44+45+```bash46+puzzletea new takuzu beginner47+puzzletea new takuzu medium48+puzzletea new takuzu extreme49+puzzletea new binairo hard # alias50+```
A takuzu/generator.go +322 -0
@@ -0,0 +1,322 @@
1+package takuzu2+3+import "math/rand/v2"4+5+// generateComplete fills an empty grid with a valid Takuzu solution using backtracking.6+func generateComplete(size int) grid {7+ g := newGrid(createEmptyState(size))8+ fillGrid(g, size)9+ return g10+}11+12+func fillGrid(g grid, size int) bool {13+ for y := range size {14+ for x := range size {15+ if g[y][x] != emptyCell {16+ continue17+ }18+ vals := [2]rune{zeroCell, oneCell}19+ if rand.IntN(2) == 0 {20+ vals[0], vals[1] = vals[1], vals[0]21+ }22+ for _, v := range vals {23+ if canPlace(g, size, x, y, v) {24+ g[y][x] = v25+ if fillGrid(g, size) {26+ return true27+ }28+ g[y][x] = emptyCell29+ }30+ }31+ return false32+ }33+ }34+ return true35+}36+37+// canPlace checks whether placing val at (x,y) would violate Takuzu constraints.38+func canPlace(g grid, size, x, y int, val rune) bool {39+ // Check no three consecutive in row.40+ if x >= 2 && g[y][x-1] == val && g[y][x-2] == val {41+ return false42+ }43+ if x >= 1 && x < size-1 && g[y][x-1] == val && g[y][x+1] == val {44+ return false45+ }46+ if x <= size-3 && g[y][x+1] == val && g[y][x+2] == val {47+ return false48+ }49+50+ // Check no three consecutive in column.51+ if y >= 2 && g[y-1][x] == val && g[y-2][x] == val {52+ return false53+ }54+ if y >= 1 && y < size-1 && g[y-1][x] == val && g[y+1][x] == val {55+ return false56+ }57+ if y <= size-3 && g[y+1][x] == val && g[y+2][x] == val {58+ return false59+ }60+61+ // Check count in row doesn't exceed size/2.62+ half := size / 263+ count := 064+ for _, c := range g[y] {65+ if c == val {66+ count++67+ }68+ }69+ if count >= half {70+ return false71+ }72+73+ // Check count in column doesn't exceed size/2.74+ count = 075+ for r := range size {76+ if g[r][x] == val {77+ count++78+ }79+ }80+ if count >= half {81+ return false82+ }83+84+ // If this placement would complete the row, check uniqueness against other complete rows.85+ // The cell at (x,y) is empty, so the row is complete iff every other cell is filled.86+ if rowFilledExcept(g, y, x, size) {87+ for other := range size {88+ if other != y && rowFilled(g, other, size) && rowEqualWith(g[y], g[other], x, val) {89+ return false90+ }91+ }92+ }93+94+ // If this placement would complete the column, check uniqueness against other complete columns.95+ if colFilledExcept(g, x, y, size) {96+ for other := range size {97+ if other != x && colFilled(g, other, size) && colEqualWith(g, size, x, other, y, val) {98+ return false99+ }100+ }101+ }102+103+ return true104+}105+106+// rowFilledExcept returns true if every cell in row y is filled except column skip.107+func rowFilledExcept(g grid, y, skip, size int) bool {108+ for x := range size {109+ if x != skip && g[y][x] == emptyCell {110+ return false111+ }112+ }113+ return true114+}115+116+// colFilledExcept returns true if every cell in column x is filled except row skip.117+func colFilledExcept(g grid, x, skip, size int) bool {118+ for y := range size {119+ if y != skip && g[y][x] == emptyCell {120+ return false121+ }122+ }123+ return true124+}125+126+// rowEqualWith compares rows a and b, treating a[overrideIdx] as overrideVal.127+func rowEqualWith(a, b []rune, overrideIdx int, overrideVal rune) bool {128+ if len(a) != len(b) {129+ return false130+ }131+ for i := range a {132+ av := a[i]133+ if i == overrideIdx {134+ av = overrideVal135+ }136+ if av != b[i] {137+ return false138+ }139+ }140+ return true141+}142+143+// colEqualWith compares columns c1 and c2, treating g[overrideRow][c1] as overrideVal.144+func colEqualWith(g grid, size, c1, c2, overrideRow int, overrideVal rune) bool {145+ for r := range size {146+ v1 := g[r][c1]147+ if r == overrideRow {148+ v1 = overrideVal149+ }150+ if v1 != g[r][c2] {151+ return false152+ }153+ }154+ return true155+}156+157+func rowFilled(g grid, y, size int) bool {158+ for x := range size {159+ if g[y][x] == emptyCell {160+ return false161+ }162+ }163+ return true164+}165+166+func colFilled(g grid, x, size int) bool {167+ for y := range size {168+ if g[y][x] == emptyCell {169+ return false170+ }171+ }172+ return true173+}174+175+// generatePuzzle removes cells from a complete grid to create a puzzle with a unique solution.176+func generatePuzzle(complete grid, size int, prefilled float64) (puzzle grid, provided [][]bool) {177+ puzzle = complete.clone()178+ provided = make([][]bool, size)179+ for y := range size {180+ provided[y] = make([]bool, size)181+ for x := range size {182+ provided[y][x] = true183+ }184+ }185+186+ target := int(prefilled * float64(size*size))187+188+ // Shuffled positions.189+ type pos struct{ x, y int }190+ positions := make([]pos, 0, size*size)191+ for y := range size {192+ for x := range size {193+ positions = append(positions, pos{x, y})194+ }195+ }196+ rand.Shuffle(len(positions), func(i, j int) {197+ positions[i], positions[j] = positions[j], positions[i]198+ })199+200+ filled := size * size201+ for _, p := range positions {202+ if filled <= target {203+ break204+ }205+ saved := puzzle[p.y][p.x]206+ puzzle[p.y][p.x] = emptyCell207+ if countSolutions(puzzle, size, 2) != 1 {208+ puzzle[p.y][p.x] = saved209+ } else {210+ provided[p.y][p.x] = false211+ filled--212+ }213+ }214+215+ return puzzle, provided216+}217+218+// countSolutions counts solutions of the grid up to limit using backtracking.219+func countSolutions(g grid, size, limit int) int {220+ for y := range size {221+ for x := range size {222+ if g[y][x] != emptyCell {223+ continue224+ }225+ count := 0226+ for _, v := range [2]rune{zeroCell, oneCell} {227+ if canPlace(g, size, x, y, v) {228+ g[y][x] = v229+ count += countSolutions(g, size, limit-count)230+ g[y][x] = emptyCell231+ if count >= limit {232+ return count233+ }234+ }235+ }236+ return count237+ }238+ }239+ // All cells filled — check uniqueness constraints.240+ if hasUniqueLines(g, size) {241+ return 1242+ }243+ return 0244+}245+246+// checkConstraints verifies no-triples and equal-count rules for every row and column.247+func checkConstraints(g grid, size int) bool {248+ half := size / 2249+ for i := range size {250+ zeroRow, oneRow := 0, 0251+ zeroCol, oneCol := 0, 0252+ for j := range size {253+ switch g[i][j] {254+ case zeroCell:255+ zeroRow++256+ case oneCell:257+ oneRow++258+ }259+ switch g[j][i] {260+ case zeroCell:261+ zeroCol++262+ case oneCell:263+ oneCol++264+ }265+ if j >= 2 && g[i][j] == g[i][j-1] && g[i][j] == g[i][j-2] {266+ return false267+ }268+ if j >= 2 && g[j][i] == g[j-1][i] && g[j][i] == g[j-2][i] {269+ return false270+ }271+ }272+ if zeroRow != half || oneRow != half {273+ return false274+ }275+ if zeroCol != half || oneCol != half {276+ return false277+ }278+ }279+ return true280+}281+282+// hasUniqueLines checks that all rows are unique and all columns are unique.283+func hasUniqueLines(g grid, size int) bool {284+ // Check rows.285+ for i := range size {286+ for j := i + 1; j < size; j++ {287+ if rowEqual(g[i], g[j]) {288+ return false289+ }290+ }291+ }292+ // Check columns.293+ for i := range size {294+ for j := i + 1; j < size; j++ {295+ if colEqual(g, size, i, j) {296+ return false297+ }298+ }299+ }300+ return true301+}302+303+func rowEqual(a, b []rune) bool {304+ if len(a) != len(b) {305+ return false306+ }307+ for i := range a {308+ if a[i] != b[i] {309+ return false310+ }311+ }312+ return true313+}314+315+func colEqual(g grid, size, c1, c2 int) bool {316+ for r := range size {317+ if g[r][c1] != g[r][c2] {318+ return false319+ }320+ }321+ return true322+}
A takuzu/grid.go +56 -0
A takuzu/keys.go +52 -0
A takuzu/style.go +110 -0
@@ -0,0 +1,110 @@
1+package takuzu2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+ "github.com/charmbracelet/lipgloss"6+)7+8+var (9+ baseStyle = lipgloss.NewStyle()10+11+ zeroStyle = baseStyle.12+ Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "222"}).13+ Background(lipgloss.AdaptiveColor{Light: "254", Dark: "235"})14+15+ oneStyle = baseStyle.16+ Foreground(lipgloss.AdaptiveColor{Light: "68", Dark: "111"}).17+ Background(lipgloss.AdaptiveColor{Light: "254", Dark: "235"})18+19+ emptyStyle = baseStyle.20+ Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"}).21+ Background(lipgloss.AdaptiveColor{Light: "254", Dark: "235"})22+23+ cursorStyle = baseStyle.24+ Bold(true).25+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).26+ Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})27+28+ crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}29+30+ gridBorderStyle = lipgloss.NewStyle().31+ Border(lipgloss.RoundedBorder()).32+ BorderForeground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"})33+34+ gridBorderSolvedStyle = lipgloss.NewStyle().35+ Border(lipgloss.RoundedBorder()).36+ BorderForeground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"})37+38+ statusBarStyle = lipgloss.NewStyle().39+ Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).40+ MarginTop(1)41+42+ cellWidth = 343+44+ renderStyleMap = map[rune]lipgloss.Style{45+ zeroCell: zeroStyle,46+ oneCell: oneStyle,47+ emptyCell: emptyStyle,48+ }49+50+ renderRuneMap = map[rune]string{51+ zeroCell: " ● ",52+ oneCell: " ○ ",53+ emptyCell: " · ",54+ }55+)56+57+func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string {58+ s, ok := renderStyleMap[val]59+ if !ok {60+ s = emptyStyle61+ }62+63+ if isProvided && val != emptyCell {64+ s = s.Bold(true)65+ }66+67+ if isCursor && !solved {68+ s = cursorStyle69+ } else if !solved && (inCursorRow || inCursorCol) {70+ s = s.Background(crosshairBG)71+ }72+73+ if solved {74+ s = s.Background(lipgloss.AdaptiveColor{Light: "151", Dark: "22"})75+ }76+77+ r, ok := renderRuneMap[val]78+ if !ok {79+ r = renderRuneMap[emptyCell]80+ }81+ return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)82+}83+84+func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {85+ var rows []string86+ for y, row := range g {87+ var rowCells []string88+ for x, val := range row {89+ isCursor := x == c.X && y == c.Y90+ inCursorRow := y == c.Y91+ inCursorCol := x == c.X92+ cell := cellView(val, provided[y][x], isCursor, inCursorRow, inCursorCol, solved)93+ rowCells = append(rowCells, cell)94+ }95+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))96+ }97+ content := lipgloss.JoinVertical(lipgloss.Left, rows...)98+99+ if solved {100+ return gridBorderSolvedStyle.Render(content)101+ }102+ return gridBorderStyle.Render(content)103+}104+105+func statusBarView(showFullHelp bool) string {106+ if showFullHelp {107+ return statusBarStyle.Render("arrows/wasd: move z: ● x: ○ bkspc: clear ctrl+n: menu ctrl+h: help")108+ }109+ return statusBarStyle.Render("z: ● x: ○ bkspc: clear")110+}
A vhs/takuzu.tape +65 -0