Skip to content

Commit

Permalink
feat: Structured text and text styles (#1830)
Browse files Browse the repository at this point in the history
This PR introduces the notions of structured text, and text styles, to support rendering of rich text bodies.

Specifically, we recognize that sometimes in games one needs to render pieces of text that are larger than a single word or even a single paragraph. These pieces may include: books, quest descriptions, mission objectives, tutorials, in-game help system, dialogues, etc. Rendering such a piece of text is non-trivial, however. In order to tackle this problem, I break into the following parts:

Text structure, represented as a tree of Nodes. The nodes describe the logical structure of the text, for example the document may contain a header, and then several paragraphs, and a list, where the list contains some list items, some of which having possibly several paragraphs, etc. This structure is similar to how in HTML the text is marked up with HTML tags.

Text styles are struct-like classes that contain properties describing how the text is to be styled: font size, font renderer, borders, backgrounds, margins, padding, etc. This representation is also tree-like, so that for example text inside paragraphs can have different style than text within headers, and paragraphs within lists can have different margins. A text style is similar to a stylesheet in HTML.

Text elements are the result of applying the document style to a document node: they are the "prepared" and laid out pieces, ready to be rendered. Elements are a bit like mini-components, or perhaps text "particles" in a particle system.
  • Loading branch information
st-pasha authored Aug 10, 2022
1 parent 4ea12b7 commit bfdc3a2
Show file tree
Hide file tree
Showing 26 changed files with 754 additions and 28 deletions.
7 changes: 7 additions & 0 deletions examples/lib/stories/rendering/rendering.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:examples/stories/rendering/layers_example.dart';
import 'package:examples/stories/rendering/nine_tile_box_example.dart';
import 'package:examples/stories/rendering/particles_example.dart';
import 'package:examples/stories/rendering/particles_interactive_example.dart';
import 'package:examples/stories/rendering/rich_text_example.dart';
import 'package:examples/stories/rendering/text_example.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -59,5 +60,11 @@ void addRenderingStories(Dashbook dashbook) {
),
codeLink: baseLink('rendering/particles_interactive_example.dart'),
info: ParticlesInteractiveExample.description,
)
..add(
'Rich Text',
(_) => GameWidget(game: RichTextExample()),
codeLink: baseLink('rendering/rich_text_example.dart'),
info: RichTextExample.description,
);
}
72 changes: 72 additions & 0 deletions examples/lib/stories/rendering/rich_text_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/text.dart';
import 'package:flutter/painting.dart';

class RichTextExample extends FlameGame {
static String description = '';

@override
Color backgroundColor() => const Color(0xFF888888);

@override
Future<void> onLoad() async {
add(MyTextComponent()..position = Vector2.all(100));
}
}

class MyTextComponent extends PositionComponent {
late final Element element;

@override
Future<void> onLoad() async {
final style = DocumentStyle(
width: 400,
height: 200,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
background: BackgroundStyle(
color: const Color(0xFFFFFFEE),
borderColor: const Color(0xFF000000),
borderWidth: 2.0,
),
paragraphStyle: BlockStyle(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 6),
background: BackgroundStyle(
color: const Color(0xFFFFF0CB),
borderColor: const Color(0xFFAAAAAA),
),
),
);
final document = DocumentNode([
ParagraphNode.simple(
'Anything could be true. The so-called laws of nature were nonsense.',
),
ParagraphNode.simple(
'The law of gravity was nonsense. "If I wished," O\'Brien had said, '
'"I could float off this floor like a soap bubble." Winston worked it '
'out. "If he thinks he floats off the floor, and I simultaneously '
'think I can see him do it, then the thing happens."',
),
ParagraphNode.simple(
'Suddenly, like a lump of submerged wreckage breaking the surface of '
'water, the thought burst into his mind: "It doesn\'t really happen. '
'We imagine it. It is hallucination."',
),
ParagraphNode.simple(
'He pushed the thought under instantly. The fallacy was obvious. It '
'presupposed that somewhere or other, outside oneself, there was a '
'"real" world where "real" things happened. But how could there be '
'such a world? What knowledge have we of anything, save through our '
'own minds? All happenings are in the mind. Whatever happens in all '
'minds, truly happens.',
),
]);
element = document.format(style);
}

