stage function

Staged stage(
  1. Level level,
  2. CollisionWorld world, {
  3. required InputState input,
  4. required EntityRegistry registry,
  5. required Inventory inventory,
  6. void onActorSpawned(
    1. Actor actor
    )?,
  7. void onFixture(
    1. Fixture fixture
    )?,
  8. double eyeOffset = 0.7,
  9. double lookSensitivity = 0.0022,
  10. GameRandom? random,
})

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 Hitscan between 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.bake complains 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',
          ),
  );
}