diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index cfb4da4..1644a4b 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -153,6 +153,14 @@ var ( // variable is the tolerance offset - if they are only this far out of bounds, put them // back in bounds but further out and they're OK. OutOfBoundsMargin = 40 + + // Level screenshot dimensions saved within the level data. + LevelScreenshotLargeFilename = "large.png" + LevelScreenshotMediumFilename = "medium.png" + LevelScreenshotSmallFilename = "small.png" + LevelScreenshotLargeSize = render.NewRect(1280, 720) + LevelScreenshotMediumSize = render.NewRect(640, 360) + LevelScreenshotSmallSize = render.NewRect(320, 180) ) // Edit Mode Values diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index 6f7ddc8..78bce9f 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -141,6 +141,13 @@ var ( Color: render.Black, } + // DangerFont is a red version of UIFont. + DangerFont = render.Text{ + Size: 12, + Padding: 4, + Color: render.Red, + } + // LabelFont is the font for strong labels in UI. LabelFont = render.Text{ Size: 12, diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index db94e60..07cc711 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -13,6 +13,7 @@ import ( "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/license" "git.kirsle.net/SketchyMaze/doodle/pkg/log" @@ -553,9 +554,28 @@ func (s *EditorScene) SaveLevel(filename string) 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 ( diff --git a/pkg/editor_ui_menubar.go b/pkg/editor_ui_menubar.go index b0a6ff4..8c3cbc3 100644 --- a/pkg/editor_ui_menubar.go +++ b/pkg/editor_ui_menubar.go @@ -11,6 +11,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/level/giant_screenshot" "git.kirsle.net/SketchyMaze/doodle/pkg/license" "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "git.kirsle.net/SketchyMaze/doodle/pkg/modal" "git.kirsle.net/SketchyMaze/doodle/pkg/native" "git.kirsle.net/SketchyMaze/doodle/pkg/userdir" "git.kirsle.net/SketchyMaze/doodle/pkg/windows" @@ -115,19 +116,41 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { }) levelMenu.AddSeparator() - levelMenu.AddItem("Giant Screenshot", func() { + levelMenu.AddItem("Screenshot", func() { // It takes a LONG TIME to render for medium+ maps. // Do so on a background thread. go func() { - filename, err := giant_screenshot.SaveGiantScreenshot(u.Scene.Level) + filename, err := giant_screenshot.SaveCroppedScreenshot(u.Scene.Level, u.Scene.GetDrawing().Viewport()) if err != nil { d.FlashError("Error: %s", err.Error()) return } - d.FlashError("Giant screenshot saved as: %s", filename) + d.FlashError("Screenshot saved as: %s", filename) }() }) + levelMenu.AddItem("Giant Screenshot", func() { + // It takes a LONG TIME to render for medium+ maps. + modal.Confirm( + "Do you want to make a 'Giant Screenshot' of\n" + + "your WHOLE level? Note: this may take several\n" + + "seconds for very large maps!", + ).WithTitle("Giant Screenshot").Then(func() { + // Show the wait modal and generate the screenshot on a background thread. + m := modal.Wait("Generating a giant screenshot...").WithTitle("Please hold") + go func() { + defer m.Dismiss(true) + + filename, err := giant_screenshot.SaveGiantScreenshot(u.Scene.Level) + if err != nil { + d.FlashError("Error: %s", err.Error()) + return + } + + d.FlashError("Giant screenshot saved as: %s", filename) + }() + }) + }) levelMenu.AddItem("Open screenshot folder", func() { native.OpenLocalURL(userdir.ScreenshotDirectory) }) diff --git a/pkg/editor_ui_popups.go b/pkg/editor_ui_popups.go index b7820e7..ffbe44d 100644 --- a/pkg/editor_ui_popups.go +++ b/pkg/editor_ui_popups.go @@ -188,6 +188,9 @@ func (u *EditorUI) SetupPopups(d *Doodle) { u.Canvas.Destroy() // clean up old textures u.Canvas.LoadLevel(scene.Level) }, + OnUpdateScreenshot: func() error { + return scene.UpdateLevelScreenshot(scene.Level) + }, OnReload: func() { log.Warn("RELOAD LEVEL") scene.Reset() diff --git a/pkg/level/filesystem.go b/pkg/level/filesystem.go index 5af8a5c..2aafbfd 100644 --- a/pkg/level/filesystem.go +++ b/pkg/level/filesystem.go @@ -37,6 +37,28 @@ func NewFileSystem() *FileSystem { } } +// Exists checks if a file exists. +func (fs *FileSystem) Exists(filename string) bool { + if fs.filemap == nil { + fs.filemap = map[string]File{} + } + + // Legacy file map. + if _, ok := fs.filemap[filename]; ok { + return ok + } + + // Check in the zipfile. + if fs.Zipfile != nil { + file, err := fs.Zipfile.Open(filename) + if err == nil { + defer file.Close() + return true + } + } + return false +} + // Get a file from the FileSystem. func (fs *FileSystem) Get(filename string) ([]byte, error) { if fs.filemap == nil { diff --git a/pkg/level/giant_screenshot/giant_screenshot.go b/pkg/level/giant_screenshot/giant_screenshot.go index 88f26d4..5ae11ae 100644 --- a/pkg/level/giant_screenshot/giant_screenshot.go +++ b/pkg/level/giant_screenshot/giant_screenshot.go @@ -72,7 +72,7 @@ func GiantScreenshot(lvl *level.Level) (image.Image, error) { // Render the wallpaper onto it. log.Debug("GiantScreenshot: Render wallpaper to image (%s)...", size) - img = WallpaperToImage(lvl, img, size.W, size.H) + img = WallpaperToImage(lvl, img, size.W, size.H, render.Origin) // Render the chunks. log.Debug("GiantScreenshot: Render level chunks...") @@ -158,7 +158,9 @@ func SaveGiantScreenshot(level *level.Level) (string, error) { // // The image is assumed to have a rect of (0,0,width,height) and that // width and height are positive. Used for the Giant Screenshot feature. -func WallpaperToImage(lvl *level.Level, target *image.RGBA, width, height int) *image.RGBA { +// +// Pass an offset point to 'scroll' the wallpaper (for the Cropped Screenshot). +func WallpaperToImage(lvl *level.Level, target *image.RGBA, width, height int, offset render.Point) *image.RGBA { wp, err := wallpaper.FromFile("assets/wallpapers/"+lvl.Wallpaper, lvl) if err != nil { log.Error("GiantScreenshot: wallpaper load: %s", err) @@ -167,30 +169,48 @@ func WallpaperToImage(lvl *level.Level, target *image.RGBA, width, height int) * var size = wp.QuarterRect() var resultImage = target - // Tile the repeat texture. - for x := 0; x < width; x += size.W { - for y := 0; y < height; y += size.H { - offset := image.Pt(x, y) - resultImage = blotImage(resultImage, wp.Repeat(), offset) + // Handling the scroll offsets: wallpapers are made up of small-ish + // repeating squares so the scroll offset needs only be a modulus within + // the size of one square. + absOffset := offset + if offset != render.Origin { + offset.X = render.AbsInt(offset.X % size.W) + offset.Y = render.AbsInt(offset.Y % size.H) + } + + // Tile the repeat texture. Go one tile extra in case of offset. + for x := 0; x < width+size.W; x += size.W { + for y := 0; y < height+size.H; y += size.H { + dst := image.Pt(x-offset.X, y-offset.Y) + resultImage = blotImage(resultImage, wp.Repeat(), dst) } } // Tile the left edge for bounded lvls. if lvl.PageType > level.Unbounded { - // The left edge. - for y := 0; y < height; y += size.H { - offset := image.Pt(0, y) - resultImage = blotImage(resultImage, wp.Left(), offset) + // The left edge (unless off screen) + if absOffset.X < size.W { + for y := 0; y < height; y += size.H { + dst := image.Pt(0-offset.X, y-offset.Y) + resultImage = blotImage(resultImage, wp.Left(), dst) + } } // The top edge. - for x := 0; x < width; x += size.W { - offset := image.Pt(x, 0) - resultImage = blotImage(resultImage, wp.Top(), offset) + if absOffset.Y < size.H { + for x := 0; x < width; x += size.W { + dst := image.Pt(x-offset.X, 0-offset.Y) + resultImage = blotImage(resultImage, wp.Top(), dst) + } } // The top left corner. - resultImage = blotImage(resultImage, wp.Corner(), image.Point{}) + if absOffset.X < size.W && absOffset.Y < size.H { + resultImage = blotImage(resultImage, wp.Corner(), image.Pt( + -offset.X, + -offset.Y, + )) + } } return resultImage diff --git a/pkg/level/giant_screenshot/regular_screenshot.go b/pkg/level/giant_screenshot/regular_screenshot.go new file mode 100644 index 0000000..3ebcbd8 --- /dev/null +++ b/pkg/level/giant_screenshot/regular_screenshot.go @@ -0,0 +1,196 @@ +package giant_screenshot + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + "time" + + "git.kirsle.net/SketchyMaze/doodle/pkg/balance" + "git.kirsle.net/SketchyMaze/doodle/pkg/doodads" + "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "git.kirsle.net/SketchyMaze/doodle/pkg/userdir" + "git.kirsle.net/go/render" + "golang.org/x/image/draw" +) + +// CroppedScreenshot returns a rendered RGBA image of the level. +func CroppedScreenshot(lvl *level.Level, viewport render.Rect) (image.Image, error) { + // Lock this to one user at a time. + if locked { + return nil, errors.New("a screenshot is still being processed; try later...") + } + locked = true + defer func() { + locked = false + }() + + // How big will our image be? + var ( + size = render.NewRect(viewport.W-viewport.X, viewport.H-viewport.Y) + chunkSize = int(lvl.Chunker.Size) + // worldSize = viewport + ) + + // Create the image. + img := image.NewRGBA(image.Rect(0, 0, size.W, size.H)) + + // Render the wallpaper onto it. + log.Debug("CroppedScreenshot: Render wallpaper to image (%s)...", size) + img = WallpaperToImage(lvl, img, size.W, size.H, viewport.Point()) + + // Render the chunks. + log.Debug("CroppedScreenshot: Render level chunks...") + for coord := range lvl.Chunker.IterViewportChunks(viewport) { + if chunk, ok := lvl.Chunker.GetChunk(coord); ok { + + // Get this chunk's rendered bitmap. + rgba, ok := chunk.CachedBitmap(render.Invisible).(*image.RGBA) + if !ok { + log.Error("CroppedScreenshot: couldn't turn chunk to RGBA") + } + log.Debug("Blot chunk %s onto image", coord) + + // Compute where on the output image to copy this bitmap to. + dst := image.Pt( + // (X * W) multiplies the chunk coord by its size, + // Then subtract the Viewport (level scroll position) + (coord.X*chunkSize)-viewport.X, + (coord.Y*chunkSize)-viewport.Y, + ) + + log.Debug("Copy chunk: %s to %s", coord, dst) + img = blotImage(img, rgba, dst) + } + } + + // Render the doodads. + log.Debug("CroppedScreenshot: Render actors...") + for _, actor := range lvl.Actors { + doodad, err := doodads.LoadFromEmbeddable(actor.Filename, lvl, false) + if err != nil { + log.Error("CroppedScreenshot: Load doodad: %s", err) + continue + } + + // Offset the doodad position by the viewport (scroll position). + drawAt := render.NewPoint(actor.Point.X, actor.Point.Y) + drawAt.X -= viewport.X + drawAt.Y -= viewport.Y + + // TODO: usually doodad sprites start at 0,0 and the chunkSize + // is the same as their sprite size. + if len(doodad.Layers) > 0 && doodad.Layers[0].Chunker != nil { + var chunker = doodad.Layers[0].Chunker + chunk, ok := chunker.GetChunk(render.Origin) + if !ok { + continue + } + + // TODO: we always use RGBA but is risky: + rgba, ok := chunk.CachedBitmap(render.Invisible).(*image.RGBA) + if !ok { + log.Error("CroppedScreenshot: couldn't turn chunk to RGBA") + } + img = blotImage(img, rgba, image.Pt(drawAt.X, drawAt.Y)) + } + + } + + return img, nil +} + +// SaveCroppedScreenshot will take a screenshot and write it to a file on disk, +// returning the filename relative to ~/.config/doodle/screenshots +func SaveCroppedScreenshot(level *level.Level, viewport render.Rect) (string, error) { + var filename = time.Now().Format("2006-01-02_15-04-05.png") + + img, err := CroppedScreenshot(level, viewport) + if err != nil { + return "", err + } + + fh, err := os.Create(filepath.Join(userdir.ScreenshotDirectory, filename)) + if err != nil { + return "", err + } + + png.Encode(fh, img) + return filename, nil +} + +// UpdateLevelScreenshots will generate and embed the screenshot PNGs into the level data. +func UpdateLevelScreenshots(lvl *level.Level, scroll render.Point) error { + // Take screenshots. + large, medium, small, err := CreateLevelScreenshots(lvl, scroll) + if err != nil { + return err + } + + // Save the images into the level's filesystem. + for filename, img := range map[string]image.Image{ + balance.LevelScreenshotLargeFilename: large, + balance.LevelScreenshotMediumFilename: medium, + balance.LevelScreenshotSmallFilename: small, + } { + var fh = bytes.NewBuffer([]byte{}) + if err := png.Encode(fh, img); err != nil { + return fmt.Errorf("encode %s: %s", filename, err) + } + + log.Debug("UpdateLevelScreenshots: add %s", filename) + lvl.Files.Set( + fmt.Sprintf("assets/screenshots/%s", filename), + fh.Bytes(), + ) + } + + return nil +} + +// CreateLevelScreenshots generates a screenshot to save with the level data. +// +// This is called by the editor upon level save, and outputs the screenshots that +// will be embedded within the level data itself. +// +// Returns the large, medium and small images. +func CreateLevelScreenshots(lvl *level.Level, scroll render.Point) (large, medium, small image.Image, err error) { + // Viewport to screenshot. + viewport := render.Rect{ + X: scroll.X, + W: scroll.X + balance.LevelScreenshotLargeSize.W, + Y: scroll.Y, + H: scroll.Y + balance.LevelScreenshotLargeSize.H, + } + + // Get the full size screenshot as an image. + large, err = CroppedScreenshot(lvl, viewport) + if err != nil { + return + } + + // Scale the medium and small versions. + medium = Scale(large, image.Rect(0, 0, balance.LevelScreenshotMediumSize.W, balance.LevelScreenshotMediumSize.H), draw.ApproxBiLinear) + small = Scale(large, image.Rect(0, 0, balance.LevelScreenshotSmallSize.W, balance.LevelScreenshotSmallSize.H), draw.ApproxBiLinear) + return large, medium, small, nil +} + +// Scale down an image. Example: +// +// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear) +func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image { + dst := image.NewRGBA(rect) + copyRect := image.Rect( + rect.Min.X, + rect.Min.Y, + rect.Min.X+rect.Max.X, + rect.Min.Y+rect.Max.Y, + ) + scale.Scale(dst, copyRect, src, src.Bounds(), draw.Over, nil) + return dst +} diff --git a/pkg/level/screenshot.go b/pkg/level/screenshot.go new file mode 100644 index 0000000..d25985e --- /dev/null +++ b/pkg/level/screenshot.go @@ -0,0 +1,61 @@ +package level + +import ( + "bytes" + "image" + "image/png" + + "git.kirsle.net/SketchyMaze/doodle/pkg/balance" + "git.kirsle.net/go/ui" +) + +// Helper functions to get a level's embedded screenshot PNGs as textures. + +// HasScreenshot returns whether screenshots exist for the level. +func (lvl *Level) HasScreenshot() bool { + var filenames = []string{ + balance.LevelScreenshotLargeFilename, + balance.LevelScreenshotMediumFilename, + balance.LevelScreenshotSmallFilename, + } + for _, filename := range filenames { + if lvl.Files.Exists("assets/screenshots/" + filename) { + return true + } + } + return false +} + +// GetScreenshotImage returns a screenshot from the level's data as a Go Image. +// The filename is like "large.png" or "medium.png" and is appended to "assets/screenshots" +func (lvl *Level) GetScreenshotImage(filename string) (image.Image, error) { + data, err := lvl.Files.Get("assets/screenshots/" + filename) + if err != nil { + return nil, err + } + + return png.Decode(bytes.NewBuffer(data)) +} + +// GetScreenshotImageAsUIImage returns a ui.Image texture of a screenshot. +func (lvl *Level) GetScreenshotImageAsUIImage(filename string) (*ui.Image, error) { + // Have it cached recently? + if lvl.cacheImages == nil { + lvl.cacheImages = map[string]*ui.Image{} + } else if img, ok := lvl.cacheImages[filename]; ok { + return img, nil + } + + img, err := lvl.GetScreenshotImage(filename) + if err != nil { + return nil, err + } + + result, err := ui.ImageFromImage(img) + if err != nil { + return nil, err + } + + lvl.cacheImages[filename] = result + return result, nil +} diff --git a/pkg/level/types.go b/pkg/level/types.go index c1411da..e32b536 100644 --- a/pkg/level/types.go +++ b/pkg/level/types.go @@ -11,6 +11,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/native" "git.kirsle.net/go/render" + "git.kirsle.net/go/ui" ) // Useful variables. @@ -73,6 +74,9 @@ type Level struct { // Undo history, temporary live data not persisted to the level file. UndoHistory *drawtool.History `json:"-"` + + // Cache of loaded images (e.g. screenshots). + cacheImages map[string]*ui.Image } // GameRule @@ -118,6 +122,13 @@ func (m *Level) Teardown() { textures += freed } + // Free any cached images (screenshots) + if m.cacheImages != nil { + for _, img := range m.cacheImages { + img.Destroy() + } + } + log.Debug("Teardown level (%s): Freed %d textures across %d level chunks", m.Title, textures, chunks) } diff --git a/pkg/windows/add_edit_level.go b/pkg/windows/add_edit_level.go index 2087fca..3c42625 100644 --- a/pkg/windows/add_edit_level.go +++ b/pkg/windows/add_edit_level.go @@ -34,6 +34,7 @@ type AddEditLevel struct { OnChangePageTypeAndWallpaper func(pageType level.PageType, wallpaper string) OnCreateNewLevel func(*level.Level) OnCreateNewDoodad func(width, height int) + OnUpdateScreenshot func() error OnReload func() OnCancel func() } @@ -74,6 +75,7 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window { } else { // Additional Level tabs (existing level only) config.setupGameRuleFrame(tabframe) + config.setupScreenshotFrame(tabframe) config.setupAdvancedFrame(tabframe) } @@ -564,6 +566,91 @@ func (config AddEditLevel) setupGameRuleFrame(tf *ui.TabFrame) { form.Create(frame, fields) } +// Level Screenshot management frame. +func (config AddEditLevel) setupScreenshotFrame(tf *ui.TabFrame) { + frame := tf.AddTab("Screenshot", ui.NewLabel(ui.Label{ + Text: "Screenshot", + Font: balance.TabFont, + })) + + var image *ui.Image + + // Have a screenshot already? + if config.EditLevel.HasScreenshot() { + if img, err := config.EditLevel.GetScreenshotImageAsUIImage(balance.LevelScreenshotSmallFilename); err != nil { + lbl := ui.NewLabel(ui.Label{ + Text: err.Error(), + Font: balance.DangerFont, + }) + frame.Pack(lbl, ui.Pack{ + Side: ui.N, + }) + } else { + // Draw the image. + log.Error("Got img: %+v", img) + frame.Pack(img, ui.Pack{ + Side: ui.N, + }) + + // Hold onto it in case we need to refresh it. + image = img + } + } else { + lbl := ui.NewLabel(ui.Label{ + Text: "This level has no screenshot available. If this is\n" + + "a new drawing, its first screenshot will be created\n" + + "upon save; playtest or close and re-open the level\n" + + "then and its screenshot should appear here and can\n" + + "be refreshed.", + Font: balance.DangerFont, + }) + frame.Pack(lbl, ui.Pack{ + Side: ui.N, + }) + + // Exit now - don't add the Refresh button in case of nil pointer exception + // while we are in this state. + return + } + + // Image refresh button. + btn := ui.NewButton("Refresh", ui.NewLabel(ui.Label{ + Text: "Update Screenshot", + Font: balance.UIFont, + })) + ui.NewTooltip(btn, ui.Tooltip{ + Text: "Update the screenshot from your current scroll point\nin the level editor.", + }) + + btn.Handle(ui.Click, func(ed ui.EventData) error { + // Take a new screenshot. + if config.OnUpdateScreenshot == nil { + shmem.FlashError("OnUpdateScreenshot handler not configured!") + } else { + if err := config.OnUpdateScreenshot(); err != nil { + modal.Alert(err.Error()).WithTitle("Error updating screenshot") + } else { + // Get the updated screenshot image from the level. + if img, err := config.EditLevel.GetScreenshotImage(balance.LevelScreenshotSmallFilename); err != nil { + shmem.FlashError("Couldn't reload screenshot from level: %s", err) + } else { + image.ReplaceFromImage(img) + } + } + } + return nil + }) + + btn.Compute(config.Engine) + + frame.Pack(btn, ui.Pack{ + Side: ui.N, + Expand: true, + }) + + config.Supervisor.Add(btn) +} + // Creates the Game Rules frame for existing level (set difficulty, etc.) func (config AddEditLevel) setupAdvancedFrame(tf *ui.TabFrame) { frame := tf.AddTab("Advanced", ui.NewLabel(ui.Label{