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
56 changes: 38 additions & 18 deletions internal/game/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,43 @@ type Stats struct {
Mistakes int // total wrong keystrokes, including corrected ones
}

type RacePoint struct {
MS int `json:"ms"`
Len int `json:"len"`
}

type Game struct {
text string
Snippet lang.Snippet
input string
errors map[int]bool // unfixed errors — used for live display coloring
mistakeAt map[int]bool // every wrong keystroke ever — never cleared by backspace
started bool
duration int
CodeMode bool // true = snippet-based typing, false = standard words
text string
Snippet lang.Snippet
input string
errors map[int]bool // unfixed errors — used for live display coloring
mistakeAt map[int]bool // every wrong keystroke ever — never cleared by backspace
started bool
duration int
CodeMode bool // true = snippet-based typing, false = standard words
difficulty string

elapsed time.Duration
lastTyped time.Time
LastTick time.Time
raceTrack []RacePoint
}

// Accessors for fields that TUI needs to read
func (g *Game) Text() string { return g.text }
func (g *Game) Input() string { return g.input }
func (g *Game) Errors() map[int]bool { return g.errors }
func (g *Game) Started() bool { return g.started }
func (g *Game) Duration() int { return g.duration }
func (g *Game) Text() string { return g.text }
func (g *Game) Input() string { return g.input }
func (g *Game) Errors() map[int]bool { return g.errors }
func (g *Game) Started() bool { return g.started }
func (g *Game) Duration() int { return g.duration }
func (g *Game) Elapsed() time.Duration { return g.elapsed }
func (g *Game) SetText(s string) { g.text = normalizeTabs(s) }
func (g *Game) SetText(s string) { g.text = normalizeTabs(s) }
func (g *Game) RaceTrack() []RacePoint { return g.raceTrack }

func New(duration int, mode string, language string, difficulty string) *Game {
g := &Game{
duration: duration, // 0 means infinite mode (tied to length of snippet)
errors: make(map[int]bool),
mistakeAt: make(map[int]bool),
duration: duration, // 0 means infinite mode (tied to length of snippet)
errors: make(map[int]bool),
mistakeAt: make(map[int]bool),
difficulty: difficulty,
}

Expand Down Expand Up @@ -138,9 +145,9 @@ func (g *Game) TypeChar(ch rune) {
break
}
}
g.recordRacePoint()
}


