doodle/pkg/pattern/pattern.go

286 lines
6.9 KiB
Go

// Package pattern applies a kind of brush texture to a palette swatch.
package pattern
import (
"errors"
"fmt"
"image"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/sprites"
"git.kirsle.net/go/render"
"git.kirsle.net/go/ui"
)
// Pattern applies a texture to a color in level drawings.
type Pattern struct {
Name string
Filename string
Hidden bool // boolProp showHiddenDoodads true
}
// Builtins are the list of the game's built-in patterns.
var Builtins = []Pattern{
{
Name: "No pattern",
Filename: "",
},
{
Name: "Noise",
Filename: "noise.png",
},
{
Name: "Marker",
Filename: "marker.png",
},
{
Name: "Ink",
Filename: "ink.png",
},
{
Name: "Perlin Noise",
Filename: "perlin-noise.png",
},
{
Name: "Bubbles",
Filename: "bubbles.png",
},
{
Name: "Circles",
Filename: "circles.png",
},
{
Name: "Grid",
Filename: "grid.png",
},
{
Name: "Bars (debug)",
Filename: "bars.png",
Hidden: true,
},
}
// Images is a map of file names to ui.Image widgets,
// after LoadBuiltins had been called.
var images map[string]*ui.Image
// Cache of cropped images (e.g. 24x24 icons for palette editor)
var croppedImages map[string]map[render.Rect]*ui.Image
// LoadBuiltins loads all of the PNG textures of built-in patterns
// into ui.Image widgets.
func LoadBuiltins(e render.Engine) {
images = map[string]*ui.Image{}
croppedImages = map[string]map[render.Rect]*ui.Image{}
for _, pat := range Builtins {
if pat.Filename == "" {
continue
}
img, err := sprites.LoadImage(e, "assets/pattern/"+pat.Filename)
if err != nil {
log.Error("Load pattern %s: %s", pat.Filename, err)
}
images[pat.Filename] = img
}
}
// GetImage returns the ui.Image for a builtin pattern.
func GetImage(filename string) (*ui.Image, error) {
if images == nil {
return nil, errors.New("pattern.GetImage: LoadBuiltins() was not called")
}
if im, ok := images[filename]; ok {
return im, nil
}
return nil, fmt.Errorf("pattern.GetImage: filename %s not found", filename)
}
// GetImageCropped gets a cropped ui.Image for a builtin pattern.
func GetImageCropped(filename string, crop render.Rect) (*ui.Image, error) {
// Have it cached already?
if sizes, ok := croppedImages[filename]; ok {
if cached, ok := sizes[crop]; ok {
return cached, nil
}
}
uiImage, err := GetImage(filename)
if err != nil {
return nil, err
}
cropped, err := CropImage(uiImage.Image, image.Rect(0, 0, crop.W, crop.H))
if err != nil {
return nil, err
}
// Cache it for the future so we don't leak textures every time the palette editor asks.
if _, ok := croppedImages[filename]; !ok {
croppedImages[filename] = map[render.Rect]*ui.Image{}
}
croppedImages[filename][crop] = cropped
return cropped, nil
}
// SampleColor samples a color with the pattern for a given coordinate in infinite space.
func SampleColor(filename string, color render.Color, point render.Point) render.Color {
if filename == "" {
return color
}
// Not loaded in memory?
if _, ok := images[filename]; !ok {
return color
}
// Translate the world coord (point) into the bounds of the texture image.
var (
image = images[filename].Image // the Go image.Image
bounds = image.Bounds()
coord = render.Point{
// The world coordinate bounded to the pattern image size.
X: render.AbsInt(point.X % bounds.Max.X),
Y: render.AbsInt(point.Y % bounds.Max.Y),
}
// Sample the color from the pattern texture.
colorAt = render.FromColor(image.At(coord.X, coord.Y))
// Average the RGBA color out to a grayscale brightness.
// sourceAvgGray = (int(color.Red) + int(color.Blue) + int(color.Green)/3) % 255
// patternAvgGray = (int(colorAt.Red) + int(colorAt.Blue) + int(colorAt.Green)/3) % 255
)
// See if the gray average is brighter or lower than the color.
// if sourceAvgGray < patternAvgGray {
// delta := patternAvgGray - sourceAvgGray
// color = color.Lighten(delta)
// } else if sourceAvgGray > patternAvgGray {
// color = color.Darken(sourceAvgGray - patternAvgGray)
// }
// return OverlayFilter(color, colorAt)
// return ScreenFilter(color, colorAt)
return GrayToColor(color, colorAt)
// log.Info("color: %s at point: %s image point: %s", color, point, coord)
// return color
}
// GrayToColor samples a colorful swatch with the grayscale pattern img.
func GrayToColor(color, grayscale render.Color) render.Color {
// The grayscale image ranges from 0 to 255.
// The color might be #FF0000 (red)
// 127 in grayscale should be FF0000 (perfectly red)
// 0 (black) in grayscale should be black in output
// 255 (white) in grayscale should be white in output
var (
AR = float64(color.Red)
AG = float64(color.Green)
AB = float64(color.Blue)
BR = float64(grayscale.Red)
BG = float64(grayscale.Green)
BB = float64(grayscale.Blue)
)
// If the pattern has a fully transparent pixel here, return transparent.
if grayscale.Alpha == 0 {
return render.RGBA(1, 0, 0, 1)
}
convert := func(cc, gs float64) uint8 {
var delta float64
if gs < 127 {
// return uint8(cc + cc/gs)
delta = cc * (gs / 255)
} else {
delta = cc * (gs / 255)
}
return uint8(delta)
}
return render.RGBA(
convert(AR, BR),
convert(AG, BG),
convert(AB, BB),
255,
)
}
// ScreenFilter applies a "screen" blend mode between the two colors (a > b).
func ScreenFilter(a, b render.Color) render.Color {
// The algorithm we're going for is:
// 1 - (1 - a) * (1 - b)
var (
AR = a.Red
AG = a.Green
AB = a.Blue
BR = b.Red
BG = b.Green
BB = b.Blue
deltaR = 255 - (255-AR)*(255-BR)
deltaG = 255 - (255-AG)*(255-BG)
deltaB = 255 - (255-AB)*(255-BB)
)
// If the pattern has a fully transparent pixel here, return transparent.
// if b.Alpha == 0 {
// return render.RGBA(1, 0, 0, 1)
// }
return render.RGBA(deltaR, deltaG, deltaB, a.Alpha)
}
// OverlayFilter applies an "overlay" blend mode between the two colors.
func OverlayFilter(a, b render.Color) render.Color {
// The algorithm we're going for is:
// If a < 0.5: 2ab
// Otherwise: 1 - 2(1 - a)(1 - b)
munch := func(a, b uint8) uint8 {
if a < 127 {
return 2 * a * b
}
return 255 - (2 * (255 - a) * (255 - b))
}
// If the pattern has a fully transparent pixel here, return transparent.
if b.Alpha == 0 {
return render.RGBA(1, 0, 0, 0)
}
var (
AR = a.Red
AG = a.Green
AB = a.Blue
BR = b.Red
BG = b.Green
BB = b.Blue
deltaR = munch(AR, BR)
deltaG = munch(AG, BG)
deltaB = munch(AB, BB)
)
return render.RGBA(deltaR, deltaG, deltaB, a.Alpha)
}
// CropImage crops an image to a smaller size (such as the 24x24 selectbox button in the Palette Editor UI)
func CropImage(img image.Image, crop image.Rectangle) (*ui.Image, error) {
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
simg, ok := img.(subImager)
if !ok {
return nil, fmt.Errorf("image does not support cropping")
}
return ui.ImageFromImage(simg.SubImage(crop))
}