From 81e69968cf52a9f1b6875c08409b1b776e65cc88 Mon Sep 17 00:00:00 2001 From: Serhii Mudryk Date: Sat, 6 May 2023 01:18:57 +0300 Subject: [PATCH] Introduced support for sprites (#8) * Introduced support for sprites * Updated readme * cleanup * Added unit test * Refactoring --- README.md | 6 +- bitblt.go | 2 + examples/game_of_life/README.md | 3 +- examples/game_of_life/game_of_life.go | 89 ++++++++++++++++++++++++--- sprite/sprite.go | 89 +++++++++++++++++++++++++++ sprite/sprite_test.go | 81 ++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 sprite/sprite.go create mode 100644 sprite/sprite_test.go diff --git a/README.md b/README.md index af51252..2d148f4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Go Graphics library for use in a text terminal. Only 1bit graphics can be used w * [Fill](https://pkg.go.dev/github.com/msoap/tcg#Buffer.Fill) area with a different [options](https://pkg.go.dev/github.com/msoap/tcg#FillOpt), for example fill with [patterns](https://pkg.go.dev/github.com/msoap/tcg#WithPattern) * Buffer manipulating: [cut](https://pkg.go.dev/github.com/msoap/tcg#Buffer.Cut), [clone](https://pkg.go.dev/github.com/msoap/tcg#Buffer.Clone), convert to/from stdlib [Image](https://pkg.go.dev/github.com/msoap/tcg#Buffer.ToImage) or text * Buffer transform: [BitBlt](https://pkg.go.dev/github.com/msoap/tcg#Buffer.BitBlt) with [options](https://pkg.go.dev/github.com/msoap/tcg#BitBltOpt), [clear](https://pkg.go.dev/github.com/msoap/tcg#Buffer.Clear), [flip](https://pkg.go.dev/github.com/msoap/tcg#Buffer.HFlip), [invert](https://pkg.go.dev/github.com/msoap/tcg#Buffer.Invert), scroll ([vertical](https://pkg.go.dev/github.com/msoap/tcg#Buffer.VScroll), [horizontal](https://pkg.go.dev/github.com/msoap/tcg#Buffer.HScroll)) - * Sub-package for [turtle graphics](https://pkg.go.dev/github.com/msoap/tcg/turtle), also available [drawing](https://pkg.go.dev/github.com/msoap/tcg@v0.0.1/turtle#Turtle.DrawScript) by text script + * Sub-package for [turtle graphics](https://pkg.go.dev/github.com/msoap/tcg/turtle), also available [drawing](https://pkg.go.dev/github.com/msoap/tcg/turtle#Turtle.DrawScript) by text script + * [Sprite](https://pkg.go.dev/github.com/msoap/tcg/sprite) support with [Put](https://pkg.go.dev/github.com/msoap/tcg/sprite#Sprite.Put), [Withdraw](https://pkg.go.dev/github.com/msoap/tcg/sprite#Sprite.Withdraw), [Move](https://pkg.go.dev/github.com/msoap/tcg/sprite#Sprite.Move), etc methods ## Install @@ -69,7 +70,8 @@ See more [screenshots](https://github.com/msoap/tcg/wiki/Screenshots). ## TODO * [ ] fonts support - * [ ] sprites, maybe with animation + * [x] sprites + * [ ] animation in sprite ## See also diff --git a/bitblt.go b/bitblt.go index 87efdaa..e33d3ed 100644 --- a/bitblt.go +++ b/bitblt.go @@ -13,6 +13,8 @@ func (b *Buffer) BitBltAll(x, y int, from Buffer, opts ...BitBltOpt) { } // BitBlt - copy part of buffer into this buffer +// xd, yd - destination coordinates +// xs, ys - source coordinates func (b *Buffer) BitBlt(xd, yd, width, height int, from Buffer, xs, ys int, opts ...BitBltOpt) { if len(opts) == 0 { for i := 0; i+ys < from.Height && i < height && i+yd < b.Height; i++ { diff --git a/examples/game_of_life/README.md b/examples/game_of_life/README.md index 7d5ee3c..7cc7190 100644 --- a/examples/game_of_life/README.md +++ b/examples/game_of_life/README.md @@ -6,7 +6,7 @@ Features: - predefined maps from image files - save map as screenshot to image file, later you can load it - - edit map with keyboard, by one pixel or with pen mode + - edit map with keyboard, by one pixel or with pen mode, can show/hide cursor - infinite map (wrap around edges) - mouse support (scroll up/down to step forward/backward) - different screen modes (1x1, 1x2, 2x2, 2x3, 2x4Braille) @@ -61,6 +61,7 @@ game_of_life -in examples/game_of_life/penta_decathlon.png * `h` - step to previous state, or mouse scroll down * `Space` - toggle pixel under cursor * `a` - toggle pen mode, when pen mode is on, you can draw with arrows keys + * `c` - toggle cursor visibility * `←`, `↑`, `→`, `↓` - move cursor ## Screenshots diff --git a/examples/game_of_life/game_of_life.go b/examples/game_of_life/game_of_life.go index b445bfa..a2cb918 100644 --- a/examples/game_of_life/game_of_life.go +++ b/examples/game_of_life/game_of_life.go @@ -14,6 +14,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/msoap/tcg" + "github.com/msoap/tcg/sprite" ) type ( @@ -21,10 +22,12 @@ type ( mode int game struct { tg *tcg.Tcg - mode mode // play or pause/edit - generation int // current generation number - cursorX, cursorY int // cursor position for edit/pen mode - pen bool // continue drawing with arrows keys + mode mode // play or pause/edit + generation int // current generation number + cursorX, cursorY int // cursor position for edit/pen mode + pen bool // continue drawing with arrows keys + showCursor bool // show cursor in edit mode + curs *sprite.Sprite scrH int // screen height in characters infMap bool // is map infinite? history *list.List // history of last generations @@ -36,6 +39,7 @@ const ( defaultInitFillFactor = 0.2 historySize = 1000 maxFPS = 9999 + curHalf = 3 modePlay mode = iota modePause @@ -44,8 +48,9 @@ const ( cmdPause cmdNext cmdPrev - cmdPixel // toggle one pixel in current position - cmdPen // toggle pen mode, when pen mode is on, you can draw with arrows keys + cmdPixel // toggle one pixel in current position + cmdPen // toggle pen mode, when pen mode is on, you can draw with arrows keys + cmdToggleCursor // toggle showing cursor in edit mode cmdUp cmdDown cmdLeft @@ -53,6 +58,27 @@ const ( cmdScreenshot ) +var ( + cursorImage = tcg.MustNewBufferFromStrings([]string{ + "...*...", + "...*...", + ".......", + "**...**", + ".......", + "...*...", + "...*...", + }) + cursorMask = tcg.MustNewBufferFromStrings([]string{ + "..***..", + "..***..", + "**...**", + "**...**", + "**...**", + "..***..", + "..***..", + }) +) + func main() { delay := flag.Duration("delay", defaultDelay, "delay between steps") size := flag.String("size", "", "screen size in chars, in 'width x height' format, example: '80x25'") @@ -121,7 +147,7 @@ func main() { tg.Buf.Rect(0, 0, tg.Width, tg.Height, tcg.Black) // coordinates in pixels tg.PrintStr(4, 1, " Game of Life ") // coordinates in chars, not pixels - tg.PrintStr(24, scrH-1, `| - Quit |

- Pause | / - Next/Prev step | / pixel/pen | - Screenshot `) + tg.PrintStr(24, scrH-1, `| - Quit |

- Pause | / - Next/Prev step | / pixel/pen | show cursor | - Screenshot `) tg.Show() if err := tg.SetClipCenter(width-2, height-2); err != nil { @@ -189,6 +215,10 @@ LOOP: if game.mode != modePlay { game.pen = !game.pen } + case cmdToggleCursor: + if game.mode != modePlay { + game.toggleCursor() + } case cmdScreenshot: if err := saveScreenshot(*screenshotName, tg.Buf); err != nil { tg.PrintStr(0, 0, fmt.Sprintf("save: %s", err)) @@ -203,9 +233,13 @@ LOOP: func newGame(tg *tcg.Tcg, mode mode) *game { _, scrH := tg.ScreenSize() + + cursorSprite := sprite.New(cursorImage).WithMask(cursorMask) + return &game{ mode: mode, tg: tg, + curs: cursorSprite, scrH: scrH, history: list.New(), } @@ -249,12 +283,21 @@ func (g *game) initFromImage(img image.Image) { func (g *game) doPause() { if g.mode == modePlay { g.mode = modePause + if g.showCursor { + g.curs.MoveAbs(g.tg.Buf, g.cursorX-curHalf, g.cursorY-curHalf).Put(g.tg.Buf) + } } else { g.mode = modePlay + if g.showCursor { + g.curs.Withdraw(g.tg.Buf) + } } + g.tg.Show() } func (g *game) togglePixel() { + defer g.handleCursor()() + color := g.tg.Buf.At(g.cursorX, g.cursorY) color = color ^ 1 g.tg.Buf.Set(g.cursorX, g.cursorY, color) @@ -262,6 +305,7 @@ func (g *game) togglePixel() { } func (g *game) moveCursor(dx, dy int) { + defer g.handleCursor()() oldX, oldY := g.cursorX, g.cursorY g.cursorX += dx @@ -287,7 +331,34 @@ func (g *game) moveCursor(dx, dy int) { g.tg.Show() } +func (g *game) toggleCursor() { + defer g.handleCursor()() + g.showCursor = !g.showCursor +} + +func (g *game) handleCursor() func() { + changed := false + if g.showCursor { + g.curs.Withdraw(g.tg.Buf) + changed = true + } + + return func() { + if g.showCursor { + g.curs.MoveAbs(g.tg.Buf, g.cursorX-curHalf, g.cursorY-curHalf).Put(g.tg.Buf) + changed = true + } + if changed { + g.tg.Show() + } + } +} + func (g *game) nextStep() { + if g.showCursor && g.mode == modePause { + defer g.handleCursor()() + } + startedAt := time.Now() g.generation++ @@ -378,6 +449,8 @@ func (g *game) prevStep() { return } + defer g.handleCursor()() + startedAt := time.Now() buf := g.history.Remove(g.history.Front()).(*tcg.Buffer) g.tg.Buf.BitBltAll(0, 0, *buf) @@ -407,6 +480,8 @@ func getCommand(tg *tcg.Tcg) chan cmds { resultCh <- cmdPixel case ev.Rune() == 'a': resultCh <- cmdPen + case ev.Rune() == 'c': + resultCh <- cmdToggleCursor case ev.Key() == tcell.KeyRight: resultCh <- cmdRight case ev.Key() == tcell.KeyLeft: diff --git a/sprite/sprite.go b/sprite/sprite.go new file mode 100644 index 0000000..e5d5e5d --- /dev/null +++ b/sprite/sprite.go @@ -0,0 +1,89 @@ +package sprite + +import ( + "github.com/msoap/tcg" +) + +// Sprite - sprite object +type Sprite struct { + Buf tcg.Buffer // sprite image + x, y int // position on buffer, can be negative + width int + height int + mask *tcg.Buffer // mask for sprite + bg tcg.Buffer // saved + isDrawn bool // is sprite drawn on buffer +} + +// New - get new sprite object from tcg.Buffer +func New(buf tcg.Buffer) *Sprite { + return &Sprite{ + width: buf.Width, + height: buf.Height, + Buf: buf, + bg: tcg.NewBuffer(buf.Width, buf.Height), + } +} + +// WithMask - add mask to sprite +func (s *Sprite) WithMask(mask tcg.Buffer) *Sprite { + s.mask = &mask + return s +} + +// Put - put sprite on buffer, save background under sprite, change state of sprite to drawn +func (s *Sprite) Put(buf tcg.Buffer) *Sprite { + s.draw(buf) + s.isDrawn = true + + return s +} + +// draw - draw sprite on buffer, save background under sprite +func (s *Sprite) draw(buf tcg.Buffer) { + // copy background + s.bg.BitBlt(0, 0, s.width, s.height, buf, s.x, s.y) + + // draw sprite + var opts []tcg.BitBltOpt + if s.mask != nil { + opts = append(opts, tcg.BBMask(s.mask)) + } + buf.BitBlt(s.x, s.y, s.width, s.height, s.Buf, 0, 0, opts...) +} + +// Withdraw - withdraw sprite from buffer +func (s *Sprite) Withdraw(buf tcg.Buffer) *Sprite { + if s.isDrawn { + s.clear(buf) + s.isDrawn = false + } + + return s +} + +// clear sprite on buffer +func (s *Sprite) clear(buf tcg.Buffer) { + buf.BitBlt(s.x, s.y, s.width, s.height, s.bg, 0, 0) +} + +// MoveAbs - move sprite on buffer to absolute position, coordinates can be negative +func (s *Sprite) MoveAbs(buf tcg.Buffer, x, y int) *Sprite { + if s.isDrawn { + s.clear(buf) + } + + s.x = x + s.y = y + + if s.isDrawn { + s.draw(buf) + } + + return s +} + +// Move - move sprite on buffer to relative position +func (s *Sprite) Move(buf tcg.Buffer, x, y int) *Sprite { + return s.MoveAbs(buf, s.x+x, s.y+y) +} diff --git a/sprite/sprite_test.go b/sprite/sprite_test.go new file mode 100644 index 0000000..07007f1 --- /dev/null +++ b/sprite/sprite_test.go @@ -0,0 +1,81 @@ +package sprite + +import ( + "fmt" + "testing" + + "github.com/msoap/tcg" + "github.com/stretchr/testify/assert" +) + +var chess = tcg.MustNewBufferFromStrings([]string{ + "*.", + ".*", +}) + +func TestSprite(t *testing.T) { + bg := tcg.NewBuffer(5, 5) + bg.Fill(0, 0, tcg.WithPattern(chess)) + + spr := New(tcg.MustNewBufferFromStrings([]string{ + "**", + "**", + })) + + spr.Move(bg, 1, 1).Put(bg) + assertEqBuffers(t, bg, tcg.MustNewBufferFromStrings([]string{ + "*.*.*", + ".***.", + "***.*", + ".*.*.", + "*.*.*", + })) + + spr.Withdraw(bg) + assertEqBuffers(t, bg, tcg.MustNewBufferFromStrings([]string{ + "*.*.*", + ".*.*.", + "*.*.*", + ".*.*.", + "*.*.*", + })) + + spr.Put(bg) + spr.Move(bg, 1, 1) + assertEqBuffers(t, bg, tcg.MustNewBufferFromStrings([]string{ + "*.*.*", + ".*.*.", + "*.***", + ".***.", + "*.*.*", + })) + + spr.MoveAbs(bg, 0, -1) + assertEqBuffers(t, bg, tcg.MustNewBufferFromStrings([]string{ + "***.*", + ".*.*.", + "*.*.*", + ".*.*.", + "*.*.*", + })) +} + +func assertEqBuffers(t *testing.T, got, expected tcg.Buffer) { + if got.Width != expected.Width { + t.Errorf("buffer width of got (%d) != expected (%d)", got.Width, expected.Width) + return + } + if got.Height != expected.Height { + t.Errorf("buffer height of got (%d) != expected (%d)", got.Height, expected.Height) + return + } + + if !expected.IsEqual(got) { + gotStrings, expectedStrings := got.Strings(), expected.Strings() + msg := fmt.Sprintf("buffers isn't equal:\n%-*s | %-*s\n", got.Width, "got", expected.Width, "expected") + for y := 0; y < got.Height; y++ { + msg += fmt.Sprintf("%-*s | %-*s\n", got.Width, gotStrings[y], expected.Width, expectedStrings[y]) + } + assert.True(t, false, msg) + } +}