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 }