@override
void render(Canvas canvas) {
element.render(canvas);
}
}
2 changes: 1 addition & 1 deletion packages/flame/lib/src/components/text_component.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'dart:ui';

import 'package:flame/components.dart';
import 'package:flame/src/text/elements/text_element.dart';
import 'package:flame/src/text/formatter_text_renderer.dart';
import 'package:flame/src/text/inline/text_element.dart';
import 'package:flame/src/text/text_renderer.dart';
import 'package:flutter/painting.dart';
import 'package:meta/meta.dart';
Expand Down
2 changes: 1 addition & 1 deletion packages/flame/lib/src/text/common/text_line.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:flame/src/text/common/line_metrics.dart';
import 'package:flame/src/text/inline/text_element.dart';
import 'package:flame/src/text/elements/text_element.dart';

/// [TextLine] is an abstract class describing a single line (or a fragment of
/// a line) of a laid-out text.
Expand Down
12 changes: 12 additions & 0 deletions packages/flame/lib/src/text/common/utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'dart:math';

import 'package:meta/meta.dart';

@internal
double collapseMargin(double margin1, double margin2) {
if (margin1 >= 0) {
return (margin2 < 0) ? margin1 + margin2 : max(margin1, margin2);
} else {
return (margin2 < 0) ? min(margin1, margin2) : margin1 + margin2;
}
}
12 changes: 12 additions & 0 deletions packages/flame/lib/src/text/elements/block_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:flame/src/text/elements/element.dart';

/// [BlockElement] is the base class for [Element]s with rectangular shape and
/// "block" placement rules.
///
/// Within HTML, this corresponds to elements with `display: block` property,
/// such as `<div>` or `<blockquote>`.
abstract class BlockElement extends Element {
BlockElement(this.width, this.height);
double width;
double height;
}
22 changes: 22 additions & 0 deletions packages/flame/lib/src/text/elements/element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'dart:ui';

/// An [Element] is a basic building block of a rich-text document.
///
/// Elements are concrete and "physical": they are objects that are ready to be
/// rendered on a canvas. This property distinguishes them from Nodes (which are
/// structured pieces of text), and from Styles (which are descriptors for how
/// arbitrary pieces of text ought to be rendered).
///
/// Elements are at the final stage of the text rendering pipeline, they are
/// created during the layout step.
abstract class Element {
void translate(double dx, double dy);

/// Renders the element on the [canvas], at coordinates determined during the
/// layout.
///
/// In order to render the element at a different location, consider either
/// calling the [translate] method, or applying a translation transform to the
/// canvas beforehand.
void render(Canvas canvas);
}
20 changes: 20 additions & 0 deletions packages/flame/lib/src/text/elements/group_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'dart:ui';

import 'package:flame/src/text/elements/block_element.dart';
import 'package:flame/src/text/elements/element.dart';

class GroupElement extends BlockElement {
GroupElement(super.width, super.height, this.children);

final List<Element> children;

@override
void translate(double dx, double dy) {
children.forEach((child) => child.translate(dx, dy));
}

@override
void render(Canvas canvas) {
children.forEach((child) => child.render(canvas));
}
}
21 changes: 21 additions & 0 deletions packages/flame/lib/src/text/elements/rect_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'dart:ui';

import 'package:flame/src/text/elements/element.dart';

class RectElement extends Element {
RectElement(double width, double height, this._paint)
: _rect = Rect.fromLTWH(0, 0, width, height);

Rect _rect;
final Paint _paint;

@override
void translate(double dx, double dy) {
_rect = _rect.translate(dx, dy);
}

@override
void render(Canvas canvas) {
canvas.drawRect(_rect, _paint);
}
}
25 changes: 25 additions & 0 deletions packages/flame/lib/src/text/elements/rrect_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'dart:ui';

import 'package:flame/src/text/elements/element.dart';

class RRectElement extends Element {
RRectElement(
double width,
double height,
double radius,
this._paint,
) : _rrect = RRect.fromLTRBR(0, 0, width, height, Radius.circular(radius));

RRect _rrect;
final Paint _paint;

@override
void translate(double dx, double dy) {
_rrect = _rrect.shift(Offset(dx, dy));
}

@override
void render(Canvas canvas) {
canvas.drawRRect(_rrect, _paint);
}
}
12 changes: 12 additions & 0 deletions packages/flame/lib/src/text/elements/text_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:flame/src/text/common/text_line.dart';
import 'package:flame/src/text/elements/element.dart';