func (g *Game) Backspace() {
if len(g.input) == 0 {
return
Expand Down Expand Up @@ -169,6 +176,7 @@ func (g *Game) Backspace() {
delete(g.errors, pos)
// mistakeAt is NOT cleared — tracks lifetime errors
}
g.recordRacePoint()
}

func (g *Game) TimeLeft() int {
Expand Down Expand Up @@ -289,4 +297,16 @@ func (g *Game) Reset(mode string, language string, difficulty string) {
g.elapsed = 0
g.lastTyped = time.Time{}
g.LastTick = time.Time{}
g.raceTrack = nil
}

func (g *Game) recordRacePoint() {
if !g.started {
return
}
ms := int(g.elapsed.Milliseconds())
g.raceTrack = append(g.raceTrack, RacePoint{
MS: ms,
Len: len(g.input),
})
}
67 changes: 66 additions & 1 deletion internal/game/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package game

import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -12,6 +13,18 @@ import (

var dataDir string

type RaceRecord struct {
ID string `json:"id"`
At string `json:"at"`
Duration int `json:"duration"`
Mode string `json:"mode"`
Language string `json:"language"`
Difficulty string `json:"difficulty"`
Text string `json:"text"`
Stats Stats `json:"stats"`
Points []RacePoint `json:"points"`
}

func init() {
configDir, _ := os.UserConfigDir()
dataDir = filepath.Join(configDir, "toofan")
Expand Down Expand Up @@ -100,6 +113,58 @@ func SavePB(duration int, mode string, wpm float64) {
}
}

func SaveRace(r RaceRecord) {
os.MkdirAll(dataDir, 0755)
path := filepath.Join(dataDir, "races.txt")

races := LoadRaces()
races = append(races, r)
if len(races) > 10 {
races = races[len(races)-10:]
}

f, err := os.Create(path)
if err != nil {
return
}
defer f.Close()

for _, rec := range races {
b, err := json.Marshal(rec)
if err != nil {
continue
}
fmt.Fprintln(f, string(b))
}
}

func LoadRaces() []RaceRecord {
path := filepath.Join(dataDir, "races.txt")
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()

var out []RaceRecord
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var r RaceRecord
if err := json.Unmarshal([]byte(scanner.Text()), &r); err != nil {
continue
}
if r.Text == "" || len(r.Points) == 0 {
continue
}
out = append(out, r)
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}


func LoadConfig() (duration int, mode string, language string, difficulty string, themeName string) {
duration, mode, language, difficulty, themeName = 30, "words", "go", "easy", "tokyonight"

Expand Down Expand Up @@ -176,7 +241,7 @@ func SaveBackup() (string, error) {
dest := filepath.Join(backupDir, fmt.Sprintf("toofan_backup_%s.txt", stamp))

var bundle strings.Builder
for _, name := range []string{"results.txt", "pb.txt", "config.txt"} {
for _, name := range []string{"results.txt", "pb.txt", "config.txt", "races.txt"} {
data, err := os.ReadFile(filepath.Join(dataDir, name))
if err != nil {
continue
Expand Down
62 changes: 43 additions & 19 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,26 @@ const (
)

type model struct {
active screen
game *game.Game
duration int
mode string // "words" or "code"
lang string
active screen
game *game.Game
duration int
mode string // "words" or "code"
lang string
difficulty string

width, height int

pickingDur bool
durCur int
pickingLang bool
langCur int
pickingLesson bool
lessonCur int
pickingTheme bool
themeCur int
pickingDur bool
durCur int
pickingLang bool
langCur int
pickingLesson bool
lessonCur int
pickingTheme bool
themeCur int
pickingDifficulty bool
diffCur int
showHelp bool
showHelp bool

result game.Stats
pb float64
Expand All @@ -57,25 +57,30 @@ type model struct {
pickingRestore bool
backups []string
restoreCur int

pickingRace bool
raceCur int
races []game.RaceRecord
activeRace *game.RaceRecord
}

func New() model {
duration, mode, language, difficulty, th := game.LoadConfig()
theme.Current = theme.ByName(th)

return model{
game: game.New(duration, mode, language, difficulty),
duration: duration,
mode: mode,
lang: language,
game: game.New(duration, mode, language, difficulty),
duration: duration,
mode: mode,
lang: language,
difficulty: difficulty,
}
}

type tick time.Time

func (m model) isPaused() bool {
return m.pickingDur || m.pickingLang || m.pickingLesson || m.pickingTheme || m.showHelp
return m.pickingDur || m.pickingLang || m.pickingLesson || m.pickingTheme || m.showHelp || m.pickingRace
}

func (m model) Init() tea.Cmd {
Expand Down Expand Up @@ -107,6 +112,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
durToSave = m.game.TimeLeft()
}
game.SaveResult(m.result, durToSave, m.mode, m.lang)
game.SaveRace(game.RaceRecord{
ID: time.Now().Format("20060102150405.000000000"),
At: time.Now().Format("2006-01-02 15:04"),
Duration: durToSave,
Mode: m.mode,
Language: m.lang,
Difficulty: m.difficulty,
Text: m.game.Text(),
Stats: m.result,
Points: m.game.RaceTrack(),
})

if m.gotNewPB {
game.SavePB(m.duration, m.mode, m.result.WPM)
}
Expand Down Expand Up @@ -148,6 +165,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
if m.pickingRace {
return m.handleRacePicker(msg)
}

switch msg.String() {
case "ctrl+c":
Expand Down Expand Up @@ -189,6 +209,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
m.duration = durations[m.durCur]
m.pickingDur = false
m.activeRace = nil
m.game = game.New(m.duration, m.mode, m.lang, m.difficulty)
m.save()
return m, nil
Expand All @@ -215,6 +236,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
m.difficulty = difficulties[m.diffCur]
m.pickingDifficulty = false
m.activeRace = nil
m.game = game.New(m.duration, m.mode, m.lang, m.difficulty)
m.save()
return m, nil
Expand Down Expand Up @@ -262,6 +284,8 @@ func (m model) View() string {
names = append(names, filepath.Base(f))
}
body = renderList(p, "restore backup", names, nil, m.restoreCur)
} else if m.pickingRace {
body = m.viewRacePicker(p)
} else {
switch m.active {
case screenTyping:
Expand Down
Loading
Loading