- commit
- 5471c93fd9cf231e97c2f8c6a18be77276741841
- parent
- 1cf6c5442f759dba7da96692c16d0bffc63d0536
- author
- Dami <dami.etoile@gmail.com>
| M | game/ | 6 | +++ |
| M | hashiwokakero/ | 5 | ++- |
| M | hashiwokakero/ | 10 | +++- |
| M | hashiwokakero/ | 15 | +++++- |
| M | model.go | 67 | +++++++++++++++++++++++++++++- |
| M | takuzu/ | 13 | ------ |
@@ -64,6 +64,12 @@ func (c Category) Title() string { return c.Name }
6464 func (c Category) Description() string { return c.Desc }6565 func (c Category) FilterValue() string { return c.Name }6666 67+// SpawnCompleteMsg is sent when an async Spawn() call finishes.68+type SpawnCompleteMsg struct {69+ Game Gamer70+ Err error71+}72+6773 // HelpToggleMsg is sent from the root model to games when the user toggles6874 // the full help display with Ctrl+H.6975 type HelpToggleMsg struct{ Show bool }@@ -36,7 +36,10 @@ func NewMode(title, description string, width, height, minIslands, maxIslands in
3636 }3737 3838 func (h HashiMode) Spawn() (game.Gamer, error) {39- puzzle := GeneratePuzzle(h)39+ puzzle, err := GeneratePuzzle(h)40+ if err != nil {41+ return nil, err42+ }4043 return New(h, puzzle), nil4144 }4245 @@ -1,19 +1,23 @@
11 package hashiwokakero22 33 import (4+ "errors"45 "math/rand/v2"56 )67 8+const maxGenerateAttempts = 2009+710 // GeneratePuzzle creates a solvable hashiwokakero puzzle for the given mode.8-func GeneratePuzzle(mode HashiMode) Puzzle {11+func GeneratePuzzle(mode HashiMode) (Puzzle, error) {912 islandCount := mode.MinIslands + rand.IntN(mode.MaxIslands-mode.MinIslands+1)1013 11- for {14+ for range maxGenerateAttempts {1215 p := tryGenerate(mode.Width, mode.Height, islandCount)1316 if p != nil {14- return *p17+ return *p, nil1518 }1619 }20+ return Puzzle{}, errors.New("failed to generate puzzle after maximum attempts")1721 }1822 1923 func tryGenerate(width, height, islandCount int) *Puzzle {@@ -936,7 +936,10 @@ func TestGeneratePuzzle(t *testing.T) {
936936 mode := NewMode("Test", "test mode", 7, 7, 8, 10)937937 938938 t.Run("produces valid puzzle", func(t *testing.T) {939- puzzle := GeneratePuzzle(mode)939+ puzzle, err := GeneratePuzzle(mode)940+ if err != nil {941+ t.Fatal(err)942+ }940943 for _, isl := range puzzle.Islands {941944 if isl.Required < 1 {942945 t.Errorf("island %d has Required=%d, want >= 1", isl.ID, isl.Required)@@ -945,14 +948,20 @@ func TestGeneratePuzzle(t *testing.T) {
945948 })946949 947950 t.Run("bridges cleared", func(t *testing.T) {948- puzzle := GeneratePuzzle(mode)951+ puzzle, err := GeneratePuzzle(mode)952+ if err != nil {953+ t.Fatal(err)954+ }949955 if len(puzzle.Bridges) != 0 {950956 t.Errorf("len(Bridges) = %d, want 0", len(puzzle.Bridges))951957 }952958 })953959 954960 t.Run("correct dimensions", func(t *testing.T) {955- puzzle := GeneratePuzzle(mode)961+ puzzle, err := GeneratePuzzle(mode)962+ if err != nil {963+ t.Fatal(err)964+ }956965 if puzzle.Width != 7 || puzzle.Height != 7 {957966 t.Errorf("dimensions = %dx%d, want 7x7", puzzle.Width, puzzle.Height)958967 }@@ -16,6 +16,7 @@ import (
1616 "github.com/FelineStateMachine/puzzletea/takuzu"1717 "github.com/FelineStateMachine/puzzletea/wordsearch"1818 "github.com/charmbracelet/bubbles/list"19+ "github.com/charmbracelet/bubbles/spinner"1920 "github.com/charmbracelet/bubbles/table"2021 tea "github.com/charmbracelet/bubbletea"2122 "github.com/charmbracelet/glamour"@@ -48,6 +49,7 @@ const (
4849 mainMenuView = iota4950 gameSelectView5051 modeSelectView52+ generatingView5153 gameView5254 continueView5355 )@@ -82,6 +84,9 @@ type model struct {
8284 mode game.Mode8385 game game.Gamer8486 87+ spinner spinner.Model88+ generating bool // true while an async Spawn is in flight89+8590 width int // available content width (terminal - rootStyle frame)8691 height int // available content height (terminal - rootStyle frame)8792 @@ -99,11 +104,15 @@ func initialModel(s *store.Store) model {
99104 r := initDebugRenderer()100105 l := initGameSelectList()101106 ml := initMainMenuList()107+ sp := spinner.New()108+ sp.Spinner = spinner.Dot109+ sp.Style = lipgloss.NewStyle().Foreground(menuAccent)102110 return model{103111 state: mainMenuView,104112 debugRenderer: r,105113 gameSelectList: l,106114 mainMenuList: ml,115+ spinner: sp,107116 store: s,108117 }109118 }@@ -114,11 +123,15 @@ func initialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, comp
114123 r := initDebugRenderer()115124 l := initGameSelectList()116125 ml := initMainMenuList()126+ sp := spinner.New()127+ sp.Spinner = spinner.Dot128+ sp.Style = lipgloss.NewStyle().Foreground(menuAccent)117129 return model{118130 state: gameView,119131 debugRenderer: r,120132 gameSelectList: l,121133 mainMenuList: ml,134+ spinner: sp,122135 store: s,123136 game: g,124137 activeGameID: activeGameID,@@ -134,6 +147,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134147 var cmd tea.Cmd135148 136149 switch msg := msg.(type) {150+ case game.SpawnCompleteMsg:151+ return m.handleSpawnComplete(msg)152+137153 case tea.WindowSizeMsg:138154 h, v := rootStyle.GetFrameSize()139155 w, ht := msg.Width-h, msg.Height-v@@ -151,6 +167,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151167 }152168 153169 case tea.KeyMsg:170+ // During generation, only allow Escape (to cancel) and Ctrl+C (to quit).171+ if m.state == generatingView {172+ switch msg.Type {173+ case tea.KeyEscape:174+ m.state = modeSelectView175+ return m, nil176+ case tea.KeyCtrlC:177+ return m, tea.Quit178+ }179+ return m, nil180+ }154181 switch msg.Type {155182 case tea.KeyCtrlN:156183 m = saveCurrentGame(m, store.StatusInProgress)@@ -182,6 +209,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182209 switch m.state {183210 case mainMenuView:184211 m.mainMenuList, cmd = m.mainMenuList.Update(msg)212+ case generatingView:213+ m.spinner, cmd = m.spinner.Update(msg)185214 case gameView:186215 m.game, cmd = m.game.Update(msg)187216 if m.debug {@@ -251,12 +280,39 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
251280 252281 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {253282 m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)254- g, err := m.mode.(game.Spawner).Spawn()255- if err != nil {283+ spawner, ok := m.mode.(game.Spawner)284+ if !ok {256285 return m, nil257286 }287+ m.state = generatingView288+ m.generating = true289+ return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner))290+}291+292+// spawnCmd returns a tea.Cmd that runs Spawn() off the main goroutine.293+func spawnCmd(spawner game.Spawner) tea.Cmd {294+ return func() tea.Msg {295+ g, err := spawner.Spawn()296+ return game.SpawnCompleteMsg{Game: g, Err: err}297+ }298+}299+300+func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {301+ m.generating = false302+303+ // If the user navigated away while generating, discard the result.304+ if m.state != generatingView {305+ return m, nil306+ }307+308+ if msg.Err != nil {309+ log.Printf("failed to spawn game: %v", msg.Err)310+ m.state = modeSelectView311+ return m, nil312+ }313+258314 name := generateUniqueName(m.store)259- m.game = g.SetTitle(name)315+ m.game = msg.Game.SetTitle(name)260316 m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})261317 m.state = gameView262318 m.completionSaved = false@@ -308,6 +364,8 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
308364 309365 func (m model) handleEscape() (tea.Model, tea.Cmd) {310366 switch m.state {367+ case generatingView:368+ m.state = modeSelectView311369 case modeSelectView:312370 m.state = gameSelectView313371 case gameSelectView, continueView:@@ -324,6 +382,9 @@ func (m model) View() string {
324382 return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.gameSelectList.View()))325383 case modeSelectView:326384 return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.modeSelectList.View()))385+ case generatingView:386+ s := m.spinner.View() + " Generating puzzle..."387+ return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))327388 case continueView:328389 var s string329390 if len(m.continueGames) == 0 {@@ -37,19 +37,6 @@ func generateComplete(size int) grid {
3737 }3838 3939 func fillGrid(g grid, size int) bool {40- order := make([]cellPos, 0, size*size)41- for y := range size {42- for x := range size {43- if g[y][x] == emptyCell {44- order = append(order, cellPos{x, y})45- }46- }47- }48-49- rand.Shuffle(len(order), func(i, j int) {50- order[i], order[j] = order[j], order[i]51- })52-5340 for y := range size {5441 for x := range size {5542 if g[y][x] != emptyCell {