diff --git a/TODO.md b/TODO.md index 62e9e20..7d4705a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,14 @@ - [X] Increase difficulty. -- [ ] Add music & sounds. +- [X] Add music & sounds. - [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). -- [ ] Scale icons (heart & star on right side) when playing. +- [X] ~~Scale icons (heart & star on right side) when playing.~~ - [ ] Change layout when playing in portrait mode. - [X] Fix wobble animation. - [ ] Add more unit tests? - [ ] Fix z-fighting of monsters. -- [ ] Add exploding animation of monsters. \ No newline at end of file +- [ ] Add exploding animation of monsters. +- [ ] Add audio settings (music & sound volume). +- [ ] Hearts must be saved as well for resume. \ No newline at end of file diff --git a/cmd/tins2021/app.go b/cmd/tins2021/app.go index 2bf15d6..2fb9ea9 100644 --- a/cmd/tins2021/app.go +++ b/cmd/tins2021/app.go @@ -61,6 +61,21 @@ func (a *app) Init(ctx ui.Context) error { }) a.context.ShowMainMenu(ctx) + err := a.context.Audio.LoadSample( + "level_completed.mp3", + "level_game_over.mp3", + "level_new_high_score.mp3", + "menu_interaction.mp3", + "monster_jump.mp3", + "player_collect_heart.mp3", + "player_collect_star.mp3", + "player_hurt.mp3", + "player_move.mp3", + ) + if err != nil { + return err + } + return nil } diff --git a/cmd/tins2021/appcontext.go b/cmd/tins2021/appcontext.go index 0cab3df..e51b68d 100644 --- a/cmd/tins2021/appcontext.go +++ b/cmd/tins2021/appcontext.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "math/rand" + "opslag.de/schobers/tins2021" "opslag.de/schobers/zntg/ui" ) @@ -12,10 +15,14 @@ type appContext struct { Score *tins2021.ScoreState Debug bool - StarTexture tins2021.AnimatedTexture - HeartTexture tins2021.AnimatedTexture - + StarTexture tins2021.AnimatedTexture + HeartTexture tins2021.AnimatedTexture MonsterTextures map[tins2021.MonsterType]tins2021.AnimatedTexture + + Audio *AudioPlayer + MenuMusic *Music + GameMusic *Music + GameMusicSong int } func newAppContext(ctx ui.Context, settings *tins2021.Settings, score *tins2021.ScoreState, setView func(ui.Control)) *appContext { @@ -23,6 +30,7 @@ func newAppContext(ctx ui.Context, settings *tins2021.Settings, score *tins2021. app := &appContext{ setView: setView, Settings: settings, + Audio: NewAudioPlayer(ctx.Resources(), "resources/sounds/"), Score: score, StarTexture: newAnimatedTexture(ctx, "star", defaultAnimationFrames, textures.Star), HeartTexture: newAnimatedTexture(ctx, "heart", defaultAnimationFrames, textures.Heart), @@ -38,12 +46,16 @@ func newAppContext(ctx ui.Context, settings *tins2021.Settings, score *tins2021. const numberOfStars = 5 +func (app *appContext) MenuInteraction() { + app.Audio.PlaySample("menu_interaction.mp3") +} + 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)) + app.show(ctx, newLevelControl(app, ctx, level)) } func (app *appContext) PlayNext(ctx ui.Context, controller *levelController) { @@ -64,29 +76,82 @@ func (app *appContext) PlayResume(ctx ui.Context) { level.Score = app.Score.Current.Score level.Randomize(app.Score.Current.Difficulty, numberOfStars) - app.show(newLevelControl(app, ctx, level)) + app.show(ctx, newLevelControl(app, ctx, level)) } -func (app *appContext) show(control ui.Control) { +func (app *appContext) show(ctx ui.Context, control ui.Control) { app.setView(control) + if _, ok := control.(*levelController); ok { + app.switchToPlayMusic(ctx) + } else { + app.switchToMenuMusic(ctx) + } } func (app *appContext) ShowCredits(ctx ui.Context) { - app.setView(newCredits(app, ctx)) + app.show(ctx, newCredits(app, ctx)) } func (app *appContext) ShowSettings(ctx ui.Context) { - app.setView(newSettings(app, ctx)) + app.show(ctx, newSettings(app, ctx)) } func (app *appContext) ShowHighscores(ctx ui.Context) { - app.setView(newHighscores(app, ctx)) + app.show(ctx, newHighscores(app, ctx)) } func (app *appContext) ShowInfo(ctx ui.Context) { - app.setView(newInfo(app, ctx)) + app.show(ctx, newInfo(app, ctx)) } func (app *appContext) ShowMainMenu(ctx ui.Context) { - app.show(newMainMenu(app, ctx)) + app.show(ctx, newMainMenu(app, ctx)) +} + +func (app *appContext) playNextGameMusic(ctx ui.Context) { + if app.GameMusic != nil { + return + } + const songs = 4 + pick := func() int { + for { + s := rand.Intn(songs) + 1 + if s == app.GameMusicSong { + continue + } + app.GameMusicSong = s + return s + } + } + song := fmt.Sprintf("song_game_%d.mp3", pick()) + app.GameMusic, _ = app.Audio.PlayMusic(song, func(m *Music) { + m.OnFinished = func() { + if app.GameMusic == nil { + return + } + app.GameMusic = nil + app.playNextGameMusic(ctx) + } + }) +} + +func (app *appContext) switchToPlayMusic(ctx ui.Context) { + app.playNextGameMusic(ctx) + menuMusic := app.MenuMusic + app.MenuMusic = nil + if menuMusic != nil { + menuMusic.Stop() + } +} + +func (app *appContext) switchToMenuMusic(ctx ui.Context) { + if app.MenuMusic != nil { + return + } + app.MenuMusic, _ = app.Audio.PlayMusic("song_menu.mp3", func(m *Music) {}) + gameMusic := app.GameMusic + app.GameMusic = nil + if gameMusic != nil { + gameMusic.Stop() + } } diff --git a/cmd/tins2021/audio.go b/cmd/tins2021/audio.go new file mode 100644 index 0000000..9f268dc --- /dev/null +++ b/cmd/tins2021/audio.go @@ -0,0 +1,181 @@ +package main + +import ( + "fmt" + "io" + "time" + + "github.com/faiface/beep" + "github.com/faiface/beep/effects" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" + "opslag.de/schobers/zntg/ui" +) + +type AudioPlayer struct { + resources ui.Resources + prefix string + + SampleRate beep.SampleRate + + Samples map[string]Sample + + SampleVolume float64 + MusicVolume float64 +} + +func NewAudioPlayer(resources ui.Resources, prefix string) *AudioPlayer { + var rate = beep.SampleRate(48000) + speaker.Init(rate, rate.N(time.Second/20)) + + return &AudioPlayer{ + resources: resources, + prefix: prefix, + SampleRate: rate, + Samples: map[string]Sample{}, + SampleVolume: 1, + MusicVolume: 1, + } +} + +func (p *AudioPlayer) LoadSample(name ...string) error { + for _, name := range name { + if _, err := p.openSample(name); err != nil { + return err + } + } + return nil +} + +func (p *AudioPlayer) openSample(name string) (Sample, error) { + sample, ok := p.Samples[name] + if ok { + return sample, nil + } + stream, format, err := p.openResource(name) + if err != nil { + return Sample{}, err + } + defer stream.Close() + + buffer := beep.NewBuffer(format) + buffer.Append(stream) + sample = Sample{Buffer: buffer, SampleRate: format.SampleRate} + p.Samples[name] = sample + return sample, nil +} + +func (p *AudioPlayer) PlaySample(name string) error { + sample, err := p.openSample(name) + if err != nil { + return err + } + speaker.Play(p.resample(sample.Stream(), sample.SampleRate)) + return nil +} + +func (p *AudioPlayer) resample(stream beep.Streamer, sampleRate beep.SampleRate) beep.Streamer { + return Resample(stream, p.SampleRate, sampleRate) +} + +func (p *AudioPlayer) PlayMusic(name string, init func(*Music)) (*Music, error) { + stream, format, err := p.openResource(name) + if err != nil { + return nil, err + } + closer := &streamCloser{stream} + music := &Music{ + Name: name, + AutoRepeat: false, + Stream: closer, + Volume: &effects.Volume{ + Streamer: p.resample(closer, format.SampleRate), + Base: 2, + Volume: 1, + Silent: false, + }, + } + if init != nil { + init(music) + } + speaker.Play(beep.Seq(music.Volume, beep.Callback(func() { go music.Finished(p) }))) + return music, nil +} + +func (p *AudioPlayer) openResource(name string) (beep.StreamSeekCloser, beep.Format, error) { + path := fmt.Sprintf("%s%s", p.prefix, name) + audio, err := p.resources.OpenResource(path) + if err != nil { + return nil, beep.Format{}, err + } + + stream, format, err := mp3.Decode(audio) + if err != nil { + return nil, beep.Format{}, err + } + return stream, format, nil +} + +type Music struct { + Name string + AutoRepeat bool + Stream beep.StreamCloser + Volume *effects.Volume + OnFinished func() +} + +func (m *Music) Stop() { + m.AutoRepeat = false + m.Stream.Close() +} + +func (m Music) Finished(player *AudioPlayer) { + m.Stream.Close() + if m.AutoRepeat { + player.PlayMusic(m.Name, func(m *Music) { m.AutoRepeat = true }) + } + onFinished := m.OnFinished + if onFinished != nil { + onFinished() + } +} + +type Sample struct { + *beep.Buffer + + SampleRate beep.SampleRate +} + +func (s Sample) Stream() beep.Streamer { + return s.Buffer.Streamer(0, s.Buffer.Len()) +} + +type streamCloser struct { + beep.StreamCloser +} + +func (c *streamCloser) Err() error { + if c.StreamCloser == nil { + return io.EOF + } + return c.StreamCloser.Err() +} + +func (c *streamCloser) Stream(samples [][2]float64) (n int, ok bool) { + if c.StreamCloser == nil { + return 0, false + } + return c.StreamCloser.Stream(samples) +} + +func (c *streamCloser) Close() error { + c.StreamCloser = nil + return nil +} + +func Resample(stream beep.Streamer, expected, actual beep.SampleRate) beep.Streamer { + if expected == actual { + return stream + } + return beep.Resample(3, actual, expected, stream) +} diff --git a/cmd/tins2021/levelcontroller.go b/cmd/tins2021/levelcontroller.go index 15a53bc..5f74bed 100644 --- a/cmd/tins2021/levelcontroller.go +++ b/cmd/tins2021/levelcontroller.go @@ -123,13 +123,35 @@ func (r *levelController) Handle(ctx ui.Context, e ui.Event) bool { return false } + checkGameOver := func() { + if r.Level.GameOver { + r.Highscore = r.updateHighscore() + if r.Highscore { + r.app.Audio.PlaySample("level_new_high_score.mp3") + } else { + r.app.Audio.PlaySample("level_game_over.mp3") + } + } + } + switch e := e.(type) { case *ui.KeyDownEvent: dir, ok := r.Controls[e.Key] if ok { + stars, lives := r.Level.StarsCollected, r.Level.Lives r.Level.MovePlayer(dir) - if r.Level.GameOver { - r.Highscore = r.updateHighscore() + switch { + case r.Level.StarsCollected > stars: + r.app.Audio.PlaySample("player_collect_star.mp3") + case r.Level.Lives < lives: + r.app.Audio.PlaySample("player_hurt.mp3") + case r.Level.Lives > lives: + r.app.Audio.PlaySample("player_collect_heart.mp3") + } + r.app.Audio.PlaySample("player_move.mp3") + checkGameOver() + if r.Level.StarsCollected == r.Level.Stars { + r.app.Audio.PlaySample("level_completed.mp3") } } } @@ -149,9 +171,8 @@ 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() - } + r.app.Audio.PlaySample("player_hurt.mp3") + checkGameOver() continue } if animation.Frame < 50 { // after 50 frames the animation has finished @@ -177,6 +198,7 @@ func (r *levelController) Handle(ctx ui.Context, e ui.Event) bool { r.Level.MonsterTargets[pos] = target r.MovingMonsters.Frame(pos) jumping = append(jumping, pos) + r.app.Audio.PlaySample("monster_jump.mp3") break } } diff --git a/cmd/tins2021/mainmenu.go b/cmd/tins2021/mainmenu.go index e94f5aa..91c55ba 100644 --- a/cmd/tins2021/mainmenu.go +++ b/cmd/tins2021/mainmenu.go @@ -29,6 +29,8 @@ func (c *center) Arrange(ctx ui.Context, bounds geom.RectangleF32, offset geom.P type mainMenu struct { ui.StackPanel + + music *Music } func newMainMenu(app *appContext, ctx ui.Context) ui.Control { @@ -37,7 +39,8 @@ 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 { + resume := app.Score.Current.Difficulty > 0 + if resume { menu.Add("Resume", func(ctx ui.Context) { app.PlayResume(ctx) }) } menu.Add("Highscores", func(ctx ui.Context) { app.ShowHighscores(ctx) }) @@ -45,7 +48,14 @@ func newMainMenu(app *appContext, ctx ui.Context) ui.Control { menu.Add("Credits", func(ctx ui.Context) { app.ShowCredits(ctx) }) menu.Add("Quit", func(ctx ui.Context) { ctx.Quit() }) - menu.Activate(1) // play + if resume { + menu.Activate(2) // resume + } else { + menu.Activate(1) // play + } + menu.ActiveChanged.AddHandlerEmpty((func(ui.Context) { + app.MenuInteraction() + })) ctx.Animate() return Center(&mainMenu{ diff --git a/cmd/tins2021/menu.go b/cmd/tins2021/menu.go index dfbdafd..baceea3 100644 --- a/cmd/tins2021/menu.go +++ b/cmd/tins2021/menu.go @@ -11,6 +11,8 @@ type Menu struct { active int buttons []*MenuButton + + ActiveChanged ui.Events } func NewMenu() *Menu { @@ -25,7 +27,7 @@ func (m *Menu) Activate(i int) { if len(m.buttons) == 0 || i < 0 { return } - m.updateActiveButton(i % len(m.buttons)) + m.updateActiveButton(nil, i%len(m.buttons)) } func (m *Menu) Add(text string, click func(ui.Context)) { @@ -54,29 +56,33 @@ func (m *Menu) Handle(ctx ui.Context, e ui.Event) bool { onEscape(ctx) } case ui.KeyDown: - m.updateActiveButton((m.active + 1) % len(m.buttons)) + m.updateActiveButton(ctx, (m.active+1)%len(m.buttons)) case ui.KeyUp: - m.updateActiveButton((m.active + len(m.buttons) - 1) % len(m.buttons)) + m.updateActiveButton(ctx, (m.active+len(m.buttons)-1)%len(m.buttons)) case ui.KeyEnter: m.buttons[m.active].InvokeClick(ctx) } case *ui.MouseMoveEvent: for i, button := range m.buttons { if button.IsOver() { - m.updateActiveButton(i) + m.updateActiveButton(ctx, i) break } } - m.updateActiveButton(m.active) + m.updateActiveButton(ctx, m.active) } return false } -func (m *Menu) updateActiveButton(active int) { +func (m *Menu) updateActiveButton(ctx ui.Context, active int) { + change := m.active != active m.active = active for i, btn := range m.buttons { btn.Over = i == m.active } + if change && ctx != nil { + m.ActiveChanged.Notify(ctx, nil) + } } type MenuButton struct { diff --git a/cmd/tins2021/settings.go b/cmd/tins2021/settings.go index f363a74..3c4c5dd 100644 --- a/cmd/tins2021/settings.go +++ b/cmd/tins2021/settings.go @@ -207,6 +207,7 @@ func (s *settings) Handle(ctx ui.Context, e ui.Event) bool { switch e.Key { case ui.KeyEscape: s.SelectingCustom = 0 + s.app.MenuInteraction() return true } key, ok := supportedCustomKeys[e.Key] @@ -229,33 +230,88 @@ func (s *settings) Handle(ctx ui.Context, e ui.Event) bool { s.SelectedLayout = 2 s.app.Settings.Controls.Type = controlsTypeCustom } + + s.app.MenuInteraction() } return true } switch e.Key { case ui.KeyEscape: s.app.ShowMainMenu(ctx) + s.app.MenuInteraction() return true case ui.KeyLeft: - s.ActiveLayout = (s.ActiveLayout + 2) % 3 + s.setActiveLayout(s.ActiveLayout - 1) case ui.KeyRight: - s.ActiveLayout = (s.ActiveLayout + 1) % 3 + s.setActiveLayout(s.ActiveLayout + 1) case ui.KeyEnter: - switch s.ActiveLayout { - case 0: - s.SelectedLayout = 0 - s.app.Settings.Controls.Type = controlsTypeWASD - case 1: - s.SelectedLayout = 1 - s.app.Settings.Controls.Type = controlsTypeArrows - case 2: - s.SelectingCustom = 1 + s.selectLayout() + } + case *ui.MouseMoveEvent: + if s.SelectingCustom == 0 { + layout := s.isOverLayout(ctx, e.Pos()) + if layout > -1 { + s.setActiveLayout(layout) + } + } + case *ui.MouseButtonDownEvent: + if s.SelectingCustom == 0 { + if e.Button == ui.MouseButtonLeft { + layout := s.isOverLayout(ctx, e.Pos()) + if layout > -1 { + s.setActiveLayout(layout) // to be sure, mouse move should've set this already + s.selectLayout() + } } } } return false } +func (s *settings) selectLayout() { + s.app.MenuInteraction() + switch s.ActiveLayout { + case 0: + s.SelectedLayout = 0 + s.app.Settings.Controls.Type = controlsTypeWASD + case 1: + s.SelectedLayout = 1 + s.app.Settings.Controls.Type = controlsTypeArrows + case 2: + s.SelectingCustom = 1 + } +} + +func (s *settings) isOverLayout(ctx ui.Context, mouse geom.PointF32) int { + bounds := s.Bounds() + center := bounds.Center() + width := bounds.Dx() + + scale := tins2021.FindScaleRound(keyboardLayoutTextureWidth, .28*width) + + font := ctx.Fonts().Font("default") + + for i := 0; i < 3; i++ { + left := (.04 + .32*float32(i)) * width + right := left + .28*width + top := center.Y - 2*font.Height() + bottom := center.Y + scale*keyboardLayoutTextureWidth + if mouse.In(geom.RectF32(left, top, right, bottom)) { + return i + } + } + return -1 +} + +func (s *settings) setActiveLayout(layout int) { + layout = (layout + 3) % 3 + change := layout != s.ActiveLayout + s.ActiveLayout = (layout + 3) % 3 + if change { + s.app.MenuInteraction() + } +} + func (s *settings) Render(ctx ui.Context) { bounds := s.Bounds() center := bounds.Center()