doodle/pkg/pattern/pattern.go
Noah Petherbridge ecaa8c6cef SemiSolid Pixels + Icons
* Add new pixel attributes: SemiSolid and Slippery (the latter is WIP)
* SemiSolid pixels are only solid below the player character. You can walk on
  them and up and down SemiSolid slopes, but can freely pass through from the
  sides or jump through from below.
* Update the Palette Editor UI to replace the Attributes buttons: instead of
  text labels they now have smaller icons (w/ tooltips) for the Solid,
  SemiSolid, Fire, Water and Slippery attributes.
* Bugfix in Palette Editor: use cropped (24x24) images for the Tex buttons so
  that the large Bubbles texture stays within its designated space!
* uix.Actor.SetGrounded() to also set the Y velocity to zero when an actor
  becomes grounded. This fixes a minor bug where the player's Y velocity (due
  to gravity) was not updated while they were grounded, which may eventually
  become useful to allow them to jump down thru a SemiSolid floor. Warp Doors
  needed a fix to work around the bug, to set the player's Grounded(false) or
  else they would hover a few pixels above the ground at their destination,
  since Grounded status paused gravity calculations.
2022-10-09 21:39:43 -07:00

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