Transforms and hierarchy¶
Layer: Child/Parent are kernel; Transform2D/WorldTransform2D are goo2d
Transform2D¶
Five columns — position, scale, rotation:
class Player extends EntityStruct with Transform2D {
// transformOffsetX, transformOffsetY (float64, default 0)
// transformScaleX, transformScaleY (float64, default 1)
// transformRotation (float64, radians, default 0)
}
transform
..transformOffsetX[entity] = 100
..transformOffsetY[entity] = -40
..transformRotation[entity] = math.pi / 4
..transformScaleX[entity] = 2
..transformScaleY[entity] = 2;
Positive Y is up
A larger transformOffsetY draws higher on the screen, and a floor sits
at a smaller y than the things falling onto it. Box2D's own examples and
goo3d agree, so gravity is (0, -10) here as it is everywhere else.
Flutter's canvas is y-down, but that is the camera's problem, not yours:
CameraProjection is the one place the sign is turned round, and
everything that maps between world and screen — drawing, pointer picking,
world-space HUD markers — goes through it.
Positive rotation is therefore counter-clockwise, the direction
atan2, sin and cos already assume.
Scale defaults to 1 instead of to the field's own 0, because a zero scale
collapses every point to the origin — an entity that simply never assigned a
scale would be invisible with nothing anywhere saying why.
Helpers¶
Transform2D carries Unity-Transform-style helpers that operate on local
values:
final d = transform.distanceTo(a, b); // local-space distance
transform.lookAt(entity, targetX, targetY);
Each resolves every Entity argument's own Transform2D fresh, instead of
reading through the receiver — a second entity may belong to a different
archetype with a different row layout entirely, and reading it through the wrong
one would silently address the wrong storage.
For world-space equivalents, use WorldTransform2D's fields.
The hierarchy¶
Child and Parent are kernel components. Child links an entity to its
parent; Parent marks an entity that can own children.
class Body() extends EntityStruct with Transform2D, WorldTransform2D, Child, Parent;
class Limb() extends EntityStruct with Transform2D, WorldTransform2D, Child;
Spawn into the hierarchy directly:
final body = scene.addEntity(bodyPrefab, parent: hubEntity);
for (var i = 0; i < 3; i++) {
scene.addEntity(limbPrefab, parent: body);
}
Or link afterwards. Each operation is on the entity it is about:
parentEntity<Parent>().addChild(childEntity); // childEntity must be unparented
parentEntity<Parent>().adopt(childEntity); // move it here from wherever it is
parentEntity<Parent>().removeChild(childEntity);// destroys it and its subtree
childEntity<Child>().detach(); // unlink, keep it alive as a root
removeChild destroys. Reach for detach when the entity has to outlive its
parent, and for adopt when it is moving — remove-then-add no longer means
reparent.
A link that would close a loop is refused where it is made: addChild and
adopt walk up from the prospective parent, and throw if they reach the child.
Child holds parent, nextSibling and prevSibling; Parent holds
firstChild and lastChild. They are readable columns like any other, so
walking a subtree by hand is an ordinary loop:
var next = parent.firstChild[entity];
while (next != null) {
// ...
next = next.get<Child>().nextSibling[next];
}
entity.destroy() destroys the whole subtree. One call on a body takes its
limbs with it.
Children a prefab always has¶
A turret that is a base plus a barrel is a fact about the turret, not about
whoever spawns it. EntityStruct.of says so in the prefab:
class Barrel extends EntityStruct with Transform2D, WorldTransform2D, Child;
class Turret extends EntityStruct with Transform2D, WorldTransform2D, Parent {
final barrel = EntityStruct.of(Barrel.new);
}
Spawning a Turret spawns a Barrel, links it under the turret, and destroying
the turret takes it with it. Declarations nest, so a Barrel with children of
its own gets them too.
The field holds the child's prefab, which is one object for the whole
archetype — the same thing descriptor.has(Turret.new) hands a scene. Which
barrel belongs to which turret is per-entity state, so it lives in a column, and
the parent is what you ask:
Barrel.new and not <Barrel>(), for the same reason descriptor.has takes a
constructor: the child's own field initialisers declare columns, so a descriptor
has to be open before the object exists. A constructor with arguments goes in a
closure, EntityStruct.of(() => Barrel(bore: 5)).
The declarer must mix in Parent and the declared type must mix in Child.
Both are reported when the scene registers, along with a struct that declares
itself — which would spawn forever.
WorldTransform2D¶
Mix it in and WorldTransformSystem composes a world-space transform for the
entity from its own local transform and its ancestors':
final world = entity.get<WorldTransform2D>();
final x = world.worldX[entity]; // composed, read-only
final y = world.worldY[entity];
The renderer and the camera read world transforms, so anything that draws or sees needs this component.
This is the whole point of the hierarchy
A limb's own Transform2D is a constant local offset, written once at
spawn and never touched again. Everything you see it do — orbiting its
body, travelling with it, swinging as the body turns — is
WorldTransformSystem composing that constant against its ancestors. No
system writes a limb after it is created.
Rotating one hub entity moves an entire swarm, and the only line of code that runs is the one writing the hub's rotation.
Phase ordering¶
WorldTransformSystem reads local transforms and writes world ones, so a system
that writes local transforms must run before it:
class MovementSystem extends GameSystem with FixedTickable {
@override
int compareTo(GameSystem other) => other is WorldTransformSystem ? -1 : 0;
}
Declaring it the other way round shows every entity one frame behind its own
parent. Consumers of world transforms — the renderer, picking — run after the
fixed tick has committed, which is the same discipline Unity's
PresentationSystemGroup-after-SimulationSystemGroup split enforces.
Building a scene graph¶
class Hub() extends EntityStruct with Transform2D, WorldTransform2D, Parent;
class Critter() extends EntityStruct
with Transform2D, WorldTransform2D, Child, Parent, Renderable2D;
class Limb() extends EntityStruct
with Transform2D, WorldTransform2D, Child, Renderable2D;
Child because it hangs off something, Parent because it owns something,
WorldTransform2D because its children need a composed transform to compose
against. That full set is what a scene graph costs.
Several children in one tick
addChild reads the tail of the child chain to append to it. An ordinary
read returns the last published snapshot, so three addChild calls in
one tick would each read the same stale tail and orphan all but the last.
The hierarchy handles this internally by reading the pending slot, and a
prefab declaring three children with EntityStruct.of is that case every
time. If you build a linked structure across component rows yourself,
expect the same problem — and fix it by placement (a later phase), not by
inventing a second read path.
Camera following¶
A camera is an entity with Transform2D, WorldTransform2D and Camera, so
"the camera follows the player" is just parenting:
final player = scene.addEntity(playerPrefab);
final camera = scene.addEntity(eyePrefab, parent: player);
eye.view[camera] = game.defaultCamera;
The camera's own local transform stays at the origin, and WorldTransformSystem
puts it wherever the player is. For a lagging or smoothed camera, write its
transform from a system that runs after movement instead.