Add resume play.
Add highscores.
This commit is contained in:
parent
c628ae4b09
commit
4f1760ad57
2
TODO.md
2
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).
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
}
|
||||
|
52
cmd/tins2021/highscores.go
Normal file
52
cmd/tins2021/highscores.go
Normal 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)
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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() })
|
||||
|
@ -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
36
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)
|
||||
}
|
||||
|
83
score.go
Normal file
83
score.go
Normal 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
70
score_test.go
Normal 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)
|
||||
}
|
22
settings.go
22
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 {
|
||||
|
Loading…
Reference in New Issue
Block a user