...

Source file src/code.rocket9labs.com/tslocum/etk/game.go

Documentation: code.rocket9labs.com/tslocum/etk

     1  package etk
     2  
     3  import (
     4  	"fmt"
     5  	"image"
     6  	"image/color"
     7  	"io"
     8  	"log"
     9  	"math"
    10  	"sync"
    11  	"time"
    12  
    13  	"code.rocket9labs.com/tslocum/etk/messeji"
    14  	"github.com/hajimehoshi/ebiten/v2"
    15  	"github.com/hajimehoshi/ebiten/v2/inpututil"
    16  	"golang.org/x/image/font"
    17  	"golang.org/x/image/font/opentype"
    18  	"golang.org/x/image/font/sfnt"
    19  	"golang.org/x/image/math/fixed"
    20  )
    21  
    22  // Alignment specifies how text is aligned within the field.
    23  type Alignment int
    24  
    25  const (
    26  	// AlignStart aligns text at the start of the field.
    27  	AlignStart Alignment = 0
    28  
    29  	// AlignCenter aligns text at the center of the field.
    30  	AlignCenter Alignment = 1
    31  
    32  	// AlignEnd aligns text at the end of the field.
    33  	AlignEnd Alignment = 2
    34  )
    35  
    36  // DebounceResize is the minimum duration between screen layout changes.
    37  // This setting can greatly improve performance when resizing the window.
    38  var DebounceResize = 250 * time.Millisecond
    39  
    40  var root Widget
    41  
    42  var drawDebug bool
    43  
    44  var (
    45  	lastWidth, lastHeight int
    46  
    47  	lastX, lastY = -math.MaxInt, -math.MaxInt
    48  
    49  	touchIDs      []ebiten.TouchID
    50  	activeTouchID = ebiten.TouchID(-1)
    51  
    52  	focusedWidget Widget
    53  
    54  	pressedWidget Widget
    55  
    56  	cursorShape ebiten.CursorShapeType
    57  
    58  	foundFocused bool
    59  
    60  	lastResize time.Time
    61  
    62  	lastBackspaceRepeat time.Time
    63  
    64  	keyBuffer  []ebiten.Key
    65  	runeBuffer []rune
    66  
    67  	fontMutex = &sync.Mutex{}
    68  )
    69  
    70  const maxScroll = 3
    71  
    72  var debugColor = color.RGBA{0, 0, 255, 255}
    73  
    74  const (
    75  	backspaceRepeatWait = 500 * time.Millisecond
    76  	backspaceRepeatTime = 75 * time.Millisecond
    77  )
    78  
    79  var deviceScale float64
    80  
    81  // ScaleFactor returns the device scale factor. When running on Android, this function
    82  // may only be called during or after the first Layout call made by Ebitengine.
    83  func ScaleFactor() float64 {
    84  	if deviceScale == 0 {
    85  		monitor := ebiten.Monitor()
    86  		if monitor != nil {
    87  			deviceScale = monitor.DeviceScaleFactor()
    88  		}
    89  		if deviceScale <= 0 {
    90  			deviceScale = ebiten.DeviceScaleFactor()
    91  		}
    92  
    93  	}
    94  	return deviceScale
    95  }
    96  
    97  // Scale applies the device scale factor to the provided value and returns the result.
    98  // When running on Android, this function may only be called during or after the first
    99  // Layout call made by Ebitengine.
   100  func Scale(v int) int {
   101  	if deviceScale == 0 {
   102  		monitor := ebiten.Monitor()
   103  		if monitor != nil {
   104  			deviceScale = monitor.DeviceScaleFactor()
   105  		}
   106  		if deviceScale <= 0 {
   107  			deviceScale = ebiten.DeviceScaleFactor()
   108  		}
   109  
   110  	}
   111  	return int(float64(v) * deviceScale)
   112  }
   113  
   114  var (
   115  	fontCache     = make(map[string]font.Face)
   116  	fontCacheLock sync.Mutex
   117  )
   118  
   119  // FontFace returns a face for the provided font and size. Scaling is not applied.
   120  func FontFace(fnt *sfnt.Font, size int) font.Face {
   121  	id := fmt.Sprintf("%p/%d", fnt, size)
   122  
   123  	fontCacheLock.Lock()
   124  	defer fontCacheLock.Unlock()
   125  
   126  	f := fontCache[id]
   127  	if f != nil {
   128  		return f
   129  	}
   130  
   131  	const dpi = 72
   132  	f, err := opentype.NewFace(fnt, &opentype.FaceOptions{
   133  		Size:    float64(size),
   134  		DPI:     dpi,
   135  		Hinting: font.HintingFull,
   136  	})
   137  	if err != nil {
   138  		log.Fatal(err)
   139  	}
   140  
   141  	fontCache[id] = f
   142  	return f
   143  }
   144  
   145  // SetRoot sets the root widget. The root widget and all of its children will
   146  // be drawn on the screen and receive user input. The root widget will also be
   147  // focused. To temporarily disable etk, set a nil root widget.
   148  func SetRoot(w Widget) {
   149  	root = w
   150  	if root != nil && (lastWidth != 0 || lastHeight != 0) {
   151  		root.SetRect(image.Rect(0, 0, lastWidth, lastHeight))
   152  	}
   153  	SetFocus(root)
   154  }
   155  
   156  // SetFocus focuses a widget.
   157  func SetFocus(w Widget) {
   158  	lastFocused := focusedWidget
   159  	if w != nil && !w.SetFocus(true) {
   160  		return
   161  	}
   162  	if lastFocused != nil && lastFocused != w {
   163  		lastFocused.SetFocus(false)
   164  	}
   165  	focusedWidget = w
   166  }
   167  
   168  // Focused returns the currently focused widget. If no widget is focused, nil is returned.
   169  func Focused() Widget {
   170  	return focusedWidget
   171  }
   172  
   173  func int26ToRect(r fixed.Rectangle26_6) image.Rectangle {
   174  	x, y := r.Min.X, r.Min.Y
   175  	w, h := r.Max.X-r.Min.X, r.Max.Y-r.Min.Y
   176  	return image.Rect(x.Round(), y.Round(), (x + w).Round(), (y + h).Round())
   177  }
   178  
   179  // BoundString returns the bounds of the provided string.
   180  func BoundString(f font.Face, s string) image.Rectangle {
   181  	fontMutex.Lock()
   182  	defer fontMutex.Unlock()
   183  
   184  	bounds, _ := font.BoundString(f, s)
   185  	return int26ToRect(bounds)
   186  }
   187  
   188  // SetDebug sets whether debug information is drawn on screen. When enabled,
   189  // all visible widgets are outlined.
   190  func SetDebug(debug bool) {
   191  	drawDebug = debug
   192  }
   193  
   194  // ScreenSize returns the current screen size.
   195  func ScreenSize() (width int, height int) {
   196  	return lastWidth, lastHeight
   197  }
   198  
   199  // Layout sets the current screen size and resizes the root widget.
   200  func Layout(outsideWidth int, outsideHeight int) {
   201  	if !lastResize.IsZero() && time.Since(lastResize) < DebounceResize && outsideWidth != 0 && outsideHeight != 0 {
   202  		return
   203  	}
   204  
   205  	outsideWidth, outsideHeight = Scale(outsideWidth), Scale(outsideHeight)
   206  	if outsideWidth != lastWidth || outsideHeight != lastHeight {
   207  		lastWidth, lastHeight = outsideWidth, outsideHeight
   208  	}
   209  
   210  	if root == nil {
   211  		return
   212  	}
   213  	root.SetRect(image.Rect(0, 0, outsideWidth, outsideHeight))
   214  }
   215  
   216  // Update handles user input and passes it to the focused or clicked widget.
   217  func Update() error {
   218  	if root == nil {
   219  		return nil
   220  	}
   221  
   222  	var cursor image.Point
   223  
   224  	// Handle touch input.
   225  
   226  	var pressed bool
   227  	var clicked bool
   228  	var touchInput bool
   229  
   230  	if activeTouchID != -1 {
   231  		x, y := ebiten.TouchPosition(activeTouchID)
   232  		if x != 0 || y != 0 {
   233  			cursor = image.Point{x, y}
   234  
   235  			pressed = true
   236  			touchInput = true
   237  		} else {
   238  			activeTouchID = -1
   239  		}
   240  	}
   241  
   242  	if activeTouchID == -1 {
   243  		touchIDs = inpututil.AppendJustPressedTouchIDs(touchIDs[:0])
   244  		for _, id := range touchIDs {
   245  			x, y := ebiten.TouchPosition(id)
   246  			if x != 0 || y != 0 {
   247  				cursor = image.Point{x, y}
   248  
   249  				pressed = true
   250  				clicked = true
   251  				touchInput = true
   252  
   253  				activeTouchID = id
   254  				break
   255  			}
   256  		}
   257  	}
   258  
   259  	// Handle mouse input.
   260  
   261  	if !touchInput {
   262  		x, y := ebiten.CursorPosition()
   263  		cursor = image.Point{x, y}
   264  
   265  		if lastX == -math.MaxInt && lastY == -math.MaxInt {
   266  			lastX, lastY = x, y
   267  		}
   268  		for _, binding := range Bindings.ConfirmMouse {
   269  			pressed = ebiten.IsMouseButtonPressed(binding)
   270  			if pressed {
   271  				break
   272  			}
   273  		}
   274  
   275  		for _, binding := range Bindings.ConfirmMouse {
   276  			clicked = inpututil.IsMouseButtonJustPressed(binding)
   277  			if clicked {
   278  				break
   279  			}
   280  		}
   281  	}
   282  
   283  	if !pressed && !clicked && pressedWidget != nil {
   284  		_, err := pressedWidget.HandleMouse(cursor, false, false)
   285  		if err != nil {
   286  			return err
   287  		}
   288  		pressedWidget = nil
   289  	}
   290  
   291  	mouseHandled, err := update(root, cursor, pressed, clicked, false)
   292  	if err != nil {
   293  		return fmt.Errorf("failed to handle widget mouse input: %s", err)
   294  	} else if !mouseHandled && cursorShape != ebiten.CursorShapeDefault {
   295  		ebiten.SetCursorShape(ebiten.CursorShapeDefault)
   296  		cursorShape = ebiten.CursorShapeDefault
   297  	}
   298  
   299  	// Handle keyboard input.
   300  
   301  	if focusedWidget == nil {
   302  		return nil
   303  	} else if ebiten.IsKeyPressed(ebiten.KeyBackspace) {
   304  		if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
   305  			lastBackspaceRepeat = time.Now().Add(backspaceRepeatWait)
   306  		} else if time.Since(lastBackspaceRepeat) >= backspaceRepeatTime {
   307  			lastBackspaceRepeat = time.Now()
   308  
   309  			_, err := focusedWidget.HandleKeyboard(ebiten.KeyBackspace, 0)
   310  			if err != nil {
   311  				return err
   312  			}
   313  		}
   314  	}
   315  
   316  	// Handle paste.
   317  	if inpututil.IsKeyJustPressed(ebiten.KeyV) && ebiten.IsKeyPressed(ebiten.KeyControl) {
   318  		focused := Focused()
   319  		if focused != nil {
   320  			writer, ok := focused.(io.Writer)
   321  			if ok {
   322  				_, err := writer.Write(clipboardBuffer())
   323  				return err
   324  			}
   325  		}
   326  	}
   327  
   328  	keyBuffer = inpututil.AppendJustPressedKeys(keyBuffer[:0])
   329  	for _, key := range keyBuffer {
   330  		_, err := focusedWidget.HandleKeyboard(key, 0)
   331  		if err != nil {
   332  			return fmt.Errorf("failed to handle widget keyboard input: %s", err)
   333  		}
   334  	}
   335  
   336  	runeBuffer = ebiten.AppendInputChars(runeBuffer[:0])
   337  INPUTCHARS:
   338  	for i, r := range runeBuffer {
   339  		if i > 0 {
   340  			for j, r2 := range runeBuffer {
   341  				if j == i {
   342  					break
   343  				} else if r2 == r {
   344  					continue INPUTCHARS
   345  				}
   346  			}
   347  		}
   348  		var err error
   349  		switch r {
   350  		case Bindings.ConfirmRune:
   351  			_, err = focusedWidget.HandleKeyboard(ebiten.KeyEnter, 0)
   352  		case Bindings.BackRune:
   353  			_, err = focusedWidget.HandleKeyboard(ebiten.KeyBackspace, 0)
   354  		default:
   355  			_, err = focusedWidget.HandleKeyboard(-1, r)
   356  		}
   357  		if err != nil {
   358  			return fmt.Errorf("failed to handle widget keyboard input: %s", err)
   359  		}
   360  	}
   361  	return nil
   362  }
   363  
   364  func at(w Widget, p image.Point) Widget {
   365  	if w == nil || !w.Visible() {
   366  		return nil
   367  	}
   368  
   369  	for _, child := range w.Children() {
   370  		result := at(child, p)
   371  		if result != nil {
   372  			return result
   373  		}
   374  	}
   375  
   376  	if p.In(w.Rect()) {
   377  		return w
   378  	}
   379  
   380  	return nil
   381  }
   382  
   383  // At returns the widget at the provided screen location.
   384  func At(p image.Point) Widget {
   385  	return at(root, p)
   386  }
   387  
   388  func update(w Widget, cursor image.Point, pressed bool, clicked bool, mouseHandled bool) (bool, error) {
   389  	if w == nil {
   390  		return false, nil
   391  	} else if !w.Visible() {
   392  		return mouseHandled, nil
   393  	}
   394  
   395  	var err error
   396  	children := w.Children()
   397  	for i := len(children) - 1; i >= 0; i-- {
   398  		mouseHandled, err = update(children[i], cursor, pressed, clicked, mouseHandled)
   399  		if err != nil {
   400  			return false, err
   401  		} else if mouseHandled {
   402  			return true, nil
   403  		}
   404  	}
   405  	if !mouseHandled && cursor.In(w.Rect()) {
   406  		if pressed && !clicked && w != pressedWidget {
   407  			return mouseHandled, nil
   408  		}
   409  		mouseHandled, err = w.HandleMouse(cursor, pressed, clicked)
   410  		if err != nil {
   411  			return false, fmt.Errorf("failed to handle widget mouse input: %s", err)
   412  		} else if mouseHandled {
   413  			if clicked {
   414  				SetFocus(w)
   415  				pressedWidget = w
   416  			} else if pressedWidget != nil && (!pressed || pressedWidget != w) {
   417  				pressedWidget = nil
   418  			}
   419  			shape := w.Cursor()
   420  			if shape == -1 {
   421  				shape = ebiten.CursorShapeDefault
   422  			}
   423  			if shape != cursorShape {
   424  				ebiten.SetCursorShape(shape)
   425  				cursorShape = shape
   426  			}
   427  		}
   428  	}
   429  	return mouseHandled, nil
   430  }
   431  
   432  // Draw draws the root widget and its children to the screen.
   433  func Draw(screen *ebiten.Image) error {
   434  	foundFocused = false
   435  	err := draw(root, screen)
   436  	if err != nil {
   437  		return err
   438  	} else if focusedWidget != nil && !foundFocused {
   439  		SetFocus(nil)
   440  	}
   441  	return nil
   442  }
   443  
   444  func draw(w Widget, screen *ebiten.Image) error {
   445  	if w == nil || !w.Visible() {
   446  		return nil
   447  	}
   448  
   449  	r := w.Rect()
   450  	subScreen := screen.SubImage(r).(*ebiten.Image)
   451  
   452  	background := w.Background()
   453  	if background.A > 0 {
   454  		subScreen.Fill(background)
   455  	}
   456  
   457  	err := w.Draw(subScreen)
   458  	if err != nil {
   459  		return fmt.Errorf("failed to draw widget: %s", err)
   460  	}
   461  
   462  	if drawDebug && !r.Empty() {
   463  		x, y := r.Min.X, r.Min.Y
   464  		w, h := r.Dx(), r.Dy()
   465  		screen.SubImage(image.Rect(x, y, x+w, y+1)).(*ebiten.Image).Fill(debugColor)
   466  		screen.SubImage(image.Rect(x, y+h-1, x+w, y+h)).(*ebiten.Image).Fill(debugColor)
   467  		screen.SubImage(image.Rect(x, y, x+1, y+h)).(*ebiten.Image).Fill(debugColor)
   468  		screen.SubImage(image.Rect(x+w-1, y, x+w, y+h)).(*ebiten.Image).Fill(debugColor)
   469  	}
   470  
   471  	children := w.Children()
   472  	for _, child := range children {
   473  		err = draw(child, subScreen)
   474  		if err != nil {
   475  			return fmt.Errorf("failed to draw widget: %s", err)
   476  		}
   477  	}
   478  
   479  	if w == focusedWidget {
   480  		foundFocused = true
   481  	}
   482  	return nil
   483  }
   484  
   485  func newText() *messeji.TextField {
   486  	f := messeji.NewTextField(FontFace(Style.TextFont, Scale(Style.TextSize)), fontMutex)
   487  	f.SetForegroundColor(Style.TextColorLight)
   488  	f.SetBackgroundColor(transparent)
   489  	f.SetScrollBarColors(Style.ScrollAreaColor, Style.ScrollHandleColor)
   490  	f.SetScrollBorderSize(Scale(Style.ScrollBorderSize))
   491  	f.SetScrollBorderColors(Style.ScrollBorderTop, Style.ScrollBorderRight, Style.ScrollBorderBottom, Style.ScrollBorderLeft)
   492  	return f
   493  }
   494  
   495  func rectAtOrigin(r image.Rectangle) image.Rectangle {
   496  	r.Max.X, r.Max.Y = r.Dx(), r.Dy()
   497  	r.Min.X, r.Min.Y = 0, 0
   498  	return r
   499  }
   500  
   501  func _clamp(x int, y int, boundary image.Rectangle) (int, int) {
   502  	if x < boundary.Min.X {
   503  		x = boundary.Min.X
   504  	} else if y > boundary.Max.X {
   505  		x = boundary.Max.X
   506  	}
   507  	if y < boundary.Min.Y {
   508  		y = boundary.Min.Y
   509  	} else if y > boundary.Max.Y {
   510  		y = boundary.Max.Y
   511  	}
   512  	return x, y
   513  }
   514  
   515  func clampRect(r image.Rectangle, boundary image.Rectangle) image.Rectangle {
   516  	r.Min.X, r.Min.Y = _clamp(r.Min.X, r.Min.Y, boundary)
   517  	r.Max.X, r.Max.Y = _clamp(r.Max.X, r.Max.Y, boundary)
   518  	if r.Min.X == r.Max.X || r.Min.Y == r.Max.Y {
   519  		return image.Rectangle{}
   520  	}
   521  	return r
   522  }
   523  

View as plain text