Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic c

## Features

- **12 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+
- **13 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+
- **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.
- **Weekly gauntlet** -- Each ISO calendar week has a shared 99-puzzle ladder. The current week unlocks sequentially from `#01` to `#99`; past weeks can be reviewed from completed saves only.
- **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP.
- **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category.
- **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.
- **Mouse support** -- Click and drag in Nonogram, Nurikabe, Shikaku, and Word Search. Lights Out supports click-to-toggle.
- **Mouse support** -- Click and drag in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search. Lights Out supports click-to-toggle.
- **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.
- **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name.

Expand All @@ -28,6 +28,7 @@ Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic c
| **Ripple Effect** | Place digits in cages without violating ripple distance | Mini 5x5 through Expert 9x9 |
| **Shikaku** | Divide grid into rectangles matching cell counts | 5 modes from 7x7 to 11x11 |
| **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical |
| **Spell Puzzle** | Connect letters to reveal crossword words and bonus anagrams | Beginner, Easy, Medium, Hard |
| **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) |
| **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids |
| **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |
Expand Down Expand Up @@ -102,6 +103,7 @@ puzzletea new nonogram medium
puzzletea new fillomino "Hard 10x10"
puzzletea new ripple-effect "Medium 7x7"
puzzletea new sudoku hard
puzzletea new spell beginner
puzzletea new lights-out
puzzletea new hashi easy
```
Expand Down Expand Up @@ -171,7 +173,7 @@ puzzletea --continue amber-falcon

### CLI Aliases

Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `binairo`/`binary` for Takuzu, `binario+` for Takuzu+, `words`/`ws` for Word Search, `rectangles` for Shikaku.
Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `binairo`/`binary` for Takuzu, `binario+` for Takuzu+, `words`/`ws` for Word Search, `rectangles` for Shikaku.

## Controls

Expand All @@ -192,7 +194,7 @@ Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement acro

### Mouse

Nonogram, Nurikabe, Shikaku, and Word Search support click and drag. Lights Out supports click to toggle. See each game's help for details.
Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support click and drag. Lights Out supports click to toggle. See each game's help for details.

## Game Persistence

Expand Down Expand Up @@ -244,13 +246,25 @@ Classic 9x9 number placement puzzle.

[Game details and controls](sudoku/README.md)

### Sudoku RGB
Fill the grid with three symbols so every row, column, and box contains `{1,1,1,2,2,2,3,3,3}`.

![Sudoku RGB](vhs/sudokurgb.gif)

[Game details and controls](sudokurgb/README.md)

### Word Search
Find hidden words in a letter grid.

![Word Search](vhs/wordsearch.gif)

[Game details and controls](wordsearch/README.md)

### Spell Puzzle
Connect letters from a fixed bank to reveal a crossword and score bonus words.

[Game details and controls](spellpuzzle/README.md)

### Hashiwokakero
Connect islands with bridges.

Expand Down
1 change: 1 addition & 0 deletions architecture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {
"github.com/FelineStateMachine/puzzletea/nurikabe",
"github.com/FelineStateMachine/puzzletea/rippleeffect",
"github.com/FelineStateMachine/puzzletea/shikaku",
"github.com/FelineStateMachine/puzzletea/spellpuzzle",
"github.com/FelineStateMachine/puzzletea/sudoku",
"github.com/FelineStateMachine/puzzletea/sudokurgb",
"github.com/FelineStateMachine/puzzletea/takuzu",
Expand Down
1 change: 1 addition & 0 deletions catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
{input: "hashi", want: "Hashiwokakero"},
{input: "lights", want: "Lights Out"},
{input: "ripple", want: "Ripple Effect"},
{input: "spell", want: "Spell Puzzle"},
{input: "wordsearch", want: "Word Search"},
}

Expand Down
43 changes: 43 additions & 0 deletions fillomino/fillomino_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ func TestCellViewConflictedCursorIsDistinct(t *testing.T) {
}
}

func TestCellViewUsesGivenTintForProvidedDigits(t *testing.T) {
p := theme.Current()
text := lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("3")

got := cellView(3, true, false, false, false, false, false, false, nil)
want := lipgloss.NewStyle().
Bold(true).
Foreground(p.FG).
Background(theme.GivenTint(p.BG)).
Width(cellWidth).
AlignHorizontal(lipgloss.Center).
Render(text)
if got != want {
t.Fatalf("provided cellView() = %q, want %q", got, want)
}
}

func TestGridViewRendersRegionBoundaries(t *testing.T) {
m := Model{
width: 3,
Expand Down Expand Up @@ -437,6 +454,32 @@ func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {
}
}

func TestBuildRenderGridStateColorsIncompleteRegions(t *testing.T) {
base := grid{
{3, 3},
{0, 1},
}
m := Model{
width: 2,
height: 2,
grid: base,
provided: newProvidedMask(base),
conflicts: validateGridState(base).conflicts,
}

renderState := buildRenderGridState(m)
incompleteZone := renderState.zones[0][0]
if got := renderState.zones[0][1]; got != incompleteZone {
t.Fatalf("connected incomplete cells should share a zone: got %d and %d", incompleteZone, got)
}
if renderState.zoneFill[incompleteZone] == nil {
t.Fatal("expected incomplete zone to receive a background color")
}
if got := renderState.zoneFill[renderState.zones[1][0]]; got != nil {
t.Fatal("expected empty zone to remain transparent")
}
}

func TestCursorRegionInfoViewNumberedCell(t *testing.T) {
m := Model{
width: 3,
Expand Down
56 changes: 41 additions & 15 deletions fillomino/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const cellWidth = game.DynamicGridCellWidth
type renderGridState struct {
zones [][]int
activeZone int
completed map[int]color.Color
zoneFill map[int]color.Color
}

func cellBaseStyle() lipgloss.Style {
Expand All @@ -34,7 +34,7 @@ func emptyCellStyle() lipgloss.Style {
func cellView(
value int,
provided, cursor, rowHighlight, colHighlight, regionHighlight, solved, conflict bool,
completedBG color.Color,
zoneBG color.Color,
) string {
p := theme.Current()
style := cellBaseStyle()
Expand All @@ -56,14 +56,27 @@ func cellView(
style = style.Foreground(game.SolvedFG()).Background(p.SuccessBG)
} else if cursor {
style = game.CursorStyle()
} else if completedBG != nil {
style = style.Background(completedBG).Foreground(theme.TextOnBG(completedBG))
} else if regionHighlight {
style = style.Background(p.HighlightBG)
} else if zoneBG != nil {
style = style.Background(zoneBG).Foreground(theme.TextOnBG(zoneBG))
} else if rowHighlight || colHighlight {
style = style.Background(p.Surface)
}

if provided && value != 0 && !conflict && !solved && !cursor {
bg := p.BG
switch {
case regionHighlight:
bg = p.HighlightBG
case zoneBG != nil:
bg = zoneBG
case rowHighlight || colHighlight:
bg = p.Surface
}
style = style.Background(theme.GivenTint(bg))
}

if cursor {
if value == 0 {
text = game.CursorLeft + "·" + game.CursorRight
Expand Down Expand Up @@ -91,7 +104,7 @@ func gridView(m Model) string {
Solved: m.solved,
Cell: func(x, y int) string {
zone := renderState.zones[y][x]
completedBG := renderState.completed[zone]
zoneBG := renderState.zoneFill[zone]
return cellView(
m.grid[y][x],
m.provided[y][x],
Expand All @@ -101,7 +114,7 @@ func gridView(m Model) string {
renderState.activeZone >= 0 && zone == renderState.activeZone,
m.solved,
m.conflicts[y][x],
completedBG,
zoneBG,
)
},
ZoneAt: func(x, y int) int {
Expand All @@ -112,12 +125,10 @@ func gridView(m Model) string {
switch {
case m.solved:
return p.SuccessBG
case renderState.completed[zone] != nil:
return renderState.completed[zone]
case renderState.activeZone >= 0 && zone == renderState.activeZone:
return p.HighlightBG
default:
return nil
return renderState.zoneFill[zone]
}
},
BridgeFill: func(bridge game.DynamicGridBridge) color.Color {
Expand All @@ -140,10 +151,10 @@ func bridgeFill(m Model, renderState renderGridState, bridge game.DynamicGridBri

if bridge.Uniform {
switch {
case renderState.completed[bridge.Zone] != nil:
return nil
case renderState.activeZone >= 0 && bridge.Zone == renderState.activeZone:
return nil
case renderState.zoneFill[bridge.Zone] != nil:
return nil
}
}

Expand Down Expand Up @@ -242,7 +253,7 @@ func buildRenderGridState(m Model) renderGridState {

palette := theme.Current()
colors := palette.ThemeColors()
completed := make(map[int]color.Color)
zoneFill := make(map[int]color.Color)
nextZone := 0
activeZone := -1
emptyZone := nextZone
Expand All @@ -264,8 +275,11 @@ func buildRenderGridState(m Model) renderGridState {
for _, cell := range comp.cells {
zones[cell.y][cell.x] = zone
}
if len(colors) > 0 {
zoneFill[zone] = incompleteRegionColor(comp, colors, palette.Surface)
}
if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {
completed[zone] = completedRegionColor(comp, colors, palette.Surface)
zoneFill[zone] = completedRegionColor(comp, colors, palette.Surface)
}
}
}
Expand All @@ -277,7 +291,7 @@ func buildRenderGridState(m Model) renderGridState {
return renderGridState{
zones: zones,
activeZone: activeZone,
completed: completed,
zoneFill: zoneFill,
}
}

Expand All @@ -291,9 +305,21 @@ func componentHasConflict(comp component, conflicts [][]bool) bool {
}

func completedRegionColor(comp component, colors []color.Color, base color.Color) color.Color {
return regionColor(comp, colors, base, 0.52)
}

func incompleteRegionColor(comp component, colors []color.Color, base color.Color) color.Color {
return regionColor(comp, colors, base, 0.26)
}

func regionColor(comp component, colors []color.Color, base color.Color, amount float64) color.Color {
if len(colors) == 0 {
return nil
}

anchor := comp.cells[0]
index := (anchor.y*37 + anchor.x*17 + comp.value*13) % len(colors)
return theme.Blend(base, colors[index], 0.52)
return theme.Blend(base, colors[index], amount)
}

func cursorRegionInfoStyle() lipgloss.Style {
Expand Down
2 changes: 2 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ vhs: build
vhs vhs/nonogram.tape
vhs vhs/nurikabe.tape
vhs vhs/sudoku.tape
vhs vhs/sudokurgb.tape
vhs vhs/spellpuzzle.tape
vhs vhs/shikaku.tape
vhs vhs/takuzu.tape
vhs vhs/wordsearch.tape
Expand Down
2 changes: 2 additions & 0 deletions registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/FelineStateMachine/puzzletea/puzzle"
"github.com/FelineStateMachine/puzzletea/rippleeffect"
"github.com/FelineStateMachine/puzzletea/shikaku"
"github.com/FelineStateMachine/puzzletea/spellpuzzle"
"github.com/FelineStateMachine/puzzletea/sudoku"
"github.com/FelineStateMachine/puzzletea/sudokurgb"
"github.com/FelineStateMachine/puzzletea/takuzu"
Expand Down Expand Up @@ -51,6 +52,7 @@ var all = []Entry{
adaptLegacy(nurikabe.Definition),
adaptLegacy(rippleeffect.Definition),
adaptLegacy(shikaku.Definition),
adaptLegacy(spellpuzzle.Definition),
adaptLegacy(sudoku.Definition),
adaptLegacy(sudokurgb.Definition),
adaptLegacy(takuzu.Definition),
Expand Down
10 changes: 5 additions & 5 deletions rippleeffect/Gamemode.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ var Modes = []list.Item{
"Compact cages with extra anchors for quick local reads.",
5,
3,
0.72,
0.69,
generationProfile{
cageWeights: []int{0, 5, 6, 3},
frontierSamples: 3,
Expand All @@ -74,7 +74,7 @@ var Modes = []list.Item{
"Gentle spacing logic with compact cages and steady clues.",
6,
3,
0.67,
0.64,
generationProfile{
cageWeights: []int{0, 3, 5, 4},
frontierSamples: 3,
Expand All @@ -87,7 +87,7 @@ var Modes = []list.Item{
"Mixed cage shapes with a balanced clue spread.",
7,
4,
0.62,
0.59,
generationProfile{
cageWeights: []int{0, 2, 4, 5, 3},
frontierSamples: 2,
Expand All @@ -100,7 +100,7 @@ var Modes = []list.Item{
"Longer cages and lighter anchors create broader ripple scans.",
8,
4,
0.58,
0.55,
generationProfile{
cageWeights: []int{0, 1, 2, 4, 5},
frontierSamples: 3,
Expand All @@ -113,7 +113,7 @@ var Modes = []list.Item{
"Sparser anchors and winding large cages push global deductions.",
9,
5,
0.54,
0.51,
generationProfile{
cageWeights: []int{0, 1, 1, 3, 4, 5},
frontierSamples: 4,
Expand Down
Loading
Loading