What a shooter adds
flutter3d_game knows what a body, a brain, a mechanism and a step are. What a monster is, what a shotgun does, what a medkit gives, and what order a shooter's step runs in are here. A platformer gets none of it.
The line is the one the game layer already drew: machinery stays, vocabulary moves.
| Machinery (stays in core) | Vocabulary (lives here) |
|---|---|
Mechanism, Actor, ActorSystem |
Inventory, Arsenal, Gift |
MechanismEvents.taken, the level format |
Pickup, PickupKind, ShooterKeyKind, NoteKind |
Brain, Mind, navigation |
ChaseBrain, MonsterDef, Bestiary |
CharacterController, CollisionWorld |
Player, WeaponDef, WeaponShot |
FixedStep, Snapshot |
GameSimulation, the step order |
Nothing here imports the renderer or names Flutter. WeaponView and the readouts do, and they live in bridge.dart for that reason; tool/structure.dart holds the split.
The pieces #
flowchart TB
subgraph combat["combat"]
weapon["WeaponDef<br><i>damage, rate, spread, falloff</i>"]
behaviour["WeaponBehaviour<br><i>hitscan · melee · projectile</i>"]
shot["WeaponShot<br><i>one call site for all three</i>"]
hitscan["Hitscan"]
proj["ProjectileSystem"]
blast["BlastResolver"]
weapon --> behaviour --> shot
shot --> hitscan
shot --> proj --> blast
end
subgraph carry["what the player carries"]
inv["Inventory"]
arsenal["Arsenal<br><i>slots, ammo, cooldown</i>"]
health["Health"]
keys["KeyRing"]
inv --> arsenal & health & keys
end
subgraph mobs["monsters"]
def["MonsterDef"]
brain["ChaseBrain<br><i>idle · alert · chase<br>attack · hurt · dead</i>"]
bestiary["Bestiary"]
def --> brain --> bestiary
end
sim["GameSimulation<br><i>the step order</i>"]
sim --> combat
sim --> carry
sim --> mobs
Weapons #
A WeaponDef is a value. Everything about how a weapon feels is a number in one of these, and a game's whole arsenal is a const list.
static const WeaponDef shotgun = WeaponDef(
name: 'Shotgun',
behavior: HitscanBehavior(),
ammo: AmmoType.shells,
damage: 11.0, // per pellet
shotsPerSecond: 1.4,
rayCount: 8, // eight pellets
spread: 0.10,
range: 60.0,
falloffStart: 6.0, // full damage up to here
falloffEnd: 26.0, // minimumDamageFraction beyond here
minimumDamageFraction: 0.2,
knockback: 2.0,
);
Three behaviours, one call site #
WeaponBehavior is sealed, and WeaponShot is where all three are delivered from, so a monster's claws and a player's rocket launcher go through the same code.
| Behaviour | What it does |
|---|---|
HitscanBehavior |
rayCount rays with spread, damage falling off between falloffStart and falloffEnd |
MeleeBehavior(arc: 1.22) (radians, 70°) |
A sphere sweep in an arc, blocked by geometry |
ProjectileBehavior |
Spawns into ProjectileSystem at projectileSpeed, detonating with a Blast |
final shot = WeaponShot(world: collision, hitscan: hitscan, projectiles: projectiles);
shot.begin(weapon, origin, aim, shooter: player.body.collider);
weapon.behavior.deliver(shot);
Falloff is per weapon, not global #
final double dealt = weapon.damageAt(distance);
A pistol keeps most of its damage to 70 m; a shotgun is down to a fifth of it by 26 m. That single method is what makes closing the distance a decision.
The arsenal #
// `Weapons` and `Monsters` are the dungeon's own roster, from the repository's
// unpublished `flutter3d_demo_content`; a game writes its own the same way.
import 'package:flutter3d_demo_content/shooter_sample.dart';
final arsenal = Arsenal(
slots: Weapons.all, // slot n is all[n]
owned: <WeaponDef>[Weapons.fists, Weapons.pistol],
ammo: <AmmoType, int>{AmmoType.bullets: 40},
startingSlot: 0,
);
arsenal.advanceTime(dt);
if (arsenal.wantsToFire(held: input.held(ShooterActions.fire),
pressed: input.pressed(ShooterActions.fire))) {
final WeaponDef? fired = arsenal.fire(); // null if on cooldown or dry
}
arsenal.fallBackIfEmpty();
wantsToFire takes both the held and pressed states because automatic is a property of the weapon: a pistol reads pressed, a shotgun reads held, and neither call site has to know which.
The inventory #
One object rather than four fields, so a pickup has somewhere to give something to, the HUD has one thing to read, and it can hang off the player's collider, which is how a locked door asks what the body in front of it is holding.
final inventory = Inventory(arsenal: arsenal, maxArmor: 200.0);
inventory.addArmor(25.0);
inventory.empower('invulnerability', 20.0);
inventory.has('berserk');
inventory.remainingOf('invulnerability');
inventory.damage(18.0); // armor first, then health
inventory.step(dt); // power-ups tick down
Gifts #
What a pickup gives is a Gift, and a game composes its own registry. A hierarchy instead of an enum with a switch, because every job that treats them differently grows its own switch over the same names in a different file, and the switches drift.
final GiftRegistry gifts = GiftRegistry(<Gift>[
const HealthGift(),
const ArmorGift(),
const AmmoGift('bullets', AmmoType.bullets, defaultAmount: 20.0),
const AmmoGift('shells', AmmoType.shells, defaultAmount: 8.0),
const AmmoGift('rockets', AmmoType.rockets, defaultAmount: 4.0),
const KeyGift(),
const PowerUpGift('invulnerability', defaultAmount: 20.0),
const PowerUpGift('berserk', defaultAmount: 30.0),
]);
A Gift answers two questions: grantTo(GiftGrant(to: inventory, amount: amount, detail: detail)), did it do anything, and announce(GiftAnnounce(amount: amount, detail: detail)), what does the HUD say. A pickup that grants nothing is refused rather than silently consumed, so walking over a medkit at full health leaves the medkit there.
Monsters #
static const MonsterDef runner = MonsterDef(
name: 'runner',
health: 45.0,
speed: 5.4,
radius: 0.35,
height: 1.7,
sightRange: 24.0,
attack: WeaponDef(
name: 'claws',
behavior: MeleeBehavior(),
ammo: AmmoType.none,
damage: 9.0,
shotsPerSecond: 1.6,
range: 1.9,
automatic: true,
),
);
A monster's attack is a WeaponDef, the same type the player's shotgun is. That is what makes a fireball, a claw and a rocket one code path.
The brain #
stateDiagram-v2
[*] --> idle
idle --> alert: sees the player
alert --> chase: alertSeconds elapsed
chase --> attack: in range and in sight
attack --> chase: out of range
chase --> idle: lost them
idle --> hurt: took damage, painChance roll passed
chase --> hurt: took damage, painChance roll passed
attack --> hurt: took damage, painChance roll passed
hurt --> chase: hurtSeconds elapsed
chase --> dead: health reached zero
attack --> dead: health reached zero
hurt --> dead: health reached zero
dead --> [*]
alertSeconds is the pause between noticing and moving. That beat makes a monster look as if it decided to move, where without it the monster would jump straight into motion. painChance and painCooldownSeconds decide whether being shot interrupts what it was doing: a tank flinches 15% of the time and no more often than every 1.4 s, so it keeps coming.
The bestiary #
final bestiary = Bestiary(
actors: actors,
shot: WeaponShot(world: collision, hitscan: hitscan, projectiles: projectiles),
catalog: Monsters.byName,
);
// The entity kind needs a world to put monsters in, and there is none until
// the level has loaded, so it is attached here rather than at construction.
(kinds[ShooterEntities.monster] as MonsterKind?)?.bestiary = bestiary;
The step order #
This is the part that is easy to get wrong, and nothing on screen shows it when you do.
void step(double dt) {
// 1. read input into a wish direction (a dead player's input moves nothing)
// 2. mechanisms.step: doors and lifts move
// 3. collision.reindex: the broadphase catches up
// 4. dynamics.step: crates fall onto where the lift is now
// 5. player.body.step: the player sweeps against all of it
// 6. dynamics.push: what they shove depends on where they ended up
// 7. collision.update / clearKinematicDeltas
// 8. the use key, then mechanisms.publish(), then read the exits
// 9. inventory.step, then the weapon
// 10. actors.step, then damage to the player
}
Two of those orderings are under test and the rest are ordering by argument; the code says which. clearKinematicDeltas after body.step is tested. reindex before body.step is not, and does not pretend to be: the narrow phase reads live positions and a broadphase cell is four metres, so a stale index loses a mover only if it left its cell inside one step, which nothing that calls itself a door does.
publish() comes after the use key, because the use key can start a door. Publishing before it would report that door a step late, every time.
Events, drained after the step #
sim.firedThisStep; // WeaponDef?, for a muzzle flash and a sound
sim.hits; // List<ShotHit> — impact marks
sim.usedThisStep; // ActivationOutcome? — "it is locked" on the HUD
sim.damageTakenThisStep; // the red flash
actors.died; // corpses, a kill counter
actors.hurtThisStep;
projectiles.detonations; // explosions
mechanisms.events.messages;
Lists filled during the step and drained by the caller after it. A Stream would deliver after the step that produced the event, which is the one property the fixed step exists to protect.
Two things a level may carry #
Both optional, both null by default, and both in the snapshot when present.
sim
..automap = Automap(navigation.grid)
..breaches = Breaches(level, collision,
breakable: (Brush b) => b.solid && b.ramp == null && b.material == 'wall');
The step reveals the automap around the player while the game is being played, and a detonation with a surface normal hands breaches.blast the hit, so a rocket against a wall the game calls breakable cuts it. The dungeon lets only its wall material break, which is what the predicate is for: a floor with a hole in it is a different game. Both are described on the simulation page.
Ready to build one? #
The shooter tutorial assembles all of this into a playable game in fourteen steps.