Skip to content

Commit

Permalink
feat: Added ability to render spritesheet-based fonts (#1634)
Browse files Browse the repository at this point in the history
This PR adds [SpriteFontRenderer], which is a new implementation of [TextRenderer], allowing to render text based on fonts embedded into a spritesheet.

See the T-Rex game example, where the score is now rendered based on a spritesheet font.

(Also added highscore tracking for the TRex game).
  • Loading branch information
st-pasha authored Jun 3, 2022
1 parent 5dcf266 commit 3f28789
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 10 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ build/
_build/

coverage
**/failures

.fvm
Makefile
Expand Down
2 changes: 1 addition & 1 deletion examples/games/padracing/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1

environment:
sdk: ">=2.16.2 <3.0.0"
sdk: ">=2.16.0 <3.0.0"

dependencies:
flutter:
Expand Down
35 changes: 26 additions & 9 deletions examples/games/trex/lib/trex_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import 'package:flame/components.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame/text.dart';
import 'package:flutter/material.dart' hide Image;
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trex_game/background/horizon.dart';
import 'package:trex_game/game_over.dart';
import 'package:trex_game/player.dart';
Expand All @@ -31,30 +31,41 @@ class TRexGame extends FlameGame
late final gameOverPanel = GameOverPanel();
late final TextComponent scoreText;

late int _score;
int _score = 0;
int _highscore = 0;
int get score => _score;
set score(int newScore) {
_score = newScore;
scoreText.text = 'Score:$score';
scoreText.text = '${scoreString(_score)} HI ${scoreString(_highscore)}';
}

String scoreString(int score) => score.toString().padLeft(5, '0');

/// Used for score calculation
double _distanceTravelled = 0;

@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load('trex.png');
add(horizon);
add(player);
add(gameOverPanel);

final textStyle = GoogleFonts.pressStart2p(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
const chars = '0123456789HI ';
final renderer = SpriteFontRenderer(
source: spriteImage,
charWidth: 20,
charHeight: 23,
glyphs: {
for (var i = 0; i < chars.length; i++)
chars[i]: GlyphData(left: 954.0 + 20 * i, top: 0)
},
letterSpacing: 2,
);
final textPaint = TextPaint(style: textStyle);
add(
scoreText = TextComponent(
position: Vector2(20, 20),
textRenderer: textPaint,
textRenderer: renderer,
)..positionType = PositionType.viewport,
);
score = 0;
Expand Down Expand Up @@ -111,7 +122,11 @@ class TRexGame extends FlameGame
currentSpeed = startSpeed;
gameOverPanel.visible = false;
timePlaying = 0.0;
if (score > _highscore) {
_highscore = score;
}
score = 0;
_distanceTravelled = 0;
}

@override
Expand All @@ -123,6 +138,8 @@ class TRexGame extends FlameGame

if (isPlaying) {
timePlaying += dt;
_distanceTravelled += dt * currentSpeed;
score = _distanceTravelled ~/ 50;

if (currentSpeed < maxSpeed) {
currentSpeed += acceleration * dt;
Expand Down
147 changes: 147 additions & 0 deletions packages/flame/lib/src/text/sprite_font_renderer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'dart:typed_data';
import 'dart:ui';

import 'package:flame/src/anchor.dart';
import 'package:flame/src/text/text_renderer.dart';
import 'package:vector_math/vector_math_64.dart';

/// [TextRenderer] implementation that uses a spritesheet of various font glyphs
/// to render text.
///
/// For example, suppose there is a spritesheet with sprites for characters from
/// A to Z. Mapping these sprites into a [SpriteFontRenderer] allows then to
/// write text using these sprite images as the font.
///
/// Currently, this class supports monospace fonts only -- the widths and the
/// heights of all characters must be the same.
/// Extra space between letters can be added via the [letterSpacing] parameter
/// (it can also be negative to "squish" characters together). Finally, the
/// [scale] parameter allows scaling the font to be bigger/smaller relative to
/// its size in the source image.
///
/// The [paint] parameter is used to composite the character images onto the
/// canvas. Its default value will draw the character images as-is. Changing
/// the opacity of the paint's color will make the text semi-transparent.
class SpriteFontRenderer extends TextRenderer {
SpriteFontRenderer({
required this.source,
required double charWidth,
required double charHeight,
required Map<String, GlyphData> glyphs,
this.scale = 1,
this.letterSpacing = 0,
}) : scaledCharWidth = charWidth * scale,
scaledCharHeight = charHeight * scale,
_glyphs = glyphs.map((char, rect) {
assert(
char.length == 1,
'A glyph must have a single character: "$char"',
);
final info = _GlyphInfo();
info.srcLeft = rect.left;
info.srcTop = rect.top;
info.srcRight = rect.right ?? rect.left + charWidth;
info.srcBottom = rect.bottom ?? rect.top + charHeight;
info.rstSCos = scale;
info.rstTy = (charHeight - (info.srcBottom - info.srcTop)) * scale;
info.width = charWidth * scale;
info.height = charHeight * scale;
return MapEntry(char.codeUnitAt(0), info);
});

final Image source;
final Map<int, _GlyphInfo> _glyphs;
final double letterSpacing;
final double scale;
final double scaledCharWidth;
final double scaledCharHeight;
bool get isMonospace => true;

Paint paint = Paint()..color = const Color(0xFFFFFFFF);

@override
double measureTextHeight(String text) => scaledCharHeight;

@override
double measureTextWidth(String text) {
final n = text.length;
return n > 0 ? scaledCharWidth * n + letterSpacing * (n - 1) : 0;
}

@override
Vector2 measureText(String text) {
return Vector2(measureTextWidth(text), measureTextHeight(text));
}

@override
void render(
Canvas canvas,
String text,
Vector2 position, {
Anchor anchor = Anchor.topLeft,
}) {
final rstTransforms = Float32List(4 * text.length);
final rects = Float32List(4 * text.length);
var j = 0;
var x0 = position.x;
final y0 = position.y;
for (final glyph in _textToGlyphs(text)) {
rects[j + 0] = glyph.srcLeft;
rects[j + 1] = glyph.srcTop;
rects[j + 2] = glyph.srcRight;
rects[j + 3] = glyph.srcBottom;
rstTransforms[j + 0] = glyph.rstSCos;
rstTransforms[j + 1] = glyph.rstSSin;
rstTransforms[j + 2] = x0 + glyph.rstTx;
rstTransforms[j + 3] = y0 + glyph.rstTy;
x0 += glyph.width + letterSpacing;
j += 4;
}
canvas.drawRawAtlas(source, rstTransforms, rects, null, null, null, paint);
}

Iterable<_GlyphInfo> _textToGlyphs(String text) {
return text.codeUnits.map(_getGlyphFromCodeUnit);
}

_GlyphInfo _getGlyphFromCodeUnit(int i) {
final glyph = _glyphs[i];
if (glyph == null) {
throw ArgumentError('No glyph for character "${String.fromCharCode(i)}"');
}
return glyph;
}
}

class GlyphData {
const GlyphData({
required this.left,
required this.top,
this.right,
this.bottom,
});

const GlyphData.fromLTWH(this.left, this.top, double width, double height)
: right = left + width,
bottom = top + height;

const GlyphData.fromLTRB(this.left, this.top, this.right, this.bottom);

final double left;
final double top;
final double? right;
final double? bottom;
}

class _GlyphInfo {
double srcLeft = 0;
double srcTop = 0;
double srcRight = 0;
double srcBottom = 0;
double rstSCos = 1;
double rstSSin = 0;
double rstTx = 0;
double rstTy = 0;
double width = 0;
double height = 0;
}
2 changes: 2 additions & 0 deletions packages/flame/lib/src/text/text_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flame/src/anchor.dart';
import 'package:flame/src/components/text_box_component.dart';
import 'package:flame/src/components/text_component.dart';
import 'package:flame/src/text/sprite_font_renderer.dart';
import 'package:flame/src/text/text_paint.dart';
import 'package:vector_math/vector_math_64.dart';

Expand All @@ -23,6 +24,7 @@ import 'package:vector_math/vector_math_64.dart';
///
/// The following text renderers are available in Flame:
/// - [TextPaint] which uses the standard Flutter's `TextPainter`;
/// - [SpriteFontRenderer] which uses a spritesheet as a font file;
abstract class TextRenderer {
/// Compute the dimensions of [text] when rendered.
Vector2 measureText(String text);
Expand Down
1 change: 1 addition & 0 deletions packages/flame/lib/text.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export 'src/text/sprite_font_renderer.dart' show SpriteFontRenderer, GlyphData;
export 'src/text/text_paint.dart' show TextPaint;
export 'src/text/text_renderer.dart' show TextRenderer;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/flame/test/_resources/alphabet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions packages/flame/test/text/sprite_font_renderer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import 'dart:ui';

import 'package:canvas_test/canvas_test.dart';
import 'package:flame/components.dart';
import 'package:flame/text.dart';
import 'package:flame_test/flame_test.dart';
import 'package:test/test.dart';

import '../_resources/load_image.dart';

void main() {
group('SpriteFontRenderer', () {
test('creating SpriteFontRenderer', () async {
final renderer = await createRenderer();
expect(renderer.source, isA<Image>());
expect(renderer.scaledCharWidth, 6);
expect(renderer.scaledCharHeight, 6);
expect(renderer.letterSpacing, 0);
expect(renderer.isMonospace, true);

expect(
() => renderer.render(MockCanvas(), 'Π‡', Vector2.zero()),
throwsArgumentError,
);
});

testGolden(
'text rendering at different scales',
(game) async {
game.addAll([
RectangleComponent(size: Vector2(800, 600)),
TextBoxComponent(
text: textSample,
textRenderer: await createRenderer(letterSpacing: 1),
boxConfig: TextBoxConfig(maxWidth: 800),
),
TextBoxComponent(
text: textSample,
textRenderer: await createRenderer(scale: 2),
boxConfig: TextBoxConfig(maxWidth: 800),
position: Vector2(0, 100),
),
TextComponent(
text: 'FLAME',
textRenderer: (await createRenderer(scale: 25))
..paint.color = const Color(0x44000000),
position: Vector2(400, 500),
anchor: Anchor.center,
),
]);
},
goldenFile: '../_goldens/sprite_font_renderer_1.png',
);

test('errors', () async {
const rect = GlyphData.fromLTWH(0, 0, 6, 6);
final image = await loadImage('alphabet.png');
expect(
() => SpriteFontRenderer(
source: image,
charWidth: 6,
charHeight: 6,
glyphs: {'πŸ”₯': rect},
),
failsAssert('A glyph must have a single character: "πŸ”₯"'),
);
});
});
}

const textSample = 'We hold these truths to be self-evident, that all men are '
'created equal, that they are endowed by their Creator with certain '
'unalienable Rights, that among these are Life, Liberty and the pursuit of '
'Happiness. β€” That to secure these rights, Governments are instituted '
'among Men, deriving their just powers from the consent of the governed, '
'β€” That whenever any Form of Government becomes destructive of these ends, '
'it is the Right of the People to alter or to abolish it, and to institute '
'new Government, laying its foundation on such principles and organizing '
'its powers in such form, as to them shall seem most likely to effect '
'their Safety and Happiness. Prudence, indeed, will dictate that '
'Governments long established should not be changed for light and '
'transient causes; and accordingly all experience hath shewn, that mankind '
'are more disposed to suffer, while evils are sufferable, than to right '
'themselves by abolishing the forms to which they are accustomed. But when '
'a long train of abuses and usurpations, pursuing invariably the same '
'Object evinces a design to reduce them under absolute Despotism, it is '
'their right, it is their duty, to throw off such Government, and to '
'provide new Guards for their future security.';

Future<SpriteFontRenderer> createRenderer({
double scale = 1,
double letterSpacing = 0,
}) async {
const lines = [
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz',
'0123456789.,:;β€”_!?@\$%+-=/*',
'#^&()[]{}<>|\\\'"`~←→↑↓ ',
];
return SpriteFontRenderer(
source: await loadImage('alphabet.png'),
charHeight: 6,
charWidth: 6,
scale: scale,
glyphs: {
for (var j = 0; j < lines.length; j++)
for (var i = 0; i < lines[j].length; i++)
lines[j][i]: GlyphData(left: i * 6, top: 1 + j * 6)
},
letterSpacing: letterSpacing,
);
}

0 comments on commit 3f28789

Please sign in to comment.