Skip to content

Commit

Permalink
feat: Add a setter for TextBoxComponent.boxConfig and add a convenien…
Browse files Browse the repository at this point in the history
…ce method to skip per-char buildup (#3490)

This PR addresses the concerns in
#3488
For convenience, here is the relevant text.

> It is a fairly common feature for a game to "type out" dialogue within
a TextBoxComponent (as the behavior would be if timePerChar > 0, but
also allow the user to skip that type-out effect, and display the
dialogue in its entirety (as the behavior would be if timePerChar == 0).

This PR implements a setter for TextBoxComponent.boxConfig, allowing for
the TextBoxConfig to be changed after TextBoxComponent is instantiated.
The `_boxConfig` has been made non-final to allow this field to be
modifiable.

Additionally, a `skip` method is implemented which more explicitly
provides the intended skipping behavior.

---------

Co-authored-by: Lukas Klingsbo <[email protected]>
  • Loading branch information
livtanong and spydon authored Feb 13, 2025
1 parent d6c83fa commit d1777b7
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 15 deletions.
7 changes: 7 additions & 0 deletions examples/lib/stories/components/components.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:examples/stories/components/keys_example.dart';
import 'package:examples/stories/components/look_at_example.dart';
import 'package:examples/stories/components/look_at_smooth_example.dart';
import 'package:examples/stories/components/priority_example.dart';
import 'package:examples/stories/components/skip_text_box_component_example.dart';
import 'package:examples/stories/components/spawn_component_example.dart';
import 'package:examples/stories/components/time_scale_example.dart';
import 'package:flame/game.dart';
Expand Down Expand Up @@ -92,5 +93,11 @@ void addComponentsStories(Dashbook dashbook) {
(_) => GameWidget(game: HasVisibilityExample()),
codeLink: baseLink('components/has_visibility_example.dart'),
info: HasVisibilityExample.description,
)
..add(
'Skip TextBoxComponent',
(_) => GameWidget(game: SkipTextBoxComponentExample()),
codeLink: baseLink('components/skip_text_box_component_example.dart'),
info: SkipTextBoxComponentExample.description,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';

class SkipTextBoxComponentExample extends FlameGame {
static const String description = '''
On this example, click on the "Skip" button to display all the text at once.
''';

@override
FutureOr<void> onLoad() {
final textBoxComponent = TextBoxComponent(
text: samplePassage,
position: Vector2(48, 48 * 2),
boxConfig: const TextBoxConfig(
maxWidth: 480,
timePerChar: 0.01,
),
);
addAll([
ButtonComponent(
position: Vector2(48, 48),
button: TextComponent(text: 'Skip'),
onReleased: textBoxComponent.skip,
),
textBoxComponent,
]);
}

static const String samplePassage = '''
Look again at that dot. That's here. That's home. That's us. On it everyone you love, everyone you know, everyone you ever heard of, every human being who ever was, lived out their lives. The aggregate of our joy and suffering, thousands of confident religions, ideologies, and economic doctrines, every hunter and forager, every hero and coward, every creator and destroyer of civilization, every king and peasant, every young couple in love, every mother and father, hopeful child, inventor and explorer, every teacher of morals, every corrupt politician, every "superstar," every "supreme leader," every saint and sinner in the history of our species lived there--on a mote of dust suspended in a sunbeam.
The Earth is a very small stage in a vast cosmic arena. Think of the rivers of blood spilled by all those generals and emperors so that, in glory and triumph, they could become the momentary masters of a fraction of a dot. Think of the endless cruelties visited by the inhabitants of one corner of this pixel on the scarcely distinguishable inhabitants of some other corner, how frequent their misunderstandings, how eager they are to kill one another, how fervent their hatreds.
Our posturings, our imagined self-importance, the delusion that we have some privileged position in the Universe, are challenged by this point of pale light. Our planet is a lonely speck in the great enveloping cosmic dark. In our obscurity, in all this vastness, there is no hint that help will come from elsewhere to save us from ourselves.
The Earth is the only world known so far to harbor life. There is nowhere else, at least in the near future, to which our species could migrate. Visit, yes. Settle, not yet. Like it or not, for the moment the Earth is where we make our stand.
It has been said that astronomy is a humbling and character-building experience. There is perhaps no better demonstration of the folly of human conceits than this distant image of our tiny world. To me, it underscores our responsibility to deal more kindly with one another, and to preserve and cherish the pale blue dot, the only home we've ever known.
— Carl Sagan, Pale Blue Dot, 1994
''';
}
42 changes: 27 additions & 15 deletions packages/flame/lib/src/components/text_box_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class TextBoxConfig {
class TextBoxComponent<T extends TextRenderer> extends TextComponent {
static final Paint _imagePaint = BasicPalette.white.paint()
..filterQuality = FilterQuality.medium;
final TextBoxConfig _boxConfig;
TextBoxConfig boxConfig;
final double pixelRatio;

@visibleForTesting
Expand Down Expand Up @@ -94,7 +94,6 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
/// Callback function to be executed after all text is displayed.
void Function()? onComplete;

TextBoxConfig get boxConfig => _boxConfig;
double get lineHeight => _lineHeight;

TextBoxComponent({
Expand All @@ -112,7 +111,7 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
super.priority,
this.onComplete,
super.key,
}) : _boxConfig = boxConfig ?? const TextBoxConfig(),
}) : boxConfig = boxConfig ?? const TextBoxConfig(),
_fixedSize = size != null,
align = align ?? Anchor.topLeft,
pixelRatio = pixelRatio ??
Expand Down Expand Up @@ -167,7 +166,7 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
void updateBounds() {
lines.clear();
var lineHeight = 0.0;
final maxBoxWidth = _fixedSize ? width : _boxConfig.maxWidth;
final maxBoxWidth = _fixedSize ? width : boxConfig.maxWidth;
for (final word in text.split(' ')) {
final wordLines = word.split('\n');
final possibleLine =
Expand All @@ -177,7 +176,7 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {

_updateMaxWidth(metrics.width);
final bool canAppend;
if (metrics.width <= maxBoxWidth - _boxConfig.margins.horizontal) {
if (metrics.width <= maxBoxWidth - boxConfig.margins.horizontal) {
canAppend = lines.isNotEmpty;
} else {
canAppend = lines.isNotEmpty && lines.last == '';
Expand All @@ -204,18 +203,18 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
}
}

double get totalCharTime => text.length * _boxConfig.timePerChar;
double get totalCharTime => text.length * boxConfig.timePerChar;

bool get finished =>
_lifeTime >= totalCharTime + (_boxConfig.dismissDelay ?? 0);
_lifeTime >= totalCharTime + (boxConfig.dismissDelay ?? 0);

int get _actualTextLength {
return lines.map((e) => e.length).sum;
}

int get currentChar => _boxConfig.timePerChar == 0.0
int get currentChar => boxConfig.timePerChar == 0.0
? _actualTextLength
: math.min(_lifeTime ~/ _boxConfig.timePerChar, _actualTextLength);
: math.min(_lifeTime ~/ boxConfig.timePerChar, _actualTextLength);

int get currentLine {
var totalCharCount = 0;
Expand All @@ -240,7 +239,7 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
Vector2 _recomputeSize() {
if (_fixedSize) {
return size;
} else if (_boxConfig.growingBox) {
} else if (boxConfig.growingBox) {
var i = 0;
var totalCharCount = 0;
final cachedCurrentChar = currentChar;
Expand All @@ -254,13 +253,13 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
return getLineWidth(line, charCount);
}).reduce(math.max);
return Vector2(
textWidth + _boxConfig.margins.horizontal,
_lineHeight * lines.length + _boxConfig.margins.vertical,
textWidth + boxConfig.margins.horizontal,
_lineHeight * lines.length + boxConfig.margins.vertical,
);
} else {
return Vector2(
_boxConfig.maxWidth + _boxConfig.margins.horizontal,
_lineHeight * _totalLines + _boxConfig.margins.vertical,
boxConfig.maxWidth + boxConfig.margins.horizontal,
_lineHeight * _totalLines + boxConfig.margins.vertical,
);
}
}
Expand Down Expand Up @@ -358,7 +357,7 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
_isOnCompleteExecuted = true;
onComplete?.call();
}
if (_boxConfig.dismissDelay != null) {
if (boxConfig.dismissDelay != null) {
removeFromParent();
}
}
Expand All @@ -371,4 +370,17 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
cache?.dispose();
cache = null;
}

/// Force [TextBoxComponent] to display [text] in its entirety.
///
/// It is possible that in the future, one may want to revert timePerChar or
/// even the old [boxConfig] value to its previous value once [onComplete]
/// is called. Such a case might be when a new value of [text] is set.
/// However, this is non-trivial task, so this implementation is intentionally
/// kept simple.
/// If this behavior is needed, the user can simply add the code for setting
/// [boxConfig] by themselves in [onComplete].
void skip() {
boxConfig = boxConfig.copyWith(timePerChar: 0);
}
}
63 changes: 63 additions & 0 deletions packages/flame/test/components/text_box_component_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ void main() {
);
});

