jengine-gunner
A top-down run-and-gun: a battlefield that scrolls under you, aim decoupled from movement, grenades that arc over cover, and prisoners worth going back for.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-gunner |
| Version | 0.1.0 |
| Entry point | jengine_gunner::GunnerPackage |
| Script namespace | Gunner |
| Listed by | projects/arcade-classics, projects/shooter-classics |
Using it
Section titled “Using it”List it in the top-level "packages" array of project.jengine:
{ "packages": [ { "name": "jengine-gunner", "source": { "type": "path", "path": "../../packages/jengine-gunner" } } ]}Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the
editor’s Rebuild viewport banner). Native and web builds link it on their own.
A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field
for a package component and keeps it as inert custom data.
A script can check that the package is linked with if Gunner == nil then return end.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
gun_war | GunWar | package only: needs a v2 scene |
gun_skirmish | GunSkirmish | package only: needs a v2 scene |
gun_arena | GunArena | package only: needs a v2 scene |
Script commands
Section titled “Script commands”Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.
| Call | Flat name |
|---|---|
Gunner.gun_new_game(e) | gun_new_game |
Gunner.gun_who(e, id) | gun_who |
Gunner.gun_next_map(e) | gun_next_map |
Gunner.gun_input(e, dx, dy, fire, grenade, lock) | gun_input |
Gunner.skirmish_new_game(e) | skirmish_new_game |
Gunner.skirmish_carry(e, score, lives) | skirmish_carry |
Gunner.skirmish_stage(e, stage) | skirmish_stage |
Gunner.skirmish_revive(e) | skirmish_revive |
Gunner.skirmish_reached_end(e) | skirmish_reached_end |
Gunner.skirmish_aim(e, dx, dy, fire, lock) | skirmish_aim |
Gunner.arena_setup(e, setup) | arena_setup |
Gunner.arena_clear(e) | arena_clear |
Gunner.arena_input(e, seat, mx, my, ax, ay, fire, select) | arena_input |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/delta.sceneprojects/arcade-classics/assets/scenes/rain1.sceneprojects/arcade-classics/assets/scenes/rain2.sceneprojects/arcade-classics/assets/scenes/rain3.sceneprojects/shooter-classics/assets/scenes/arena.scene
From packages/jengine-gunner/README.md.
A top-down run-and-gun: a front that creeps north whether you do or not, aim that comes apart from movement, and grenades that go where bullets cannot.
Used by three games: projects/arcade-classics’ Brass Delta, seen
from above, and Steel Rain, seen from the side - and
projects/shooter-classics’ Frag Arena, a top-down Quake III deathmatch
(the deathmatch module, which the online room
projects/shooter-classics/server/games/arena runs too).
| Module | What it is |
|---|---|
arms | Aiming and weapons, in no particular view. Shared by both games. |
field | Brass Delta’s ground, and the view that crawls up it. |
war | Brass Delta’s tick, at a fixed 60 Hz. |
skirmish | Steel Rain’s fight: what jengine-platformer does not do. |
side | The gun_skirmish component, which reads a platformer hero and never writes it. |
content | Both games’ data tables. A map is an ASCII drawing. |
deathmatch | Frag Arena: the gun_arena component - a deathmatch for up to eight at a fixed 60 Hz, weapons and items on timers, pads, teleporters, and bots at three levels. |
Two games, one set of rules
Section titled “Two games, one set of rules”arms exists because the two games share almost nothing about the world -
one has water and huts, the other gravity and ledges - and share exactly
the rules about pointing and shooting. So those live there once:
- where you are pointing is not where you are walking, and a lock holds it while you move;
- a weapon has a rate, and pulling faster than the rate does nothing;
- a spread is several shots laid out evenly about one direction.
When the top-down game was refactored onto it, its 27 tests passed unchanged - which is the only evidence worth having that an extraction is real rather than a second copy with the same comments.
The side-on game is two packages over one hero
Section titled “The side-on game is two packages over one hero”Steel Rain has no package of its own. It is:
jengine-platformer, unmodified, deciding where the hero is - the running, the jump, the coyote time, the slopes, the ledges;gun_skirmishhere, deciding whether the hero is alive - the aim, the weapons, the pods, everyone shooting back, the boss.
The arrangement holds because of one restraint: this package reads the
hero’s Transform by name each tick and never writes it. There is no
&mut Transform anywhere in side.rs, and
the_skirmish_reads_the_hero_and_never_moves_it fails if one appears. It
also reads PlatformerState.facing rather than deciding which way the hero
faces, so the sprite, the aim and the platformer all quote one number.
Rule one: aim is not movement
Section titled “Rule one: aim is not movement”You face where you walk — until you hold the lock, and then you keep facing where you were while you move. That single modifier is the difference between Commando and Ikari Warriors, and it is what lets a player back out of a corridor still shooting what is in it.
if self.commando.walking && !input.lock { self.commando.ax = dx / len; self.commando.ay = dy / len;}The game publishes aim_x / aim_y separately from the position for one
reason: the sprite has to be rotated by the aim, not by the walk. Do it
the other way and the picture contradicts the rules every time the lock is
used, which is worse than drawing no direction at all.
Rule two: a grenade goes where a bullet cannot
Section titled “Rule two: a grenade goes where a bullet cannot”| walk through | bullet through | grenade clears | |
|---|---|---|---|
| open | yes | yes | — |
| cover | no | no | yes |
| wall | no | no | no |
| water | no | yes | no |
| hut | no | no | yes |
Take the cover row away and there is nothing to be pinned behind and no reason to throw anything. A grenade is in the air the whole way: it consults nothing it passes over, lands, and clears the cover under it — which is also how a hut comes off a prisoner.
tests/rules.rs pins the whole grid, and both halves of the pair: the
bullet that dies on the sandbags and the grenade that takes them away.
The front
Section titled “The front”It creeps north on its own, hurries when the player pushes into the top of the view, and never retreats. That last one is not a nicety: an enemy that falls off the bottom is dropped, and a view that went back would bring it with it. Enemies wake as the front reaches them, so a map does not send its whole garrison at you from the first second.
Content
Section titled “Content”gun_commandos, gun_enemies, gun_maps. The maps in Brass Delta are
built from eight-row bands — a band is one tactical idea (a sandbag
line, a crossing, a camp, a redoubt) and a map is a sequence of them
getting harder.
Content::load refuses a map with nowhere to start, a letter the
enemies list does not explain (it would quietly become open ground), and
a prisoner standing outside a hut — freeing one means blowing the hut
off them, so a prisoner in the open is one nobody can ever rescue.
The deathmatch: rule one, taken to its end
Section titled “The deathmatch: rule one, taken to its end”Frag Arena is a twin-stick: the aim is a stick of its own, which is rule one
of arms - where you point is not where you walk - with nothing left of
the walk in it. Every gun is an arms::Trigger, so the rate that makes a
railgun a railgun and the spread laid evenly about the aim that makes a
shotgun aimable are the same lines Brass Delta shoots with.
What is new is the timers. A mega health back every thirty-five seconds
and a quad damage every minute are not scenery: who is standing on them
when they come back wins. So the top bot level times them - it goes for an
item that is not there yet because it will be by the time it arrives - and
the levels differ only in a data row (reaction, aim wander, turn rate,
lead, sidestep, timing), ranked by the_levels_rank_rookie_veteran_nightmare
(rookie 36, veteran 111, nightmare 240 frags over fifteen free-for-alls).
| stops feet | stops shots | ||
|---|---|---|---|
# wall | yes | yes | |
: rail | yes | no | shoot over it, walk round it |
~ pit | a fall | no | a death; your frag, or whoever shoved you in |
1..9 are the ends of a jump pad ("pad 1 2": through the air, over
walls and pits - rockets and splash pass beneath you, a hitscan gun does
not) or a teleporter ("tele 3 4",
which telefrags whoever stands at the far end). A link’s start is a ride,
not a floor: stepping on it takes it, so the bots’ distance fields reach a
link’s start only through its far end, and a map whose landing cell is
another link’s start is refused rather than half-walkable.
Component ("version": 2 scenes)
Section titled “Component ("version": 2 scenes)”"gun_arena": { "tables": { "weapons": "data/arena_weapons", "items": "data/arena_items", "levels": "data/arena_levels", "maps": "data/arena_maps" }, "speed": 1.0 }Script commands (Gunner.*)
Section titled “Script commands (Gunner.*)”| Call | Does |
|---|---|
arena_setup(e, setup) | a new match: { map = "forge" or 0.., fighters = { { name, human, level } }, frag_limit, time_limit, seed, advise, speed } - advise (a bot level) publishes what that bot would do in each person’s seat (an autopilot); speed fast-forwards (tests) |
arena_input(e, seat, mx, my, ax, ay, fire, select) | a person’s sticks (y down the map), trigger, and gun (select a slot, -1 previous, -2 next) |
arena_clear(e) | no match |
What scripts read
Section titled “What scripts read”ArenaMap(once a match):map,id,name,seed,width,height,items([item, x, y]),links([kind, x0, y0, x1, y1]),names,humans,levels,frag_limit,time_limit.ArenaState(every frame):Match::view-phase(warmup, fight, over),seed,clock,left,countdown,sudden,winner,f(a row a fighter:[x, y, aim_x, aim_y, hp, armor, weapon, ammo, frags, deaths, flags, air, respawn, quad, vx, vy, beam, owned],flags1 alive 2 shielded 4 quad 8 just fired 16 hurt 32 a person’s),s(shots[weapon, x, y, vx, vy]),items(a1/0a spot) and, withadvise,advice([mx, my, ax, ay, fire, select]a seat).ArenaEvents.events(every frame, replaced):fire,tracer,rail,boom,hit,frag,pickup,item,pad,land,tele,spawn,fall,fight,lead,sudden,quad_off,over- everything since the last frame. Read it every frame.
The view is the same shape the online room sends, so one Lua draws both.
Positions are in tiles, y down the map; a game draws them however it
likes. The simulation is deterministic (its own random numbers, a fixed
step, no clocks, no hash maps): the_same_seed_plays_the_same_match.
cargo test -p jengine-gunner27 tests of the run-and-gun, 22 of the side-on game and 21 of the
deathmatch. The two that matter most for the first are the pair that reads
like one sentence: cover_stops_a_bullet and
a_grenade_clears_the_cover_a_bullet_could_not; for the deathmatch,
the_top_bots_time_the_mega_and_are_on_it_when_it_comes_back.