Text Tool and Pan Tool

Two new tools added to the Level Editor:

* Pan Tool: left-click to scroll the level around safely.
* Text Tool: write text onto your level.

Features of the Text Tool:

* Can choose from the game's built-in fonts, size and enter the message
  you want to write.
* The mouse cursor previews the text when hovered over the level.
* Click to "stamp" the text onto your level. The currently selected
  color swatch will be used to color the text in.
* Adds two new fonts: Azulian.ttf and Rive.ttf that can be selected in
  the Text Tool.

Some implementation notes:

* Added package native/engine_sdl.go that handles the lower-level
  SDL2_TTF logic to rasterize the text into a black&white image.
* WASM not supported yet (if the game even still built for WASM);
  native/engine_wasm.go stubs out the TextToImage() call with a "not
  supported" error just in case.

Other changes:

* New Toolbar icons: they are 24x24 instead of 32x32 to make more room
  for more tools.
* The toolbar now shows two buttons per row for a more densely packed
  layout. For very narrow screen widths (< 600px) the default Vertical
  Toolbar layout will use one-button-per-row to not eat too much screen
  real estate.
* In the Horizontal Toolbars layout there are 2 buttons per column.
pull/84/head
Noah 2022-03-05 15:31:09 -08:00
parent bc15155b68
commit 77297fd60d
22 changed files with 498 additions and 26 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 B

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 B

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 665 B

BIN
assets/sprites/pan-tool.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 B

View File