test('boxConfig gets set', () {
const firstConfig = TextBoxConfig(maxWidth: 400, timePerChar: 0.1);
const secondConfig = TextBoxConfig(maxWidth: 300, timePerChar: 0.2);
final c = TextBoxComponent(
text: 'The quick brown fox jumps over the lazy dog.',
boxConfig: firstConfig,
);
expect(
c.boxConfig,
firstConfig,
);
c.boxConfig = secondConfig;
expect(
c.boxConfig,
secondConfig,
);
});

test('skip method sets boxConfig timePerChar to 0', () {
const firstConfig = TextBoxConfig(maxWidth: 400, timePerChar: 0.1);
final c = TextBoxComponent(
text: 'The quick brown fox jumps over the lazy dog.',
boxConfig: firstConfig,
);
expect(
c.boxConfig,
firstConfig,
);
c.skip();
expect(c.boxConfig.timePerChar, 0);
// other props are preserved
expect(c.boxConfig.maxWidth, 400);
});

testWithFlameGame(
'setting dismissDelay removes component when finished',
(game) async {
Expand Down Expand Up @@ -142,6 +176,35 @@ lines.''',
expect(lineSize, greaterThan(0));
});

testWithFlameGame('TextBoxComponent skips to the end of text',
(game) async {
final textBoxComponent1 = TextBoxComponent(
text: 'aaa',
boxConfig: const TextBoxConfig(timePerChar: 1.0),
);
await game.ensureAdd(textBoxComponent1);
// forward time by 2.5 seconds
game.update(2.5);
expect(textBoxComponent1.finished, false);
// flush
game.update(0.6);
expect(textBoxComponent1.finished, true);

// reset
await game.ensureRemove(textBoxComponent1);

final textBoxComponent2 = TextBoxComponent(
text: 'aaa',
boxConfig: const TextBoxConfig(timePerChar: 1.0),
);
await game.ensureAdd(textBoxComponent2);
expect(textBoxComponent2.finished, false);
// Simulate running 0.5 seconds before skipping
game.update(0.5);
textBoxComponent2.skip();
expect(textBoxComponent2.finished, true);
});

testGolden(
'Alignment options',
(game) async {
Expand Down

0 comments on commit d1777b7

Please sign in to comment.