stage function
Turns a level document into a run, given a world it has already been added to.
There were two copies of this, and only one of them shipped. The
application assembled a level in _loadLevel and playthrough_test.dart
assembled its own, and they had already drifted in four places:
- the application shares one
Hitscanbetween the player's weapon and the monsters'; the test built two, so a test of what a shot hits was a test of an arrangement the game does not have; - the application starts the player with all four weapons and 90/30/12 rounds, the test with fists and a pistol and 40 bullets — so "the crypt can be finished" was proved with a loadout the game never gives anybody;
- the application collects what
Navigation.bakecomplains about and prints it, the test discarded it; - the application sets the player's yaw after the fact, in
setState; the test set it in its harness.
The platformer went through the same thing at six copies, and the note its harness still carries is the argument: a harness that is not the game is a harness that agrees with any bug the game has.
This is the shipped assembly, and now it is the only one. What is not
here is everything that needs a graphics device — reading the document,
building the scene, FixtureVisuals, ActorVisuals — because a test has no
device and that is the whole reason the copies existed.
world must already have the level's brushes in it: the application gets
them from LevelLoader, which builds collision and scene together, and a
test calls level.addTo(world). That is the seam where the two differ, and
it is one line on each side rather than sixty.
Implementation
Staged stage(
Level level,
CollisionWorld world, {
required InputState input,
required EntityRegistry registry,
required Inventory inventory,
void Function(Actor actor)? onActorSpawned,
void Function(Fixture fixture)? onFixture,
double eyeOffset = 0.7,
double lookSensitivity = 0.0022,
GameRandom? random,
}) {
final entities = EcsWorld();
// **One generator, shared by everything in this world that rolls.** It was
// three: `ActorSystem` and `Hitscan` each defaulted to an unseeded
// `math.Random`, and `GameSimulation.random` was left null, so `save()` wrote
// no dice at all. A crypt restored from a save agreed about where everything
// stood and disagreed about the first monster that decided whether to flinch
// — which is ARCHITECTURE.md §9.3 not being kept by anything, in the game the document's
// performance budgets are written for.
final dice = random ?? GameRandom(1);
final projectiles = ProjectileSystem(world: world, entities: entities);
final actors = ActorSystem(world: world, entities: entities, random: dice);
// Baked from the level's brushes and deliberately not from the collision
// world: that holds the doors and the lift, and whichever position they
// happen to be in at load would be frozen into the grid — a closed door
// becoming a wall nothing ever paths through again.
//
// Quarter-metre cells, not the default half. Measured on the crypt: at half a
// metre a one-metre corridor is two cells, both of them touching a wall, so
// every cell in it has a clearance of one — and a monster 0.7 wide, which
// physically fits, is refused the whole passage. The grid then silently falls
// back to walking straight at the player in exactly the places a route is
// worth having. Four times the cells and twice the bake, both load-time and
// both small.
final navIssues = <LevelIssue>[];
final navigation = Navigation.bake(level, cellSize: 0.25, issues: navIssues);
actors.navigation = navigation;
// One ray-caster and one shot for the whole world. The application already
// shared the ray-caster and built the shot twice; sharing both is the same
// statement made once.
final hitscan = Hitscan(world: world, random: dice);
final shot = WeaponShot(
world: world,
hitscan: hitscan,
projectiles: projectiles,
);
// The bestiary is attached here rather than at construction because it needs
// a world to put monsters in, and there is no world until the level has
// loaded. A registry with no monster kind in it — which is how a test says
// "leave the fight out" — simply is not told, and the three in the document
// spawn nothing.
(registry[ShooterEntities.monster] as MonsterKind?)?.bestiary = Bestiary(
actors: actors,
shot: shot,
catalog: Monsters.byName,
);
final mechanisms = MechanismWorld(world);
level.spawnInto(
SpawnContext(
world: world,
actors: actors,
mechanisms: mechanisms,
onActorSpawned: onActorSpawned,
onFixture: onFixture,
),
registry: registry,
);
final spawn = level.playerStart;
final start = spawn?.position ?? Vector3.zero();
// Who the collider *is*, rather than what it happens to be carrying. A locked
// door reads the keys off the player and a rocket asks the player to take
// damage, without the physics knowing what either is.
final player = Player(
body: CharacterController(
world: world,
// Lifted by half the body height: a spawn is authored where the player's
// feet go, which is the only place an author can see.
position: start + Vector3(0.0, 0.9, 0.0),
),
inventory: inventory,
eyeOffset: eyeOffset,
lookSensitivity: lookSensitivity,
)..yaw = spawn?.yaw ?? 0.0;
return Staged(
entities: entities,
projectiles: projectiles,
actors: actors,
mechanisms: mechanisms,
hitscan: hitscan,
shot: shot,
player: player,
start: start,
navIssues: navIssues,
sim:
GameSimulation(
random: dice,
player: player,
collision: world,
input: input,
mechanisms: mechanisms,
actors: actors,
projectiles: projectiles,
shot: shot,
levelNext: level.next,
)
..automap = Automap(navigation.grid)
// Walls crumble; floors, ceilings and the stone of the crypt's
// fixtures do not. A rocket through the floor is a player out of the
// level, and a ceiling with a hole in it looks out on nothing.
..breaches = Breaches(
level,
world,
breakable: (Brush brush) =>
brush.solid && brush.ramp == null && brush.material == 'wall',
),
);
}