Brush Pattern Textures

Palette swatches gain a new property: Pattern.

Patterns are grayscale textures that the swatch color will sample
against when drawing pixels to the level, by taking the world coordinate
modulo a value inside the texture.

A few algorithms were tried (Screen, Overlay), this branch lands on one
that tries to cast the color from grayscale which comes out rather dark;
to get a patterned color to look black while still seeing the pattern,
the color needs to be as bright as #777 to get the effect.
This commit is contained in:
Noah 2021-06-09 22:36:32 -07:00
parent e8388fafad
commit eb24858830
17 changed files with 327 additions and 6 deletions

BIN
assets/pattern/bars.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

BIN
assets/pattern/circles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

BIN
assets/pattern/dashed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

BIN
assets/pattern/grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

BIN
assets/pattern/ink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/pattern/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/pattern/noise.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -4,7 +4,7 @@ package branding
const ( const (
AppName = "Sketchy Maze" AppName = "Sketchy Maze"
Summary = "A drawing-based maze game" Summary = "A drawing-based maze game"
Version = "0.6.0-alpha" Version = "0.6.1-alpha"
Website = "https://www.sketchymaze.com" Website = "https://www.sketchymaze.com"
Copyright = "2021 Noah Petherbridge" Copyright = "2021 Noah Petherbridge"

View File

@ -13,6 +13,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/modal"
"git.kirsle.net/apps/doodle/pkg/native" "git.kirsle.net/apps/doodle/pkg/native"
"git.kirsle.net/apps/doodle/pkg/pattern"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
golog "git.kirsle.net/go/log" golog "git.kirsle.net/go/log"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
@ -95,6 +96,9 @@ func (d *Doodle) SetupEngine() error {
// Initialize the UI modal manager. // Initialize the UI modal manager.
modal.Initialize(d.Engine) modal.Initialize(d.Engine)
// Preload the builtin brush patterns.
pattern.LoadBuiltins(d.Engine)
return nil return nil
} }

View File

@ -18,6 +18,7 @@ type Stroke struct {
ID int // Unique ID per each stroke ID int // Unique ID per each stroke
Shape Shape Shape Shape
Color render.Color Color render.Color
Pattern string
Thickness int // 0 = 1px; thickness creates a box N pixels away from each point Thickness int // 0 = 1px; thickness creates a box N pixels away from each point
ExtraData interface{} // arbitrary storage for extra data to attach ExtraData interface{} // arbitrary storage for extra data to attach

View File

@ -8,6 +8,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/pattern"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"github.com/google/uuid" "github.com/google/uuid"
@ -150,6 +151,7 @@ func (c *Chunk) ToBitmap(filename string, mask render.Color) (render.Texturer, e
img := image.NewRGBA(imgSize) img := image.NewRGBA(imgSize)
// Blank out the pixels. // Blank out the pixels.
// TODO PERF: may be slow?
for x := 0; x < img.Bounds().Max.X; x++ { for x := 0; x < img.Bounds().Max.X; x++ {
for y := 0; y < img.Bounds().Max.Y; y++ { for y := 0; y < img.Bounds().Max.Y; y++ {
img.Set(x, y, balance.DebugChunkBitmapBackground.ToColor()) img.Set(x, y, balance.DebugChunkBitmapBackground.ToColor())
@ -166,6 +168,12 @@ func (c *Chunk) ToBitmap(filename string, mask render.Color) (render.Texturer, e
// Blot all the pixels onto it. // Blot all the pixels onto it.
for px := range c.Iter() { for px := range c.Iter() {
var color = px.Swatch.Color var color = px.Swatch.Color
// If the swatch has a pattern, mesh it in.
if px.Swatch.Pattern != "" {
color = pattern.SampleColor(px.Swatch.Pattern, color, px.Point())
}
if mask != render.Invisible { if mask != render.Invisible {
// A semi-transparent mask will overlay on top of the actual color. // A semi-transparent mask will overlay on top of the actual color.
if mask.Alpha < 255 { if mask.Alpha < 255 {

View File

@ -34,6 +34,7 @@ func DefaultPalette() *Palette {
} }
// NewBlueprintPalette returns the blueprint theme's color palette. // NewBlueprintPalette returns the blueprint theme's color palette.
// DEPRECATED in favor of DefaultPalettes.
func NewBlueprintPalette() *Palette { func NewBlueprintPalette() *Palette {
return &Palette{ return &Palette{
Swatches: []*Swatch{ Swatches: []*Swatch{

View File

@ -11,6 +11,7 @@ import (
type Swatch struct { type Swatch struct {
Name string `json:"name"` Name string `json:"name"`
Color render.Color `json:"color"` Color render.Color `json:"color"`
Pattern string `json:"pattern"` // like "noise.png"
// Optional attributes. // Optional attributes.
Solid bool `json:"solid,omitempty"` Solid bool `json:"solid,omitempty"`

230
pkg/pattern/pattern.go Normal file
View File

@ -0,0 +1,230 @@
// Package pattern applies a kind of brush texture to a palette swatch.
package pattern
import (
"errors"
"fmt"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/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: "Dashed Lines",
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
// 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{}
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)
}
// 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)
}

View File

@ -146,6 +146,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.AddStroke(w.currentStroke) w.AddStroke(w.currentStroke)
@ -198,6 +199,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
@ -219,6 +221,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
@ -237,6 +240,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
if ev.Button1 { if ev.Button1 {
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)

View File

@ -5,6 +5,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/pattern"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/ui" "git.kirsle.net/go/ui"
@ -237,6 +238,12 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) {
continue continue
} }
// Does the swatch have a pattern to sample?
color := stroke.Color
if stroke.Pattern != "" {
color = pattern.SampleColor(stroke.Pattern, color, rect.Point())
}
// Destination rectangle to draw to screen, taking into account // Destination rectangle to draw to screen, taking into account
// the position of the Canvas itself. // the position of the Canvas itself.
dest := render.Rect{ dest := render.Rect{
@ -264,7 +271,7 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) {
if balance.DebugCanvasStrokeColor != render.Invisible { if balance.DebugCanvasStrokeColor != render.Invisible {
e.DrawBox(balance.DebugCanvasStrokeColor, dest) e.DrawBox(balance.DebugCanvasStrokeColor, dest)
} else { } else {
e.DrawBox(stroke.Color, dest) e.DrawBox(color, dest)
} }
} }
} else { } else {
@ -273,6 +280,12 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) {
continue continue
} }
// Does the swatch have a pattern to sample?
color := stroke.Color
if stroke.Pattern != "" {
color = pattern.SampleColor(stroke.Pattern, color, point)
}
dest := render.Point{ dest := render.Point{
X: P.X + w.Scroll.X + w.BoxThickness(1) + point.X, X: P.X + w.Scroll.X + w.BoxThickness(1) + point.X,
Y: P.Y + w.Scroll.Y + w.BoxThickness(1) + point.Y, Y: P.Y + w.Scroll.Y + w.BoxThickness(1) + point.Y,
@ -281,7 +294,7 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) {
if balance.DebugCanvasStrokeColor != render.Invisible { if balance.DebugCanvasStrokeColor != render.Invisible {
e.DrawPoint(balance.DebugCanvasStrokeColor, dest) e.DrawPoint(balance.DebugCanvasStrokeColor, dest)
} else { } else {
e.DrawPoint(stroke.Color, dest) e.DrawPoint(color, dest)
} }
} }
} }

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/pattern"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/ui" "git.kirsle.net/go/ui"
@ -44,8 +45,9 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
height = (buttonSize * balance.DoodadDropperRows) + 64 // account for button borders :( height = (buttonSize * balance.DoodadDropperRows) + 64 // account for button borders :(
// Column sizes of the palette table. // Column sizes of the palette table.
col1 = 30 // ID no. col1 = 15 // ID no.
col2 = 24 // Color col2 = 24 // Color
col5 = 24 // Texture
col3 = 130 // Name col3 = 130 // Name
col4 = 140 // Attributes col4 = 140 // Attributes
// col5 = 150 // Delete // col5 = 150 // Delete
@ -80,6 +82,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
}{ }{
{"ID", col1}, {"ID", col1},
{"Col", col2}, {"Col", col2},
{"Tex", col5},
{"Name", col3}, {"Name", col3},
{"Attributes", col4}, {"Attributes", col4},
// {"Delete", col5}, // {"Delete", col5},
@ -125,6 +128,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
row.Hide() row.Hide()
} }
//////////////
// ID label. // ID label.
idLabel := ui.NewLabel(ui.Label{ idLabel := ui.NewLabel(ui.Label{
Text: idStr + ".", Text: idStr + ".",
@ -135,6 +139,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
Height: 24, Height: 24,
}) })
//////////////
// Name button (click to rename the swatch) // Name button (click to rename the swatch)
btnName := ui.NewButton("Name", ui.NewLabel(ui.Label{ btnName := ui.NewButton("Name", ui.NewLabel(ui.Label{
TextVariable: &swatch.Name, TextVariable: &swatch.Name,
@ -157,6 +162,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
}) })
config.Supervisor.Add(btnName) config.Supervisor.Add(btnName)
//////////////
// Color Choice button. // Color Choice button.
btnColor := ui.NewButton("Color", ui.NewFrame("Color Frame")) btnColor := ui.NewButton("Color", ui.NewFrame("Color Frame"))
btnColor.SetStyle(&style.Button{ btnColor.SetStyle(&style.Button{
@ -209,6 +215,43 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
}) })
config.Supervisor.Add(btnColor) config.Supervisor.Add(btnColor)
//////////////
// Texture (pattern) option.
selTexture := ui.NewSelectBox("Texture", ui.Label{
Font: balance.MenuFont,
})
selTexture.Configure(ui.Config{
Width: col5,
Height: 24,
})
for _, t := range pattern.Builtins {
if t.Hidden && !balance.ShowHiddenDoodads {
continue
}
selTexture.AddItem(t.Name, t.Filename, func() {})
}
selTexture.SetValue(swatch.Pattern)
setImageOnSelect(selTexture, swatch.Pattern)
selTexture.Handle(ui.Change, func(ed ui.EventData) error {
if val, ok := selTexture.GetValue(); ok {
filename, _ := val.Value.(string)
setImageOnSelect(selTexture, filename)
swatch.Pattern = filename
if config.OnChange != nil {
config.OnChange()
}
}
return nil
})
selTexture.Supervise(config.Supervisor)
config.Supervisor.Add(selTexture)
//////////////
// Attribute flags. // Attribute flags.
attrFrame := ui.NewFrame("Attributes") attrFrame := ui.NewFrame("Attributes")
attrFrame.Configure(ui.Config{ attrFrame.Configure(ui.Config{
@ -254,6 +297,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
} }
} }
//////////////
// Pack all the widgets. // Pack all the widgets.
row.Pack(idLabel, ui.Pack{ row.Pack(idLabel, ui.Pack{
Side: ui.W, Side: ui.W,
@ -263,6 +307,10 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
Side: ui.W, Side: ui.W,
PadX: 2, PadX: 2,
}) })
row.Pack(selTexture, ui.Pack{
Side: ui.W,
PadX: 2,
})
row.Pack(btnName, ui.Pack{ row.Pack(btnName, ui.Pack{
Side: ui.W, Side: ui.W,
PadX: 2, PadX: 2,
@ -377,3 +425,14 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
window.Hide() window.Hide()
return window return window
} }
// Helper function to get the Tex (pattern) select box to
// show the image by its filename... for both onChange and
// initial render needs.
func setImageOnSelect(sel *ui.SelectBox, filename string) {
if image, err := pattern.GetImage(filename); err == nil {
sel.SetImage(image)
} else {
sel.SetImage(nil)
}
}