diff --git a/assets/pattern/bars.png b/assets/pattern/bars.png new file mode 100644 index 0000000..b4fd1cf Binary files /dev/null and b/assets/pattern/bars.png differ diff --git a/assets/pattern/circles.png b/assets/pattern/circles.png new file mode 100644 index 0000000..5fbdc29 Binary files /dev/null and b/assets/pattern/circles.png differ diff --git a/assets/pattern/dashed.png b/assets/pattern/dashed.png new file mode 100644 index 0000000..374156c Binary files /dev/null and b/assets/pattern/dashed.png differ diff --git a/assets/pattern/grid.png b/assets/pattern/grid.png new file mode 100644 index 0000000..95d272a Binary files /dev/null and b/assets/pattern/grid.png differ diff --git a/assets/pattern/ink.png b/assets/pattern/ink.png new file mode 100644 index 0000000..0a2abfe Binary files /dev/null and b/assets/pattern/ink.png differ diff --git a/assets/pattern/marker.png b/assets/pattern/marker.png new file mode 100644 index 0000000..5504a78 Binary files /dev/null and b/assets/pattern/marker.png differ diff --git a/assets/pattern/noise.png b/assets/pattern/noise.png new file mode 100644 index 0000000..e8b5ec6 Binary files /dev/null and b/assets/pattern/noise.png differ diff --git a/pkg/branding/branding.go b/pkg/branding/branding.go index dfcd769..289fafb 100644 --- a/pkg/branding/branding.go +++ b/pkg/branding/branding.go @@ -4,7 +4,7 @@ package branding const ( AppName = "Sketchy Maze" Summary = "A drawing-based maze game" - Version = "0.6.0-alpha" + Version = "0.6.1-alpha" Website = "https://www.sketchymaze.com" Copyright = "2021 Noah Petherbridge" diff --git a/pkg/doodle.go b/pkg/doodle.go index 83e0125..26690d9 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -13,6 +13,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/native" + "git.kirsle.net/apps/doodle/pkg/pattern" "git.kirsle.net/apps/doodle/pkg/shmem" golog "git.kirsle.net/go/log" "git.kirsle.net/go/render" @@ -95,6 +96,9 @@ func (d *Doodle) SetupEngine() error { // Initialize the UI modal manager. modal.Initialize(d.Engine) + // Preload the builtin brush patterns. + pattern.LoadBuiltins(d.Engine) + return nil } diff --git a/pkg/drawtool/stroke.go b/pkg/drawtool/stroke.go index 75350e8..d16d433 100644 --- a/pkg/drawtool/stroke.go +++ b/pkg/drawtool/stroke.go @@ -18,6 +18,7 @@ type Stroke struct { ID int // Unique ID per each stroke Shape Shape Color render.Color + Pattern string Thickness int // 0 = 1px; thickness creates a box N pixels away from each point ExtraData interface{} // arbitrary storage for extra data to attach diff --git a/pkg/level/chunk.go b/pkg/level/chunk.go index 2a69813..c4aa27e 100644 --- a/pkg/level/chunk.go +++ b/pkg/level/chunk.go @@ -8,6 +8,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/pattern" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/go/render" "github.com/google/uuid" @@ -150,6 +151,7 @@ func (c *Chunk) ToBitmap(filename string, mask render.Color) (render.Texturer, e img := image.NewRGBA(imgSize) // Blank out the pixels. + // TODO PERF: may be slow? for x := 0; x < img.Bounds().Max.X; x++ { for y := 0; y < img.Bounds().Max.Y; y++ { 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. for px := range c.Iter() { 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 { // A semi-transparent mask will overlay on top of the actual color. if mask.Alpha < 255 { diff --git a/pkg/level/palette.go b/pkg/level/palette.go index bd9300a..2dc29c6 100644 --- a/pkg/level/palette.go +++ b/pkg/level/palette.go @@ -34,6 +34,7 @@ func DefaultPalette() *Palette { } // NewBlueprintPalette returns the blueprint theme's color palette. +// DEPRECATED in favor of DefaultPalettes. func NewBlueprintPalette() *Palette { return &Palette{ Swatches: []*Swatch{ diff --git a/pkg/level/swatch.go b/pkg/level/swatch.go index 0e5c52b..5640a0e 100644 --- a/pkg/level/swatch.go +++ b/pkg/level/swatch.go @@ -9,8 +9,9 @@ import ( // Swatch holds details about a single value in the palette. type Swatch struct { - Name string `json:"name"` - Color render.Color `json:"color"` + Name string `json:"name"` + Color render.Color `json:"color"` + Pattern string `json:"pattern"` // like "noise.png" // Optional attributes. Solid bool `json:"solid,omitempty"` diff --git a/pkg/pattern/pattern.go b/pkg/pattern/pattern.go new file mode 100644 index 0000000..e58ca17 --- /dev/null +++ b/pkg/pattern/pattern.go @@ -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) +} diff --git a/pkg/uix/canvas_editable.go b/pkg/uix/canvas_editable.go index 3527dda..f09e290 100644 --- a/pkg/uix/canvas_editable.go +++ b/pkg/uix/canvas_editable.go @@ -146,6 +146,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { // Initialize a new Stroke for this atomic drawing operation? if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) + w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern w.currentStroke.Thickness = w.BrushSize w.currentStroke.ExtraData = w.Palette.ActiveSwatch 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? if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color) + w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern w.currentStroke.Thickness = w.BrushSize w.currentStroke.ExtraData = w.Palette.ActiveSwatch 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? if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color) + w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern w.currentStroke.Thickness = w.BrushSize w.currentStroke.ExtraData = w.Palette.ActiveSwatch 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 w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color) + w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern w.currentStroke.Thickness = w.BrushSize w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y) diff --git a/pkg/uix/canvas_strokes.go b/pkg/uix/canvas_strokes.go index b902610..72c1f07 100644 --- a/pkg/uix/canvas_strokes.go +++ b/pkg/uix/canvas_strokes.go @@ -5,6 +5,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/pattern" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/go/render" "git.kirsle.net/go/ui" @@ -237,6 +238,12 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) { 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 // the position of the Canvas itself. dest := render.Rect{ @@ -264,7 +271,7 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) { if balance.DebugCanvasStrokeColor != render.Invisible { e.DrawBox(balance.DebugCanvasStrokeColor, dest) } else { - e.DrawBox(stroke.Color, dest) + e.DrawBox(color, dest) } } } else { @@ -273,6 +280,12 @@ func (w *Canvas) DrawStrokes(e render.Engine, strokes []*drawtool.Stroke) { 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{ X: P.X + w.Scroll.X + w.BoxThickness(1) + point.X, 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 { e.DrawPoint(balance.DebugCanvasStrokeColor, dest) } else { - e.DrawPoint(stroke.Color, dest) + e.DrawPoint(color, dest) } } } diff --git a/pkg/windows/palette_editor.go b/pkg/windows/palette_editor.go index 2d8e73c..5d0c8dc 100644 --- a/pkg/windows/palette_editor.go +++ b/pkg/windows/palette_editor.go @@ -7,6 +7,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/pattern" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/go/render" "git.kirsle.net/go/ui" @@ -44,8 +45,9 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { height = (buttonSize * balance.DoodadDropperRows) + 64 // account for button borders :( // Column sizes of the palette table. - col1 = 30 // ID no. + col1 = 15 // ID no. col2 = 24 // Color + col5 = 24 // Texture col3 = 130 // Name col4 = 140 // Attributes // col5 = 150 // Delete @@ -80,6 +82,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { }{ {"ID", col1}, {"Col", col2}, + {"Tex", col5}, {"Name", col3}, {"Attributes", col4}, // {"Delete", col5}, @@ -125,6 +128,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { row.Hide() } + ////////////// // ID label. idLabel := ui.NewLabel(ui.Label{ Text: idStr + ".", @@ -135,6 +139,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { Height: 24, }) + ////////////// // Name button (click to rename the swatch) btnName := ui.NewButton("Name", ui.NewLabel(ui.Label{ TextVariable: &swatch.Name, @@ -157,6 +162,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { }) config.Supervisor.Add(btnName) + ////////////// // Color Choice button. btnColor := ui.NewButton("Color", ui.NewFrame("Color Frame")) btnColor.SetStyle(&style.Button{ @@ -209,6 +215,43 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { }) 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. attrFrame := ui.NewFrame("Attributes") attrFrame.Configure(ui.Config{ @@ -254,6 +297,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { } } + ////////////// // Pack all the widgets. row.Pack(idLabel, ui.Pack{ Side: ui.W, @@ -263,6 +307,10 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { Side: ui.W, PadX: 2, }) + row.Pack(selTexture, ui.Pack{ + Side: ui.W, + PadX: 2, + }) row.Pack(btnName, ui.Pack{ Side: ui.W, PadX: 2, @@ -377,3 +425,14 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { window.Hide() 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) + } +}