Noah Petherbridge
c2c91e45a9
In the editor, clicking and dragging with the middle mouse button will scroll the view of the editor in place of the arrow keys. When entering Play Mode, the original scroll position in the level editor is remembered for when you come back - no more having to scroll from 0,0 each time to get back to where you were working!
598 lines
16 KiB
Go
598 lines
16 KiB
Go
package doodle
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.kirsle.net/apps/doodle/pkg/balance"
|
|
"git.kirsle.net/apps/doodle/pkg/collision"
|
|
"git.kirsle.net/apps/doodle/pkg/doodads"
|
|
"git.kirsle.net/apps/doodle/pkg/keybind"
|
|
"git.kirsle.net/apps/doodle/pkg/level"
|
|
"git.kirsle.net/apps/doodle/pkg/log"
|
|
"git.kirsle.net/apps/doodle/pkg/modal"
|
|
"git.kirsle.net/apps/doodle/pkg/modal/loadscreen"
|
|
"git.kirsle.net/apps/doodle/pkg/physics"
|
|
"git.kirsle.net/apps/doodle/pkg/scripting"
|
|
"git.kirsle.net/apps/doodle/pkg/uix"
|
|
"git.kirsle.net/go/render"
|
|
"git.kirsle.net/go/render/event"
|
|
"git.kirsle.net/go/ui"
|
|
)
|
|
|
|
// PlayScene manages the "Edit Level" game mode.
|
|
type PlayScene struct {
|
|
// Configuration attributes.
|
|
Filename string
|
|
Level *level.Level
|
|
CanEdit bool // i.e. you came from the Editor Mode
|
|
HasNext bool // has a next level to load next
|
|
RememberScrollPosition render.Point // for the Editor quality of life
|
|
|
|
// Private variables.
|
|
d *Doodle
|
|
drawing *uix.Canvas
|
|
scripting *scripting.Supervisor
|
|
running bool
|
|
deathBarrier int // Y position of death barrier in case of falling OOB.
|
|
|
|
// UI widgets.
|
|
supervisor *ui.Supervisor
|
|
screen *ui.Frame // A window sized invisible frame to position UI elements.
|
|
editButton *ui.Button
|
|
|
|
// Custom debug labels.
|
|
debPosition *string
|
|
debViewport *string
|
|
debScroll *string
|
|
debWorldIndex *string
|
|
|
|
// Player character
|
|
Player *uix.Actor
|
|
playerPhysics *physics.Mover
|
|
lastCheckpoint render.Point
|
|
antigravity bool // Cheat: disable player gravity
|
|
noclip bool // Cheat: disable player clipping
|
|
playerJumpCounter int // limit jump length
|
|
|
|
// Inventory HUD. Impl. in play_inventory.go
|
|
invenFrame *ui.Frame
|
|
invenItems []string // item list
|
|
invenDoodads map[string]*uix.Canvas
|
|
|
|
// Touchscreen controls state.
|
|
isTouching bool
|
|
playerIsIdle bool // LoopTouchable watches for inactivity on input controls.
|
|
idleLastStart time.Time
|
|
idleHelpAlpha int // fade in UI hints
|
|
}
|
|
|
|
// Name of the scene.
|
|
func (s *PlayScene) Name() string {
|
|
return "Play"
|
|
}
|
|
|
|
// Setup the play scene.
|
|
func (s *PlayScene) Setup(d *Doodle) error {
|
|
s.d = d
|
|
s.scripting = scripting.NewSupervisor()
|
|
s.supervisor = ui.NewSupervisor()
|
|
|
|
// Show the loading screen.
|
|
loadscreen.ShowWithProgress()
|
|
go func() {
|
|
if err := s.setupAsync(d); err != nil {
|
|
log.Error("PlayScene.setupAsync: %s", err)
|
|
return
|
|
}
|
|
|
|
loadscreen.Hide()
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupAsync initializes the play screen in the background, underneath
|
|
// a Loading screen.
|
|
func (s *PlayScene) setupAsync(d *Doodle) error {
|
|
// Create an invisible 'screen' frame for UI elements to use for positioning.
|
|
s.screen = ui.NewFrame("Screen")
|
|
s.screen.Resize(render.NewRect(d.width, d.height))
|
|
|
|
// Level Exit handler.
|
|
s.scripting.OnLevelExit(s.BeatLevel)
|
|
s.scripting.OnLevelFail(s.FailLevel)
|
|
s.scripting.OnSetCheckpoint(s.SetCheckpoint)
|
|
|
|
// Initialize debug overlay values.
|
|
s.debPosition = new(string)
|
|
s.debViewport = new(string)
|
|
s.debScroll = new(string)
|
|
s.debWorldIndex = new(string)
|
|
customDebugLabels = []debugLabel{
|
|
{"Pixel:", s.debWorldIndex},
|
|
{"Player:", s.debPosition},
|
|
{"Viewport:", s.debViewport},
|
|
{"Scroll:", s.debScroll},
|
|
}
|
|
|
|
// Initialize the "Edit Map" button.
|
|
s.editButton = ui.NewButton("Edit", ui.NewLabel(ui.Label{
|
|
Text: "Edit (E)",
|
|
Font: balance.PlayButtonFont,
|
|
}))
|
|
s.editButton.Handle(ui.Click, func(ed ui.EventData) error {
|
|
s.EditLevel()
|
|
return nil
|
|
})
|
|
s.supervisor.Add(s.editButton)
|
|
|
|
// Set up the inventory HUD.
|
|
s.setupInventoryHud()
|
|
|
|
// Initialize the drawing canvas.
|
|
s.drawing = uix.NewCanvas(balance.ChunkSize, false)
|
|
s.drawing.Name = "play-canvas"
|
|
s.drawing.MoveTo(render.Origin)
|
|
s.drawing.Resize(render.NewRect(d.width, d.height))
|
|
s.drawing.Compute(d.Engine)
|
|
|
|
// Handler when an actor touches water or fire.
|
|
s.drawing.OnLevelCollision = func(a *uix.Actor, col *collision.Collide) {
|
|
if col.InFire != "" {
|
|
a.Canvas.MaskColor = render.Black
|
|
if a.ID() == "PLAYER" { // only the player dies in fire.
|
|
s.DieByFire(col.InFire)
|
|
}
|
|
} else if col.InWater {
|
|
a.Canvas.MaskColor = render.DarkBlue
|
|
} else {
|
|
a.Canvas.MaskColor = render.Invisible
|
|
}
|
|
}
|
|
|
|
// Given a filename or map data to play?
|
|
if s.Level != nil {
|
|
log.Debug("PlayScene.Setup: received level from scene caller")
|
|
s.drawing.LoadLevel(s.Level)
|
|
s.drawing.InstallActors(s.Level.Actors)
|
|
} else if s.Filename != "" {
|
|
loadscreen.SetSubtitle("Opening: " + s.Filename)
|
|
log.Debug("PlayScene.Setup: loading map from file %s", s.Filename)
|
|
// NOTE: s.LoadLevel also calls s.drawing.InstallActors
|
|
s.LoadLevel(s.Filename)
|
|
}
|
|
|
|
if s.Level == nil {
|
|
log.Debug("PlayScene.Setup: no grid given, initializing empty grid")
|
|
s.Level = level.New()
|
|
s.drawing.LoadLevel(s.Level)
|
|
s.drawing.InstallActors(s.Level.Actors)
|
|
}
|
|
|
|
// Choose a death barrier in case the user falls off the map,
|
|
// so they don't fall forever.
|
|
worldSize := s.Level.Chunker.WorldSize()
|
|
s.deathBarrier = worldSize.H + 1000
|
|
log.Debug("Death barrier at %d", s.deathBarrier)
|
|
|
|
// Set the loading screen text with the level metadata.
|
|
loadscreen.SetSubtitle(
|
|
s.Level.Title,
|
|
"by "+s.Level.Author,
|
|
)
|
|
|
|
// Load all actor scripts.
|
|
s.drawing.SetScriptSupervisor(s.scripting)
|
|
if err := s.scripting.InstallScripts(s.Level); err != nil {
|
|
log.Error("PlayScene.Setup: failed to InstallScripts: %s", err)
|
|
}
|
|
|
|
// Load in the player character.
|
|
s.setupPlayer()
|
|
|
|
// Run all the actor scripts' main() functions.
|
|
if err := s.drawing.InstallScripts(); err != nil {
|
|
log.Error("PlayScene.Setup: failed to drawing.InstallScripts: %s", err)
|
|
}
|
|
|
|
if s.CanEdit {
|
|
d.Flash("Entered Play Mode. Press 'E' to edit this map.")
|
|
} else {
|
|
d.Flash("%s", s.Level.Title)
|
|
}
|
|
|
|
// Pre-cache all bitmap images from the level chunks.
|
|
// Note: we are not running on the main thread, so SDL2 Textures
|
|
// don't get created yet, but we do the full work of caching bitmap
|
|
// images which later get fed directly into SDL2 saving speed at
|
|
// runtime, + the bitmap generation is pretty wicked fast anyway.
|
|
loadscreen.PreloadAllChunkBitmaps(s.Level.Chunker)
|
|
|
|
s.running = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupPlayer creates and configures the Player Character in the level.
|
|
func (s *PlayScene) setupPlayer() {
|
|
// Find the spawn point of the player. Search the level for the
|
|
// "start-flag.doodad"
|
|
var (
|
|
playerCharacterFilename = balance.PlayerCharacterDoodad
|
|
|
|
spawn render.Point
|
|
flag = &level.Actor{}
|
|
flagSize = render.NewRect(86, 86) // TODO: start-flag.doodad is 86x86 px
|
|
flagCount int
|
|
)
|
|
for actorID, actor := range s.Level.Actors {
|
|
if actor.Filename == "start-flag.doodad" {
|
|
// Support alternative player characters: if the Start Flag is linked
|
|
// to another actor, that actor becomes the player character.
|
|
for _, linkID := range actor.Links {
|
|
if linkedActor, ok := s.Level.Actors[linkID]; ok {
|
|
playerCharacterFilename = linkedActor.Filename
|
|
log.Info("Playing as: %s", playerCharacterFilename)
|
|
}
|
|
break
|
|
}
|
|
|
|
// TODO: start-flag.doodad is 86x86 pixels but we can't tell that
|
|
// from right here.
|
|
log.Info("Found start-flag.doodad at %s (ID %s)", actor.Point, actorID)
|
|
flag = actor
|
|
flagCount++
|
|
break
|
|
}
|
|
}
|
|
|
|
// The Start Flag becomes the player's initial checkpoint.
|
|
s.lastCheckpoint = flag.Point
|
|
|
|
// Load in the player character.
|
|
player, err := doodads.LoadFile(playerCharacterFilename)
|
|
if err != nil {
|
|
log.Error("PlayScene.Setup: failed to load player doodad: %s", err)
|
|
player = doodads.NewDummy(32)
|
|
}
|
|
|
|
spawn = render.NewPoint(
|
|
// X: centered inside the flag.
|
|
flag.Point.X+(flagSize.W/2)-(player.Layers[0].Chunker.Size/2),
|
|
|
|
// Y: the bottom of the flag, 4 pixels from the floor.
|
|
flag.Point.Y+flagSize.H-4-(player.Layers[0].Chunker.Size),
|
|
)
|
|
|
|
// Surface warnings around the spawn flag.
|
|
if flagCount == 0 {
|
|
s.d.Flash("Warning: this level contained no Start Flag.")
|
|
} else if flagCount > 1 {
|
|
s.d.Flash("Warning: this level contains multiple Start Flags. Player spawn point is ambiguous.")
|
|
}
|
|
|
|
s.Player = uix.NewActor("PLAYER", &level.Actor{}, player)
|
|
s.Player.MoveTo(spawn)
|
|
s.drawing.AddActor(s.Player)
|
|
s.drawing.FollowActor = s.Player.ID()
|
|
|
|
// Set up the movement physics for the player.
|
|
s.playerPhysics = &physics.Mover{
|
|
MaxSpeed: physics.NewVector(balance.PlayerMaxVelocity, balance.PlayerMaxVelocity),
|
|
// Gravity: physics.NewVector(balance.Gravity, balance.Gravity),
|
|
Acceleration: 0.025,
|
|
Friction: 0.1,
|
|
}
|
|
|
|
// Set up the player character's script in the VM.
|
|
if err := s.scripting.AddLevelScript(s.Player.ID()); err != nil {
|
|
log.Error("PlayScene.Setup: scripting.InstallActor(player) failed: %s", err)
|
|
}
|
|
}
|
|
|
|
// EditLevel toggles out of Play Mode to edit the level.
|
|
func (s *PlayScene) EditLevel() {
|
|
log.Info("Edit Mode, Go!")
|
|
s.d.Goto(&EditorScene{
|
|
Filename: s.Filename,
|
|
Level: s.Level,
|
|
RememberScrollPosition: s.RememberScrollPosition,
|
|
})
|
|
}
|
|
|
|
// RestartLevel starts the level over again.
|
|
func (s *PlayScene) RestartLevel() {
|
|
log.Info("Restart Level")
|
|
s.d.Goto(&PlayScene{
|
|
Filename: s.Filename,
|
|
Level: s.Level,
|
|
CanEdit: s.CanEdit,
|
|
})
|
|
}
|
|
|
|
// SetCheckpoint sets the player's checkpoint.
|
|
func (s *PlayScene) SetCheckpoint(where render.Point) {
|
|
s.lastCheckpoint = where
|
|
}
|
|
|
|
// RetryCheckpoint moves the player back to their last checkpoint.
|
|
func (s *PlayScene) RetryCheckpoint() {
|
|
log.Info("Move player back to last checkpoint")
|
|
s.Player.MoveTo(s.lastCheckpoint)
|
|
s.running = true
|
|
}
|
|
|
|
// BeatLevel handles the level success condition.
|
|
func (s *PlayScene) BeatLevel() {
|
|
s.d.Flash("Hurray!")
|
|
s.ShowEndLevelModal(
|
|
true,
|
|
"Level Completed",
|
|
"Congratulations on clearing the level!",
|
|
)
|
|
}
|
|
|
|
// FailLevel handles a level failure triggered by a doodad.
|
|
func (s *PlayScene) FailLevel(message string) {
|
|
s.d.Flash(message)
|
|
s.ShowEndLevelModal(
|
|
false,
|
|
"You've died!",
|
|
message,
|
|
)
|
|
}
|
|
|
|
// DieByFire ends the level by "fire", or w/e the swatch is named.
|
|
func (s *PlayScene) DieByFire(name string) {
|
|
s.FailLevel(fmt.Sprintf("Watch out for %s!", name))
|
|
}
|
|
|
|
// ShowEndLevelModal centralizes the EndLevel modal config.
|
|
// This is the common handler function between easy methods such as
|
|
// BeatLevel, FailLevel, and DieByFire.
|
|
func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) {
|
|
config := modal.ConfigEndLevel{
|
|
Success: success,
|
|
OnRestartLevel: s.RestartLevel,
|
|
OnRetryCheckpoint: s.RetryCheckpoint,
|
|
OnExitToMenu: func() {
|
|
s.d.Goto(&MainScene{})
|
|
},
|
|
}
|
|
|
|
if s.CanEdit {
|
|
config.OnEditLevel = s.EditLevel
|
|
}
|
|
|
|
// Beaten the level?
|
|
if success {
|
|
config.OnRetryCheckpoint = nil
|
|
}
|
|
|
|
// Show the modal.
|
|
modal.EndLevel(config, title, message)
|
|
|
|
// Stop the simulation.
|
|
s.running = false
|
|
}
|
|
|
|
// Loop the editor scene.
|
|
func (s *PlayScene) Loop(d *Doodle, ev *event.State) error {
|
|
// Skip if still loading.
|
|
if loadscreen.IsActive() {
|
|
return nil
|
|
}
|
|
|
|
// Update debug overlay values.
|
|
*s.debWorldIndex = s.drawing.WorldIndexAt(render.NewPoint(ev.CursorX, ev.CursorY)).String()
|
|
*s.debPosition = s.Player.Position().String() + " vel " + s.Player.Velocity().String()
|
|
*s.debViewport = s.drawing.Viewport().String()
|
|
*s.debScroll = s.drawing.Scroll.String()
|
|
|
|
s.supervisor.Loop(ev)
|
|
|
|
// Has the window been resized?
|
|
if ev.WindowResized {
|
|
w, h := d.Engine.WindowSize()
|
|
if w != d.width || h != d.height {
|
|
d.width = w
|
|
d.height = h
|
|
s.drawing.Resize(render.NewRect(d.width, d.height))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Switching to Edit Mode?
|
|
if s.CanEdit && keybind.GotoEdit(ev) {
|
|
s.EditLevel()
|
|
return nil
|
|
}
|
|
|
|
// Is the simulation still running?
|
|
if s.running {
|
|
// Loop the script supervisor so timeouts/intervals can fire in scripts.
|
|
if err := s.scripting.Loop(); err != nil {
|
|
log.Error("PlayScene.Loop: scripting.Loop: %s", err)
|
|
}
|
|
|
|
// Touch regions.
|
|
s.LoopTouchable(ev)
|
|
|
|
s.movePlayer(ev)
|
|
if err := s.drawing.Loop(ev); err != nil {
|
|
log.Error("Drawing loop error: %s", err.Error())
|
|
}
|
|
|
|
// Check if the player hit the death barrier.
|
|
if s.Player.Position().Y > s.deathBarrier {
|
|
s.DieByFire("falling off the map")
|
|
}
|
|
|
|
// Update the inventory HUD.
|
|
s.computeInventory()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Draw the pixels on this frame.
|
|
func (s *PlayScene) Draw(d *Doodle) error {
|
|
// Skip if still loading.
|
|
if loadscreen.IsActive() {
|
|
return nil
|
|
}
|
|
|
|
// Clear the canvas and fill it with white.
|
|
d.Engine.Clear(render.White)
|
|
|
|
// Draw the level.
|
|
s.drawing.Present(d.Engine, s.drawing.Point())
|
|
|
|
// Draw out bounding boxes.
|
|
if DebugCollision {
|
|
for _, actor := range s.drawing.Actors() {
|
|
d.DrawCollisionBox(s.drawing, actor)
|
|
}
|
|
}
|
|
|
|
// Draw the UI screen and any widgets that attached to it.
|
|
s.screen.Compute(d.Engine)
|
|
s.screen.Present(d.Engine, render.Origin)
|
|
|
|
// Draw the Edit button.
|
|
var (
|
|
canSize = s.drawing.Size()
|
|
size = s.editButton.Size()
|
|
padding = 8
|
|
)
|
|
s.editButton.MoveTo(render.Point{
|
|
X: canSize.W - size.W - padding,
|
|
Y: canSize.H - size.H - padding,
|
|
})
|
|
s.editButton.Present(d.Engine, s.editButton.Point())
|
|
|
|
// Visualize the touch regions?
|
|
s.DrawTouchable()
|
|
|
|
return nil
|
|
}
|
|
|
|
// movePlayer updates the player's X,Y coordinate based on key pressed.
|
|
func (s *PlayScene) movePlayer(ev *event.State) {
|
|
var (
|
|
playerSpeed = float64(balance.PlayerMaxVelocity)
|
|
velocity = s.Player.Velocity()
|
|
direction float64
|
|
jumping bool
|
|
)
|
|
|
|
// Antigravity: player can move anywhere with arrow keys.
|
|
if s.antigravity || !s.Player.HasGravity() {
|
|
velocity.X = 0
|
|
velocity.Y = 0
|
|
|
|
// Shift to slow your roll to 1 pixel per tick.
|
|
if keybind.Shift(ev) {
|
|
playerSpeed = 1
|
|
}
|
|
|
|
if keybind.Left(ev) {
|
|
velocity.X = -playerSpeed
|
|
} else if keybind.Right(ev) {
|
|
velocity.X = playerSpeed
|
|
}
|
|
if keybind.Up(ev) {
|
|
velocity.Y = -playerSpeed
|
|
} else if keybind.Down(ev) {
|
|
velocity.Y = playerSpeed
|
|
}
|
|
} else {
|
|
// Moving left or right.
|
|
if keybind.Left(ev) {
|
|
direction = -1
|
|
} else if keybind.Right(ev) {
|
|
direction = 1
|
|
}
|
|
|
|
// Up button to signal they want to jump.
|
|
if keybind.Up(ev) && (s.Player.Grounded() || s.playerJumpCounter >= 0) {
|
|
jumping = true
|
|
|
|
if s.Player.Grounded() {
|
|
// Allow them to sustain the jump this many ticks.
|
|
s.playerJumpCounter = 32
|
|
}
|
|
}
|
|
|
|
// Moving left or right? Interpolate their velocity by acceleration.
|
|
if direction != 0 {
|
|
// TODO: fast turn-around if they change directions so they don't
|
|
// slip and slide while their velocity updates.
|
|
velocity.X = physics.Lerp(
|
|
velocity.X,
|
|
direction*s.playerPhysics.MaxSpeed.X,
|
|
s.playerPhysics.Acceleration,
|
|
)
|
|
} else {
|
|
// Slow them back to zero using friction.
|
|
velocity.X = physics.Lerp(
|
|
velocity.X,
|
|
0,
|
|
s.playerPhysics.Friction,
|
|
)
|
|
}
|
|
|
|
// Moving upwards (jumping): give them full acceleration upwards.
|
|
if jumping {
|
|
velocity.Y = -playerSpeed
|
|
}
|
|
|
|
// While in the air, count down their jump counter; when zero they
|
|
// cannot jump again until they touch ground.
|
|
if !s.Player.Grounded() {
|
|
s.playerJumpCounter--
|
|
}
|
|
}
|
|
|
|
// Move the player unless frozen.
|
|
// TODO: if Y=0 then gravity fails, but not doing this allows the
|
|
// player to jump while frozen. Not a HUGE deal right now as only Warp Doors
|
|
// freeze the player currently but do address this later.
|
|
if s.Player.IsFrozen() {
|
|
velocity.X = 0
|
|
}
|
|
s.Player.SetVelocity(velocity)
|
|
|
|
// If the "Use" key is pressed, set an actor flag on the player.
|
|
s.Player.SetUsing(keybind.Use(ev))
|
|
|
|
s.scripting.To(s.Player.ID()).Events.RunKeypress(keybind.FromEvent(ev))
|
|
}
|
|
|
|
// Drawing returns the private world drawing, for debugging with the console.
|
|
func (s *PlayScene) Drawing() *uix.Canvas {
|
|
return s.drawing
|
|
}
|
|
|
|
// LoadLevel loads a level from disk.
|
|
func (s *PlayScene) LoadLevel(filename string) error {
|
|
s.Filename = filename
|
|
|
|
level, err := level.LoadFile(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("PlayScene.LoadLevel(%s): %s", filename, err)
|
|
}
|
|
|
|
s.Level = level
|
|
s.drawing.LoadLevel(s.Level)
|
|
s.drawing.InstallActors(s.Level.Actors)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Destroy the scene.
|
|
func (s *PlayScene) Destroy() error {
|
|
return nil
|
|
}
|