Files
termdoku/internal/ui/game.go

474 lines
11 KiB
Go

package ui
import (
"fmt"
"strconv"
"strings"
"time"
"termdoku/internal/config"
"termdoku/internal/game"
"termdoku/internal/generator"
"termdoku/internal/savegame"
"termdoku/internal/solver"
"termdoku/internal/theme"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type timerTickMsg struct{}
type flashDoneMsg struct{ Row, Col int }
type Model struct {
keymap KeyMap
styles UIStyles
theme theme.Theme
board game.Board
solution game.Grid
cursorRow int
cursorCol int
autoCheck bool
timerEnabled bool
startTime time.Time
elapsed time.Duration
completed bool
paused bool
difficulty string
undoStack []game.Move
redoStack []game.Move
flashes map[[2]int]time.Time
showHelp bool
hintsUsed int
noteMode bool
notes map[[2]int][]uint8 // cell -> candidate numbers
showWinAnim bool
winAnimStart time.Time
}
func New(p generator.Grid, th theme.Theme, cfg config.Config) Model {
b := game.NewBoardFromPuzzle(game.Grid(p))
// Solve once for auto-check
sg := b.Values
if s := solveCopy(b.Values); s != nil {
sg = *s
}
km := DefaultKeyMap()
km.ApplyBindings(cfg.Bindings)
m := Model{
keymap: km,
styles: BuildStyles(th),
theme: th,
board: b,
solution: sg,
cursorRow: 0,
cursorCol: 0,
autoCheck: cfg.AutoCheck,
timerEnabled: cfg.TimerEnabled,
startTime: time.Now(),
flashes: map[[2]int]time.Time{},
notes: make(map[[2]int][]uint8),
}
return m
}
func solveCopy(g game.Grid) *game.Grid {
var sg solver.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
sg[r][c] = g[r][c]
}
}
if solver.Solve(&sg, 2*time.Second) {
var out game.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
out[r][c] = sg[r][c]
}
}
return &out
}
return nil
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
if m.timerEnabled {
cmds = append(cmds, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} }))
}
return tea.Batch(cmds...)
}
func (m Model) View() string { return Render(m) }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case timerTickMsg:
if m.timerEnabled && !m.completed {
m.elapsed = time.Since(m.startTime)
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
}
return m, nil
case flashDoneMsg:
delete(m.flashes, [2]int{msg.Row, msg.Col})
return m, nil
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg
// Help overlay toggle
if key.Matches(k, m.keymap.Help) {
m.showHelp = !m.showHelp
return m, nil
}
// When help is shown, only allow closing it
if m.showHelp {
return m, nil
}
// Pause toggle
if key.Matches(k, m.keymap.Pause) {
m.paused = !m.paused
return m, nil
}
// When paused, only allow unpause or quit
if m.paused {
if k.String() == "q" || k.String() == "esc" || k.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
// Toggle features
if key.Matches(k, m.keymap.ToggleAuto) {
m.autoCheck = !m.autoCheck
return m, nil
}
if key.Matches(k, m.keymap.ToggleTimer) {
m.timerEnabled = !m.timerEnabled
if m.timerEnabled && !m.completed {
m.startTime = time.Now().Add(-m.elapsed)
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
}
return m, nil
}
if key.Matches(k, m.keymap.ToggleNote) {
m.noteMode = !m.noteMode
return m, nil
}
// Hint
if key.Matches(k, m.keymap.Hint) && !m.completed {
m = m.applyHint()
return m, nil
}
// Undo/Redo
if key.Matches(k, m.keymap.Undo) {
m = m.applyUndo()
return m, nil
}
if key.Matches(k, m.keymap.Redo) {
m = m.applyRedo()
return m, nil
}
// Save/Load
if key.Matches(k, m.keymap.Save) && !m.completed {
_ = m.saveGame() // Ignore errors for now
return m, nil
}
if key.Matches(k, m.keymap.Load) {
if loadedModel, err := m.loadGame(); err == nil {
m = loadedModel
}
return m, nil
}
s := k.String()
switch s {
case "up", "k":
m.cursorRow = clamp(m.cursorRow-1, 0, 8)
case "down", "j":
m.cursorRow = clamp(m.cursorRow+1, 0, 8)
case "left", "h":
m.cursorCol = clamp(m.cursorCol-1, 0, 8)
case "right", "l":
m.cursorCol = clamp(m.cursorCol+1, 0, 8)
case " ", "0":
return m.applyInput(0)
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
v := uint8(s[0] - '0')
return m.applyInput(v)
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m Model) applyInput(v uint8) (tea.Model, tea.Cmd) {
if m.board.IsGiven(m.cursorRow, m.cursorCol) {
return m, nil
}
// Note mode: toggle candidate number
if m.noteMode && v != 0 {
key := [2]int{m.cursorRow, m.cursorCol}
notes := m.notes[key]
// Toggle the note
found := false
for i, n := range notes {
if n == v {
// Remove note
notes = append(notes[:i], notes[i+1:]...)
found = true
break
}
}
if !found {
// Add note
notes = append(notes, v)
}
if len(notes) > 0 {
m.notes[key] = notes
} else {
delete(m.notes, key)
}
return m, nil
}
// Normal mode: set value
prev, ok := m.board.SetValue(m.cursorRow, m.cursorCol, v)
if !ok {
return m, nil
}
// Clear notes for this cell when setting a value
if v != 0 {
delete(m.notes, [2]int{m.cursorRow, m.cursorCol})
}
mv := game.Move{Row: m.cursorRow, Col: m.cursorCol, Prev: prev, Next: v, At: time.Now()}
m.undoStack = append(m.undoStack, mv)
m.redoStack = nil
m.flashes[[2]int{m.cursorRow, m.cursorCol}] = time.Now().Add(120 * time.Millisecond)
if isSolved(m.board.Values, m.solution) {
m.completed = true
m.showWinAnim = true
m.winAnimStart = time.Now()
}
return m, tea.Tick(130*time.Millisecond, func(time.Time) tea.Msg { return flashDoneMsg{Row: mv.Row, Col: mv.Col} })
}
func (m Model) applyUndo() Model {
if len(m.undoStack) == 0 {
return m
}
last := m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.board.Values[last.Row][last.Col] = last.Prev
m.redoStack = append(m.redoStack, last)
m.cursorRow, m.cursorCol = last.Row, last.Col
m.completed = isSolved(m.board.Values, m.solution)
return m
}
func (m Model) applyRedo() Model {
if len(m.redoStack) == 0 {
return m
}
last := m.redoStack[len(m.redoStack)-1]
m.redoStack = m.redoStack[:len(m.redoStack)-1]
m.board.Values[last.Row][last.Col] = last.Next
m.undoStack = append(m.undoStack, last)
m.cursorRow, m.cursorCol = last.Row, last.Col
m.completed = isSolved(m.board.Values, m.solution)
return m
}
func (m Model) applyHint() Model {
// Find first empty cell and fill it with solution value
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if !m.board.Given[r][c] && m.board.Values[r][c] == 0 {
solutionVal := m.solution[r][c]
if solutionVal != 0 {
m.board.Values[r][c] = solutionVal
m.cursorRow, m.cursorCol = r, c
m.hintsUsed++
m.flashes[[2]int{r, c}] = time.Now().Add(500 * time.Millisecond)
// Clear notes for hinted cell
delete(m.notes, [2]int{r, c})
if isSolved(m.board.Values, m.solution) {
m.completed = true
m.showWinAnim = true
m.winAnimStart = time.Now()
}
return m
}
}
}
}
return m
}
func (m Model) saveGame() error {
// Convert notes map to string keys for JSON
notesJSON := make(map[string][]uint8)
for key, notes := range m.notes {
keyStr := strconv.Itoa(key[0]) + "," + strconv.Itoa(key[1])
notesJSON[keyStr] = notes
}
sg := savegame.SavedGame{
Board: m.board.Values,
Solution: m.solution,
Given: m.board.Given,
Difficulty: m.difficulty,
Elapsed: int64(m.elapsed.Seconds()),
StartTime: m.startTime,
HintsUsed: m.hintsUsed,
Notes: notesJSON,
SavedAt: time.Now(),
AutoCheck: m.autoCheck,
TimerEnabled: m.timerEnabled,
}
return savegame.Save(sg)
}
func (m Model) loadGame() (Model, error) {
sg, err := savegame.Load()
if err != nil {
return m, err
}
// Restore board
m.board.Values = sg.Board
m.board.Given = sg.Given
m.solution = sg.Solution
m.difficulty = sg.Difficulty
m.hintsUsed = sg.HintsUsed
m.autoCheck = sg.AutoCheck
m.timerEnabled = sg.TimerEnabled
// Restore notes (convert string keys back to [2]int)
m.notes = make(map[[2]int][]uint8)
for keyStr, notes := range sg.Notes {
// Parse "r,c" format
var r, c int
fmt.Sscanf(keyStr, "%d,%d", &r, &c)
m.notes[[2]int{r, c}] = notes
}
// Restore timer state
if m.timerEnabled {
m.elapsed = time.Duration(sg.Elapsed) * time.Second
m.startTime = time.Now().Add(-m.elapsed)
}
m.completed = isSolved(m.board.Values, m.solution)
return m, nil
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func (m Model) StatusLine() string {
// Completed UI
if m.completed {
adaptiveColors := theme.NewAdaptiveColors(m.theme)
gradientColors := adaptiveColors.GetGradientColors()
completeGrad := gradientColors["complete"]
var completeText string
if m.timerEnabled {
secs := int(m.elapsed.Truncate(time.Second).Seconds())
mins := (secs / 60) % 100
s := secs % 60
timeStr := fmt.Sprintf("%02d:%02d", mins, s)
if m.hintsUsed > 0 {
completeText = fmt.Sprintf("✭ Clear %s (%d hints) ! Press 'm' for menu ✭", timeStr, m.hintsUsed)
} else {
completeText = fmt.Sprintf("✭ Clear %s ! Press 'm' for menu ✭", timeStr)
}
} else {
completeText = "✭ Clear! Press 'm' for menu ✭"
}
return gradientText(completeText, completeGrad[0], completeGrad[1])
}
// All filled but not solved → Try again
if allFilled(m.board.Values) && !isSolved(m.board.Values, m.solution) {
return m.styles.StatusError.Render("✭ Try again... ✭")
}
// Normal status (fixed width segments)
var parts []string
// Timer
if m.timerEnabled {
secs := int(m.elapsed.Truncate(time.Second).Seconds())
mins := (secs / 60) % 100
s := secs % 60
timeValue := fmt.Sprintf("%02d:%02d", mins, s)
parts = append(parts, m.styles.Status.Render("Timer: ")+m.styles.BoolTrue.Render(timeValue))
}
// Hints used
if m.hintsUsed > 0 {
parts = append(parts, m.styles.Status.Render(fmt.Sprintf("Hints: %d", m.hintsUsed)))
}
// Note mode indicator
if m.noteMode {
parts = append(parts, m.styles.BoolTrue.Render("NOTE MODE"))
}
// Help hint
parts = append(parts, m.styles.Status.Render("Help: ?"))
separator := m.styles.Status.Render(" | ")
return strings.Join(parts, separator)
}
func allFilled(g game.Grid) bool {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
return false
}
}
}
return true
}
func isSolved(cur game.Grid, sol game.Grid) bool {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if cur[r][c] != sol[r][c] {
return false
}
}
}
return true
}