├── 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 | 
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 |
--------------------------------------------------------------------------------