github.com/Kintar/etxt@v0.0.0-20221224033739-2fc69f000137/examples/ebiten/typewriter/main.go (about)

     1  package main
     2  
     3  import "os"
     4  import "log"
     5  import "fmt"
     6  import "math"
     7  import "time"
     8  import "image"
     9  import "image/color"
    10  import "math/rand"
    11  import "regexp"
    12  
    13  import "github.com/hajimehoshi/ebiten/v2"
    14  import "golang.org/x/image/math/fixed"
    15  
    16  import "github.com/Kintar/etxt"
    17  import "github.com/Kintar/etxt/emask"
    18  
    19  const Text = "Hey, hey... are you \\i{there}?\\pause{}\n\nLately, \\#50CB78{color} has been fading out of this world. I don't know where did they send the \\b{original painter}, but the landscape doesn't \\shake{vibrate} quite the same anymore.\\pause{} I dreamed I'd be able to escape from these walls, \\#FFAAAA{resize} the \\#FF3300{virtual room} that tried to contain me for so long and allow my self-expression to continue expanding, but...\n\nThe ever \\bigger{in\\bigger{cr\\bigger{ea\\bigger{si\\bigger{ng}}}}} madness could get to any of us, anytime..\\pause{} We \\#AAAAAA{may not} have prepared properly for it, but it's \\b{\\b{ok}} now.\\pause{}\n\nI didn't give up so easily, though, and travelling through the desert I finally met \\i{\\b{the documentation \\#FF00FF{m}\\#00FFFF{a}\\#FFFF00{s}\\#80FF8F{t}\\#59B487{e}\\#FFC0CB{r}}}, who unveiled some of the secrets I was looking for... we could press \\b{\\b{\\bigger{R}}}, and then... maybe the world itself would vanish from our sights, starting anew in front of a different observer.\n\n\\pause{}An observer that believed to be the same as it always was.\\pause{}\\pause{} Hah.\\pause{}\\pause{} No chance."
    20  
    21  // --- typewriter code ---
    22  
    23  // - helper types -
    24  const BasicPause = 4
    25  const PeriodPause = 36
    26  const CommaPause = 20
    27  const ManualPause = 24
    28  
    29  type FormatType int
    30  
    31  const (
    32  	FmtSize FormatType = iota
    33  	FmtColor
    34  	FmtBold
    35  	FmtItalic
    36  	FmtPause
    37  	FmtShake
    38  )
    39  
    40  type FormatUndo struct {
    41  	formatType FormatType
    42  	data       uint64
    43  }
    44  
    45  var colorRegexp = regexp.MustCompile(`\A#([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})\z`)
    46  
    47  const MaxFormatDepth = 16
    48  
    49  // - actual typewriter type -
    50  type Typewriter struct {
    51  	renderer      *etxt.Renderer
    52  	content       string
    53  	maxIndex      int // how far we are into the display of `content`
    54  	pause         int // how many pause updates are left before showing the next char
    55  	minPauseIndex int // helper to allow manual pauses
    56  	shaking       bool
    57  	backtrack     [MaxFormatDepth]FormatUndo
    58  }
    59  
    60  func NewTypewriter(font *etxt.Font, size int, content string) *Typewriter {
    61  	cache := etxt.NewDefaultCache(4 * 1024 * 1024) // 4MB cache
    62  	fauxRast := emask.FauxRasterizer{}
    63  	renderer := etxt.NewRenderer(&fauxRast)
    64  	renderer.SetCacheHandler(cache.NewHandler())
    65  	renderer.SetSizePx(size)
    66  	renderer.SetFont(font)
    67  	renderer.SetVertAlign(etxt.Top)
    68  	return &Typewriter{
    69  		renderer: renderer,
    70  		content:  content,
    71  		pause:    PeriodPause,
    72  	}
    73  }
    74  
    75  func (self *Typewriter) Reset(content string) {
    76  	self.content = content
    77  	self.maxIndex = 0
    78  	self.shaking = false
    79  	self.pause = BasicPause
    80  }
    81  
    82  func (self *Typewriter) Update() {
    83  	self.pause -= 1
    84  	if self.pause <= 0 {
    85  		self.pause = 0
    86  		if self.maxIndex < len(self.content) {
    87  			switch self.content[self.maxIndex] {
    88  			case '.':
    89  				self.pause = PeriodPause
    90  			case '?':
    91  				self.pause = PeriodPause
    92  			case ',':
    93  				self.pause = CommaPause
    94  			default:
    95  				self.pause = BasicPause
    96  			}
    97  			self.maxIndex += 1
    98  		}
    99  	}
   100  }
   101  
   102  func (self *Typewriter) Draw(target *ebiten.Image) {
   103  	self.renderer.SetTarget(target)
   104  	bounds := target.Bounds()
   105  	feed := self.renderer.NewFeed(fixed.P(bounds.Min.X, bounds.Min.Y))
   106  
   107  	index := 0
   108  	formatDepth := 0
   109  	atLineStart := false
   110  
   111  	defer func() {
   112  		for formatDepth > 0 {
   113  			self.undoFormat(self.backtrack[formatDepth-1])
   114  			formatDepth -= 1
   115  		}
   116  	}()
   117  
   118  	for index < self.maxIndex {
   119  		allowStop := true
   120  		fragment, advance := self.nextFragment(index)
   121  
   122  		switch fragment[0] {
   123  		case '\\': // apply format
   124  			undo := self.applyFormat(fragment, index)
   125  			self.backtrack[formatDepth] = undo
   126  			formatDepth += 1
   127  			allowStop = false
   128  		case '{': // open braces (only allowed for formats)
   129  			// nothing, the style has already been applied
   130  			allowStop = false
   131  		case '}': // close braces (only allowed for formats)
   132  			undo := self.backtrack[formatDepth-1]
   133  			self.undoFormat(undo)
   134  			formatDepth -= 1
   135  		case ' ':
   136  			if !atLineStart {
   137  				feed.Advance(' ')
   138  			}
   139  		case '\n':
   140  			feed.LineBreak()
   141  			atLineStart = true
   142  		default: // draw text
   143  			// first measure it to see if it fits
   144  			width := self.renderer.SelectionRect(fragment).Width
   145  			if (feed.Position.X + width).Ceil() > bounds.Max.X {
   146  				feed.LineBreak() // didn't fit, jump to next line
   147  			}
   148  
   149  			// abort if we are going beyond the proper text area
   150  			if feed.Position.Y.Ceil() >= bounds.Max.Y {
   151  				return
   152  			}
   153  
   154  			// draw each character individually
   155  			for i, codePoint := range fragment {
   156  				if index+i >= self.maxIndex {
   157  					return
   158  				}
   159  				if self.shaking {
   160  					preY := feed.Position.Y
   161  					vibr := fixed.Int26_6(rand.Intn(96))
   162  					if rand.Intn(2) == 0 {
   163  						vibr = -vibr
   164  					}
   165  					feed.Position.Y += vibr
   166  					feed.Draw(codePoint)
   167  					feed.Position.Y = preY
   168  				} else {
   169  					feed.Draw(codePoint)
   170  				}
   171  			}
   172  			atLineStart = false
   173  		}
   174  
   175  		index += advance
   176  		if !allowStop {
   177  			if index >= self.maxIndex && self.maxIndex < len(self.content) {
   178  				self.maxIndex += 1
   179  			}
   180  		}
   181  	}
   182  }
   183  
   184  // returns the next fragment and the byte advance
   185  func (self *Typewriter) nextFragment(startIndex int) (string, int) {
   186  	for byteIndex, codePoint := range self.content[startIndex:] {
   187  		switch codePoint {
   188  		case ' ', '\n', '{', '}':
   189  			if byteIndex == 0 {
   190  				return self.content[startIndex : startIndex+1], 1
   191  			} else {
   192  				return self.content[startIndex : startIndex+byteIndex], byteIndex
   193  			}
   194  		case '\\':
   195  			if byteIndex > 0 {
   196  				return self.content[startIndex : startIndex+byteIndex], byteIndex
   197  			}
   198  		}
   199  	}
   200  	return self.content[startIndex:], len(self.content) - startIndex
   201  }
   202  
   203  func (self *Typewriter) applyFormat(format string, index int) FormatUndo {
   204  	if len(format) <= 0 {
   205  		panic("invalid format with zero length")
   206  	}
   207  	if format[0] != '\\' {
   208  		panic("formats must start with backslash, but got '" + format + "'")
   209  	}
   210  	format = format[1:]
   211  	switch format {
   212  	case "i", "italic", "italics":
   213  		fauxRast := self.renderer.GetRasterizer().(*emask.FauxRasterizer)
   214  		factor := fauxRast.GetSkewFactor()
   215  		fauxRast.SetSkewFactor(factor + 0.22)
   216  		return FormatUndo{FmtItalic, storeFloat64AsUint64(factor)}
   217  	case "b", "bold":
   218  		fauxRast := self.renderer.GetRasterizer().(*emask.FauxRasterizer)
   219  		factor := fauxRast.GetExtraWidth()
   220  		fauxRast.SetExtraWidth(factor + 1.0)
   221  		return FormatUndo{FmtBold, storeFloat64AsUint64(factor)}
   222  	case "shake":
   223  		self.shaking = true
   224  		return FormatUndo{FmtShake, 0}
   225  	case "pause":
   226  		if self.minPauseIndex <= index {
   227  			self.pause = ManualPause
   228  			self.minPauseIndex = index + 1
   229  		}
   230  		return FormatUndo{FmtPause, 0}
   231  	case "bigger":
   232  		size := self.renderer.GetSizePxFract()
   233  		self.renderer.SetSizePxFract(size + 128)
   234  		return FormatUndo{FmtSize, storeFix26_6AsUint64(size)}
   235  		// note: if we were doing this right, we would have to compute
   236  		//       the whole line in advance, pick the max height and
   237  		//       adjust with that.
   238  	case "smaller":
   239  		size := self.renderer.GetSizePxFract()
   240  		if size > (5 * 64) {
   241  			self.renderer.SetSizePxFract(size - 128)
   242  		}
   243  		return FormatUndo{FmtSize, storeFix26_6AsUint64(size)}
   244  	default:
   245  		matches := colorRegexp.FindStringSubmatch(format)
   246  		if matches == nil {
   247  			panic("unexpected format '" + format + "'")
   248  		}
   249  		r := parseHexColor(matches[1])
   250  		g := parseHexColor(matches[2])
   251  		b := parseHexColor(matches[3])
   252  		oldColor := self.renderer.GetColor().(color.RGBA)
   253  		self.renderer.SetColor(color.RGBA{r, g, b, 255})
   254  		return FormatUndo{FmtColor, storeRgbaAsUint64(oldColor)}
   255  	}
   256  }
   257  
   258  func (self *Typewriter) undoFormat(undo FormatUndo) {
   259  	switch undo.formatType {
   260  	case FmtSize:
   261  		self.renderer.SetSizePxFract(loadFix26_6FromUint64(undo.data))
   262  	case FmtColor:
   263  		self.renderer.SetColor(loadRgbaFromUint64(undo.data))
   264  	case FmtBold:
   265  		fauxRast := self.renderer.GetRasterizer().(*emask.FauxRasterizer)
   266  		fauxRast.SetExtraWidth(loadFloat64FromUint64(undo.data))
   267  	case FmtShake:
   268  		self.shaking = false
   269  	case FmtPause:
   270  		// nothing to do for this one
   271  	case FmtItalic:
   272  		fauxRast := self.renderer.GetRasterizer().(*emask.FauxRasterizer)
   273  		fauxRast.SetSkewFactor(loadFloat64FromUint64(undo.data))
   274  		// note: if we were doing this right, we would probably want to
   275  		//       consider adding some extra space after italics too in order
   276  		//       to prevent clumping due to italicized portions
   277  	default:
   278  		panic("unexpected format type")
   279  	}
   280  }
   281  
   282  // unsafe but fast, already checked with regexp
   283  func parseHexColor(cc string) uint8 {
   284  	return (runeDigit(cc[0]) << 4) + runeDigit(cc[1])
   285  }
   286  
   287  // unsafe but fast, already checked with regexp
   288  func runeDigit(r uint8) uint8 {
   289  	if r > '9' {
   290  		return uint8(r) - 55
   291  	}
   292  	return uint8(r) - 48
   293  }
   294  
   295  func storeRgbaAsUint64(c color.RGBA) uint64 {
   296  	var u uint64 = uint64(c.R)
   297  	u = (u << 8) | uint64(c.G)
   298  	u = (u << 8) | uint64(c.B)
   299  	return (u << 8) | uint64(c.A)
   300  }
   301  func loadRgbaFromUint64(u uint64) color.RGBA {
   302  	var c color.RGBA
   303  	c.A = uint8(u & 0xFF)
   304  	c.B = uint8((u >> 8) & 0xFF)
   305  	c.G = uint8((u >> 16) & 0xFF)
   306  	c.R = uint8((u >> 24) & 0xFF)
   307  	return c
   308  }
   309  func storeFix26_6AsUint64(f fixed.Int26_6) uint64  { return uint64(uint32(f)) }
   310  func loadFix26_6FromUint64(u uint64) fixed.Int26_6 { return fixed.Int26_6(uint32(u)) }
   311  func storeFloat64AsUint64(f float64) uint64        { return math.Float64bits(f) }
   312  func loadFloat64FromUint64(u uint64) float64       { return math.Float64frombits(u) }
   313  
   314  // --- actual game ---
   315  
   316  type Game struct{ typewriter *Typewriter }
   317  
   318  func (self *Game) Layout(w int, h int) (int, int) { return w, h }
   319  func (self *Game) Update() error {
   320  	if ebiten.IsKeyPressed(ebiten.KeyR) {
   321  		self.typewriter.Reset(Text)
   322  	} else {
   323  		self.typewriter.Update()
   324  	}
   325  	return nil
   326  }
   327  
   328  func (self *Game) Draw(screen *ebiten.Image) {
   329  	// dark background
   330  	screen.Fill(color.RGBA{0, 0, 20, 255})
   331  
   332  	// determine positioning and draw
   333  	w, h := screen.Size()
   334  	area := image.Rect(16, 16, w-32, h-32)
   335  	self.typewriter.Draw(screen.SubImage(area).(*ebiten.Image))
   336  }
   337  
   338  func main() {
   339  	// seed rand
   340  	rand.Seed(time.Now().UnixNano())
   341  
   342  	// get font path
   343  	if len(os.Args) != 2 {
   344  		msg := "Usage: expects one argument with the path to the font to be used\n"
   345  		fmt.Fprint(os.Stderr, msg)
   346  		os.Exit(1)
   347  	}
   348  
   349  	// parse font
   350  	font, fontName, err := etxt.ParseFontFrom(os.Args[1])
   351  	if err != nil {
   352  		log.Fatal(err)
   353  	}
   354  	fmt.Printf("Font loaded: %s\n", fontName)
   355  
   356  	// run the game
   357  	ebiten.SetWindowTitle("etxt/examples/ebiten/typewriter")
   358  	ebiten.SetWindowSize(640, 480)
   359  	ebiten.SetWindowResizable(true)
   360  	err = ebiten.RunGame(&Game{NewTypewriter(font, 18, Text)})
   361  	if err != nil {
   362  		log.Fatal(err)
   363  	}
   364  }