Add resume play.

Add highscores.
This commit is contained in:
Sander Schobers 2021-08-11 08:03:02 +02:00
parent c628ae4b09
commit 4f1760ad57
11 changed files with 303 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

36
io.go
View File

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

83
score.go Normal file
View File

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

70
score_test.go Normal file
View File

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

View File

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