From 4f1760ad57bb67ad9ff93ccdaef6a31e8b6e628f Mon Sep 17 00:00:00 2001 From: Sander Schobers Date: Wed, 11 Aug 2021 08:03:02 +0200 Subject: [PATCH] Add resume play. Add highscores. --- TODO.md | 2 +- cmd/tins2021/app.go | 3 +- cmd/tins2021/appcontext.go | 17 ++++++- cmd/tins2021/highscores.go | 52 +++++++++++++++++++++ cmd/tins2021/levelcontroller.go | 27 +++++++++++ cmd/tins2021/mainmenu.go | 4 ++ cmd/tins2021/tins2021.go | 8 ++++ io.go | 36 +++++++++++++- score.go | 83 +++++++++++++++++++++++++++++++++ score_test.go | 70 +++++++++++++++++++++++++++ settings.go | 22 +++------ 11 files changed, 303 insertions(+), 21 deletions(-) create mode 100644 cmd/tins2021/highscores.go create mode 100644 score.go create mode 100644 score_test.go diff --git a/TODO.md b/TODO.md index b23cef4..62e9e20 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ - [X] Increase difficulty. - [ ] Add music & sounds. -- [ ] Keep score/difficulty level (resume & restart). +- [X] Keep score/difficulty level (resume & restart). - [X] ~~Explain controls on info page~~ add settings for controls. - [X] Fix usage of go/embed (and remove rice again). - [X] Add monster animations (~~jumping on tile &~~ towards new tile). diff --git a/cmd/tins2021/app.go b/cmd/tins2021/app.go index 682b3ae..2bf15d6 100644 --- a/cmd/tins2021/app.go +++ b/cmd/tins2021/app.go @@ -10,6 +10,7 @@ type app struct { ui.Proxy settings *tins2021.Settings + score *tins2021.ScoreState context *appContext } @@ -55,7 +56,7 @@ func (a *app) Init(ctx ui.Context) error { ctx.Overlays().AddOnTop(fpsOverlayName, &play.FPS{Align: ui.AlignRight}, false) - a.context = newAppContext(ctx, a.settings, func(control ui.Control) { + a.context = newAppContext(ctx, a.settings, a.score, func(control ui.Control) { a.Content = control }) a.context.ShowMainMenu(ctx) diff --git a/cmd/tins2021/appcontext.go b/cmd/tins2021/appcontext.go index 2f61b3c..0cab3df 100644 --- a/cmd/tins2021/appcontext.go +++ b/cmd/tins2021/appcontext.go @@ -9,6 +9,7 @@ type appContext struct { setView func(ui.Control) Settings *tins2021.Settings + Score *tins2021.ScoreState Debug bool StarTexture tins2021.AnimatedTexture @@ -17,11 +18,12 @@ type appContext struct { MonsterTextures map[tins2021.MonsterType]tins2021.AnimatedTexture } -func newAppContext(ctx ui.Context, settings *tins2021.Settings, setView func(ui.Control)) *appContext { +func newAppContext(ctx ui.Context, settings *tins2021.Settings, score *tins2021.ScoreState, setView func(ui.Control)) *appContext { textures := textureGenerator{} app := &appContext{ setView: setView, Settings: settings, + Score: score, StarTexture: newAnimatedTexture(ctx, "star", defaultAnimationFrames, textures.Star), HeartTexture: newAnimatedTexture(ctx, "heart", defaultAnimationFrames, textures.Heart), MonsterTextures: map[tins2021.MonsterType]tins2021.AnimatedTexture{ @@ -40,6 +42,7 @@ func (app *appContext) Play(ctx ui.Context) { level := tins2021.NewLevel() level.Randomize(0, numberOfStars) + app.Score.Current = tins2021.Score{} app.show(newLevelControl(app, ctx, level)) } @@ -56,6 +59,14 @@ func (app *appContext) PlayNext(ctx ui.Context, controller *levelController) { controller.Play(level) } +func (app *appContext) PlayResume(ctx ui.Context) { + level := tins2021.NewLevel() + level.Score = app.Score.Current.Score + level.Randomize(app.Score.Current.Difficulty, numberOfStars) + + app.show(newLevelControl(app, ctx, level)) +} + func (app *appContext) show(control ui.Control) { app.setView(control) } @@ -68,6 +79,10 @@ func (app *appContext) ShowSettings(ctx ui.Context) { app.setView(newSettings(app, ctx)) } +func (app *appContext) ShowHighscores(ctx ui.Context) { + app.setView(newHighscores(app, ctx)) +} + func (app *appContext) ShowInfo(ctx ui.Context) { app.setView(newInfo(app, ctx)) } diff --git a/cmd/tins2021/highscores.go b/cmd/tins2021/highscores.go new file mode 100644 index 0000000..1df3d21 --- /dev/null +++ b/cmd/tins2021/highscores.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + + "opslag.de/schobers/zntg/ui" +) + +type highscores struct { + ui.StackPanel + + app *appContext +} + +func newHighscores(app *appContext, ctx ui.Context) ui.Control { + scores := ui.BuildStackPanel(ui.OrientationVertical, func(p *ui.StackPanel) { + p.AddChild(label("RANK", "default")) + for i, score := range app.Score.Highscores { + p.AddChild(label(fmt.Sprintf("%d. %d", i+1, score.Score), "score")) + } + }) + difficulties := ui.BuildStackPanel(ui.OrientationVertical, func(p *ui.StackPanel) { + p.AddChild(label(" DIFFICULTY", "default")) + for _, score := range app.Score.Highscores { + p.AddChild(labelOpts(fmt.Sprintf("%d", score.Difficulty), "score", labelOptions{TextAlignment: ui.AlignRight})) + } + }) + content := []ui.Control{ + labelOpts("HIGHSCORES", "title", labelOptions{TextAlignment: ui.AlignCenter}), + label("", "score"), + Center(ui.BuildStackPanel(ui.OrientationHorizontal, func(p *ui.StackPanel) { + p.AddChild(scores, difficulties) + })), + } + + return Center(&highscores{StackPanel: ui.StackPanel{ + ContainerBase: ui.ContainerBase{ + Children: content, + }, + Orientation: ui.OrientationVertical, + }, app: app}) +} + +func (i *highscores) Handle(ctx ui.Context, e ui.Event) bool { + switch e := e.(type) { + case *ui.KeyDownEvent: + if e.Key == ui.KeyEscape || e.Key == ui.KeyEnter { + i.app.ShowMainMenu(ctx) + } + } + return i.ControlBase.Handle(ctx, e) +} diff --git a/cmd/tins2021/levelcontroller.go b/cmd/tins2021/levelcontroller.go index 1e3b1a4..15a53bc 100644 --- a/cmd/tins2021/levelcontroller.go +++ b/cmd/tins2021/levelcontroller.go @@ -30,6 +30,8 @@ type levelController struct { SmallFont *tins2021.BitmapFont Controls map[ui.Key]tins2021.Direction + + Highscore bool } func newLevelControl(app *appContext, ctx ui.Context, level *tins2021.Level) *levelController { @@ -84,11 +86,23 @@ func IsModifierPressed(modifiers ui.KeyModifier, pressed ui.KeyModifier) bool { return modifiers&pressed == pressed } +func (r *levelController) updateHighscore() bool { + highscores, highscore := r.app.Score.Highscores.AddScore(r.Level.Score, r.Level.Difficulty) + if highscore { + r.app.Score.Highscores = highscores + } + r.app.Score.Current = tins2021.Score{} // reset score + return highscore +} + func (r *levelController) Handle(ctx ui.Context, e ui.Event) bool { switch e := e.(type) { case *ui.KeyDownEvent: switch e.Key { case ui.KeyEscape: + if r.Level.StarsCollected == r.Level.Stars { + r.app.Score.Current = tins2021.NewScore(r.Level.Score, r.Level.Difficulty+1) + } r.app.ShowMainMenu(ctx) } } @@ -102,6 +116,7 @@ func (r *levelController) Handle(ctx ui.Context, e ui.Event) bool { case *ui.KeyDownEvent: switch e.Key { case ui.KeyEnter: + r.app.Score.Current = tins2021.NewScore(r.Level.Score, r.Level.Difficulty+1) r.app.PlayNext(ctx, r) } } @@ -113,6 +128,9 @@ func (r *levelController) Handle(ctx ui.Context, e ui.Event) bool { dir, ok := r.Controls[e.Key] if ok { r.Level.MovePlayer(dir) + if r.Level.GameOver { + r.Highscore = r.updateHighscore() + } } } @@ -131,6 +149,9 @@ func (r *levelController) Handle(ctx ui.Context, e ui.Event) bool { r.Level.DestroyMonster(pos) jumped = append(jumped, pos) r.Level.DecrementLive() + if r.Level.GameOver { + r.Highscore = r.updateHighscore() + } continue } if animation.Frame < 50 { // after 50 frames the animation has finished @@ -328,6 +349,12 @@ func (r levelController) Render(ctx ui.Context) { offsetY := .5*bounds.Dy() - titleFont.Height() renderer.TextAlign(titleFont, geom.PtF32(centerX, offsetY), textColor, "GAME OVER", ui.AlignCenter) + if r.Highscore { + highscoreFont := ctx.Fonts().Font("default") + offsetY += titleFont.Height() + scoreFont.Height() + renderer.TextAlign(highscoreFont, geom.PtF32(centerX, offsetY), ctx.Style().Palette.Primary, "NEW HIGHSCORE", ui.AlignCenter) + } + offsetY += titleFont.Height() + scoreFont.Height() renderer.TextAlign(scoreFont, geom.PtF32(centerX, offsetY), textColor, fmt.Sprintf("Final score: %d", r.Level.Score), ui.AlignCenter) diff --git a/cmd/tins2021/mainmenu.go b/cmd/tins2021/mainmenu.go index 0048a24..e94f5aa 100644 --- a/cmd/tins2021/mainmenu.go +++ b/cmd/tins2021/mainmenu.go @@ -37,6 +37,10 @@ func newMainMenu(app *appContext, ctx ui.Context) ui.Control { menu.Add("Play", func(ctx ui.Context) { app.Play(ctx) }) + if app.Score.Current.Difficulty > 0 { + menu.Add("Resume", func(ctx ui.Context) { app.PlayResume(ctx) }) + } + menu.Add("Highscores", func(ctx ui.Context) { app.ShowHighscores(ctx) }) menu.Add("Controls", func(ctx ui.Context) { app.ShowSettings(ctx) }) menu.Add("Credits", func(ctx ui.Context) { app.ShowCredits(ctx) }) menu.Add("Quit", func(ctx ui.Context) { ctx.Quit() }) diff --git a/cmd/tins2021/tins2021.go b/cmd/tins2021/tins2021.go index 1f0814a..681996c 100644 --- a/cmd/tins2021/tins2021.go +++ b/cmd/tins2021/tins2021.go @@ -50,6 +50,11 @@ func run() error { } defer settings.Store() + score, err := tins2021.LoadScores() + if err != nil { + log.Printf("unable to load score; error: %v", err) + } + var location *geom.PointF32 if settings.Window.Location != nil { location = &geom.PointF32{X: float32(settings.Window.Location.X), Y: float32(settings.Window.Location.Y)} @@ -74,7 +79,10 @@ func run() error { app := &app{ settings: settings, + score: &score, } + defer tins2021.SaveScores(app.score) + style := ui.DefaultStyle() style.Palette = &ui.Palette{ Background: zntg.MustHexColor(`#494949`), diff --git a/io.go b/io.go index 98bea25..0fd1128 100644 --- a/io.go +++ b/io.go @@ -1,11 +1,43 @@ package tins2021 import ( + "encoding/json" + "os" + "opslag.de/schobers/zntg" ) const appName = "tins2021_qbitter" -func UserDir() (string, error) { return zntg.UserDir(appName) } +func UserDir() (string, error) { return zntg.UserConfigDir(appName) } -func UserFile(name string) (string, error) { return zntg.UserFile(appName, name) } +func UserFile(name string) (string, error) { return zntg.UserConfigFile(appName, name) } + +func LoadUserFileJSON(name string, v interface{}) error { + path, err := UserFile(name) + if err != nil { + return err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.ErrNotExist + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return json.NewDecoder(f).Decode(v) +} + +func SaveUserFileJSON(name string, v interface{}) error { + path, err := UserFile(name) + if err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return json.NewEncoder(f).Encode(v) +} diff --git a/score.go b/score.go new file mode 100644 index 0000000..6d2f83e --- /dev/null +++ b/score.go @@ -0,0 +1,83 @@ +package tins2021 + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" +) + +const scoreFileName = "score.json" + +type Highscores []Score + +func (h Highscores) AddScore(score, difficulty int) (Highscores, bool) { + highscores := len(h) + var rank = highscores + for ; rank > 0; rank-- { + if score <= h[rank-1].Score { + break + } + } + highscore := NewScore(score, difficulty) + if rank == highscores && highscores < 10 { + return append(h, highscore), true + } + if rank < 10 { + return append(h[:rank], append([]Score{highscore}, h[rank:highscores-1]...)...), true + } + return h, false +} + +type Score struct { + Score int + Difficulty int + Hash string +} + +func NewScore(score, difficulty int) Score { + s := Score{Score: score, Difficulty: difficulty} + s.Hash = s.hash() + return s +} + +func (s *Score) hash() string { + hashText := fmt.Sprintf("tins2021_qbitter, %d, %d", s.Score, s.Difficulty) + hash := sha256.Sum256([]byte(hashText)) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +func (s *Score) Validate() bool { + hash := s.hash() + if hash == s.Hash { + return true + } + s.Score = 0 + s.Difficulty = 0 + return false +} + +type ScoreState struct { + Current Score + Highscores Highscores +} + +func LoadScores() (ScoreState, error) { + var state ScoreState + if err := LoadUserFileJSON(scoreFileName, &state); err != nil { + return ScoreState{}, err + } + state.Current.Validate() + for i := 0; i < len(state.Highscores); { + if !state.Highscores[i].Validate() { + state.Highscores = append(state.Highscores[:i], state.Highscores[i+1:]...) + } else { + i++ + } + } + + return state, nil +} + +func SaveScores(s *ScoreState) error { + return SaveUserFileJSON(scoreFileName, s) +} diff --git a/score_test.go b/score_test.go new file mode 100644 index 0000000..e8f8159 --- /dev/null +++ b/score_test.go @@ -0,0 +1,70 @@ +package tins2021 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func newFullHighscore() Highscores { + return Highscores{ + NewScore(100, 100), + NewScore(90, 90), + NewScore(80, 80), + NewScore(70, 70), + NewScore(60, 60), + NewScore(50, 50), + NewScore(40, 40), + NewScore(30, 30), + NewScore(20, 20), + NewScore(10, 10), + } +} + +func TestAddScoreBelowBottom(t *testing.T) { + h := newFullHighscore() + updated, high := h.AddScore(1, 1) + assert.False(t, high) + assert.Len(t, updated, 10) + for _, s := range h { + assert.Greater(t, s.Score, 1) + } +} + +func TestAddScoreBottom(t *testing.T) { + h := newFullHighscore() + updated, high := h.AddScore(11, 11) + assert.True(t, high) + assert.Len(t, updated, 10) + assert.Equal(t, 11, updated[9].Score) +} + +func TestAddScoreBottomNotFull(t *testing.T) { + h := Highscores{ + NewScore(100, 100), + NewScore(90, 90), + NewScore(80, 80), + NewScore(70, 70), + } + + updated, high := h.AddScore(50, 50) + assert.True(t, high) + assert.Len(t, updated, 5) + assert.Equal(t, 50, updated[4].Score) +} + +func TestAddScoreMiddle(t *testing.T) { + h := newFullHighscore() + updated, high := h.AddScore(51, 51) + assert.True(t, high) + assert.Len(t, updated, 10) + assert.Equal(t, 51, updated[5].Score) +} + +func TestAddScoreTop(t *testing.T) { + h := newFullHighscore() + updated, high := h.AddScore(101, 101) + assert.True(t, high) + assert.Len(t, updated, 10) + assert.Equal(t, 101, updated[0].Score) +} diff --git a/settings.go b/settings.go index 9476566..7abcfca 100644 --- a/settings.go +++ b/settings.go @@ -4,35 +4,25 @@ import ( "os" "opslag.de/schobers/geom" - "opslag.de/schobers/zntg" ) +const settingsFileName = "settings.json" + type Settings struct { Controls ControlsSettings Window WindowSettings } -func SettingsPath() (string, error) { - return UserFile("settings.json") -} - func (s *Settings) Init() error { - path, err := SettingsPath() - if err != nil { - return err - } - if _, err := os.Stat(path); os.IsNotExist(err) { + err := LoadUserFileJSON(settingsFileName, s) + if os.IsNotExist(err) { return nil } - return zntg.DecodeJSON(path, s) + return err } func (s *Settings) Store() error { - path, err := SettingsPath() - if err != nil { - return err - } - return zntg.EncodeJSON(path, s) + return SaveUserFileJSON(settingsFileName, s) } type ControlsSettings struct {