clone

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

feat: add takuzu with basic modes.

Dami committed 4c7d08e
commit
4c7d08e2948ccaca091db4f1ef31f5fc33030079
parent
400a626648e61f80d33409ddfe966c12e26cc000
author
Dami <dami.etoile@gmail.com>
10 files changed, +788 -0
Mmodel.go2+
Mresolve.go3+
Atakuzu/Export.go93++++++++++++++
Atakuzu/Gamemode.go48+++++++
Atakuzu/Model.go164+++++++++++++++++++++++++
Atakuzu/generator.go195++++++++++++++++++++++++++++++
Atakuzu/grid.go56+++++++++
Atakuzu/keys.go52++++++++
Atakuzu/style.go110+++++++++++++++++
Avhs/takuzu.tape65++++++++++
M model.go +2 -0
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 +48 -0
A takuzu/Model.go +164 -0
@@ -0,0 +1,164 @@
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+ size := m.size92+ half := size / 293+94+ // All cells must be filled.95+ for y := range size {96+ for x := range size {97+ if m.grid[y][x] == emptyCell {98+ return false99+ }100+ }101+ }102+103+ for i := range size {104+ zeroRow, oneRow := 0, 0105+ zeroCol, oneCol := 0, 0106+ for j := range size {107+ // Count in row.108+ switch m.grid[i][j] {109+ case zeroCell:110+ zeroRow++111+ case oneCell:112+ oneRow++113+ }114+ // Count in column.115+ switch m.grid[j][i] {116+ case zeroCell:117+ zeroCol++118+ case oneCell:119+ oneCol++120+ }121+122+ // Check no three consecutive in row.123+ if j >= 2 && m.grid[i][j] == m.grid[i][j-1] && m.grid[i][j] == m.grid[i][j-2] {124+ return false125+ }126+ // Check no three consecutive in column.127+ if j >= 2 && m.grid[j][i] == m.grid[j-1][i] && m.grid[j][i] == m.grid[j-2][i] {128+ return false129+ }130+ }131+ if zeroRow != half || oneRow != half {132+ return false133+ }134+ if zeroCol != half || oneCol != half {135+ return false136+ }137+ }138+139+ // Unique rows and columns.140+ return hasUniqueLines(m.grid, size)141+}142+143+func (m Model) GetDebugInfo() string {144+ status := "In Progress"145+ if m.solved {146+ status = "Solved"147+ }148+149+ filled := 0150+ for y := range m.size {151+ for x := range m.size {152+ if m.grid[y][x] != emptyCell {153+ filled++154+ }155+ }156+ }157+158+ return game.DebugHeader("Takuzu", [][2]string{159+ {"Status", status},160+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},161+ {"Grid Size", fmt.Sprintf("%d×%d", m.size, m.size)},162+ {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.size*m.size)},163+ })164+}
A takuzu/generator.go +195 -0
@@ -0,0 +1,195 @@
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+ return true85+}86+87+// generatePuzzle removes cells from a complete grid to create a puzzle with a unique solution.88+func generatePuzzle(complete grid, size int, prefilled float64) (puzzle grid, provided [][]bool) {89+ puzzle = complete.clone()90+ provided = make([][]bool, size)91+ for y := range size {92+ provided[y] = make([]bool, size)93+ for x := range size {94+ provided[y][x] = true95+ }96+ }97+98+ target := int(prefilled * float64(size*size))99+100+ // Shuffled positions.101+ type pos struct{ x, y int }102+ positions := make([]pos, 0, size*size)103+ for y := range size {104+ for x := range size {105+ positions = append(positions, pos{x, y})106+ }107+ }108+ rand.Shuffle(len(positions), func(i, j int) {109+ positions[i], positions[j] = positions[j], positions[i]110+ })111+112+ filled := size * size113+ for _, p := range positions {114+ if filled <= target {115+ break116+ }117+ saved := puzzle[p.y][p.x]118+ puzzle[p.y][p.x] = emptyCell119+ if countSolutions(puzzle, size, 2) != 1 {120+ puzzle[p.y][p.x] = saved121+ } else {122+ provided[p.y][p.x] = false123+ filled--124+ }125+ }126+127+ return puzzle, provided128+}129+130+// countSolutions counts solutions of the grid up to limit using backtracking.131+func countSolutions(g grid, size, limit int) int {132+ for y := range size {133+ for x := range size {134+ if g[y][x] != emptyCell {135+ continue136+ }137+ count := 0138+ for _, v := range [2]rune{zeroCell, oneCell} {139+ if canPlace(g, size, x, y, v) {140+ g[y][x] = v141+ count += countSolutions(g, size, limit-count)142+ g[y][x] = emptyCell143+ if count >= limit {144+ return count145+ }146+ }147+ }148+ return count149+ }150+ }151+ // All cells filled — check uniqueness constraints.152+ if hasUniqueLines(g, size) {153+ return 1154+ }155+ return 0156+}157+158+// hasUniqueLines checks that all rows are unique and all columns are unique.159+func hasUniqueLines(g grid, size int) bool {160+ // Check rows.161+ for i := range size {162+ for j := i + 1; j < size; j++ {163+ if rowEqual(g[i], g[j]) {164+ return false165+ }166+ }167+ }168+ // Check columns.169+ for i := range size {170+ for j := i + 1; j < size; j++ {171+ if colEqual(g, size, i, j) {172+ return false173+ }174+ }175+ }176+ return true177+}178+179+func rowEqual(a, b []rune) bool {180+ for i := range a {181+ if a[i] != b[i] {182+ return false183+ }184+ }185+ return true186+}187+188+func colEqual(g grid, size, c1, c2 int) bool {189+ for r := range size {190+ if g[r][c1] != g[r][c2] {191+ return false192+ }193+ }194+ return true195+}
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