doodle/pkg/editor_scene.go

671 lines
17 KiB
Go

package doodle
import (
"errors"
"fmt"
"strings"
"time"
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
"git.kirsle.net/SketchyMaze/doodle/pkg/cursor"
"git.kirsle.net/SketchyMaze/doodle/pkg/doodads"
"git.kirsle.net/SketchyMaze/doodle/pkg/drawtool"
"git.kirsle.net/SketchyMaze/doodle/pkg/enum"
"git.kirsle.net/SketchyMaze/doodle/pkg/keybind"
"git.kirsle.net/SketchyMaze/doodle/pkg/level"
"git.kirsle.net/SketchyMaze/doodle/pkg/level/giant_screenshot"
"git.kirsle.net/SketchyMaze/doodle/pkg/level/publishing"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/modal"
"git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen"
"git.kirsle.net/SketchyMaze/doodle/pkg/native"
"git.kirsle.net/SketchyMaze/doodle/pkg/plus"
"git.kirsle.net/SketchyMaze/doodle/pkg/usercfg"
"git.kirsle.net/SketchyMaze/doodle/pkg/userdir"
"git.kirsle.net/SketchyMaze/doodle/pkg/windows"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
)
// EditorScene manages the "Edit Level" game mode.
type EditorScene struct {
// Configuration for the scene initializer.
DrawingType enum.DrawingType
OpenFile bool
Filename string
DoodadSize render.Rect
RememberScrollPosition render.Point // Play mode remembers it for us
UI *EditorUI
d *Doodle
// The current level or doodad object being edited, based on the
// DrawingType.
Level *level.Level
Doodad *doodads.Doodad
ActiveLayer int // which layer (of a doodad) is being edited now?
// Custom debug overlay values.
debTool *string
debSwatch *string
debWorldIndex *string
debLoadingViewport *string
// Last saved filename by the user.
filename string
lastAutosaveAt time.Time
winOpenLevel *ui.Window
}
// Name of the scene.
func (s *EditorScene) Name() string {
return "Edit"
}
// Setup the editor scene.
func (s *EditorScene) Setup(d *Doodle) error {
// Debug overlay values.
s.debTool = new(string)
s.debSwatch = new(string)
s.debWorldIndex = new(string)
s.debLoadingViewport = new(string)
customDebugLabels = []debugLabel{
{"Pixel:", s.debWorldIndex},
{"Tool:", s.debTool},
{"Swatch:", s.debSwatch},
{"Chunks:", s.debLoadingViewport},
}
// Initialize autosave time.
s.lastAutosaveAt = time.Now()
// Show the loading screen.
loadscreen.ShowWithProgress()
go func() {
if err := s.setupAsync(d); err != nil {
log.Error("EditorScene.setupAsync: %s", err)
}
loadscreen.Hide()
}()
return nil
}
// Reset the editor scene from scratch. Good nuclear option when you change the level's
// palette on-the-fly or some other sticky situation and want to reload the editor.
func (s *EditorScene) Reset() {
if s.Level != nil {
s.Level.Chunker.Redraw()
}
if s.Doodad != nil {
s.Doodad.Layers[s.ActiveLayer].Chunker.Redraw()
}
s.d.Goto(&EditorScene{
Filename: s.Filename,
Level: s.Level,
Doodad: s.Doodad,
})
}
// setupAsync initializes trhe editor scene in the background,
// underneath a loading screen.
func (s *EditorScene) setupAsync(d *Doodle) error {
// Initialize the user interface. It references the palette and such so it
// must be initialized after those things.
s.d = d
s.UI = NewEditorUI(d, s)
// Were we given configuration data?
if s.Filename != "" {
log.Debug("EditorScene.Setup: Set filename to %s", s.Filename)
s.filename = s.Filename
s.Filename = ""
}
// Loading a Level or a Doodad?
switch s.DrawingType {
case enum.LevelDrawing:
if s.Level != nil {
log.Debug("EditorScene.Setup: received level from scene caller")
loadscreen.SetSubtitle(
"Opening: "+s.Level.Title,
"by "+s.Level.Author,
)
s.UI.Canvas.LoadLevel(s.Level)
if err := s.installActors(); err != nil {
log.Error("InstallActors: %s", err)
}
} else if s.filename != "" && s.OpenFile {
log.Debug("EditorScene.Setup: Loading map from filename at %s", s.filename)
loadscreen.SetSubtitle(
"Opening: " + s.filename,
)
if err := s.LoadLevel(s.filename); err != nil {
d.FlashError("LoadLevel error: %s", err)
} else {
if err := s.installActors(); err != nil {
log.Error("InstallActors: %s", err)
}
}
}
// Write locked level?
if s.Level != nil && s.Level.Locked {
if usercfg.Current.WriteLockOverride {
d.Flash("Note: write lock has been overridden")
} else {
d.FlashError("That level is write-protected and cannot be viewed in the editor.")
s.Level = nil
s.UI.Canvas.ClearActors()
s.filename = ""
}
}
// No level?
if s.Level == nil {
log.Debug("EditorScene.Setup: initializing a new Level")
s.Level = level.New()
s.Level.Palette = level.DefaultPalette()
s.UI.Canvas.LoadLevel(s.Level)
s.UI.Canvas.ScrollTo(render.Origin)
s.UI.Canvas.Scrollable = true
}
// Update the loading screen with level info.
loadscreen.SetSubtitle(
"Opening: "+s.Level.Title,
"by "+s.Level.Author,
)
case enum.DoodadDrawing:
// Getting a doodad from file?
if s.filename != "" && s.OpenFile {
log.Debug("EditorScene.Setup: Loading doodad from filename at %s", s.filename)
if err := s.LoadDoodad(s.filename); err != nil {
d.FlashError("LoadDoodad error: %s", err)
}
}
// Write locked doodad?
if s.Doodad != nil && s.Doodad.Locked {
if usercfg.Current.WriteLockOverride {
d.Flash("Note: write lock has been overridden")
} else {
d.FlashError("That doodad is write-protected and cannot be viewed in the editor.")
s.Doodad = nil
s.filename = ""
}
}
// No Doodad?
if s.Doodad == nil {
log.Debug("EditorScene.Setup: initializing a new Doodad")
s.Doodad = doodads.New(s.DoodadSize.W, s.DoodadSize.H)
s.UI.Canvas.LoadDoodad(s.Doodad)
}
// Update the loading screen with level info.
loadscreen.SetSubtitle(
s.Doodad.Title,
"by "+s.Doodad.Author,
)
// TODO: move inside the UI. Just an approximate position for now.
s.UI.Canvas.Resize(s.DoodadSize)
s.UI.Canvas.ScrollTo(render.Origin)
s.UI.Canvas.Scrollable = false
s.UI.Workspace.Compute(d.Engine)
}
// 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.UI.Canvas.Chunker())
// Recompute the UI Palette window for the level's palette.
s.UI.FinishSetup(d)
// Scroll the level to the remembered position from when we went
// to Play Mode and back. If no remembered position, this is zero
// anyway.
if s.RememberScrollPosition.IsZero() && s.Level != nil {
s.UI.Canvas.ScrollTo(s.Level.ScrollPosition)
} else {
s.UI.Canvas.ScrollTo(s.RememberScrollPosition)
}
d.Flash("Editor Mode.")
if s.DrawingType == enum.LevelDrawing {
d.Flash("Press 'P' to playtest this level.")
}
return nil
}
// Common function to install the actors into the level.
//
// InstallActors may return an error if doodads were not found - because the
// player is on the free version and can't load attached doodads from nonsigned
// files.
func (s *EditorScene) installActors() error {
if err := s.UI.Canvas.InstallActors(s.Level.Actors); err != nil {
summary := "This level references some doodads that were not found:"
if strings.Contains(err.Error(), plus.ErrRegisteredFeature.Error()) {
summary = "This level contains embedded doodads, but this is not\n" +
"available in the free version of the game. The following\n" +
"doodads could not be loaded:"
}
modal.Alert("%s\n\n%s", summary, err).WithTitle("Level Errors")
return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err)
}
return nil
}
// Playtest switches the level into Play Mode.
func (s *EditorScene) Playtest() {
log.Info("Play Mode, Go!")
s.d.Goto(&PlayScene{
Filename: s.filename,
Level: s.Level,
CanEdit: true,
RememberScrollPosition: s.UI.Canvas.Scroll,
})
}
// PlaytestFrom enters play mode starting at a custom spawn point.
func (s *EditorScene) PlaytestFrom(p render.Point) {
log.Info("Play Mode, Go!")
s.d.Goto(&PlayScene{
Filename: s.filename,
Level: s.Level,
CanEdit: true,
RememberScrollPosition: s.UI.Canvas.Scroll,
SpawnPoint: p,
})
}
// ConfirmUnload may pop up a confirmation modal to save the level before the
// user performs an action that may close the level, such as click File->New.
func (s *EditorScene) ConfirmUnload(fn func()) {
if !s.UI.Canvas.Modified() {
fn()
return
}
modal.Confirm(
"This drawing has unsaved changes. Are you sure you\nwant to continue and lose your changes?",
).WithTitle("Confirm Closing Drawing").Then(fn)
}
// Loop the editor scene.
func (s *EditorScene) Loop(d *Doodle, ev *event.State) error {
// Skip if still loading.
if loadscreen.IsActive() {
return nil
}
// Update debug overlay values.
*s.debTool = s.UI.Canvas.Tool.String()
*s.debSwatch = "???"
*s.debWorldIndex = s.UI.Canvas.WorldIndexAt(s.UI.cursor).String()
*s.debLoadingViewport = "???"
// Safely...
if s.UI.Canvas.Palette != nil && s.UI.Canvas.Palette.ActiveSwatch != nil {
*s.debSwatch = s.UI.Canvas.Palette.ActiveSwatch.Name
}
if s.UI.Canvas != nil {
inside, outside := s.UI.Canvas.LoadUnloadMetrics()
*s.debLoadingViewport = fmt.Sprintf("%d in %d out %d cached %d gc", inside, outside, s.UI.Canvas.Chunker().CacheSize(), s.UI.Canvas.Chunker().GCSize())
}
// Has the window been resized?
if ev.WindowResized {
s.UI.Resized(d)
return nil
}
// Run all of the keybinds.
binders := []struct {
v bool
f func()
}{
{
keybind.NewLevel(ev), func() {
// Ctrl-N, New Level
s.MenuNewLevel()
},
},
{
keybind.SaveAs(ev), func() {
// Shift-Ctrl-S, Save As
s.MenuSave(true)()
},
},
{
keybind.Save(ev), func() {
// Ctrl-S, Save
s.MenuSave(false)()
},
},
{
keybind.Open(ev), func() {
// Ctrl-O, Open
s.MenuOpen()
},
},
{
keybind.Undo(ev), func() {
// Ctrl-Z, Undo
s.UI.Canvas.UndoStroke()
ev.ResetKeyDown()
},
},
{
keybind.Redo(ev), func() {
// Ctrl-Y, Undo
s.UI.Canvas.RedoStroke()
ev.ResetKeyDown()
},
},
{
keybind.ZoomIn(ev), func() {
s.UI.Canvas.Zoom++
ev.ResetKeyDown()
},
},
{
keybind.ZoomOut(ev), func() {
s.UI.Canvas.Zoom--
ev.ResetKeyDown()
},
},
{
keybind.ZoomReset(ev), func() {
s.UI.Canvas.Zoom = 0
ev.ResetKeyDown()
},
},
{
keybind.Origin(ev), func() {
d.Flash("Scrolled back to level origin (0,0)")
s.UI.Canvas.ScrollTo(render.Origin)
ev.ResetKeyDown()
},
},
{
keybind.CloseAllWindows(ev), func() {
s.UI.Supervisor.CloseAllWindows()
},
},
{
keybind.CloseTopmostWindow(ev), func() {
s.UI.Supervisor.CloseActiveWindow()
},
},
{
keybind.NewViewport(ev), func() {
if s.DrawingType != enum.LevelDrawing {
return
}
pip := windows.MakePiPWindow(d.width, d.height, windows.PiP{
Supervisor: s.UI.Supervisor,
Engine: s.d.Engine,
Level: s.Level,
Event: s.d.event,
Tool: &s.UI.Canvas.Tool,
BrushSize: &s.UI.Canvas.BrushSize,
})
pip.Show()
},
},
}
for _, bind := range binders {
if bind.v {
bind.f()
}
}
// s.UI.Loop(ev)
// Switching to Play Mode?
if s.DrawingType == enum.LevelDrawing && keybind.GotoPlay(ev) {
s.Playtest()
} else if keybind.LineTool(ev) {
d.Flash("Line Tool selected.")
s.UI.Canvas.Tool = drawtool.LineTool
s.UI.activeTool = s.UI.Canvas.Tool.String()
} else if keybind.PencilTool(ev) {
d.Flash("Pencil Tool selected.")
s.UI.Canvas.Tool = drawtool.PencilTool
s.UI.activeTool = s.UI.Canvas.Tool.String()
} else if keybind.RectTool(ev) {
d.Flash("Rectangle Tool selected.")
s.UI.Canvas.Tool = drawtool.RectTool
s.UI.activeTool = s.UI.Canvas.Tool.String()
} else if keybind.EllipseTool(ev) {
d.Flash("Ellipse Tool selected.")
s.UI.Canvas.Tool = drawtool.EllipseTool
s.UI.activeTool = s.UI.Canvas.Tool.String()
} else if keybind.EraserTool(ev) {
d.Flash("Eraser Tool selected.")
s.UI.Canvas.Tool = drawtool.EraserTool
s.UI.activeTool = s.UI.Canvas.Tool.String()
} else if keybind.DoodadDropper(ev) {
s.UI.OpenDoodadDropper()
}
s.UI.Loop(ev)
// Trigger auto-save of the level in case of crash or accidental closure.
if time.Since(s.lastAutosaveAt) > balance.AutoSaveInterval {
s.lastAutosaveAt = time.Now()
if !usercfg.Current.DisableAutosave {
if err := s.AutoSave(); err != nil {
d.FlashError("Autosave error: %s", err)
}
}
}
return nil
}
// Draw the current frame.
func (s *EditorScene) Draw(d *Doodle) error {
// Skip if still loading.
if loadscreen.IsActive() {
return nil
}
// Clear the canvas and fill it with magenta so it's clear if any spots are missed.
d.Engine.Clear(render.RGBA(160, 120, 160, 255))
s.UI.Present(d.Engine)
return nil
}
// LoadLevel loads a level from disk.
func (s *EditorScene) LoadLevel(filename string) error {
s.filename = filename
level, err := level.LoadFile(filename)
if err != nil {
return fmt.Errorf("EditorScene.LoadLevel(%s): %s", filename, err)
}
s.DrawingType = enum.LevelDrawing
s.Level = level
s.UI.Canvas.LoadLevel(s.Level)
log.Info("Installing %d actors into the drawing", len(level.Actors))
if err := s.UI.Canvas.InstallActors(level.Actors); err != nil {
summary := "This level references some doodads that were not found:"
if strings.Contains(err.Error(), plus.ErrRegisteredFeature.Error()) {
summary = "This level contains embedded doodads, but this is not\n" +
"available in the free version of the game. The following\n" +
"doodads could not be loaded:"
}
modal.Alert("%s\n\n%s", summary, err).WithTitle("Level Errors")
return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err)
}
return nil
}
// SaveLevel saves the level to disk.
func (s *EditorScene) SaveLevel(filename string) error {
if s.DrawingType != enum.LevelDrawing {
return errors.New("SaveLevel: current drawing is not a Level type")
}
if !strings.HasSuffix(filename, enum.LevelExt) {
filename += enum.LevelExt
}
s.filename = filename
m := s.Level
if m.Title == "" {
m.Title = "Alpha"
}
if m.Author == "" {
m.Author = native.DefaultAuthor
}
m.Palette = s.UI.Canvas.Palette
m.Chunker = s.UI.Canvas.Chunker()
// Store the scroll position.
m.ScrollPosition = s.UI.Canvas.Scroll
// Clear the modified flag on the level.
s.UI.Canvas.SetModified(false)
// Attach doodads to the level on save.
if err := publishing.Publish(m); err != nil {
log.Error("Error publishing level: %s", err.Error())
}
s.lastAutosaveAt = time.Now()
// Save screenshots into the level file.
if !m.HasScreenshot() {
s.UpdateLevelScreenshot(m)
}
return m.WriteFile(filename)
}
// UpdateLevelScreenshot updates a level screenshot in its zipfile.
func (s *EditorScene) UpdateLevelScreenshot(lvl *level.Level) error {
// The level must have been saved and have a filename to update in.
if s.filename == "" {
return errors.New("Save your level to disk before updating its screenshot.")
}
if err := giant_screenshot.UpdateLevelScreenshots(lvl, s.UI.Canvas.Viewport().Point()); err != nil {
return fmt.Errorf("Error saving level screenshots: %s", err)
}
return nil
}
// AutoSave takes an autosave snapshot of the level or drawing.
func (s *EditorScene) AutoSave() error {
var (
filename = "_autosave.level"
err error
)
s.d.FlashError("Beginning AutoSave() in a background thread")
// Trigger the auto-save in the background to not block the main thread.
go func() {
var err error
switch s.DrawingType {
case enum.LevelDrawing:
err = s.Level.WriteFile(filename)
s.d.Flash("Automatically saved level to %s", filename)
case enum.DoodadDrawing:
filename = "_autosave.doodad"
err = s.Doodad.WriteFile(filename)
s.d.Flash("Automatically saved doodad to %s", filename)
}
if err != nil {
s.d.FlashError("Error saving %s: %s", filename, err)
}
}()
return err
}
// LoadDoodad loads a doodad from disk.
func (s *EditorScene) LoadDoodad(filename string) error {
s.filename = filename
doodad, err := doodads.LoadFile(filename)
if err != nil {
return fmt.Errorf("EditorScene.LoadDoodad(%s): %s", filename, err)
}
s.DrawingType = enum.DoodadDrawing
s.Doodad = doodad
s.DoodadSize = doodad.Size
s.UI.Canvas.LoadDoodad(s.Doodad)
return nil
}
// SaveDoodad saves the doodad to disk.
func (s *EditorScene) SaveDoodad(filename string) error {
if s.DrawingType != enum.DoodadDrawing {
return errors.New("SaveDoodad: current drawing is not a Doodad type")
}
if !strings.HasSuffix(filename, enum.DoodadExt) {
filename += enum.DoodadExt
}
s.filename = filename
d := s.Doodad
if d.Title == "" {
d.Title = "Untitled Doodad"
}
if d.Author == "" {
d.Author = native.DefaultAuthor
}
// TODO: is this copying necessary?
d.Palette = s.UI.Canvas.Palette
d.Layers[s.ActiveLayer].Chunker = s.UI.Canvas.Chunker()
// Clear the modified flag on the level.
s.UI.Canvas.SetModified(false)
// Save it to their profile directory.
filename = userdir.DoodadPath(filename)
log.Info("Write Doodad: %s", filename)
return d.WriteFile(filename)
}
// Destroy the scene.
func (s *EditorScene) Destroy() error {
// Free SDL2 textures. Note: if they are switching to the Editor, the chunks still have
// their bitmaps cached and will regen the textures as needed.
s.UI.Teardown()
// Reset the cursor to default.
cursor.Current = cursor.NewPointer(s.d.Engine)
return nil
}