/// [TextElement] is the base class describing a span of text that has *inline*
/// placement rules.
///
/// Concrete implementations of this class must know how to perform own layout
/// (i.e. determine the exact placement and size of each internal piece), and
/// then render on a canvas afterwards.
abstract class TextElement extends Element {
TextLine get lastLine;
}
2 changes: 1 addition & 1 deletion packages/flame/lib/src/text/formatters/text_formatter.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flame/src/text/inline/text_element.dart';
import 'package:flame/src/text/elements/text_element.dart';

/// [TextFormatter] is an abstract interface for a class that can convert an
/// arbitrary string of text into a renderable [TextElement].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'dart:ui';

import 'package:flame/src/text/common/line_metrics.dart';
import 'package:flame/src/text/common/text_line.dart';
import 'package:flame/src/text/inline/text_element.dart';
import 'package:flame/src/text/elements/text_element.dart';

class SpriteFontTextElement extends TextElement implements TextLine {
SpriteFontTextElement({
Expand Down
22 changes: 0 additions & 22 deletions packages/flame/lib/src/text/inline/text_element.dart

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import 'dart:ui';

import 'package:flame/src/text/common/line_metrics.dart';
import 'package:flame/src/text/common/text_line.dart';
import 'package:flame/src/text/inline/text_element.dart';
import 'package:flame/src/text/elements/element.dart';
import 'package:flame/src/text/elements/text_element.dart';
import 'package:flutter/rendering.dart' show TextBaseline, TextPainter;

class TextPainterTextElement extends TextElement implements TextLine {
class TextPainterTextElement extends TextElement implements TextLine, Element {
TextPainterTextElement(this._textPainter)
: _box = LineMetrics(
ascent: _textPainter
Expand Down
41 changes: 41 additions & 0 deletions packages/flame/lib/src/text/nodes.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flame/src/text/nodes/block_node.dart';

class GroupBlockNode extends BlockNode {
GroupBlockNode(this.children);

final List<BlockNode> children;
}

class BlockquoteNode extends GroupBlockNode {
BlockquoteNode(super.children);
}

abstract class TextNode {}

class PlainTextNode extends TextNode {
PlainTextNode(this.text);

final String text;
}

class GroupTextNode extends TextNode {
GroupTextNode(this.children);

final List<TextNode> children;
}

class BoldTextNode extends GroupTextNode {
BoldTextNode(super.children);
}

class ItalicTextNode extends GroupTextNode {
ItalicTextNode(super.children);
}

class StrikethroughTextNode extends GroupTextNode {
StrikethroughTextNode(super.children);
}

class HighlightedTextNode extends GroupTextNode {
HighlightedTextNode(super.children);
}
55 changes: 55 additions & 0 deletions packages/flame/lib/src/text/nodes/block_node.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:flame/src/text/elements/element.dart';
import 'package:flame/src/text/elements/group_element.dart';
import 'package:flame/src/text/elements/rect_element.dart';
import 'package:flame/src/text/elements/rrect_element.dart';
import 'package:flame/src/text/styles/background_style.dart';

/// An abstract base class for all entities with "block" placement rules.
abstract class BlockNode {
Element? makeBackground(BackgroundStyle? style, double width, double height) {
if (style == null) {
return null;
}
final out = <Element>[];
final backgroundPaint = style.backgroundPaint;
final borderPaint = style.borderPaint;
final borders = style.borderWidths;
final radius = style.borderRadius;

if (backgroundPaint != null) {
if (radius == 0) {
out.add(RectElement(width, height, backgroundPaint));
} else {
out.add(RRectElement(width, height, radius, backgroundPaint));
}
}
if (borderPaint != null) {
if (radius == 0) {
out.add(
RectElement(
width - borders.horizontal / 2,
height - borders.vertical / 2,
borderPaint,
)..translate(borders.left / 2, borders.top / 2),
);
} else {
out.add(
RRectElement(
width - borders.horizontal / 2,
height - borders.vertical / 2,
radius,
borderPaint,
)..translate(borders.left / 2, borders.top / 2),
);
}
}
if (out.isEmpty) {
return null;
}
if (out.length == 1) {
return out.first;
} else {
return GroupElement(width, height, out);
}
}
}
Loading

0 comments on commit bfdc3a2

Please sign in to comment.