doodle/pkg/level/giant_screenshot/giant_screenshot.go
Noah Petherbridge da83231559 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.
2023-12-08 19:48:02 -08:00

226 lines
6.3 KiB
Go

package giant_screenshot
import (
"errors"
"image"
"image/draw"
"image/png"
"os"
"path/filepath"
"time"
"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/shmem"
"git.kirsle.net/SketchyMaze/doodle/pkg/userdir"
"git.kirsle.net/SketchyMaze/doodle/pkg/wallpaper"
"git.kirsle.net/go/render"
)
/*
Giant Screenshot functionality for the Level Editor.
*/
var locked bool
// GiantScreenshot returns a rendered RGBA image of the entire level.
//
// Only one thread should be doing this at a time. A sync.Mutex will cause
// an error to return if another goroutine is already in the process of
// generating a screenshot, and you'll have to wait and try later.
func GiantScreenshot(lvl *level.Level) (image.Image, error) {
// Lock this to one user at a time.
if locked {
return nil, errors.New("a giant screenshot is still being processed; try later...")
}
locked = true
defer func() {
locked = false
}()
shmem.Flash("Saving a giant screenshot (this takes a moment)...")
// How big will our image be?
var (
size = lvl.Chunker.WorldSizePositive()
chunkSize = int(lvl.Chunker.Size)
chunkLow, chunkHigh = lvl.Chunker.Bounds()
worldSize = render.Rect{
X: chunkLow.X,
Y: chunkLow.Y,
W: chunkHigh.X,
H: chunkHigh.Y,
}
x int
y int
)
// Bounded levels: set the image output size precisely.
if lvl.PageType == level.Bounded || lvl.PageType == level.Bordered {
size = render.NewRect(int(lvl.MaxWidth), int(lvl.MaxHeight))
}
// Levels without negative space: set the lower chunk coord to 0,0
if lvl.PageType > level.Unbounded {
worldSize.X = 0
worldSize.Y = 0
}
// Create the image.
img := image.NewRGBA(image.Rect(0, 0, size.W, size.H))
// Render the wallpaper onto it.
log.Debug("GiantScreenshot: Render wallpaper to image (%s)...", size)
img = WallpaperToImage(lvl, img, size.W, size.H, render.Origin)
// Render the chunks.
log.Debug("GiantScreenshot: Render level chunks...")
for chunkX := worldSize.X; chunkX <= worldSize.W; chunkX++ {
y = 0
for chunkY := worldSize.Y; chunkY <= worldSize.H; chunkY++ {
if chunk, ok := lvl.Chunker.GetChunk(render.NewPoint(chunkX, chunkY)); ok {
// TODO: we always use RGBA but is risky:
rgba, ok := chunk.CachedBitmap(render.Invisible).(*image.RGBA)
if !ok {
log.Error("GiantScreenshot: couldn't turn chunk to RGBA")
}
img = blotImage(img, rgba, image.Pt(x, y))
}
y += chunkSize
}
x += chunkSize
}
// Render the doodads.
log.Debug("GiantScreenshot: Render actors...")
for _, actor := range lvl.Actors {
doodad, err := doodads.LoadFromEmbeddable(actor.Filename, lvl, false)
if err != nil {
log.Error("GiantScreenshot: Load doodad: %s", err)
continue
}
// Offset the doodad position if the image is displaying
// negative coordinates.
drawAt := render.NewPoint(actor.Point.X, actor.Point.Y)
if worldSize.X < 0 {
var offset = render.AbsInt(worldSize.X) * chunkSize
drawAt.X += offset
}
if worldSize.Y < 0 {
var offset = render.AbsInt(worldSize.Y) * chunkSize
drawAt.Y += offset
}
// 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("GiantScreenshot: couldn't turn chunk to RGBA")
}
img = blotImage(img, rgba, image.Pt(drawAt.X, drawAt.Y))
}
}
return img, nil
}
// SaveGiantScreenshot will take a screenshot and write it to a file on disk,
// returning the filename relative to ~/.config/doodle/screenshots
func SaveGiantScreenshot(level *level.Level) (string, error) {
var filename = time.Now().Format("2006-01-02_15-04-05.png")
img, err := GiantScreenshot(level)
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
}
// WallpaperToImage accurately draws the wallpaper into an Image.
//
// 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.
//
// 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)
}
var size = wp.QuarterRect()
var resultImage = target
// 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 (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.
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.
if absOffset.X < size.W && absOffset.Y < size.H {
resultImage = blotImage(resultImage, wp.Corner(), image.Pt(
-offset.X,
-offset.Y,
))
}
}
return resultImage
}
func blotImage(target, source *image.RGBA, offset image.Point) *image.RGBA {
b := target.Bounds()
newImg := image.NewRGBA(b)
draw.Draw(newImg, b, target, image.Point{}, draw.Src)
draw.Draw(newImg, source.Bounds().Add(offset), source, image.Point{}, draw.Over)
return newImg
}