Level Screenshots and Thumbnails

Adds some support for "less giant" level screenshots.

* In the Editor, the Level->Take Screenshot menu will render a cropped screen
  shot of just the level viewport on screen. Note: it is not an SDL2 screen
  copy but generated from scratch from the level data.
* In levels themselves, screenshots can be stored inside the level data in
  three different sizes: large (1280x720), medium and small (each a halved
  size of the previous).
* The first screenshot is created when the level is saved, starting from
  wherever the scroll position in the editor is at, and recording the 720p
  view of the level from there.
* The level screenshot can be previewed and updated in the Level Properties
  window of the editor: so you can scroll the editor to just the right position
  and take a good screenshot to represent your level.
* In the future: these embedded level screenshots will be displayed on the
  Story Mode and other screens to see a preview of each level.

Other tweaks:

* When taking a Giant Screenshot: a confirm modal will warn the player that
  it may take a while. And during the screenshot, show the new Wait Modal to
  block player interaction until the screenshot has finished.
This commit is contained in:
Noah 2023-12-08 19:48:02 -08:00
parent 481638bea6
commit da83231559
11 changed files with 476 additions and 18 deletions

View File

@ -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

View File

@ -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,

View File

@ -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 (

View File

@ -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)
})

View File

@ -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()

View File

@ -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 {

View File

@ -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

View File

@ -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
}

61
pkg/level/screenshot.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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{