1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-01-22 05:29:44 +02:00

integrate snake game into lazygit

This commit is contained in:
Jesse Duffield 2022-12-30 11:34:01 +11:00
parent 81281a49b2
commit af5b3be286
14 changed files with 341 additions and 162 deletions

View File

@ -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 {

View File

@ -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',
},
},
},

View File

@ -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,

View File

@ -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(),

View File

@ -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() {

View 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)
}

View File

@ -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()

View File

@ -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
View 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
}

View File

@ -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
}

View File

@ -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)",

View File

@ -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())
}

View File

@ -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
}

View File

@ -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)
}
}