@ -59,6 +59,10 @@ var (
DefaultEraserBrushSize = 8
MaxEraserBrushSize = 32 // the bigger, the slower
// Default font filename selected for Text Tool in the editor.
// TODO: better centralize font filenames, here and in theme.go
TextToolDefaultFont = "DejaVuSans.ttf"
// Interval for auto-save in the editor
AutoSaveInterval = 5 * time.Minute

56
pkg/drawtool/text_tool.go Normal file
View File

@ -0,0 +1,56 @@
package drawtool
import (
"git.kirsle.net/apps/doodle/pkg/native"
"git.kirsle.net/go/render"
"git.kirsle.net/go/ui"
)
// TextSettings holds currently selected Text Tool settings.
type TextSettings struct {
Font string // like 'DejaVuSans.ttf'
Size int
Message string
Label *ui.Label // cached label texture
}
// Currently active settings (global variable)
var TT TextSettings
// IsZero checks if the TextSettings are populated.
func (tt TextSettings) IsZero() bool {
return tt.Font == "" && tt.Size == 0 && tt.Message == ""
}
// ToStroke converts a TextSettings configuration into a Freehand
// Stroke, coloring in all of the pixels.
func (tt TextSettings) ToStroke(e render.Engine, color render.Color, at render.Point) (*Stroke, error) {
stroke := NewStroke(Freehand, color)
// Render the text to a Go image so we can get the colors from
// it uniformly.
img, err := native.TextToImage(e, tt.Label.Font)
if err != nil {
return nil, err
}
// Pull all its pixels.
var (
max = img.Bounds().Max
x = 0
y = 0
)
for x = 0; x < max.X; x++ {
for y = 0; y < max.Y; y++ {
hue := img.At(x, y)
r, g, b, _ := hue.RGBA()
if r == 65535 && g == r && b == r {
continue
}
stroke.Points = append(stroke.Points, render.NewPoint(x+at.X, y+at.Y))
}
}
return stroke, nil
}

View File

@ -12,6 +12,8 @@ const (
ActorTool // drag and move actors
LinkTool
EraserTool
PanTool
TextTool
)
var toolNames = []string{
@ -22,6 +24,8 @@ var toolNames = []string{
"Doodad", // readable name for ActorTool
"Link",
"Eraser",
"PanTool",
"TextTool",
}
func (t Tool) String() string {

View File

@ -51,6 +51,7 @@ type EditorUI struct {
doodadWindow *ui.Window
paletteEditor *ui.Window
layersWindow *ui.Window
textToolWindow *ui.Window
publishWindow *ui.Window
filesystemWindow *ui.Window
licenseWindow *ui.Window

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/doodads"
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/license"
"git.kirsle.net/apps/doodle/pkg/log"
@ -49,6 +50,12 @@ func (u *EditorUI) OpenDoodadDropper() {
u.Supervisor.FocusWindow(u.doodadWindow)
}
// OpenTextTool opens the Text Tool window.
func (u *EditorUI) OpenTextTool() {
u.textToolWindow.Show()
u.Supervisor.FocusWindow(u.textToolWindow)
}
// OpenPublishWindow opens the Publisher window.
func (u *EditorUI) OpenPublishWindow() {
scene, _ := u.d.Scene.(*EditorScene)
@ -148,6 +155,23 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
u.ConfigureWindow(d, u.doodadWindow)
}
// Text Tool window.
if u.textToolWindow == nil {
u.textToolWindow = windows.NewTextToolWindow(windows.TextTool{
Supervisor: u.Supervisor,
Engine: d.Engine,
OnChangeSettings: func(font string, size int, message string) {
log.Info("Updated Text Tool settings: %s (%d): %s", font, size, message)
drawtool.TT = drawtool.TextSettings{
Font: font,
Size: size,
Message: message,
}
},
})
u.ConfigureWindow(d, u.textToolWindow)
}
// Page Settings
if u.levelSettingsWindow == nil {
scene, _ := d.Scene.(*EditorScene)

View File

@ -1,6 +1,8 @@
package doodle
import (
"fmt"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/enum"
@ -11,33 +13,54 @@ import (
"git.kirsle.net/go/ui/style"
)
// Width of the toolbar frame.
var toolbarWidth = 44 // 38px button (32px sprite + borders) + padding
var toolbarSpriteSize = 32 // 32x32 sprites.
// Global toolbarWidth, TODO: editor_ui.go wants it
var toolbarWidth int
// SetupToolbar configures the UI for the Tools panel.
func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
// Horizontal toolbar instead of vertical?
var (
isHoz = usercfg.Current.HorizontalToolbars
packAlign = ui.N
frameSize = render.NewRect(toolbarWidth, 100)
tooltipEdge = ui.Right
btnPack = ui.Pack{
toolbarSpriteSize = 24 // size of sprite images
frameSize render.Rect
isHoz = usercfg.Current.HorizontalToolbars
buttonsPerRow = 2
packAlign = ui.N
tooltipEdge = ui.Right
btnRowPack = ui.Pack{
Side: packAlign,
PadY: 2,
PadY: 1,
Fill: true,
}
btnPack = ui.Pack{
Side: ui.W,
PadX: 1,
}
)
if isHoz {
packAlign = ui.W
frameSize = render.NewRect(100, toolbarWidth)
tooltipEdge = ui.Bottom
btnPack = ui.Pack{
btnRowPack = ui.Pack{
Side: packAlign,
PadX: 2,
}
btnPack = ui.Pack{
Side: ui.N,
PadY: 1,
}
}
// Button Layout Controls:
// We can draw 2 buttons per row, but for very small screens
// e.g. mobile in portrait orientation, draw 1 button per row.
buttonsPerRow = 1
if isHoz || d.width >= enum.ScreenWidthSmall {
buttonsPerRow = 2
}
// Compute toolbar size to accommodate all buttons (+10 for borders/padding)
toolbarWidth = buttonsPerRow * (toolbarSpriteSize + 10)
frameSize = render.NewRect(toolbarWidth, 100)
frame := ui.NewFrame("Tool Bar")
frame.Resize(frameSize)
frame.Configure(ui.Config{
@ -62,6 +85,16 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
// Optional fields.
NoDoodad bool // tool not available for Doodad editing (Levels only)
}{
{
Value: drawtool.PanTool.String(),
Icon: "assets/sprites/pan-tool.png",
Tooltip: "Pan Tool",
Click: func() {
u.Canvas.Tool = drawtool.PanTool
d.Flash("Pan Tool selected.")
},
},
{
Value: drawtool.PencilTool.String(),
Icon: "assets/sprites/pencil-tool.png",
@ -102,6 +135,17 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
},
},
{
Value: drawtool.TextTool.String(),
Icon: "assets/sprites/text-tool.png",
Tooltip: "Text Tool",
Click: func() {
u.Canvas.Tool = drawtool.TextTool
u.OpenTextTool()
d.Flash("Text Tool selected.")
},
},
{
Value: drawtool.ActorTool.String(),
Icon: "assets/sprites/actor-tool.png",
@ -146,12 +190,20 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
},
},
}
for _, button := range buttons {
// Arrange the buttons 2x2.
var btnRow *ui.Frame
for i, button := range buttons {
button := button
if button.NoDoodad && u.Scene.DrawingType == enum.DoodadDrawing {
continue
}
if buttonsPerRow == 1 || i%buttonsPerRow == 0 {
btnRow = ui.NewFrame(fmt.Sprintf("Button Row %d", i))
btnFrame.Pack(btnRow, btnRowPack)
}
image, err := sprites.LoadImage(d.Engine, button.Icon)
if err != nil {
panic(err)
@ -168,6 +220,7 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
}
var btnSize = btn.BoxThickness(2) + toolbarSpriteSize
btn.SetBorderSize(1)
btn.Resize(render.NewRect(btnSize, btnSize))
btn.Handle(ui.Click, func(ed ui.EventData) error {
@ -181,7 +234,7 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Edge: tooltipEdge,
})
btnFrame.Pack(btn, btnPack)
btnRow.Pack(btn, btnPack)
}
// Doodad Editor: show the Layers button.

84
pkg/native/engine_sdl.go Normal file
View File

@ -0,0 +1,84 @@
// +build !js
package native
import (
"image"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/sdl"
sdl2 "github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf"
)
// Native render engine functions (SDL2 edition),
// not for JavaScript/WASM yet.
/*
TextToImage takes an SDL2_TTF texture and makes it into a Go image.
Notes:
- The text is made Black & White with a white background on the image.
- Drop shadow, stroke, etc. probably not supported.
- Returns a non-antialiased image.
*/
func TextToImage(e render.Engine, text render.Text) (image.Image, error) {
// engine, _ := e.(*sdl.Renderer)
// Make the text black & white for ease of identifying pixels.
text.Color = render.Black
var (
// renderer = engine.GetSDL2Renderer()
font *ttf.Font
surface *sdl2.Surface
pixFmt *sdl2.PixelFormat
surface2 *sdl2.Surface
err error
)
if font, err = sdl.LoadFont(text.FontFilename, text.Size); err != nil {
return nil, err
}
if surface, err = font.RenderUTF8Solid(text.Text, sdl.ColorToSDL(text.Color)); err != nil {
return nil, err
}
defer surface.Free()
log.Error("surf fmt: %+v", surface.Format)
// Convert the Surface into a pixelformat that supports the .At(x,y)
// function properly, as the one we got above is "Not implemented"
if pixFmt, err = sdl2.AllocFormat(sdl2.PIXELFORMAT_RGB888); err != nil {
return nil, err
}
if surface2, err = surface.Convert(pixFmt, 0); err != nil {
return nil, err
}
defer surface2.Free()
// Read back the pixels.
var (
x int
y int
w = int(surface2.W)
h = int(surface2.H)
img = image.NewRGBA(image.Rect(x, y, w, h))
)
for x = 0; x < w; x++ {
for y = 0; y < h; y++ {
hue := surface2.At(x, y)
img.Set(x, y, hue)
// log.Warn("hue: %s", hue)
// r, g, b, _ := hue.RGBA()
// if r == 0 && g == 0 && b == 0 {
// img.Set(x, y, hue)
// } else {
// img.Set(x, y, color.Transparent)
// }
}
}
return img, nil
}

13
pkg/native/engine_wasm.go Normal file
View File

@ -0,0 +1,13 @@
// +build js,wasm
package native
import (
"image"
"git.kirsle.net/go/render"
)
func TextToImage(e render.Engine, text render.Text) (image.Image, error) {
return nil, errors.New("not supported on WASM")
}

View File

@ -2,7 +2,9 @@ package uix
import (
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
@ -137,6 +139,30 @@ func (w *Canvas) loopEditable(ev *event.State) error {
}
switch w.Tool {
case drawtool.PanTool:
// Pan tool = click to pan the level.
if ev.Button1 || keybind.MiddleClick(ev) {
if !w.scrollDragging {
w.scrollDragging = true
w.scrollStartAt = shmem.Cursor
w.scrollWasAt = w.Scroll
} else {
delta := shmem.Cursor.Compare(w.scrollStartAt)
w.Scroll = w.scrollWasAt
w.Scroll.Subtract(delta)
// TODO: if I don't call this, the user is able to (temporarily!)
// pan outside the level boundaries before it snaps-back when they
// release. But the normal middle-click to pan code doesn't let
// them do this.. investigate why later.
w.loopConstrainScroll()
}
} else {
if w.scrollDragging {
w.scrollDragging = false
}
}
case drawtool.PencilTool:
// If no swatch is active, do nothing with mouse clicks.
if w.Palette.ActiveSwatch == nil {
@ -253,6 +279,47 @@ func (w *Canvas) loopEditable(ev *event.State) error {
} else {
w.commitStroke(w.Tool, true)
}
case drawtool.TextTool:
// The Text Tool popup should initialize this for us, if somehow not
// initialized skip this tool processing.
if w.Palette.ActiveSwatch == nil || drawtool.TT.IsZero() {
return nil
}
// Do we need to create the Label?
if drawtool.TT.Label == nil {
drawtool.TT.Label = ui.NewLabel(ui.Label{
Text: drawtool.TT.Message,
Font: render.Text{
FontFilename: drawtool.TT.Font,
Size: drawtool.TT.Size,
Color: w.Palette.ActiveSwatch.Color,
},
})
}
// Do we need to update the color of the label?
if drawtool.TT.Label.Font.Color != w.Palette.ActiveSwatch.Color {
drawtool.TT.Label.Font.Color = w.Palette.ActiveSwatch.Color
}
// NOTE: Canvas.presentStrokes() will handle drawing the font preview
// at the cursor location while the TextTool is active.
// On mouse click, commit the text to the drawing.
if ev.Button1 {
if stroke, err := drawtool.TT.ToStroke(shmem.CurrentRenderEngine, w.Palette.ActiveSwatch.Color, cursor); err != nil {
shmem.FlashError("Text Tool error: %s", err)
return nil
} else {
w.currentStroke = stroke
w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.commitStroke(drawtool.PencilTool, true)
}
ev.Button1 = false
}
case drawtool.EraserTool:
// Clicking? Log all the pixels while doing so.
if ev.Button1 {

View File

@ -5,6 +5,7 @@ import (
"fmt"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/shmem"
@ -89,19 +90,22 @@ func (w *Canvas) loopEditorScroll(ev *event.State) error {
}
// Middle click of the mouse to pan the level.
if keybind.MiddleClick(ev) {
if !w.scrollDragging {
w.scrollDragging = true
w.scrollStartAt = shmem.Cursor
w.scrollWasAt = w.Scroll
// NOTE: PanTool intercepts both Left and MiddleClick.
if w.Tool != drawtool.PanTool {
if keybind.MiddleClick(ev) {
if !w.scrollDragging {
w.scrollDragging = true
w.scrollStartAt = shmem.Cursor
w.scrollWasAt = w.Scroll
} else {
delta := shmem.Cursor.Compare(w.scrollStartAt)
w.Scroll = w.scrollWasAt
w.Scroll.Subtract(delta)
}
} else {
delta := shmem.Cursor.Compare(w.scrollStartAt)
w.Scroll = w.scrollWasAt
w.Scroll.Subtract(delta)
}
} else {
if w.scrollDragging {
w.scrollDragging = false
if w.scrollDragging {
w.scrollDragging = false
}
}
}

View File

@ -143,6 +143,11 @@ func (w *Canvas) presentStrokes(e render.Engine) {
if w.Tool == drawtool.ActorTool || w.Tool == drawtool.LinkTool {
w.presentActorLinks(e)
}
// Text Tool preview.
if w.Tool == drawtool.TextTool && drawtool.TT.Label != nil {
drawtool.TT.Label.Present(e, shmem.Cursor)
}
}
// presentActorLinks draws strokes connecting actors together by their links.

View File

@ -31,6 +31,8 @@ type Form struct {
// For vertical forms.
Vertical bool
LabelWidth int // size of left frame for labels.
PadY int // spacer between (vertical) forms
PadX int
}
/*
@ -61,6 +63,7 @@ type Field struct {
// Variable bindings, the type may infer to be:
BoolVariable *bool // Checkbox
TextVariable *string // Textbox
IntVariable *int // Textbox
Options []Option // Selectbox
SelectValue interface{} // Selectbox default choice
@ -100,6 +103,7 @@ func (form Form) Create(into *ui.Frame, fields []Field) {
into.Pack(frame, ui.Pack{
Side: ui.N,
FillX: true,
PadY: form.PadY,
})
// Pager row?
@ -177,6 +181,35 @@ func (form Form) Create(into *ui.Frame, fields []Field) {
})
}
// Buttons and Text fields (for now).
if row.Type == Button || row.Type == Textbox {
btn := ui.NewButton("Button", ui.NewLabel(ui.Label{
Text: row.Label,
Font: row.Font,
TextVariable: row.TextVariable,
IntVariable: row.IntVariable,
}))
form.Supervisor.Add(btn)
frame.Pack(btn, ui.Pack{
Side: ui.W,
FillX: true,
Expand: true,
})
// Tooltip? TODO - make nicer.
if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil {
ui.NewTooltip(btn, row.Tooltip)
}
// Handlers
btn.Handle(ui.Click, func(ed ui.EventData) error {
if row.OnClick != nil {
row.OnClick()
}
return nil
})
}
// Checkbox?
if row.Type == Checkbox {
cb := ui.NewCheckbox("Checkbox", row.BoolVariable, ui.NewLabel(ui.Label{
@ -266,7 +299,7 @@ func (field Field) Infer() Type {
return Selectbox
}
if field.TextVariable != nil {
if field.TextVariable != nil || field.IntVariable != nil {
return Textbox
}

124
pkg/windows/text_tool.go Normal file
View File

@ -0,0 +1,124 @@
package windows
import (
"strconv"
"git.kirsle.net/apps/doodle/assets"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/branding"
"git.kirsle.net/apps/doodle/pkg/shmem"
magicform "git.kirsle.net/apps/doodle/pkg/uix/magic-form"
"git.kirsle.net/go/render"
"git.kirsle.net/go/ui"
)
// TextTool window.
type TextTool struct {
// Settings passed in by doodle
Supervisor *ui.Supervisor
Engine render.Engine
// Callback when font settings are changed.
OnChangeSettings func(font string, size int, message string)
}
// NewTextToolWindow initializes the window.
func NewTextToolWindow(cfg TextTool) *ui.Window {
window := ui.NewWindow("Text Tool")
window.SetButtons(ui.CloseButton)
window.Configure(ui.Config{
Width: 330,
Height: 170,
Background: render.Grey,
})
// Text variables
var (
currentText = branding.AppName
fontName = balance.TextToolDefaultFont
fontSize = 16
)
// Get a listing of the available fonts.
fonts, _ := assets.AssetDir("assets/fonts")
var fontOption = []magicform.Option{}
for _, font := range fonts {
// Select the first font by default.
if fontName == "" {
fontName = font
}
fontOption = append(fontOption, magicform.Option{
Label: font,
Value: font,
})
}
// Send the default config out.
if cfg.OnChangeSettings != nil {
cfg.OnChangeSettings(fontName, fontSize, currentText)
}
form := magicform.Form{
Supervisor: cfg.Supervisor,
Engine: cfg.Engine,
Vertical: true,
LabelWidth: 100,
PadY: 2,
}
form.Create(window.ContentFrame(), []magicform.Field{
{
Label: "Font Face:",
Font: balance.LabelFont,
Options: fontOption,
SelectValue: fontName,
OnSelect: func(v interface{}) {
fontName = v.(string)
if cfg.OnChangeSettings != nil {
cfg.OnChangeSettings(fontName, fontSize, currentText)
}
},
},
{
Label: "Font Size:",
Font: balance.LabelFont,
IntVariable: &fontSize,
OnClick: func() {
shmem.Prompt("Enter new font size: ", func(answer string) {
if answer != "" {
if i, err := strconv.Atoi(answer); err == nil {
fontSize = i
if cfg.OnChangeSettings != nil {
cfg.OnChangeSettings(fontName, fontSize, currentText)
}
} else {
shmem.FlashError("Not a valid font size: %s", answer)
}
}
})
},
},
{
Label: "Message:",
Font: balance.LabelFont,
TextVariable: &currentText,
OnClick: func() {
shmem.Prompt("Enter new message: ", func(answer string) {
if answer != "" {
currentText = answer
if cfg.OnChangeSettings != nil {
cfg.OnChangeSettings(fontName, fontSize, currentText)
}
}
})
},
},
{
Label: "Be sure the Text Tool is selected, and click onto your\n" +
"drawing to place this text onto it.",
Font: balance.UIFont,
},
})
return window
}