(Feat): Initial Commit, Termdoku
This commit is contained in:
473
internal/ui/game.go
Normal file
473
internal/ui/game.go
Normal file
@@ -0,0 +1,473 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user