Skip to content

Commit b170299

Browse files
authored
feat: Add dynamic layer opacity support to flame_tiled (#3843)
`flame_tiled` supported reading layer opacity from `.tmx` files at load time, but there was no way to change a layer's opacity at runtime. This PR adds `setLayerOpacity` and `getLayerOpacity` methods to `RenderableTiledMap`, enabling users to dynamically adjust layer opacity after the map has loaded. - `RenderableLayer.opacity` is converted from a late field (computed once at init) to a getter/setter pair. The getter returns the effective opacity (own × parent chain); the setter stores the own opacity and fires onOpacityChanged(). - A protected `onOpacityChanged()` hook is added to `RenderableLayer`. `FlameTileLayer` overrides it to rebuild its cached Paint object; `GroupLayer` overrides it to propagate the call recursively to all children so nested tile layers stay in sync. - `RenderableTiledMap` gains `setLayerOpacity(int layerIndex, {required double opacity})` and `getLayerOpacity(int layerIndex)`. - `FlameImageLayer` required no changes — it already reads opacity live on every render() call.
1 parent b8e2bab commit b170299

5 files changed

Lines changed: 148 additions & 3 deletions

File tree

packages/flame_tiled/lib/src/renderable_layers/group_layer.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,11 @@ class GroupLayer extends RenderableLayer<Group> {
4747
child.update(dt);
4848
}
4949
}
50+
51+
@override
52+
void onOpacityChanged() {
53+
for (final child in children) {
54+
child.onOpacityChanged();
55+
}
56+
}
5057
}

packages/flame_tiled/lib/src/renderable_layers/renderable_layer.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,20 @@ abstract class RenderableLayer<T extends Layer> {
113113

114114
late double offsetY = layer.offsetY * scaleY + (parent?.offsetY ?? 0);
115115

116-
late double opacity = layer.opacity * (parent?.opacity ?? 1);
116+
double get opacity => layer.opacity * (parent?.opacity ?? 1);
117+
118+
set opacity(double value) {
119+
layer.opacity = value;
120+
onOpacityChanged();
121+
}
122+
123+
/// Called after [opacity] is changed.
124+
///
125+
/// Override to react to opacity updates (e.g. to rebuild a cached paint).
126+
/// When overriding inside a container layer, propagate the call to children
127+
/// so that descendant layers with cached paints are also updated.
128+
@protected
129+
void onOpacityChanged() {}
117130

118131
late double parallaxX = layer.parallaxX * (parent?.parallaxX ?? 1);
119132

packages/flame_tiled/lib/src/renderable_layers/tile_layers/tile_layer.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import 'package:meta/meta.dart';
3333
/// {@endtemplate}
3434
@internal
3535
abstract class FlameTileLayer extends RenderableLayer<TileLayer> {
36-
late final _layerPaint = layerPaintFactory(opacity);
36+
late Paint _layerPaint;
3737
final TiledAtlas tiledAtlas;
3838
late List<List<MutableRSTransform?>> transforms;
3939
final animations = <TileAnimation>[];
@@ -51,7 +51,14 @@ abstract class FlameTileLayer extends RenderableLayer<TileLayer> {
5151
required this.ignoreFlip,
5252
required this.layerPaintFactory,
5353
super.filterQuality,
54-
});
54+
}) {
55+
_layerPaint = layerPaintFactory(opacity);
56+
}
57+
58+
@override
59+
void onOpacityChanged() {
60+
_layerPaint = layerPaintFactory(opacity);
61+
}
5562

