doodle/pkg/level/giant_screenshot/regular_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

197 lines
5.7 KiB
Go

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
}