mirror of
				https://github.com/jesseduffield/lazygit.git
				synced 2025-10-30 23:57:43 +02:00 
			
		
		
		
	integrate snake game into lazygit
This commit is contained in:
		| @@ -131,7 +131,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b | ||||
| 			return false | ||||
| 		} | ||||
|  | ||||
| 		return (binding.Description != "" || binding.Alternative != "") | ||||
| 		return (binding.Description != "" || binding.Alternative != "") && binding.Key != nil | ||||
| 	}) | ||||
|  | ||||
| 	bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header { | ||||
|   | ||||
| @@ -27,6 +27,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "stage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 			}, | ||||
| 			expected: []*bindingSection{ | ||||
| @@ -36,6 +37,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "stage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -47,6 +49,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 				{ | ||||
| 					ViewName:    "", | ||||
| 					Description: "quit", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 			}, | ||||
| 			expected: []*bindingSection{ | ||||
| @@ -56,6 +59,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "", | ||||
| 							Description: "quit", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -67,14 +71,17 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "stage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "unstage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "submodules", | ||||
| 					Description: "drop submodule", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 			}, | ||||
| 			expected: []*bindingSection{ | ||||
| @@ -84,10 +91,12 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "stage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "unstage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -97,6 +106,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "submodules", | ||||
| 							Description: "drop submodule", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -108,19 +118,23 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "stage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "unstage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "scroll", | ||||
| 					Key:         'a', | ||||
| 					Tag:         "navigation", | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "commits", | ||||
| 					Description: "revert commit", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 			}, | ||||
| 			expected: []*bindingSection{ | ||||
| @@ -130,6 +144,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "scroll", | ||||
| 							Key:         'a', | ||||
| 							Tag:         "navigation", | ||||
| 						}, | ||||
| 					}, | ||||
| @@ -140,6 +155,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "commits", | ||||
| 							Description: "revert commit", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -149,10 +165,12 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "stage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "unstage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -164,28 +182,34 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "stage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "unstage file", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "files", | ||||
| 					Description: "scroll", | ||||
| 					Key:         'a', | ||||
| 					Tag:         "navigation", | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "commits", | ||||
| 					Description: "revert commit", | ||||
| 					Key:         'a', | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "commits", | ||||
| 					Description: "scroll", | ||||
| 					Key:         'a', | ||||
| 					Tag:         "navigation", | ||||
| 				}, | ||||
| 				{ | ||||
| 					ViewName:    "commits", | ||||
| 					Description: "page up", | ||||
| 					Key:         'a', | ||||
| 					Tag:         "navigation", | ||||
| 				}, | ||||
| 			}, | ||||
| @@ -196,11 +220,13 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "scroll", | ||||
| 							Key:         'a', | ||||
| 							Tag:         "navigation", | ||||
| 						}, | ||||
| 						{ | ||||
| 							ViewName:    "commits", | ||||
| 							Description: "page up", | ||||
| 							Key:         'a', | ||||
| 							Tag:         "navigation", | ||||
| 						}, | ||||
| 					}, | ||||
| @@ -211,6 +237,7 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "commits", | ||||
| 							Description: "revert commit", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| @@ -220,10 +247,12 @@ func TestGetBindingSections(t *testing.T) { | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "stage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 						{ | ||||
| 							ViewName:    "files", | ||||
| 							Description: "unstage file", | ||||
| 							Key:         'a', | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| const ( | ||||
| 	GLOBAL_CONTEXT_KEY                   types.ContextKey = "global" | ||||
| 	STATUS_CONTEXT_KEY                   types.ContextKey = "status" | ||||
| 	SNAKE_CONTEXT_KEY                    types.ContextKey = "snake" | ||||
| 	FILES_CONTEXT_KEY                    types.ContextKey = "files" | ||||
| 	LOCAL_BRANCHES_CONTEXT_KEY           types.ContextKey = "localBranches" | ||||
| 	REMOTES_CONTEXT_KEY                  types.ContextKey = "remotes" | ||||
| @@ -74,6 +75,7 @@ var AllContextKeys = []types.ContextKey{ | ||||
| type ContextTree struct { | ||||
| 	Global                      types.Context | ||||
| 	Status                      types.Context | ||||
| 	Snake                       types.Context | ||||
| 	Files                       *WorkingTreeContext | ||||
| 	Menu                        *MenuContext | ||||
| 	Branches                    *BranchesContext | ||||
| @@ -112,6 +114,7 @@ func (self *ContextTree) Flatten() []types.Context { | ||||
| 	return []types.Context{ | ||||
| 		self.Global, | ||||
| 		self.Status, | ||||
| 		self.Snake, | ||||
| 		self.Submodules, | ||||
| 		self.Files, | ||||
| 		self.SubCommits, | ||||
|   | ||||
| @@ -32,6 +32,26 @@ func (gui *Gui) contextTree() *context.ContextTree { | ||||
| 				OnRenderToMain: gui.statusRenderToMain, | ||||
| 			}, | ||||
| 		), | ||||
| 		Snake: context.NewSimpleContext( | ||||
| 			context.NewBaseContext(context.NewBaseContextOpts{ | ||||
| 				Kind:       types.SIDE_CONTEXT, | ||||
| 				View:       gui.Views.Snake, | ||||
| 				WindowName: "files", | ||||
| 				Key:        context.SNAKE_CONTEXT_KEY, | ||||
| 				Focusable:  true, | ||||
| 			}), | ||||
| 			context.ContextCallbackOpts{ | ||||
| 				OnFocus: func(opts types.OnFocusOpts) error { | ||||
| 					gui.startSnake() | ||||
| 					return nil | ||||
| 				}, | ||||
| 				OnFocusLost: func(opts types.OnFocusLostOpts) error { | ||||
| 					gui.snakeGame.Exit() | ||||
| 					gui.moveToTopOfWindow(gui.State.Contexts.Submodules) | ||||
| 					return nil | ||||
| 				}, | ||||
| 			}, | ||||
| 		), | ||||
| 		Files:          gui.filesListContext(), | ||||
| 		Submodules:     gui.submodulesListContext(), | ||||
| 		Menu:           gui.menuListContext(), | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import ( | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/snake" | ||||
| ) | ||||
|  | ||||
| func (gui *Gui) resetControllers() { | ||||
| @@ -130,6 +131,7 @@ func (gui *Gui) resetControllers() { | ||||
| 	stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false) | ||||
| 	stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true) | ||||
| 	patchBuildingController := controllers.NewPatchBuildingController(common) | ||||
| 	snakeController := controllers.NewSnakeController(common, func() *snake.Game { return gui.snakeGame }) | ||||
|  | ||||
| 	setSubCommits := func(commits []*models.Commit) { gui.State.Model.SubCommits = commits } | ||||
|  | ||||
| @@ -248,6 +250,10 @@ func (gui *Gui) resetControllers() { | ||||
| 		contextLinesController, | ||||
| 	) | ||||
|  | ||||
| 	controllers.AttachControllers(gui.State.Contexts.Snake, | ||||
| 		snakeController, | ||||
| 	) | ||||
|  | ||||
| 	// this must come last so that we've got our click handlers defined against the context | ||||
| 	listControllerFactory := controllers.NewListControllerFactory(gui.c) | ||||
| 	for _, context := range gui.getListContexts() { | ||||
|   | ||||
							
								
								
									
										68
									
								
								pkg/gui/controllers/snake_controller.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								pkg/gui/controllers/snake_controller.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/types" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/snake" | ||||
| ) | ||||
|  | ||||
| type SnakeController struct { | ||||
| 	baseController | ||||
| 	*controllerCommon | ||||
|  | ||||
| 	getGame func() *snake.Game | ||||
| } | ||||
|  | ||||
| var _ types.IController = &SnakeController{} | ||||
|  | ||||
| func NewSnakeController( | ||||
| 	common *controllerCommon, | ||||
| 	getGame func() *snake.Game, | ||||
| ) *SnakeController { | ||||
| 	return &SnakeController{ | ||||
| 		baseController:   baseController{}, | ||||
| 		controllerCommon: common, | ||||
| 		getGame:          getGame, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (self *SnakeController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { | ||||
| 	bindings := []*types.Binding{ | ||||
| 		{ | ||||
| 			Key:     opts.GetKey(opts.Config.Universal.NextItem), | ||||
| 			Handler: self.SetDirection(snake.Down), | ||||
| 		}, | ||||
| 		{ | ||||
| 			Key:     opts.GetKey(opts.Config.Universal.PrevItem), | ||||
| 			Handler: self.SetDirection(snake.Up), | ||||
| 		}, | ||||
| 		{ | ||||
| 			Key:     opts.GetKey(opts.Config.Universal.PrevBlock), | ||||
| 			Handler: self.SetDirection(snake.Left), | ||||
| 		}, | ||||
| 		{ | ||||
| 			Key:     opts.GetKey(opts.Config.Universal.NextBlock), | ||||
| 			Handler: self.SetDirection(snake.Right), | ||||
| 		}, | ||||
| 		{ | ||||
| 			Key:     opts.GetKey(opts.Config.Universal.Return), | ||||
| 			Handler: self.Escape, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	return bindings | ||||
| } | ||||
|  | ||||
| func (self *SnakeController) Context() types.Context { | ||||
| 	return self.contexts.Snake | ||||
| } | ||||
|  | ||||
| func (self *SnakeController) SetDirection(direction snake.Direction) func() error { | ||||
| 	return func() error { | ||||
| 		self.getGame().SetDirection(direction) | ||||
| 		return nil | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (self *SnakeController) Escape() error { | ||||
| 	return self.c.PushContext(self.contexts.Submodules) | ||||
| } | ||||
| @@ -69,6 +69,11 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []* | ||||
| 			Description: self.c.Tr.LcViewBulkSubmoduleOptions, | ||||
| 			OpensMenu:   true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Key:         nil, | ||||
| 			Handler:     self.easterEgg, | ||||
| 			Description: self.c.Tr.EasterEgg, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -219,6 +224,10 @@ func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) erro | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (self *SubmodulesController) easterEgg() error { | ||||
| 	return self.c.PushContext(self.contexts.Snake) | ||||
| } | ||||
|  | ||||
| func (self *SubmodulesController) checkSelected(callback func(*models.SubmoduleConfig) error) func() error { | ||||
| 	return func() error { | ||||
| 		submodule := self.context().GetSelected() | ||||
|   | ||||
| @@ -33,6 +33,7 @@ import ( | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/types" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/integration/components" | ||||
| 	integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/snake" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/tasks" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/theme" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/updates" | ||||
| @@ -154,6 +155,8 @@ type Gui struct { | ||||
|  | ||||
| 	c       *types.HelperCommon | ||||
| 	helpers *helpers.Helpers | ||||
|  | ||||
| 	snakeGame *snake.Game | ||||
| } | ||||
|  | ||||
| // we keep track of some stuff from one render to the next to see if certain | ||||
|   | ||||
							
								
								
									
										56
									
								
								pkg/gui/snake.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								pkg/gui/snake.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| package gui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/style" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/snake" | ||||
| ) | ||||
|  | ||||
| func (gui *Gui) startSnake() { | ||||
| 	view := gui.Views.Snake | ||||
|  | ||||
| 	game := snake.NewGame(view.Width(), view.Height(), gui.renderSnakeGame, gui.c.LogAction) | ||||
| 	gui.snakeGame = game | ||||
| 	game.Start() | ||||
| } | ||||
|  | ||||
| func (gui *Gui) renderSnakeGame(cells [][]snake.CellType, alive bool) { | ||||
| 	view := gui.Views.Snake | ||||
|  | ||||
| 	if !alive { | ||||
| 		_ = gui.c.ErrorMsg(gui.Tr.YouDied) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	output := drawSnakeGame(cells) | ||||
|  | ||||
| 	view.Clear() | ||||
| 	fmt.Fprint(view, output) | ||||
| 	gui.c.Render() | ||||
| } | ||||
|  | ||||
| func drawSnakeGame(cells [][]snake.CellType) string { | ||||
| 	writer := &strings.Builder{} | ||||
|  | ||||
| 	for i, row := range cells { | ||||
| 		for _, cell := range row { | ||||
| 			switch cell { | ||||
| 			case snake.None: | ||||
| 				writer.WriteString(" ") | ||||
| 			case snake.Snake: | ||||
| 				writer.WriteString("█") | ||||
| 			case snake.Food: | ||||
| 				writer.WriteString(style.FgMagenta.Sprint("█")) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if i < len(cells) { | ||||
| 			writer.WriteString("\n") | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	output := writer.String() | ||||
| 	return output | ||||
| } | ||||
| @@ -40,6 +40,9 @@ type Views struct { | ||||
| 	Suggestions   *gocui.View | ||||
| 	Tooltip       *gocui.View | ||||
| 	Extras        *gocui.View | ||||
|  | ||||
| 	// for playing the easter egg snake game | ||||
| 	Snake *gocui.View | ||||
| } | ||||
|  | ||||
| type viewNameMapping struct { | ||||
| @@ -58,6 +61,7 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping { | ||||
| 		// first layer. Ordering within this layer does not matter because there are | ||||
| 		// no overlapping views | ||||
| 		{viewPtr: &gui.Views.Status, name: "status"}, | ||||
| 		{viewPtr: &gui.Views.Snake, name: "snake"}, | ||||
| 		{viewPtr: &gui.Views.Submodules, name: "submodules"}, | ||||
| 		{viewPtr: &gui.Views.Files, name: "files"}, | ||||
| 		{viewPtr: &gui.Views.Tags, name: "tags"}, | ||||
| @@ -220,5 +224,8 @@ func (gui *Gui) createAllViews() error { | ||||
| 	gui.Views.Extras.Autoscroll = true | ||||
| 	gui.Views.Extras.Wrap = true | ||||
|  | ||||
| 	gui.Views.Snake.Title = gui.c.Tr.SnakeTitle | ||||
| 	gui.Views.Snake.FgColor = gocui.ColorGreen | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,8 @@ type TranslationSet struct { | ||||
| 	BranchesTitle                       string | ||||
| 	CommitsTitle                        string | ||||
| 	StashTitle                          string | ||||
| 	SnakeTitle                          string | ||||
| 	EasterEgg                           string | ||||
| 	UnstagedChanges                     string | ||||
| 	StagedChanges                       string | ||||
| 	MainTitle                           string | ||||
| @@ -213,6 +215,7 @@ type TranslationSet struct { | ||||
| 	ErrorOccurred                       string | ||||
| 	NoRoom                              string | ||||
| 	YouAreHere                          string | ||||
| 	YouDied                             string | ||||
| 	LcRewordNotSupported                string | ||||
| 	LcCherryPickCopy                    string | ||||
| 	LcCherryPickCopyRange               string | ||||
| @@ -665,6 +668,8 @@ func EnglishTranslationSet() TranslationSet { | ||||
| 		BranchesTitle:                       "Branches", | ||||
| 		CommitsTitle:                        "Commits", | ||||
| 		StashTitle:                          "Stash", | ||||
| 		SnakeTitle:                          "Snake", | ||||
| 		EasterEgg:                           "easter egg", | ||||
| 		UnstagedChanges:                     `Unstaged Changes`, | ||||
| 		StagedChanges:                       `Staged Changes`, | ||||
| 		MainTitle:                           "Main", | ||||
| @@ -861,6 +866,7 @@ func EnglishTranslationSet() TranslationSet { | ||||
| 		ErrorOccurred:                       "An error occurred! Please create an issue at", | ||||
| 		NoRoom:                              "Not enough room", | ||||
| 		YouAreHere:                          "YOU ARE HERE", | ||||
| 		YouDied:                             "YOU DIED!", | ||||
| 		LcRewordNotSupported:                "rewording commits while interactively rebasing is not currently supported", | ||||
| 		LcCherryPickCopy:                    "copy commit (cherry-pick)", | ||||
| 		LcCherryPickCopyRange:               "copy commit range (cherry-pick)", | ||||
|   | ||||
| @@ -1,74 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/jesseduffield/lazygit/pkg/snake" | ||||
| ) | ||||
|  | ||||
| func main() { | ||||
| 	game := snake.NewGame(10, 10, render) | ||||
| 	ctx := context.Background() | ||||
| 	game.Start(ctx) | ||||
|  | ||||
| 	go func() { | ||||
| 		for { | ||||
| 			var input string | ||||
| 			fmt.Scanln(&input) | ||||
|  | ||||
| 			switch input { | ||||
| 			case "w": | ||||
| 				game.SetDirection(snake.Up) | ||||
| 			case "s": | ||||
| 				game.SetDirection(snake.Down) | ||||
| 			case "a": | ||||
| 				game.SetDirection(snake.Left) | ||||
| 			case "d": | ||||
| 				game.SetDirection(snake.Right) | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	time.Sleep(100 * time.Second) | ||||
| } | ||||
|  | ||||
| func render(cells [][]snake.CellType, alive bool) { | ||||
| 	if !alive { | ||||
| 		log.Fatal("YOU DIED!") | ||||
| 	} | ||||
|  | ||||
| 	writer := &strings.Builder{} | ||||
|  | ||||
| 	width := len(cells[0]) | ||||
|  | ||||
| 	writer.WriteString(strings.Repeat("\n", 20)) | ||||
|  | ||||
| 	writer.WriteString(strings.Repeat("█", width+2) + "\n") | ||||
|  | ||||
| 	for _, row := range cells { | ||||
| 		writer.WriteString("█") | ||||
|  | ||||
| 		for _, cell := range row { | ||||
| 			switch cell { | ||||
| 			case snake.None: | ||||
| 				writer.WriteString(" ") | ||||
| 			case snake.Snake: | ||||
| 				writer.WriteString("X") | ||||
| 			case snake.Food: | ||||
| 				writer.WriteString("o") | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		writer.WriteString("█") | ||||
|  | ||||
| 		writer.WriteString("\n") | ||||
| 	} | ||||
|  | ||||
| 	writer.WriteString(strings.Repeat("█", width+2)) | ||||
|  | ||||
| 	fmt.Println(writer.String()) | ||||
| } | ||||
| @@ -1,14 +1,47 @@ | ||||
| package snake | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/samber/lo" | ||||
| ) | ||||
|  | ||||
| type Game struct { | ||||
| 	// width/height of the board | ||||
| 	width  int | ||||
| 	height int | ||||
|  | ||||
| 	// function for rendering the game. If alive is false, the cells are expected | ||||
| 	// to be ignored. | ||||
| 	render func(cells [][]CellType, alive bool) | ||||
|  | ||||
| 	// closed when the game is exited | ||||
| 	exit chan (struct{}) | ||||
|  | ||||
| 	// channel for specifying the direction the player wants the snake to go in | ||||
| 	setNewDir chan (Direction) | ||||
|  | ||||
| 	// allows logging for debugging | ||||
| 	logger func(string) | ||||
|  | ||||
| 	// putting this on the struct for deterministic testing | ||||
| 	randIntFn func(int) int | ||||
| } | ||||
|  | ||||
| type State struct { | ||||
| 	// first element is the head, final element is the tail | ||||
| 	snakePositions []Position | ||||
|  | ||||
| 	foodPosition Position | ||||
|  | ||||
| 	// direction of the snake | ||||
| 	direction Direction | ||||
| 	// direction as of the end of the last tick. We hold onto this so that | ||||
| 	// the snake can't do a 180 turn inbetween ticks | ||||
| 	lastTickDirection Direction | ||||
| } | ||||
|  | ||||
| type Position struct { | ||||
| 	x int | ||||
| 	y int | ||||
| @@ -31,70 +64,75 @@ const ( | ||||
| 	Food | ||||
| ) | ||||
|  | ||||
| type State struct { | ||||
| 	// first element is the head, final element is the tail | ||||
| 	snakePositions []Position | ||||
| 	direction      Direction | ||||
| 	foodPosition   Position | ||||
| } | ||||
|  | ||||
| type Game struct { | ||||
| 	state State | ||||
|  | ||||
| 	width  int | ||||
| 	height int | ||||
| 	render func(cells [][]CellType, alive bool) | ||||
|  | ||||
| 	randIntFn func(int) int | ||||
| } | ||||
|  | ||||
| func NewGame(width, height int, render func(cells [][]CellType, dead bool)) *Game { | ||||
| func NewGame(width, height int, render func(cells [][]CellType, alive bool), logger func(string)) *Game { | ||||
| 	return &Game{ | ||||
| 		width:     width, | ||||
| 		height:    height, | ||||
| 		render:    render, | ||||
| 		randIntFn: rand.Intn, | ||||
| 		exit:      make(chan struct{}), | ||||
| 		logger:    logger, | ||||
| 		setNewDir: make(chan Direction), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (self *Game) Start(ctx context.Context) { | ||||
| 	self.initializeState() | ||||
| func (self *Game) Start() { | ||||
| 	go self.gameLoop() | ||||
| } | ||||
|  | ||||
| 	go func() { | ||||
| 		for { | ||||
| 			select { | ||||
| 			case <-ctx.Done(): | ||||
| func (self *Game) Exit() { | ||||
| 	close(self.exit) | ||||
| } | ||||
|  | ||||
| func (self *Game) SetDirection(direction Direction) { | ||||
| 	self.setNewDir <- direction | ||||
| } | ||||
|  | ||||
| func (self *Game) gameLoop() { | ||||
| 	state := self.initializeState() | ||||
| 	var alive bool | ||||
|  | ||||
| 	self.render(self.getCells(state), true) | ||||
|  | ||||
| 	ticker := time.NewTicker(time.Duration(75) * time.Millisecond) | ||||
|  | ||||
| 	for { | ||||
| 		select { | ||||
| 		case <-self.exit: | ||||
| 			return | ||||
| 		case dir := <-self.setNewDir: | ||||
| 			state.direction = self.newDirection(state, dir) | ||||
| 		case <-ticker.C: | ||||
| 			state, alive = self.tick(state) | ||||
| 			self.render(self.getCells(state), alive) | ||||
| 			if !alive { | ||||
| 				return | ||||
| 			case <-time.After(time.Duration(500/self.getSpeed()) * time.Millisecond): | ||||
| 				fmt.Println("updating") | ||||
|  | ||||
| 				alive := self.tick() | ||||
| 				self.render(self.getCells(), alive) | ||||
| 				if !alive { | ||||
| 					return | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (self *Game) initializeState() { | ||||
| func (self *Game) initializeState() State { | ||||
| 	centerOfScreen := Position{self.width / 2, self.height / 2} | ||||
| 	snakePositions := []Position{centerOfScreen} | ||||
|  | ||||
| 	self.state = State{ | ||||
| 		snakePositions: []Position{centerOfScreen}, | ||||
| 	state := State{ | ||||
| 		snakePositions: snakePositions, | ||||
| 		direction:      Right, | ||||
| 		foodPosition:   self.newFoodPos(snakePositions), | ||||
| 	} | ||||
|  | ||||
| 	self.state.foodPosition = self.setNewFoodPos() | ||||
| 	return state | ||||
| } | ||||
|  | ||||
| // assume the player never actually wins, meaning we don't get stuck in a loop | ||||
| func (self *Game) setNewFoodPos() Position { | ||||
| 	for i := 0; i < 1000; i++ { | ||||
| func (self *Game) newFoodPos(snakePositions []Position) Position { | ||||
| 	// arbitrarily setting a limit of attempts to place food | ||||
| 	attemptLimit := 1000 | ||||
|  | ||||
| 	for i := 0; i < attemptLimit; i++ { | ||||
| 		newFoodPos := Position{self.randIntFn(self.width), self.randIntFn(self.height)} | ||||
|  | ||||
| 		if !lo.Contains(self.state.snakePositions, newFoodPos) { | ||||
| 		if !lo.Contains(snakePositions, newFoodPos) { | ||||
| 			return newFoodPos | ||||
| 		} | ||||
| 	} | ||||
| @@ -103,10 +141,13 @@ func (self *Game) setNewFoodPos() Position { | ||||
| } | ||||
|  | ||||
| // returns whether the snake is alive | ||||
| func (self *Game) tick() bool { | ||||
| 	newHeadPos := self.state.snakePositions[0] | ||||
| func (self *Game) tick(currentState State) (State, bool) { | ||||
| 	nextState := currentState // copy by value | ||||
| 	newHeadPos := nextState.snakePositions[0] | ||||
|  | ||||
| 	switch self.state.direction { | ||||
| 	nextState.lastTickDirection = nextState.direction | ||||
|  | ||||
| 	switch nextState.direction { | ||||
| 	case Up: | ||||
| 		newHeadPos.y-- | ||||
| 	case Down: | ||||
| @@ -117,30 +158,25 @@ func (self *Game) tick() bool { | ||||
| 		newHeadPos.x++ | ||||
| 	} | ||||
|  | ||||
| 	if newHeadPos.x < 0 || newHeadPos.x >= self.width || newHeadPos.y < 0 || newHeadPos.y >= self.height { | ||||
| 		return false | ||||
| 	outOfBounds := newHeadPos.x < 0 || newHeadPos.x >= self.width || newHeadPos.y < 0 || newHeadPos.y >= self.height | ||||
| 	eatingOwnTail := lo.Contains(nextState.snakePositions, newHeadPos) | ||||
|  | ||||
| 	if outOfBounds || eatingOwnTail { | ||||
| 		return State{}, false | ||||
| 	} | ||||
|  | ||||
| 	if lo.Contains(self.state.snakePositions, newHeadPos) { | ||||
| 		return false | ||||
| 	} | ||||
| 	nextState.snakePositions = append([]Position{newHeadPos}, nextState.snakePositions...) | ||||
|  | ||||
| 	self.state.snakePositions = append([]Position{newHeadPos}, self.state.snakePositions...) | ||||
|  | ||||
| 	if newHeadPos == self.state.foodPosition { | ||||
| 		self.state.foodPosition = self.setNewFoodPos() | ||||
| 	if newHeadPos == nextState.foodPosition { | ||||
| 		nextState.foodPosition = self.newFoodPos(nextState.snakePositions) | ||||
| 	} else { | ||||
| 		self.state.snakePositions = self.state.snakePositions[:len(self.state.snakePositions)-1] | ||||
| 		nextState.snakePositions = nextState.snakePositions[:len(nextState.snakePositions)-1] | ||||
| 	} | ||||
|  | ||||
| 	return true | ||||
| 	return nextState, true | ||||
| } | ||||
|  | ||||
| func (self *Game) getSpeed() int { | ||||
| 	return len(self.state.snakePositions) | ||||
| } | ||||
|  | ||||
| func (self *Game) getCells() [][]CellType { | ||||
| func (self *Game) getCells(state State) [][]CellType { | ||||
| 	cells := make([][]CellType, self.height) | ||||
|  | ||||
| 	setCell := func(pos Position, value CellType) { | ||||
| @@ -151,15 +187,23 @@ func (self *Game) getCells() [][]CellType { | ||||
| 		cells[i] = make([]CellType, self.width) | ||||
| 	} | ||||
|  | ||||
| 	for _, pos := range self.state.snakePositions { | ||||
| 	for _, pos := range state.snakePositions { | ||||
| 		setCell(pos, Snake) | ||||
| 	} | ||||
|  | ||||
| 	setCell(self.state.foodPosition, Food) | ||||
| 	setCell(state.foodPosition, Food) | ||||
|  | ||||
| 	return cells | ||||
| } | ||||
|  | ||||
| func (self *Game) SetDirection(direction Direction) { | ||||
| 	self.state.direction = direction | ||||
| func (self *Game) newDirection(state State, direction Direction) Direction { | ||||
| 	// don't allow the snake to turn 180 degrees | ||||
| 	if (state.lastTickDirection == Up && direction == Down) || | ||||
| 		(state.lastTickDirection == Down && direction == Up) || | ||||
| 		(state.lastTickDirection == Left && direction == Right) || | ||||
| 		(state.lastTickDirection == Right && direction == Left) { | ||||
| 		return state.direction | ||||
| 	} | ||||
|  | ||||
| 	return direction | ||||
| } | ||||
|   | ||||
| @@ -14,49 +14,51 @@ func TestSnake(t *testing.T) { | ||||
| 	}{ | ||||
| 		{ | ||||
| 			state: State{ | ||||
| 				snakePositions: []Position{{x: 5, y: 5}}, | ||||
| 				direction:      Right, | ||||
| 				foodPosition:   Position{x: 9, y: 9}, | ||||
| 				snakePositions:    []Position{{x: 5, y: 5}}, | ||||
| 				direction:         Right, | ||||
| 				lastTickDirection: Right, | ||||
| 				foodPosition:      Position{x: 9, y: 9}, | ||||
| 			}, | ||||
| 			expectedState: State{ | ||||
| 				snakePositions: []Position{{x: 6, y: 5}}, | ||||
| 				direction:      Right, | ||||
| 				foodPosition:   Position{x: 9, y: 9}, | ||||
| 				snakePositions:    []Position{{x: 6, y: 5}}, | ||||
| 				direction:         Right, | ||||
| 				lastTickDirection: Right, | ||||
| 				foodPosition:      Position{x: 9, y: 9}, | ||||
| 			}, | ||||
| 			expectedAlive: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			state: State{ | ||||
| 				snakePositions: []Position{{x: 5, y: 5}, {x: 4, y: 5}, {x: 4, y: 4}, {x: 5, y: 4}}, | ||||
| 				direction:      Up, | ||||
| 				foodPosition:   Position{x: 9, y: 9}, | ||||
| 				snakePositions:    []Position{{x: 5, y: 5}, {x: 4, y: 5}, {x: 4, y: 4}, {x: 5, y: 4}}, | ||||
| 				direction:         Up, | ||||
| 				lastTickDirection: Up, | ||||
| 				foodPosition:      Position{x: 9, y: 9}, | ||||
| 			}, | ||||
| 			expectedState: State{}, | ||||
| 			expectedAlive: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			state: State{ | ||||
| 				snakePositions: []Position{{x: 5, y: 5}}, | ||||
| 				direction:      Right, | ||||
| 				foodPosition:   Position{x: 6, y: 5}, | ||||
| 				snakePositions:    []Position{{x: 5, y: 5}}, | ||||
| 				direction:         Right, | ||||
| 				lastTickDirection: Right, | ||||
| 				foodPosition:      Position{x: 6, y: 5}, | ||||
| 			}, | ||||
| 			expectedState: State{ | ||||
| 				snakePositions: []Position{{x: 6, y: 5}, {x: 5, y: 5}}, | ||||
| 				direction:      Right, | ||||
| 				foodPosition:   Position{x: 8, y: 8}, | ||||
| 				snakePositions:    []Position{{x: 6, y: 5}, {x: 5, y: 5}}, | ||||
| 				direction:         Right, | ||||
| 				lastTickDirection: Right, | ||||
| 				foodPosition:      Position{x: 8, y: 8}, | ||||
| 			}, | ||||
| 			expectedAlive: true, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, scenario := range scenarios { | ||||
| 		game := NewGame(10, 10, nil) | ||||
| 		game.state = scenario.state | ||||
| 		game := NewGame(10, 10, nil, func(string) {}) | ||||
| 		game.randIntFn = func(int) int { return 8 } | ||||
| 		alive := game.tick() | ||||
| 		state, alive := game.tick(scenario.state) | ||||
| 		assert.Equal(t, scenario.expectedAlive, alive) | ||||
| 		if scenario.expectedAlive { | ||||
| 			assert.EqualValues(t, scenario.expectedState, game.state) | ||||
| 		} | ||||
| 		assert.EqualValues(t, scenario.expectedState, state) | ||||
| 	} | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user