5663
/// {@macro flame_tile_layer}
5764
static FlameTileLayer load({

packages/flame_tiled/lib/src/renderable_tile_map.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ class RenderableTiledMap {
9090
return map.layers[layerId].visible;
9191
}
9292

93+
/// Changes the opacity of the layer at [layerIndex] to [opacity].
94+
///
95+
/// [opacity] must be between 0.0 (fully transparent) and 1.0 (fully opaque).
96+
void setLayerOpacity(int layerIndex, {required double opacity}) {
97+
assert(
98+
opacity >= 0.0 && opacity <= 1.0,
99+
'opacity must be between 0.0 and 1.0',
100+
);
101+
final renderableLayer = renderableLayers[layerIndex];
102+
renderableLayer.opacity = opacity;
103+
}
104+
105+
/// Gets the opacity of the layer at [layerIndex].
106+
double getLayerOpacity(int layerIndex) {
107+
return renderableLayers[layerIndex].opacity;
108+
}
109+
93110
/// Changes the Gid of the corresponding layer at the given layerId,
94111
/// if different
95112
void setTileData({

packages/flame_tiled/test/tiled_test.dart

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flame/extensions.dart';
66
import 'package:flame/flame.dart';
77
import 'package:flame/game.dart';
88
import 'package:flame_tiled/flame_tiled.dart';
9+
import 'package:flame_tiled/src/renderable_layers/group_layer.dart';
910
import 'package:flame_tiled/src/renderable_layers/tile_layers/tile_layer.dart';
1011
import 'package:flutter/services.dart';
1112
import 'package:flutter_test/flutter_test.dart';
@@ -1141,4 +1142,104 @@ void main() {
11411142
expect(renderableTiledMap.getTileData(layerId: 5, x: 1, y: 1), isNull);
11421143
});
11431144
});
1145+
1146+
group('RenderableTiledMap.LayerOpacity', () {
1147+
late RenderableTiledMap renderableTiledMap;
1148+
1149+
setUp(() async {
1150+
Flame.bundle = TestAssetBundle(
1151+
imageNames: ['map-level1.png'],
1152+
stringNames: ['layers_test.tmx'],
1153+
);
1154+
renderableTiledMap = await RenderableTiledMap.fromFile(
1155+
'layers_test.tmx',
1156+
Vector2.all(32),
1157+
bundle: Flame.bundle,
1158+
);
1159+
});
1160+
1161+
test('getLayerOpacity returns 1.0 by default', () {
1162+
expect(renderableTiledMap.getLayerOpacity(0), equals(1.0));
1163+
});
1164+
1165+
test('setLayerOpacity changes the layer opacity', () {
1166+
renderableTiledMap.setLayerOpacity(0, opacity: 0.5);
1167+
expect(renderableTiledMap.getLayerOpacity(0), equals(0.5));
1168+
});
1169+
1170+
test('setLayerOpacity sets opacity to 0', () {
1171+
renderableTiledMap.setLayerOpacity(0, opacity: 0.0);
1172+
expect(renderableTiledMap.getLayerOpacity(0), equals(0.0));
1173+
});
1174+
1175+
test('setLayerOpacity sets opacity to 1', () {
1176+
renderableTiledMap.setLayerOpacity(0, opacity: 0.25);
1177+
renderableTiledMap.setLayerOpacity(0, opacity: 1.0);
1178+
expect(renderableTiledMap.getLayerOpacity(0), equals(1.0));
1179+
});
1180+
1181+
test('setLayerOpacity asserts on out-of-range value', () {
1182+
expect(
1183+
() => renderableTiledMap.setLayerOpacity(0, opacity: 1.5),
1184+
throwsA(isA<AssertionError>()),
1185+
);
1186+
expect(
1187+
() => renderableTiledMap.setLayerOpacity(0, opacity: -0.1),
1188+
throwsA(isA<AssertionError>()),
1189+
);
1190+
});
1191+
});
1192+
1193+
group('RenderableTiledMap.LayerOpacity nested groups', () {
1194+
// map.tmx layer structure (renderableLayers indices):
1195+
// 0: FlameTileLayer "Ground" (opacity 1.0)
1196+
// 1: GroupLayer "Background" (opacity 0.8)
1197+
// └─ FlameTileLayer "Sky tiles" (own opacity 0.9, effective 0.72)
1198+
// 2: FlameImageLayer "Image Layer 2" (opacity 1.0)
1199+
// 3: FlameImageLayer "Sky artifact" (opacity 0.2)
1200+
late RenderableTiledMap renderableTiledMap;
1201+
1202+
setUp(() async {
1203+
Flame.bundle = TestAssetBundle(
1204+
imageNames: ['image1.png', 'map-level1.png'],
1205+
stringNames: ['map.tmx'],
1206+
);
1207+
renderableTiledMap = await RenderableTiledMap.fromFile(
1208+
'map.tmx',
1209+
Vector2.all(16),
1210+
bundle: Flame.bundle,
1211+
);
1212+
});
1213+
1214+
test('child effective opacity is parent * own', () {
1215+
// GroupLayer opacity=0.8, child TileLayer own opacity=0.9 → 0.72
1216+
final group = renderableTiledMap.renderableLayers[1] as GroupLayer;
1217+
final child = group.children.first as FlameTileLayer;
1218+
expect(child.opacity, closeTo(0.72, 1e-6));
1219+
});
1220+
1221+
test('setting GroupLayer opacity updates child effective opacity', () {
1222+
// Change group from 0.8→0.5; child own opacity stays 0.9→effective 0.45
1223+
renderableTiledMap.setLayerOpacity(1, opacity: 0.5);
1224+
final group = renderableTiledMap.renderableLayers[1] as GroupLayer;
1225+
expect(group.opacity, equals(0.5));
1226+
final child = group.children.first as FlameTileLayer;
1227+
expect(child.opacity, closeTo(0.45, 1e-6));
1228+
});
1229+
1230+
test('setting GroupLayer opacity to 0 makes child fully transparent', () {
1231+
renderableTiledMap.setLayerOpacity(1, opacity: 0.0);
1232+
final group = renderableTiledMap.renderableLayers[1] as GroupLayer;
1233+
final child = group.children.first as FlameTileLayer;
1234+
expect(child.opacity, equals(0.0));
1235+
});
1236+
1237+
test('sibling layers are not affected by group opacity change', () {
1238+
renderableTiledMap.setLayerOpacity(1, opacity: 0.1);
1239+
// "Ground" at index 0 has no parent, so remains 1.0
1240+
expect(renderableTiledMap.getLayerOpacity(0), equals(1.0));
1241+
// "Sky artifact" at index 3 is independent
1242+
expect(renderableTiledMap.getLayerOpacity(3), equals(0.2));
1243+
});
1244+
});
11441245
}

0 commit comments

Comments
 (0)