├── memoryalike.gif ├── go.mod ├── menu.go ├── go.sum ├── README.md ├── LICENSE ├── difficulties.go ├── state_test.go ├── main.go ├── render.go └── state.go /memoryalike.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bios-Marcel/memoryalike/HEAD/memoryalike.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Bios-Marcel/memoryalike 2 | 3 | go 1.14 4 | 5 | require github.com/gdamore/tcell v1.4.0 6 | -------------------------------------------------------------------------------- /menu.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type menuState struct { 4 | selectedDifficulty int 5 | } 6 | 7 | func newMenuState() *menuState { 8 | return &menuState{ 9 | //Default difficulty normal 10 | selectedDifficulty: 1, 11 | } 12 | } 13 | 14 | // getDiffculty returns the diffculty chosen by the user. 15 | func (menuState *menuState) getDiffculty() *difficulty { 16 | return difficulties[menuState.selectedDifficulty] 17 | } 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 2 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 3 | github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= 4 | github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= 5 | github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= 6 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 7 | github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= 8 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 9 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10= 10 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 11 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 12 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Memoryalike 2 | 3 | Just a tiny terminal game where you have to remember the characters presented 4 | to you and hit the respective key when they are hidden by a box. It's kinda 5 | like memory. 6 | 7 | ![preview](memoryalike.gif) 8 | 9 | ## Rules 10 | 11 | The rules are quite simple. Your goal is to guess all characters correctly. 12 | If you can't do that, you can't win. If at least 40% of the board is 13 | hidden, you lose. So speed does matter. Every incorrect guess will give you 14 | minus points. While achieving a victory might not be easy, you can still get 15 | a good loss. 16 | 17 | ## Controls 18 | 19 | You can give up on ESC and restart on Ctrl + R. 20 | 21 | If you hit ESC again while still in the "Game Over" / "Victory" 22 | screen, you'll be taken to the main menu. 23 | 24 | ## How to use it 25 | 26 | You need to download Golang 1.14 or later and either create an executable 27 | with `go build .` or run it directly via `go run .`. 28 | 29 | ## What's up with the name 30 | 31 | That's as far as my imagination goes. If you have suggestions for a better 32 | name, feel free to hit me up. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Marcel Schramm 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /difficulties.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type difficulty struct { 6 | visibleName string 7 | 8 | startDelay time.Duration 9 | hideTimes time.Duration 10 | 11 | correctGuessPoints int 12 | invalidKeyPressPenality int 13 | 14 | rowCount int 15 | columnCount int 16 | runePools [][]rune 17 | } 18 | 19 | var difficulties = []*difficulty{ 20 | { 21 | visibleName: "easy", 22 | correctGuessPoints: 5, 23 | //You better take easy seriously! 24 | invalidKeyPressPenality: 4, 25 | rowCount: 3, 26 | columnCount: 2, 27 | startDelay: 750 * time.Millisecond, 28 | hideTimes: 1250 * time.Millisecond, 29 | runePools: [][]rune{ 30 | runeRange('1', '6'), 31 | }, 32 | }, { 33 | visibleName: "normal", 34 | correctGuessPoints: 5, 35 | invalidKeyPressPenality: 2, 36 | rowCount: 3, 37 | columnCount: 3, 38 | startDelay: 1500 * time.Millisecond, 39 | hideTimes: 1250 * time.Millisecond, 40 | runePools: [][]rune{ 41 | runeRange('0', '9'), 42 | }, 43 | }, { 44 | visibleName: "hard", 45 | correctGuessPoints: 5, 46 | invalidKeyPressPenality: 5, 47 | rowCount: 3, 48 | columnCount: 3, 49 | startDelay: 1500 * time.Millisecond, 50 | hideTimes: 1500 * time.Millisecond, 51 | runePools: [][]rune{ 52 | runeRange('a', 'z'), 53 | }, 54 | }, { 55 | visibleName: "extreme", 56 | correctGuessPoints: 4, 57 | invalidKeyPressPenality: 5, 58 | rowCount: 4, 59 | columnCount: 3, 60 | startDelay: 1500 * time.Millisecond, 61 | hideTimes: 1500 * time.Millisecond, 62 | runePools: [][]rune{ 63 | runeRange('0', '9'), 64 | runeRange('a', 'z'), 65 | }, 66 | }, { 67 | visibleName: "nightmare", 68 | correctGuessPoints: 4, 69 | invalidKeyPressPenality: 10, 70 | rowCount: 5, 71 | columnCount: 5, 72 | startDelay: 2500 * time.Millisecond, 73 | hideTimes: 1500 * time.Millisecond, 74 | runePools: [][]rune{ 75 | runeRange('0', '9'), 76 | runeRange('a', 'z'), 77 | }, 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /state_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type guessType int 8 | 9 | const ( 10 | none guessType = iota 11 | anyhiddenRune 12 | anyShownRune 13 | nonExistantRune 14 | ) 15 | 16 | type stateIteration struct { 17 | hideRune bool 18 | input guessType 19 | expectedScore int 20 | expectedInvalidKeyPresses int 21 | expectedGameState gameState 22 | } 23 | 24 | // TestState tests the gamestate as a whole. E.g. simulating user interaction 25 | // and seeing whether the results are as expected. 26 | func TestState(t *testing.T) { 27 | testDifficulty := &difficulty{ 28 | visibleName: "easy", 29 | correctGuessPoints: 5, 30 | invalidKeyPressPenality: 2, 31 | rowCount: 3, 32 | columnCount: 2, 33 | runePools: [][]rune{ 34 | runeRange('1', '6'), 35 | }, 36 | } 37 | 38 | t.Run("No input gameover due to 50% hidden fields", func(t *testing.T) { 39 | iterations := []stateIteration{ 40 | {false, none, 0, 0, ongoing}, 41 | {true, none, 0, 0, ongoing}, 42 | {true, none, 0, 0, ongoing}, 43 | {true, none, 0, 0, gameOver}, 44 | } 45 | state := newGameSession(make(chan bool, 100), testDifficulty) 46 | runIterations(t, iterations, state) 47 | }) 48 | 49 | t.Run("invalid input without hidden fields", func(t *testing.T) { 50 | iterations := []stateIteration{ 51 | {false, nonExistantRune, -2, 1, ongoing}, 52 | {false, nonExistantRune, -4, 2, ongoing}, 53 | {false, nonExistantRune, -6, 3, ongoing}, 54 | {false, nonExistantRune, -8, 4, ongoing}, 55 | } 56 | state := newGameSession(make(chan bool, 100), testDifficulty) 57 | runIterations(t, iterations, state) 58 | }) 59 | 60 | t.Run("valid input without hidden fields", func(t *testing.T) { 61 | iterations := []stateIteration{ 62 | {false, anyShownRune, -2, 1, ongoing}, 63 | {false, anyShownRune, -4, 2, ongoing}, 64 | {false, anyShownRune, -6, 3, ongoing}, 65 | {false, anyShownRune, -8, 4, ongoing}, 66 | } 67 | state := newGameSession(make(chan bool, 100), testDifficulty) 68 | runIterations(t, iterations, state) 69 | }) 70 | 71 | t.Run("all guesses correct", func(t *testing.T) { 72 | iterations := []stateIteration{ 73 | {true, anyhiddenRune, 5, 0, ongoing}, 74 | {true, anyhiddenRune, 10, 0, ongoing}, 75 | {true, anyhiddenRune, 15, 0, ongoing}, 76 | {true, anyhiddenRune, 20, 0, ongoing}, 77 | {true, anyhiddenRune, 25, 0, ongoing}, 78 | {true, anyhiddenRune, 30, 0, victory}, 79 | } 80 | state := newGameSession(make(chan bool, 100), testDifficulty) 81 | runIterations(t, iterations, state) 82 | }) 83 | 84 | t.Run("one incorrect guess no gameover", func(t *testing.T) { 85 | iterations := []stateIteration{ 86 | {true, anyhiddenRune, 5, 0, ongoing}, 87 | {true, anyhiddenRune, 10, 0, ongoing}, 88 | {true, anyhiddenRune, 15, 0, ongoing}, 89 | {true, nonExistantRune, 13, 1, ongoing}, 90 | {true, anyhiddenRune, 18, 1, ongoing}, 91 | {true, anyhiddenRune, 23, 1, ongoing}, 92 | } 93 | state := newGameSession(make(chan bool, 100), testDifficulty) 94 | runIterations(t, iterations, state) 95 | }) 96 | 97 | t.Run("one incorrect guess with victory", func(t *testing.T) { 98 | iterations := []stateIteration{ 99 | {true, anyhiddenRune, 5, 0, ongoing}, 100 | {true, anyhiddenRune, 10, 0, ongoing}, 101 | {true, anyhiddenRune, 15, 0, ongoing}, 102 | {true, nonExistantRune, 13, 1, ongoing}, 103 | {true, anyhiddenRune, 18, 1, ongoing}, 104 | {true, anyhiddenRune, 23, 1, ongoing}, 105 | {false, anyhiddenRune, 28, 1, victory}, 106 | } 107 | state := newGameSession(make(chan bool, 100), testDifficulty) 108 | runIterations(t, iterations, state) 109 | }) 110 | } 111 | 112 | func runIterations(t *testing.T, iterations []stateIteration, state *gameSession) { 113 | for _, iteration := range iterations { 114 | if iteration.hideRune { 115 | state.hideRune() 116 | } 117 | 118 | switch iteration.input { 119 | case anyhiddenRune: 120 | for _, cell := range state.gameBoard { 121 | if cell.state == hidden { 122 | state.inputRunePress(cell.character) 123 | break 124 | } 125 | } 126 | case anyShownRune: 127 | for _, cell := range state.gameBoard { 128 | if cell.state == shown { 129 | state.inputRunePress(cell.character) 130 | break 131 | } 132 | } 133 | case nonExistantRune: 134 | state.inputRunePress('-') 135 | } 136 | 137 | if iteration.expectedInvalidKeyPresses != state.invalidKeyPresses { 138 | t.Errorf("Invalid keypresses %d, expected %d", state.invalidKeyPresses, iteration.expectedInvalidKeyPresses) 139 | } 140 | 141 | if iteration.expectedScore != state.score { 142 | t.Errorf("score %d, expected %d", state.score, iteration.expectedScore) 143 | } 144 | 145 | if iteration.expectedGameState != state.state { 146 | t.Errorf("gamestate %d, expected %d", state.state, iteration.expectedGameState) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gdamore/tcell" 7 | ) 8 | 9 | func main() { 10 | screen, screenCreationError := createScreen() 11 | if screenCreationError != nil { 12 | panic(screenCreationError) 13 | } 14 | 15 | //Cleans up the terminal buffer and returns it to the shell. 16 | defer screen.Fini() 17 | 18 | //renderer used for drawing the board and the menu. 19 | renderer := newRenderer() 20 | //menuState is reused throughout the runtime of the app. This allows 21 | //us to remember the selection inbetween sessions. 22 | menuState := newMenuState() 23 | 24 | //blocks till it's closed. 25 | openMenu(menuState, screen, renderer) 26 | 27 | renderNotificationChannel := make(chan bool) 28 | gameSession := newGameSession(renderNotificationChannel, menuState.getDiffculty()) 29 | gameSession.startRuneHidingCoroutine() 30 | 31 | //Listen for key input on the gameboard. 32 | go func() { 33 | for { 34 | switch event := screen.PollEvent().(type) { 35 | case *tcell.EventKey: 36 | if event.Key() == tcell.KeyCtrlC { 37 | screen.Fini() 38 | os.Exit(0) 39 | } else if event.Key() == tcell.KeyEscape { 40 | //SURRENDER! 41 | oldGameSession := gameSession 42 | oldGameSession.mutex.Lock() 43 | 44 | //When hitting ESC twice, e.g. when already in the 45 | //end-screen, we want to go to the menu instead. 46 | if oldGameSession.state != ongoing { 47 | openMenu(menuState, screen, renderer) 48 | //We have to reset the state, as it's still in the 49 | //"game over" state. 50 | gameSession = newGameSession(renderNotificationChannel, 51 | menuState.getDiffculty()) 52 | gameSession.startRuneHidingCoroutine() 53 | } else { 54 | oldGameSession.state = gameOver 55 | } 56 | oldGameSession.mutex.Unlock() 57 | renderNotificationChannel <- true 58 | } else if event.Key() == tcell.KeyCtrlR { 59 | //RESTART! 60 | //Remove previous game over message and such and create 61 | //a fresh state, as we needn't save any information for 62 | //the next session. 63 | oldGameSession := gameSession 64 | oldGameSession.mutex.Lock() 65 | 66 | //Make sure the state knows it's supposed to be dead. 67 | oldGameSession.state = gameOver 68 | screen.Clear() 69 | gameSession = newGameSession(renderNotificationChannel, 70 | menuState.getDiffculty()) 71 | gameSession.startRuneHidingCoroutine() 72 | gameSession.mutex.Lock() 73 | 74 | oldGameSession.mutex.Unlock() 75 | gameSession.mutex.Unlock() 76 | renderNotificationChannel <- true 77 | 78 | } else if event.Key() == tcell.KeyRune { 79 | gameSession.mutex.Lock() 80 | gameSession.inputRunePress(event.Rune()) 81 | gameSession.mutex.Unlock() 82 | } 83 | case *tcell.EventResize: 84 | gameSession.mutex.Lock() 85 | screen.Clear() 86 | gameSession.mutex.Unlock() 87 | renderNotificationChannel <- true 88 | //TODO Handle resize; Validate session; 89 | default: 90 | //Unsupported or irrelevant event 91 | } 92 | } 93 | }() 94 | 95 | //Gameloop; We draw whenever there's a frame-change. This means we 96 | //don't have any specific frame-rates and it could technically happen 97 | //that we don't draw for a while. The first frame is drawn without 98 | //waiting for a change, so that the screen doesn't stay empty. 99 | 100 | for { 101 | //We start lock before draw in order to avoid drawing crap. 102 | gameSession.mutex.Lock() 103 | renderer.drawGameBoard(screen, gameSession) 104 | gameSession.mutex.Unlock() 105 | 106 | <-renderNotificationChannel 107 | } 108 | } 109 | 110 | // openMenu draws the game menu and listens for keyboard input. 111 | // This method blocks until a difficulty has been selected. 112 | func openMenu(menuState *menuState, targetScreen tcell.Screen, renderer *renderer) { 113 | MENU_KEY_LOOP: 114 | for { 115 | //We draw the menu initially and then once after any event. 116 | renderer.drawMenu(targetScreen, menuState) 117 | 118 | switch event := targetScreen.PollEvent().(type) { 119 | case *tcell.EventKey: 120 | if event.Key() == tcell.KeyDown || event.Rune() == 's' || event.Rune() == 'k' { 121 | if menuState.selectedDifficulty >= len(difficulties)-1 { 122 | menuState.selectedDifficulty = 0 123 | } else { 124 | menuState.selectedDifficulty++ 125 | } 126 | } else if event.Key() == tcell.KeyUp || event.Rune() == 'w' || event.Rune() == 'j' { 127 | if menuState.selectedDifficulty <= 0 { 128 | menuState.selectedDifficulty = len(difficulties) - 1 129 | } else { 130 | menuState.selectedDifficulty-- 131 | } 132 | } else if event.Key() == tcell.KeyEnter { 133 | //We clear in order to get rid of the menu for sure. 134 | targetScreen.Clear() 135 | break MENU_KEY_LOOP 136 | //Implicitly proceed. 137 | } else if event.Key() == tcell.KeyCtrlC { 138 | targetScreen.Fini() 139 | os.Exit(0) 140 | } 141 | default: 142 | //Unsupported or irrelevant event 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | ) 8 | 9 | const ( 10 | chooseDifficultyText = "Choose difficulty" 11 | gameOverMessage = "GAME OVER" 12 | victoryMessage = "Congratulations! You have won!" 13 | restartMessage = "Hit 'Ctrl R' to restart or 'ESC' to show the menu." 14 | 15 | fullBlock = '█' 16 | checkMark = '✓' 17 | ) 18 | 19 | var titleStyle = tcell.StyleDefault.Bold(true) 20 | 21 | // renderer represents a utility object to present a gameSession on a 22 | // terminal screen. 23 | type renderer struct { 24 | horizontalSpacing int 25 | verticalSpacing int 26 | } 27 | 28 | // newRenderer creates a new reusable renderer. It can be used for any 29 | // gameSession and any screen. It is also able to draw the game menu. 30 | // The renderer itself is stateless, which is why it can be used for 31 | // multiple sessions and screens. Technically, you could draw on multiple 32 | // screens at once. 33 | func newRenderer() *renderer { 34 | return &renderer{ 35 | horizontalSpacing: 2, 36 | verticalSpacing: 1, 37 | } 38 | } 39 | 40 | // drawMenu draws the main menu of the game. It allows for selecting the 41 | // difficulty. Selected menu entries are rendered with the reverse attribute 42 | // activated. 43 | func (r *renderer) drawMenu(targetScreen tcell.Screen, sourceMenuState *menuState) { 44 | targetScreen.Clear() 45 | 46 | unselectedStyle := tcell.StyleDefault 47 | selectedStyle := tcell.StyleDefault.Reverse(true) 48 | 49 | determineStyle := func(difficulty int) tcell.Style { 50 | if sourceMenuState.selectedDifficulty == difficulty { 51 | return selectedStyle 52 | } 53 | 54 | return unselectedStyle 55 | } 56 | 57 | screenWidth, _ := targetScreen.Size() 58 | 59 | //Draw "Choose difficulties text" 60 | r.printStyledLine(targetScreen, chooseDifficultyText, titleStyle, 61 | getHorizontalCenterForText(screenWidth, chooseDifficultyText), 2) 62 | 63 | //Draw difficulties into menu. 64 | nextY := 4 65 | for diffIndex, diff := range difficulties { 66 | r.printStyledLine(targetScreen, diff.visibleName, determineStyle(diffIndex), 67 | getHorizontalCenterForText(screenWidth, diff.visibleName), nextY) 68 | nextY += 2 69 | } 70 | 71 | targetScreen.Show() 72 | } 73 | 74 | // getHorizontalCenterForText returns the x-coordinate at which the caller must 75 | // start drawing in order to horizontally center given text. Note that this 76 | // function doesn't take rune-width into count, as it is currently irrelevant. 77 | func getHorizontalCenterForText(screenWidth int, text string) int { 78 | return screenWidth/2 - len(text)/2 79 | } 80 | 81 | // drawGameBoard fills the targetScreen with data from the passed gameSession. 82 | func (r *renderer) drawGameBoard(targetScreen tcell.Screen, session *gameSession) { 83 | boardWidth := (session.difficulty.rowCount / 2 * (r.horizontalSpacing + 1)) 84 | boardHeight := (session.difficulty.columnCount / 2 * (r.verticalSpacing + 1)) 85 | 86 | width, height := targetScreen.Size() 87 | 88 | //Draw gameBoard to screen. This block contains no game-logic. 89 | //We draw this regardless of the game state, since the player 90 | //wouldn't be able to see the effect of their last move otherwise. 91 | nextY := height/2 - boardHeight 92 | for y := 0; y < session.difficulty.columnCount; y++ { 93 | nextX := width/2 - boardWidth 94 | for x := 0; x < session.difficulty.rowCount; x++ { 95 | var renderRune rune 96 | boardCell := session.gameBoard[x+(session.difficulty.rowCount*y)] 97 | switch boardCell.state { 98 | case shown: 99 | renderRune = boardCell.character 100 | case hidden: 101 | renderRune = fullBlock 102 | case guessed: 103 | renderRune = checkMark 104 | } 105 | 106 | targetScreen.SetContent(nextX, nextY, renderRune, nil, tcell.StyleDefault) 107 | nextX += r.horizontalSpacing + 1 108 | } 109 | nextY += r.verticalSpacing + 1 110 | } 111 | 112 | switch session.state { 113 | case victory: 114 | r.printStyledLine(targetScreen, victoryMessage, titleStyle, width/2-len(victoryMessage)/2, 2) 115 | case gameOver: 116 | r.printStyledLine(targetScreen, gameOverMessage, titleStyle, width/2-len(gameOverMessage)/2, 2) 117 | } 118 | 119 | if session.state != ongoing { 120 | r.printGameResults(width, targetScreen, session) 121 | } 122 | 123 | targetScreen.Show() 124 | } 125 | 126 | // printGameResults prints the score, amount of invalid key presses and 127 | // information on how to restart or get to the menu. 128 | func (r *renderer) printGameResults(width int, targetScreen tcell.Screen, session *gameSession) { 129 | scoreMessage := r.createScoreMessage(session) 130 | r.printLine(targetScreen, scoreMessage, width/2-len(scoreMessage)/2, 4) 131 | invalidKeyPressesMessage := r.createInvalidKeyPressesMessage(session) 132 | r.printLine(targetScreen, invalidKeyPressesMessage, width/2-len(invalidKeyPressesMessage)/2, 5) 133 | r.printLine(targetScreen, restartMessage, width/2-len(restartMessage)/2, 7) 134 | } 135 | 136 | func (r *renderer) createInvalidKeyPressesMessage(session *gameSession) string { 137 | return fmt.Sprintf("Amount of invalid key presses: %d", session.invalidKeyPresses) 138 | } 139 | 140 | func (r *renderer) createScoreMessage(session *gameSession) string { 141 | return fmt.Sprintf("Your score is %d out of possible %d", 142 | session.score, len(session.gameBoard)*session.difficulty.correctGuessPoints) 143 | } 144 | 145 | // printLine draws the given text at the desired position. The text will be 146 | // drawn using the default style (tcell.StyleDefault). 147 | func (r *renderer) printLine(targetScreen tcell.Screen, message string, x, y int) { 148 | r.printStyledLine(targetScreen, message, tcell.StyleDefault, x, y) 149 | } 150 | 151 | // printStyledLine is the same as printLine, but you can override the default 152 | // text style. 153 | func (r *renderer) printStyledLine(targetScreen tcell.Screen, message string, style tcell.Style, x, y int) { 154 | nextX := x 155 | for _, char := range message { 156 | targetScreen.SetContent(nextX, y, char, nil, style) 157 | nextX++ 158 | } 159 | } 160 | 161 | // createScreen generates a ready to use screen. The screen has 162 | // no cursor and doesn't support mouse eventing. 163 | func createScreen() (tcell.Screen, error) { 164 | screen, screenCreationError := tcell.NewScreen() 165 | if screenCreationError != nil { 166 | return nil, screenCreationError 167 | } 168 | 169 | screenInitError := screen.Init() 170 | if screenInitError != nil { 171 | return nil, screenInitError 172 | } 173 | 174 | //Make sure it's disable, even though it should be by default. 175 | screen.DisableMouse() 176 | //Make sure cursor is hidden by default. 177 | screen.HideCursor() 178 | 179 | return screen, nil 180 | } 181 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math/rand" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type cellState int 12 | 13 | const ( 14 | shown cellState = iota 15 | hidden 16 | guessed 17 | ) 18 | 19 | // gameBoardCell represents a single character visible to the user. 20 | type gameBoardCell struct { 21 | character rune 22 | state cellState 23 | } 24 | 25 | type gameState int 26 | 27 | const ( 28 | ongoing = iota 29 | gameOver 30 | victory 31 | ) 32 | 33 | // gameSession represents all game state for a session. All operations on 34 | // this state should make sure that the state is locked using the internal 35 | // mutex. 36 | type gameSession struct { 37 | mutex *sync.Mutex 38 | renderNotificationChannel chan bool 39 | 40 | state gameState 41 | score int 42 | //invalidKeyPresses counts the invalid keyPresses made by the player. 43 | //This only tracks runes, not stuff like CTRL, ArrowUp ... 44 | invalidKeyPresses int 45 | 46 | gameBoard []*gameBoardCell 47 | indicesToHide []int 48 | 49 | difficulty *difficulty 50 | } 51 | 52 | // newGameSession produces a ready-to-use session state. The ticker that 53 | // hides cell contents is started on construction. 54 | func newGameSession(renderNotificationChannel chan bool, difficulty *difficulty) *gameSession { 55 | characterSet, charSetError := getCharacterSet(difficulty.rowCount*difficulty.columnCount, difficulty.runePools...) 56 | if charSetError != nil { 57 | panic(charSetError) 58 | } 59 | gameBoard := make([]*gameBoardCell, 0, len(characterSet)) 60 | for _, char := range characterSet { 61 | gameBoard = append(gameBoard, &gameBoardCell{char, shown}) 62 | } 63 | 64 | //This decides which cells will be hidden in which order. If this stack 65 | //is empty, the game is over. 66 | indicesToHide := make([]int, len(gameBoard)) 67 | for i := 0; i < len(indicesToHide); i++ { 68 | indicesToHide[i] = i 69 | } 70 | rand.Seed(time.Now().Unix()) 71 | rand.Shuffle(len(indicesToHide), func(a, b int) { 72 | indicesToHide[a], indicesToHide[b] = indicesToHide[b], indicesToHide[a] 73 | }) 74 | 75 | return &gameSession{ 76 | mutex: &sync.Mutex{}, 77 | renderNotificationChannel: renderNotificationChannel, 78 | 79 | state: ongoing, 80 | 81 | gameBoard: gameBoard, 82 | indicesToHide: indicesToHide, 83 | 84 | difficulty: difficulty, 85 | } 86 | } 87 | 88 | // startRuneHidingCoroutine starts a goroutine that hides one rune on the 89 | // gameboard each X milliseconds. X is defined by the hidingTime defined in 90 | // the referenced difficulty of the session. If no more characters can be 91 | // hidden or the game has ended, this coroutine exists. 92 | func (s *gameSession) startRuneHidingCoroutine() { 93 | go func() { 94 | <-time.NewTimer(s.difficulty.startDelay).C 95 | 96 | characterHideTicker := time.NewTicker(s.difficulty.hideTimes) 97 | for { 98 | <-characterHideTicker.C 99 | 100 | if len(s.indicesToHide) == 0 || s.state != ongoing { 101 | characterHideTicker.Stop() 102 | break 103 | } 104 | 105 | s.mutex.Lock() 106 | s.hideRune() 107 | s.mutex.Unlock() 108 | } 109 | }() 110 | } 111 | 112 | // hideRune hides a rune that's currently visible on the gameboard. 113 | func (s *gameSession) hideRune() { 114 | nextIndexToHide := len(s.indicesToHide) - 1 115 | if nextIndexToHide != -1 { 116 | s.gameBoard[s.indicesToHide[nextIndexToHide]].state = hidden 117 | s.indicesToHide = s.indicesToHide[:len(s.indicesToHide)-1] 118 | s.updateGameState() 119 | } 120 | } 121 | 122 | // applyKeyEvents checks the key-events for possible matches and updates the 123 | // gameSession accordingly. Meaning that if a match between a hidden 124 | // cell, it's underlying character and the input rune is found, the player 125 | // gets a point. 126 | func (s *gameSession) inputRunePress(pressed rune) { 127 | //Game is already over. All further checks are unnecessary. 128 | if s.state != ongoing { 129 | return 130 | } 131 | 132 | for _, cell := range s.gameBoard { 133 | if cell.character == pressed { 134 | if cell.state == hidden { 135 | cell.state = guessed 136 | s.updateGameState() 137 | return 138 | } 139 | 140 | break 141 | } 142 | } 143 | 144 | //Pressed rune wasn't hidden or wasn't present, therefore the user gets 145 | //minus points 146 | s.invalidKeyPresses++ 147 | s.updateGameState() 148 | } 149 | 150 | // updateGameState determines whether the game is over and what the players 151 | // score is. 152 | func (s *gameSession) updateGameState() { 153 | //Game is already over. All further checks are unnecessary. 154 | if s.state != ongoing { 155 | return 156 | } 157 | 158 | var guessedCellCount, hiddenCellCount, shownCellCount int 159 | for _, cell := range s.gameBoard { 160 | if cell.state == hidden { 161 | hiddenCellCount++ 162 | } else if cell.state == guessed { 163 | guessedCellCount++ 164 | } else { 165 | shownCellCount++ 166 | } 167 | } 168 | 169 | s.score = guessedCellCount*s.difficulty.correctGuessPoints - 170 | s.invalidKeyPresses*s.difficulty.invalidKeyPressPenality 171 | 172 | //if at least 40 percent of the board is hidden, the player loses. 173 | //In case of a normal game for example, this should mean 4 hidden cells. 174 | if hiddenCellCount != 0 && float32(hiddenCellCount)/float32(len(s.gameBoard)) >= 0.4 { 175 | s.state = gameOver 176 | } else if shownCellCount == 0 && hiddenCellCount == 0 { 177 | //The game is only over if all cells have been guessed correctly 178 | 179 | //Even if all cells have been guessed correctly, we deem zero score 180 | //as a loss, as the player probably smashed his keyboard randomly. 181 | if s.score <= 0 { 182 | s.state = gameOver 183 | } else { 184 | s.state = victory 185 | } 186 | } 187 | 188 | // In order to avoid dead-locking the caller. 189 | go func() { 190 | s.renderNotificationChannel <- true 191 | }() 192 | } 193 | 194 | // runeRange creates a new rune array containing all the runes between the 195 | // two passed ones. Both from and to are inclusive. 196 | func runeRange(from, to rune) []rune { 197 | runes := make([]rune, 0, to-from+1) 198 | for r := from; r <= to; r++ { 199 | runes = append(runes, r) 200 | } 201 | return runes 202 | } 203 | 204 | // getCharacterSet creates a unique set of characters to be used for the 205 | // game board. The size must be greater than 0. For sourcing the 206 | // characters, the rune arrays passed to this method will be used. 207 | func getCharacterSet(size int, pools ...[]rune) ([]rune, error) { 208 | var availableCharacters []rune 209 | for _, pool := range pools { 210 | availableCharacters = append(availableCharacters, pool...) 211 | } 212 | 213 | if size > len(availableCharacters) { 214 | return nil, fmt.Errorf("the characterset can't be bigger than %d; you passed %d", len(availableCharacters), size) 215 | } 216 | 217 | if size <= 0 { 218 | return nil, errors.New("the request amount of characters must be greater than 0") 219 | } 220 | 221 | rand.Seed(time.Now().Unix()) 222 | rand.Shuffle(len(availableCharacters), func(a, b int) { 223 | availableCharacters[a], availableCharacters[b] = availableCharacters[b], availableCharacters[a] 224 | }) 225 | 226 | return availableCharacters[0:size], nil 227 | } 228 | --------------------------------------------------------------------------------