diff --git a/README.md b/README.md index 4d60115..34fabcd 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ npm run dev # http://127.0.0.1:5173 | `↑` `↓` `A` `D` | Steer without a mouse | | `Q` / `E` | Roll left / right | | `Space` or left click | Fire | +| `F` or right click | Hold to spool the BFG, release to abort | | `Shift` | Phase dash (Hornet only) | | `Tab` / `T` | Switch target lock | | `Esc` / `P` | Pause | @@ -65,6 +66,38 @@ lock out for 2.9 seconds, which costs a third of your output. The card bars in t *derived* from these numbers rather than typed in, so they cannot drift away from the flight model — see `sustainedDps` in `src/ships/specs.ts`. +## The BFG + +Officially a Bulk Fusion Generator. Nobody calls it that. + +**Two rounds a run, no refills.** Hold `F` for 1.3 seconds and a slow green ball +leaves the nose; anything within 340 units of where it stops takes up to 260 +damage, falling off sharply toward the edge. That kills any airframe in the game +outright at the centre and is a hard shove and a scare at the rim. + +Three things make it a decision rather than a button: + +**Charging costs you everything else.** While it spools the guns are cold, the +dash is locked and the throttle is capped at 55%. You are committing to a +heading for a second and a half in an arena where everyone else is still +manoeuvring. A launch consumes the trigger — holding through the shot does not +wind the second round; you have to let go and mean it. + +**The blast does not care who fired it.** The pilot takes 60% — 156 damage at +point-blank, which kills a Wasp outright and takes a bite out of a Drone. A +held Shield does not change this: the Shield is for the gunfight, not for +standing in a fusion blast. The distance you keep is the price of the damage +you get. + +**Hostiles run from a live round.** Each one registers as a steering hazard the +size of its own blast, so the AI scatters as it crosses the arena. A round that +hits nothing still breaks a formation off your tail, which makes it a zoning +tool as much as a killing one: fire it where you want people to *not be*. It +also chain-detonates any mines it goes off near. + +Aborting a charge keeps the round, so a mispress costs you a moment rather than +a third of your firepower. The numbers all live at the top of `src/game/bfg.ts`. + ## Hazards **Stations** are solid on their core only — rings, trusses and solar panels are fly-through, so @@ -396,9 +429,11 @@ silently freezes the loop and makes every behavioural observation meaningless. I combat contract (hits land, kills register, friendly fire is off), the hull quirks, the boundary, the power-up pods (placement, collection, respawn, that a boosted bolt still does exactly its spec damage, that a Shield refuses damage without crediting the shooter, and that -both timed buffs stack, expire and do not survive a respawn), that clearing the roster reports -a win, and that a fatal hit plays its death animation out in full before the debrief takes the -screen. +both timed buffs stack, expire and do not survive a respawn), the BFG (spool, abort, ammo, +falloff, self-damage, mine chaining, that charging really does silence the guns on the first +frame after warp-in, that a long hold cannot spend the second round, and that a Shield does +not make the blast free), that clearing the roster reports a win, and that a fatal hit plays +its death animation out in full before the debrief takes the screen. `scripts/balance.ts` is the same idea pointed at fairness instead of correctness. It flies pinned duels — every airframe against every other, every bolt on target — and prints alpha strike, diff --git a/scripts/balance.ts b/scripts/balance.ts index 84a7eea..9895112 100644 --- a/scripts/balance.ts +++ b/scripts/balance.ts @@ -27,6 +27,14 @@ import * as THREE from 'three' import type { Audio } from '../src/core/audio' import { createBolts, FACTION_AI, FACTION_PLAYER } from '../src/game/bolts' import { Ship, type Controls, type ShipContext } from '../src/game/ship' +import { + blastFraction, + BFG_CHARGES, + BLAST_DAMAGE, + BLAST_RADIUS, + SELF_DAMAGE, + SPOOL_TIME, +} from '../src/game/bfg' import { SHIPS, SHIP_ORDER, @@ -93,6 +101,9 @@ function silentAudio(): Audio { pickup() {}, overheat() {}, alarm() {}, + charge() {}, + siege() {}, + detonation() {}, uiSelect() {}, uiLaunch() {}, fanfare() {}, @@ -109,6 +120,7 @@ function controls(overrides: Partial = {}): Controls { throttle: 0, fire: false, dash: false, + secondary: false, aim: null, spread: 0, ...overrides, @@ -388,6 +400,29 @@ for (const attacker of SHIP_ORDER) { console.log(` ${pad(SHIPS[attacker].name, 8)}${cells.join('')}`) } +section('BFG') +console.log(` ${pad('', 14)}${padLeft('damage', 9)}${padLeft('vs Wasp', 10)}${padLeft('vs Hornet', 12)}${padLeft('vs Drone', 11)}`) +for (const [label, distance] of [ + ['centre', 0], + ['quarter radius', BLAST_RADIUS * 0.25], + ['half radius', BLAST_RADIUS * 0.5], + ['three quarters', BLAST_RADIUS * 0.75], +] as [string, number][]) { + const damage = BLAST_DAMAGE * blastFraction(distance) + const share = (id: ShipId): string => { + const pct = Math.min(100, (damage / SHIPS[id].maxHull) * 100) + return pct >= 100 ? 'KILL' : `${pct.toFixed(0)}%` + } + console.log( + ` ${pad(label, 14)}${padLeft(damage.toFixed(0), 9)}${padLeft(share('wasp'), 10)}` + + `${padLeft(share('hornet'), 12)}${padLeft(share('drone'), 11)}`, + ) +} +const selfBlast = BLAST_DAMAGE * SELF_DAMAGE +console.log( + ` ${BFG_CHARGES} rounds a run · ${SPOOL_TIME}s spool · point-blank self-damage ${selfBlast.toFixed(0)}`, +) + section('Hangar cards') for (const id of SHIP_ORDER) { const b = SHIPS[id].bars @@ -490,6 +525,30 @@ for (const [matchup, t] of ttk) { ) } +/** + * The BFG is a moment, not a build. Two rounds spread over even a short + * engagement have to come out below the *weakest* gun in the fleet, or the + * right way to play becomes opening with both and mopping up — and every + * balance number above stops describing the game. + */ +const ENGAGEMENT = 30 +const bfgDpsOverAFight = (BFG_CHARGES * BLAST_DAMAGE) / ENGAGEMENT +check( + 'the BFG cannot replace the guns', + bfgDpsOverAFight < Math.min(...SHIP_ORDER.map(best)), + `${bfgDpsOverAFight.toFixed(1)} DPS across a ${ENGAGEMENT}s fight vs ${Math.min(...SHIP_ORDER.map(best)).toFixed(1)} from the weakest gun`, +) + +/** + * And it has to be able to kill you. A blast the pilot can safely stand inside + * is a free button, and a free button gets pressed on cooldown. + */ +check( + 'a point-blank BFG kills the pilot who fired it', + selfBlast > SHIPS.wasp.maxHull, + `${selfBlast.toFixed(0)} self-damage vs a ${SHIPS.wasp.maxHull} hull`, +) + /** A hazard that one-shots an airframe stops being a hazard and becomes a wall. */ for (const id of SHIP_ORDER) { const spec = SHIPS[id] diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index 45ad3fb..ce989f8 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -33,22 +33,23 @@ const MUTATIONS = [ { name: 'every seat flies intents[0]', file: 'src/game/game.ts', - from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n seat.ship.step(seat.lastControls, STEP, ctx)', - to: ' recordControls(seat, intents[0])\n seat.ship.step(seat.lastControls, STEP, ctx)', + from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)', + to: ' recordControls(seat, intents[0])\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)', }, { name: "every seat flies the drawn seat's intent", file: 'src/game/game.ts', - from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n seat.ship.step(seat.lastControls, STEP, ctx)', - to: ' recordControls(seat, intents[localIndex])\n seat.ship.step(seat.lastControls, STEP, ctx)', + from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)', + to: ' recordControls(seat, intents[localIndex])\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)', }, { name: 'seat i flies intents[i+1], wrapped', file: 'src/game/game.ts', - from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n seat.ship.step(seat.lastControls, STEP, ctx)', + from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)', to: ' const j = (i + 1) % seats.length\n' + ' recordControls(seat, intents[j])\n' + + ' applyBfgInterlock(seat, seat.lastControls)\n' + ' seat.ship.step(seat.lastControls, STEP, ctx)', }, { @@ -127,8 +128,8 @@ const MUTATIONS = [ { name: 'a late packet keeps firing', file: 'src/game/intent.ts', - from: ' out.fire = false\n out.dash = false\n out.aim = null', - to: ' out.fire = held.fire\n out.dash = held.dash\n out.aim = null', + from: ' out.fire = false\n out.dash = false\n out.secondary = false\n out.aim = null', + to: ' out.fire = held.fire\n out.dash = held.dash\n out.secondary = false\n out.aim = null', }, { name: 'a late packet stalls the throttle', @@ -139,8 +140,8 @@ const MUTATIONS = [ { name: 'an admitted intent keeps the aim override', file: 'src/game/intent.ts', - from: ' out.dash = claim.dash === true\n out.aim = null', - to: ' out.dash = claim.dash === true\n out.aim = claim.aim as THREE.Vector3 | null', + from: ' out.dash = claim.dash === true\n out.secondary = claim.secondary === true\n out.aim = null', + to: ' out.dash = claim.dash === true\n out.secondary = claim.secondary === true\n out.aim = claim.aim as THREE.Vector3 | null', }, /* ---- Snapshots: the world on the wire ----------------------------------- */ @@ -308,7 +309,7 @@ const MUTATIONS = [ { name: 'predict records the intent but never flies it', file: 'src/game/game.ts', - from: ' recordControls(s, controls)\n s.ship.step(s.lastControls, STEP, dryCtx)', + from: ' recordControls(s, controls)\n applyBfgInterlock(s, s.lastControls)\n s.ship.step(s.lastControls, STEP, dryCtx)', to: ' recordControls(s, controls)', }, { @@ -334,8 +335,8 @@ const MUTATIONS = [ { name: 'the last hitter is never remembered', file: 'src/game/game.ts', - from: ' if (direct) {\n lastHitter.set(self, direct)\n creditHit(direct, amount)\n return\n }', - to: ' if (direct) {\n creditHit(direct, amount)\n return\n }', + from: ' if (direct) {\n lastHitter.set(self, direct)\n // A BFG blast that catches three hulls is one shot, not three. Points\n // still land per hull; the accuracy numerator is bumped once in\n // `resolveBfg` after the sphere has finished.\n if (resolvingBlast) creditDamage(direct, amount)\n else creditHit(direct, amount)\n return\n }', + to: ' if (direct) {\n if (resolvingBlast) creditDamage(direct, amount)\n else creditHit(direct, amount)\n return\n }', }, { name: "the arena's damage is nobody's, even in a match of one", @@ -346,7 +347,7 @@ const MUTATIONS = [ { name: 'a hit on a participant pays nothing', file: 'src/game/game.ts', - from: ' lastHitter.set(self, direct)\n creditHit(direct, amount)', + from: ' lastHitter.set(self, direct)\n if (resolvingBlast) creditDamage(direct, amount)\n else creditHit(direct, amount)', to: ' lastHitter.set(self, direct)', }, { @@ -830,6 +831,38 @@ const MUTATIONS = [ ' return out.applyAxisAngle(_launchAxis, (index / count) * Math.PI * 2)', to: ' void count\n void index\n return out.copy(PLAYER_SPAWN)', }, + + /* ---- BFG --------------------------------------------------------------- */ + { + name: 'holding through a BFG launch winds the next round', + file: 'src/game/bfg.ts', + from: ' recovery = ABORT_RECOVERY\n needsRelease = true', + to: ' recovery = ABORT_RECOVERY\n needsRelease = false', + }, + { + name: 'the BFG interlock waits for last tick\'s spooling flag', + file: 'src/game/bfg.ts', + from: ' return spoolTimer > 0 || canBegin(hold, owner)', + to: ' return spoolTimer > 0', + }, + { + name: 'a Shield eats a BFG blast', + file: 'src/game/ship.ts', + from: ' if (this.shieldTimer > 0 && !pierceShield) {', + to: ' if (this.shieldTimer > 0) {', + }, + { + name: 'the BFG does not hurt the pilot who fired it', + file: 'src/game/bfg.ts', + from: 'export const SELF_DAMAGE = 0.6', + to: 'export const SELF_DAMAGE = 0', + }, + { + name: 'charging the BFG does not silence the guns', + file: 'src/game/game.ts', + from: ' applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)', + to: ' seat.ship.step(seat.lastControls, STEP, ctx)', + }, ] function dirty() { @@ -864,7 +897,7 @@ function runSuite() { * If you added or removed checks on purpose, bump this in the same commit. If you did not, * something stopped running. */ -const EXPECTED_ASSERTIONS = 629 +const EXPECTED_ASSERTIONS = 675 const PASS_SUMMARY = 'All checks passed.' const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/ diff --git a/scripts/simcheck.ts b/scripts/simcheck.ts index 4f29958..92bbf99 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -15,6 +15,17 @@ import * as THREE from 'three' import type { Audio } from '../src/core/audio' import type { Input, InputState } from '../src/core/input' import type { MatchResult, RunResult, SeatLine } from '../src/core/scores' +import { + blastFraction, + BLAST_DAMAGE, + BLAST_RADIUS, + BFG_CHARGES, + createBfg, + ROUND_LIFETIME, + SELF_DAMAGE, + SPOOL_TIME, + type BfgEvent, +} from '../src/game/bfg' import { createBolts, FACTION_AI, FACTION_ENVIRONMENT, FACTION_PLAYER, humanFaction, type Faction } from '../src/game/bolts' import { createStepClock } from '../src/core/loop' import { createPilot, type Pilot } from '../src/game/controls' @@ -102,6 +113,9 @@ function silentAudio(): Audio & { laserCount: number } { pickup() {}, overheat() {}, alarm() {}, + charge() {}, + siege() {}, + detonation() {}, uiSelect() {}, uiLaunch() {}, fanfare() {}, @@ -118,6 +132,7 @@ function controls(overrides: Partial = {}): Controls { throttle: 0, fire: false, dash: false, + secondary: false, aim: null, spread: 0, ...overrides, @@ -995,6 +1010,7 @@ function stubInput(): Input & { write: InputState } { throttleDown: false, fire: false, dash: false, + secondary: false, } const noop = () => {} return { @@ -1767,6 +1783,324 @@ function testPickups(): void { mines.dispose() } +function testBfg(): void { + section('The BFG is a moment, not a button') + + const forward = new THREE.Vector3(0, 0, -1) + + const owner = new Ship(SHIPS.hornet, FACTION_PLAYER) + owner.spawn(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1000)) + owner.warpTimer = 0 + + const near = new Ship(SHIPS.wasp, FACTION_AI) + near.spawn(new THREE.Vector3(0, 0, -600), new THREE.Vector3(0, 0, -4000)) + near.warpTimer = 0 + + const grazed = new Ship(SHIPS.hornet, FACTION_AI) + grazed.spawn(new THREE.Vector3(200, 0, -600), new THREE.Vector3(0, 0, -4000)) + grazed.warpTimer = 0 + + const clear = new Ship(SHIPS.hornet, FACTION_AI) + clear.spawn(new THREE.Vector3(520, 0, -600), new THREE.Vector3(0, 0, -4000)) + clear.warpTimer = 0 + + const bfg = createBfg() + const ships = [owner, near, grazed, clear] + + function frame(hold: boolean): BfgEvent[] { + owner.position.set(0, 0, 0) + near.position.set(0, 0, -600) + grazed.position.set(200, 0, -600) + clear.position.set(520, 0, -600) + for (const ship of ships) ship.velocity.set(0, 0, 0) + return bfg.update(STEP, { + owner, + forward, + hold, + targets: ships, + hazards: [], + minefield: null, + arenaLimit: ARENA_HARD_LIMIT, + }) + } + + const early = Math.floor((SPOOL_TIME / STEP) * 0.6) + for (let i = 0; i < early; i++) frame(true) + check('holding the trigger spools rather than firing', bfg.spool > 0.5 && bfg.spool < 1) + check('nothing has launched yet', bfg.roundsInFlight === 0 && bfg.charges === BFG_CHARGES) + + const aborted = frame(false) + check('letting go aborts the charge', aborted.some((e) => e.kind === 'abort')) + check('an aborted charge is not spent', bfg.charges === BFG_CHARGES, `charges=${bfg.charges}`) + check('the spool resets to empty', bfg.spool === 0) + + for (let i = 0; i < Math.ceil((SPOOL_TIME + 1) / STEP); i++) frame(false) + let launched: BfgEvent | undefined + for (let i = 0; i < Math.ceil((SPOOL_TIME + 0.2) / STEP) && !launched; i++) { + launched = frame(true).find((e) => e.kind === 'launch') + } + check('a full charge launches a round', launched !== undefined) + check('the launch spends a charge', bfg.charges === BFG_CHARGES - 1, `charges=${bfg.charges}`) + check('the round is in flight', bfg.roundsInFlight === 1) + check('the AI is told to steer around it', bfg.avoidance.length === 1) + check( + 'its avoid bubble covers the blast', + (bfg.avoidance[0]?.avoidRange ?? 0) >= BLAST_RADIUS, + `avoidRange=${bfg.avoidance[0]?.avoidRange}`, + ) + + const nearHull = near.hull + const grazedHull = grazed.hull + let blast: Extract | undefined + for (let i = 0; i < Math.ceil(ROUND_LIFETIME / STEP) && !blast; i++) { + blast = frame(false).find((e) => e.kind === 'detonate') as typeof blast + } + + check('the round detonates on contact', blast !== undefined) + check('the hull it hit is destroyed', !near.alive, `hull=${near.hull.toFixed(0)}/${nearHull}`) + check( + 'a hull at the edge of the sphere is hurt, not deleted', + grazed.alive && grazed.hull < grazedHull, + `hull=${grazed.hull.toFixed(0)}/${grazedHull}`, + ) + check( + 'damage falls off with distance', + grazed.hull > grazedHull - BLAST_DAMAGE * 0.5, + `took ${(grazedHull - grazed.hull).toFixed(0)}`, + ) + check('a hull outside the sphere is untouched', clear.hull === clear.spec.maxHull, `hull=${clear.hull}`) + check( + 'the blast reports its casualties', + blast?.kills === 1 && blast?.enemiesHit === 2, + `kills=${blast?.kills}, hit=${blast?.enemiesHit}`, + ) + check('the shockwave shoves what it does not kill', grazed.velocity.length() > 0, `speed=${grazed.velocity.length().toFixed(0)}`) + check('a spent round stops steering the AI', bfg.avoidance.length === 0 && bfg.roundsInFlight === 0) + + check('the blast is lethal at the centre', blastFraction(0) === 1) + check('and nothing at all at the edge', blastFraction(BLAST_RADIUS) === 0) + check( + 'with a small lethal core rather than a uniform sphere', + blastFraction(BLAST_RADIUS / 2) < 0.4, + `half-radius fraction ${blastFraction(BLAST_RADIUS / 2).toFixed(2)}`, + ) + + bfg.dispose() + for (const ship of ships) ship.dispose() +} + +function testBfgHurtsThePilot(): void { + section('The BFG does not care who fired it') + + const forward = new THREE.Vector3(0, 0, -1) + + const owner = new Ship(SHIPS.drone, FACTION_PLAYER) + owner.spawn(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1000)) + owner.warpTimer = 0 + + const victim = new Ship(SHIPS.hornet, FACTION_AI) + const victimAt = new THREE.Vector3(0, 0, -190) + victim.spawn(victimAt, new THREE.Vector3(0, 0, -4000)) + victim.warpTimer = 0 + + const bfg = createBfg() + const ships = [owner, victim] + let blast: Extract | undefined + + for (let i = 0; i < Math.ceil((SPOOL_TIME + ROUND_LIFETIME + 1) / STEP) && !blast; i++) { + owner.position.set(0, 0, 0) + owner.velocity.set(0, 0, 0) + victim.position.copy(victimAt) + victim.velocity.set(0, 0, 0) + blast = bfg + .update(STEP, { + owner, + forward, + hold: true, + targets: ships, + hazards: [], + minefield: null, + arenaLimit: ARENA_HARD_LIMIT, + }) + .find((e) => e.kind === 'detonate') as typeof blast + } + + const selfDamage = owner.spec.maxHull - owner.hull + const enemyDamage = victim.spec.maxHull - victim.hull + + check('a point-blank shot catches the pilot', selfDamage > 0, `took ${selfDamage.toFixed(0)}`) + check('the blast reports the self-hit', blast?.selfHit === true) + check( + 'the pilot takes a discounted share, not the full blast', + Math.abs(selfDamage / Math.max(1, enemyDamage) - SELF_DAMAGE) < 0.25, + `self ${selfDamage.toFixed(0)} vs enemy ${enemyDamage.toFixed(0)}`, + ) + check( + 'a Wasp would not survive its own round at this range', + selfDamage > SHIPS.wasp.maxHull * 0.5, + `${selfDamage.toFixed(0)} damage`, + ) + + bfg.dispose() + owner.dispose() + victim.dispose() +} + +function testBfgAmmoAndChaining(): void { + section('Two rounds a run, and the shockwave sets off mines') + + const forward = new THREE.Vector3(0, 0, -1) + const owner = new Ship(SHIPS.hornet, FACTION_PLAYER) + owner.spawn(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1000)) + owner.warpTimer = 0 + + const field = buildMinefield({ + count: 1, + arenaRadius: 800, + hazards: [], + spawn: new THREE.Vector3(0, 0, 4000), + }) + const mine = field.mines[0] + const bfg = createBfg() + + let launches = 0 + let chained = 0 + function tick(hold: boolean): void { + owner.position.set(0, 0, 0) + owner.velocity.set(0, 0, 0) + forward.copy(mine.position).normalize() + for (const event of bfg.update(STEP, { + owner, + forward, + hold, + targets: [owner], + hazards: [], + minefield: field, + arenaLimit: ARENA_HARD_LIMIT, + })) { + if (event.kind === 'launch') launches++ + if (event.kind === 'detonate') chained += event.minesChained + } + } + + const holdBudget = Math.ceil((SPOOL_TIME + 2) / STEP) + for (let i = 0; i < holdBudget; i++) tick(true) + check('holding through a launch does not spend the second round', launches === 1, `launched ${launches}`) + check('the second charge is still aboard', bfg.charges === BFG_CHARGES - 1, `charges=${bfg.charges}`) + + for (let i = 0; i < Math.ceil(1 / STEP); i++) tick(false) + for (let i = 0; i < holdBudget; i++) tick(true) + check(`a release then a second hold yields the second round`, launches === BFG_CHARGES, `launched ${launches}`) + check('charges bottom out at zero', bfg.charges === 0) + + const wait = Math.ceil((ROUND_LIFETIME * 2 + 1) / STEP) + for (let i = 0; i < wait; i++) tick(false) + check('the shockwave chain-detonates mines', chained > 0, `chained ${chained}`) + check('a chained mine is actually dead', !mine.live) + + bfg.reset() + check('a new run re-arms the weapon', bfg.charges === BFG_CHARGES && bfg.roundsInFlight === 0) + + bfg.dispose() + owner.dispose() + field.dispose() +} + +function testSpoolingSilencesTheGuns(): void { + section('Spooling the BFG costs you the guns') + + const game = createGame({ + scene: new THREE.Scene(), + camera: new THREE.PerspectiveCamera(74, 16 / 9, 1, 150000), + environment: stubEnvironment(), + input: stubInput(), + audio: silentAudio(), + hud: stubHud(), + bestScoreFor: () => 0, + onEnd: () => {}, + }) + + game.start({ ships: ['hornet'] }) + + // Warp-in locks the guns for 0.85s. The first-frame leak only shows after + // that window: a test that starts charging on tick 0 is hidden by it. + for (let i = 0; i < Math.ceil(1 / STEP); i++) game.step([controls()]) + check('warp-in has cleared before the charge', game.snapshot()?.elapsed! >= 1) + + const charging = controls({ fire: true, secondary: true, throttle: 1, dash: true }) + game.step([charging]) + check( + 'the first charge frame does not leak a gun shot', + game.snapshot()?.shotsFired === 0, + `shots=${game.snapshot()?.shotsFired}`, + ) + check('wouldCharge is true on that same frame', (game.snapshot()?.bfgSpool ?? 0) > 0, `spool=${game.snapshot()?.bfgSpool}`) + + const spoolFrames = Math.floor((SPOOL_TIME / STEP) * 0.8) + for (let i = 0; i < spoolFrames; i++) game.step([charging]) + + const mid = game.snapshot() + check('the guns stay cold for the whole charge', mid?.shotsFired === 0, `shots=${mid?.shotsFired}`) + check('the spool is visibly filling', (mid?.bfgSpool ?? 0) > 0.5, `spool=${mid?.bfgSpool?.toFixed(2)}`) + check('both rounds are still aboard', mid?.bfgCharges === BFG_CHARGES) + + for (let i = 0; i < Math.ceil(0.6 / STEP); i++) game.step([charging]) + const fired = game.snapshot() + check('the round launches at full charge', fired?.bfgCharges === BFG_CHARGES - 1, `charges=${fired?.bfgCharges}`) + + const guns = controls({ fire: true, throttle: 1 }) + for (let i = 0; i < Math.ceil(1.2 / STEP); i++) game.step([guns]) + const shooting = game.snapshot() + check('releasing it hands the guns back', (shooting?.shotsFired ?? 0) > 0, `shots=${shooting?.shotsFired}`) + + game.dispose() +} + +function testBfgPiercesShield(): void { + section('A Shield does not make the BFG free') + + const forward = new THREE.Vector3(0, 0, -1) + const owner = new Ship(SHIPS.drone, FACTION_PLAYER) + owner.spawn(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1000)) + owner.warpTimer = 0 + owner.engageShield(SHIELD_DURATION) + + const victim = new Ship(SHIPS.hornet, FACTION_AI) + const victimAt = new THREE.Vector3(0, 0, -190) + victim.spawn(victimAt, new THREE.Vector3(0, 0, -4000)) + victim.warpTimer = 0 + victim.engageShield(SHIELD_DURATION) + + const bfg = createBfg() + let blast: Extract | undefined + for (let i = 0; i < Math.ceil((SPOOL_TIME + ROUND_LIFETIME + 1) / STEP) && !blast; i++) { + owner.position.set(0, 0, 0) + owner.velocity.set(0, 0, 0) + victim.position.copy(victimAt) + victim.velocity.set(0, 0, 0) + blast = bfg + .update(STEP, { + owner, + forward, + hold: true, + targets: [owner, victim], + hazards: [], + minefield: null, + arenaLimit: ARENA_HARD_LIMIT, + }) + .find((e) => e.kind === 'detonate') as typeof blast + } + + check('the shielded pilot still takes their own blast', owner.hull < owner.spec.maxHull, `hull=${owner.hull.toFixed(0)}`) + check('the Shield is still up afterwards', owner.shielded, `timer=${owner.shieldTimer.toFixed(2)}`) + check('a shielded hostile is not safe inside the sphere either', victim.hull < victim.spec.maxHull, `hull=${victim.hull.toFixed(0)}`) + check('the blast still reports the self-hit', blast?.selfHit === true) + + bfg.dispose() + owner.dispose() + victim.dispose() +} + /** * The determinism contract: a run is a function of its seed and its inputs, and * of nothing else. @@ -2626,15 +2960,18 @@ function testIntentIsAdmittedNotTrusted(): void { check('fire: 1 does not', admit({ fire: 1 }).fire === false) check('dash: true dashes', admit({ dash: true }).dash === true) check("dash: 'false' does not", admit({ dash: 'false' }).dash === false) + check('secondary: true spools', admit({ secondary: true }).secondary === true) + check("secondary: 'yes' does not", admit({ secondary: 'yes' }).secondary === false) /* Two fields never survive. */ check('an aim override is dropped', admit({ aim: new THREE.Vector3(0, 0, -1) }).aim === null) check('a spread is zeroed', admit({ spread: 0.7 }).spread === 0) /* A late tick holds the last intent, except for the triggers. The held intent - has both triggers *down*, or "dropped" and "held" would read the same. */ + has all three triggers *down*, or "dropped" and "held" would read the same. */ held.fire = true held.dash = true + held.secondary = true for (const [label, late] of [['undefined', undefined], ['null', null], ['a number', 42]] as const) { const a = admit(late) check( @@ -2642,11 +2979,12 @@ function testIntentIsAdmittedNotTrusted(): void { a.pitch === 0.4 && a.yaw === -0.2 && a.roll === 1 && a.throttle === 0.6, `got ${a.pitch}/${a.yaw}/${a.roll}/${a.throttle}`, ) - check(`and ${label} does not keep firing or dashing`, a.fire === false && a.dash === false) + check(`and ${label} does not keep firing, dashing or spooling`, a.fire === false && a.dash === false && a.secondary === false) } held.fire = false held.dash = false + held.secondary = false /* Nothing in the packet is retained. */ const packet = { pitch: 0.5, throttle: 0.6, fire: true } @@ -3217,7 +3555,7 @@ function testAnIntentFrameEndsInAdmission(): void { const held = controls({ throttle: 0.5 }) const out = controls({ pitch: 0.123 }) - const sent = controls({ pitch: 0.25, yaw: -1, roll: 1, throttle: 0.51, fire: true, dash: true }) + const sent = controls({ pitch: 0.25, yaw: -1, roll: 1, throttle: 0.51, fire: true, dash: true, secondary: true }) const bytes = encodeIntent(2, 4242, sent) check('an intent frame is fixed-size', bytes.length === INTENT_FRAME_BYTES, `${bytes.length}`) @@ -3225,7 +3563,7 @@ function testAnIntentFrameEndsInAdmission(): void { check('seat and tick come back', frame.seat === 2 && frame.tick === 4242) check( 'the controls come back through admission', - out.pitch === 0.25 && out.yaw === -1 && out.roll === 1 && out.throttle === Math.fround(0.51) && out.fire && out.dash, + out.pitch === 0.25 && out.yaw === -1 && out.roll === 1 && out.throttle === Math.fround(0.51) && out.fire && out.dash && out.secondary, JSON.stringify(out), ) check('the decoded intent is the out struct, not a fresh one', frame.controls === out) @@ -7359,6 +7697,11 @@ testSolarSear() testBoltPoolDoesNotLeak() testMines() testPickups() +testBfg() +testBfgHurtsThePilot() +testBfgAmmoAndChaining() +testSpoolingSilencesTheGuns() +testBfgPiercesShield() testARunCanBeWon() testDeathPlaysBeforeTheDebrief() testASeededRunReproduces() diff --git a/src/core/audio.ts b/src/core/audio.ts index 4f9ebc6..78bd86d 100644 --- a/src/core/audio.ts +++ b/src/core/audio.ts @@ -40,6 +40,12 @@ export interface Audio { pickup(big: boolean): void overheat(): void alarm(): void + /** BFG spool ratchet. `progress` 0..1 walks the pitch up. */ + charge(progress: number): void + /** BFG launch. */ + siege(): void + /** BFG detonation. The loudest thing in the game, and it should be. */ + detonation(): void uiSelect(): void uiLaunch(): void fanfare(win: boolean): void @@ -207,6 +213,31 @@ export function createAudio(): Audio { tone('square', 660, 660, 0.09, 0.075, 0.13) }, + // A ratchet rather than a held whine: every other voice in here is a + // transient with its stop already scheduled, and a sustained tone would be + // the only thing in the mix that has to be turned off by hand — exactly the + // bug that got the engine note deleted. Ticking also carries information a + // drone does not, since the interval tightens as the charge fills. + charge(progress) { + const base = 220 + progress * 620 + tone('square', base, base * 1.35, 0.05, 0.05) + tone('sine', base * 0.5, base * 0.62, 0.07, 0.035) + }, + + siege() { + tone('sawtooth', 820, 90, 0.42, 0.16) + tone('sine', 260, 44, 0.5, 0.14, 0.02) + noise(0.34, 0.2, 'lowpass', 3200, 300) + }, + + detonation() { + noise(1.9, 0.6, 'lowpass', 2600, 60) + tone('sine', 92, 18, 1.7, 0.4) + tone('sawtooth', 240, 38, 0.7, 0.14) + tone('triangle', 60, 22, 2.1, 0.22, 0.06) + music?.duck() + }, + uiSelect() { tone('triangle', 720, 1080, 0.07, 0.07) }, diff --git a/src/core/input.ts b/src/core/input.ts index b7905ed..65ec7cf 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -27,6 +27,8 @@ export interface InputState { throttleDown: boolean fire: boolean dash: boolean + /** BFG trigger — held to spool, released to abort. */ + secondary: boolean } export interface Input { @@ -53,6 +55,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { let stickX = 0 // yaw deflection let stickY = 0 // pitch deflection let mouseFiring = false + let mouseCharging = false let locked = false let invertPitch = false @@ -64,6 +67,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { throttleDown: false, fire: false, dash: false, + secondary: false, } /* ---- Keyboard --------------------------------------------------------- */ @@ -97,10 +101,18 @@ export function createInput(canvas: HTMLCanvasElement): Input { function onMouseDown(e: MouseEvent) { if (e.button === 0) mouseFiring = true + if (e.button === 2) mouseCharging = true } function onMouseUp(e: MouseEvent) { if (e.button === 0) mouseFiring = false + if (e.button === 2) mouseCharging = false + } + + // Right-drag is the BFG trigger, so the context menu has to stay shut. Only + // over the canvas — the rest of the page keeps its normal behaviour. + function onContextMenu(e: MouseEvent) { + e.preventDefault() } function onPointerLockChange() { @@ -108,6 +120,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { if (locked && !nowLocked) { locked = false mouseFiring = false + mouseCharging = false stickX = 0 stickY = 0 for (const h of lockLostHandlers) h() @@ -121,6 +134,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { window.addEventListener('mousemove', onMouseMove) window.addEventListener('mousedown', onMouseDown) window.addEventListener('mouseup', onMouseUp) + canvas.addEventListener('contextmenu', onContextMenu) window.addEventListener('blur', () => held.clear()) document.addEventListener('pointerlockchange', onPointerLockChange) @@ -146,6 +160,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { state.throttleDown = held.has('KeyS') state.fire = mouseFiring || held.has('Space') state.dash = held.has('ShiftLeft') || held.has('ShiftRight') + state.secondary = mouseCharging || held.has('KeyF') } return { @@ -178,6 +193,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { reset() { held.clear() mouseFiring = false + mouseCharging = false stickX = 0 stickY = 0 update(0) @@ -188,6 +204,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mousedown', onMouseDown) window.removeEventListener('mouseup', onMouseUp) + canvas.removeEventListener('contextmenu', onContextMenu) document.removeEventListener('pointerlockchange', onPointerLockChange) }, } @@ -204,6 +221,9 @@ const TRACKED = new Set([ 'Space', 'ShiftLeft', 'ShiftRight', + // The BFG trigger, for pilots who would rather not hold a mouse button down + // for a second and a half. + 'KeyF', 'ArrowUp', 'ArrowDown', 'ArrowLeft', diff --git a/src/game/ai.ts b/src/game/ai.ts index 96fb16e..c639dfa 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -134,6 +134,7 @@ export class EnemyPilot { throttle: 0.8, fire: false, dash: false, + secondary: false, aim: null, spread: 0, } diff --git a/src/game/autopilot.ts b/src/game/autopilot.ts index 16a33ab..af4fa19 100644 --- a/src/game/autopilot.ts +++ b/src/game/autopilot.ts @@ -76,6 +76,7 @@ export function createSeatAutopilot(ship: ShipId, rng: Rng): SeatAutopilot { throttle: 0.6, fire: false, dash: false, + secondary: false, aim: null, spread: 0, } diff --git a/src/game/bfg.ts b/src/game/bfg.ts new file mode 100644 index 0000000..816d605 --- /dev/null +++ b/src/game/bfg.ts @@ -0,0 +1,460 @@ +/** + * The BFG. + * + * Officially a Bulk Fusion Generator. Nobody calls it that. + * + * Everything else in this game is a decision you make with the stick. This is + * the one you make with the clock: hold the trigger for over a second while the + * guns are cold, the throttle is capped and the dash is locked out, and hope the + * furball is still where you left it. A weapon that only did enormous damage + * would be a stat. Charging in the open is what makes it a choice. + * + * Three rules do most of the design work: + * + * **Two shots a run, no refills.** Anything renewable becomes a rotation you + * press on cooldown. Two means every launch is a judgement about whether *this* + * is the moment, and firing the second one is a small tragedy. + * + * **The blast does not care whose side you are on.** It hurts the pilot who + * fired it at 60% — enough that a point-blank shot kills a Wasp outright and + * takes a third of a Drone. The distance you keep is the price of the damage + * you get, and it is paid in the same currency the enemy pays. A held Shield + * does not change this: the Shield is for the gunfight, not for standing in a + * fusion blast. A blast that is safe to stand inside is a free button. + * + * **The AI runs from a live round.** Each one registers as a steering hazard + * with a bubble the size of its blast, so hostiles scatter as it crosses the + * arena. That makes it a zoning tool as much as a killing one: the shot that + * misses everything still breaks a formation off your tail, and a good pilot + * learns to fire it where they want people to *not be*. + * + * Player-only. An AI holding one of these would either never use it or nuke its + * own wing, and neither is a fight anyone wants. Each human seat carries its + * own two charges. + * + * Pure maths over three.js vectors apart from the two meshes it owns, so the + * whole thing runs headless in `scripts/simcheck.ts`. + */ + +import * as THREE from 'three' +import type { Hazard } from '../world/environment' +import type { Minefield } from '../world/mines' +import type { Faction } from './bolts' + +/** Shots per run. There is no way to earn more. */ +export const BFG_CHARGES = 2 +/** Seconds on the trigger before it launches. */ +export const SPOOL_TIME = 1.3 +/** Throttle ceiling while spooling — you commit to a heading, not just a moment. */ +export const SPOOL_THROTTLE_CAP = 0.55 +/** Seconds of dead trigger after an abort, so tapping is not free. */ +export const ABORT_RECOVERY = 0.6 + +export const ROUND_SPEED = 420 +/** Contact sphere. Fat, because a slow round that clips through a hull is a bug. */ +export const ROUND_RADIUS = 22 +/** Seconds before it cooks off on its own. ~1750 units of reach. */ +export const ROUND_LIFETIME = 4.2 +export const MAX_ROUNDS = 3 + +export const BLAST_RADIUS = 340 +/** Damage at the centre of the blast. Deletes any airframe in the game. */ +export const BLAST_DAMAGE = 260 +/** + * Falloff exponent. Above 1 so the lethal core is genuinely small and the outer + * two thirds of the sphere is a hard shove and a scare — at half radius this is + * 86 damage, which hurts a Hornet and does not decide the fight. + */ +export const BLAST_FALLOFF = 1.6 +/** What the pilot who fired it takes. Enough to be a real mistake. */ +export const SELF_DAMAGE = 0.6 +/** Velocity added away from the blast at the centre, units/sec. */ +export const BLAST_KNOCKBACK = 260 + +/** Everything the weapon needs from a ship. `Ship` satisfies this structurally. */ +export interface BfgTarget { + readonly position: THREE.Vector3 + readonly velocity: THREE.Vector3 + readonly radius: number + readonly alive: boolean + /** False while warping in or phase-dashing. */ + readonly targetable: boolean + readonly faction: Faction + takeDamage(amount: number, from: Faction, pierceShield?: boolean): void +} + +export type BfgEvent = + | { kind: 'spool'; progress: number } + | { kind: 'abort' } + | { kind: 'launch'; position: THREE.Vector3 } + | { + kind: 'detonate' + position: THREE.Vector3 + /** Hostiles that took damage. */ + enemiesHit: number + /** Hostiles the blast finished off. */ + kills: number + /** Whether the pilot caught their own blast. */ + selfHit: boolean + /** Mines set off by the shockwave. */ + minesChained: number + } + +export interface BfgFrame { + /** The pilot. Null between runs. */ + owner: BfgTarget | null + /** Nose direction, for the launch vector. */ + forward: THREE.Vector3 + /** Secondary trigger held. */ + hold: boolean + /** Everything the blast can touch, including the owner. */ + targets: readonly BfgTarget[] + /** Solid geometry a round detonates against. */ + hazards: readonly Hazard[] + minefield: Minefield | null + /** Rounds cook off rather than leave the arena. */ + arenaLimit: number +} + +export interface Bfg { + group: THREE.Group + readonly charges: number + /** Spool progress, 0..1. */ + readonly spool: number + readonly spooling: boolean + readonly roundsInFlight: number + /** + * Live rounds as AI steering hazards. A stable array whose contents change as + * rounds launch and detonate, so callers can concat it once per frame. + */ + readonly avoidance: Hazard[] + /** + * True when a hold this frame is currently costing the guns — already + * spooling, or about to start. Gated on the *current* trigger, not last + * frame's spooling flag: `Ship.step` runs before `update`, so a first-frame + * check against `spooling` alone lets a ready gun fire. + */ + wouldCharge(hold: boolean, owner: BfgTarget | null): boolean + update(dt: number, frame: BfgFrame): BfgEvent[] + /** Re-arm for a new run. */ + reset(): void + syncVisual(dt: number): void + clear(): void + dispose(): void +} + +interface Round { + live: boolean + position: THREE.Vector3 + velocity: THREE.Vector3 + life: number + faction: Faction + hazard: Hazard + core: THREE.Mesh + halo: THREE.Mesh + spin: number +} + +const _push = new THREE.Vector3() +const _muzzle = new THREE.Vector3() + +/** Blast strength at a distance, 1 at the centre and 0 at the edge. */ +export function blastFraction(distance: number): number { + if (distance >= BLAST_RADIUS) return 0 + return Math.pow(1 - distance / BLAST_RADIUS, BLAST_FALLOFF) +} + +export function createBfg(accent = 0x9dff3b): Bfg { + const group = new THREE.Group() + + const coreGeometry = new THREE.IcosahedronGeometry(ROUND_RADIUS * 0.72, 1) + const haloGeometry = new THREE.IcosahedronGeometry(ROUND_RADIUS * 1.55, 1) + + const rounds: Round[] = Array.from({ length: MAX_ROUNDS }, () => { + // White core inside a coloured shell: under bloom the white blows out into + // the shell and the whole thing reads as one object with a hot middle, + // which a single emissive sphere never manages. + const core = new THREE.Mesh( + coreGeometry, + new THREE.MeshBasicMaterial({ color: 0xffffff }), + ) + const halo = new THREE.Mesh( + haloGeometry, + new THREE.MeshBasicMaterial({ + color: accent, + transparent: true, + opacity: 0.45, + blending: THREE.AdditiveBlending, + depthWrite: false, + }), + ) + core.visible = false + halo.visible = false + core.frustumCulled = false + halo.frustumCulled = false + group.add(core, halo) + + const position = new THREE.Vector3() + return { + live: false, + position, + velocity: new THREE.Vector3(), + life: 0, + faction: 0 as Faction, + // The hazard holds the same vector instance the round moves, so the AI + // reads a live position without anything having to copy it per frame. + hazard: { + center: position, + radius: ROUND_RADIUS, + avoidRange: BLAST_RADIUS * 1.15, + name: 'BFG round', + }, + core, + halo, + spin: 0, + } + }) + + const avoidance: Hazard[] = [] + + let charges = BFG_CHARGES + let spoolTimer = 0 + let recovery = 0 + /** + * A launch or abort consumes the trigger. Holding through the recovery + * window must not spool the next round on its own — two scarce shots from + * one long hold is the opposite of the design. + */ + let needsRelease = false + + function refreshAvoidance(): void { + avoidance.length = 0 + for (const round of rounds) if (round.live) avoidance.push(round.hazard) + } + + function canBegin(hold: boolean, owner: BfgTarget | null): boolean { + return ( + hold && + !needsRelease && + recovery <= 0 && + charges > 0 && + owner !== null && + owner.alive && + rounds.some((r) => !r.live) + ) + } + + function launch(frame: BfgFrame): BfgEvent | null { + const owner = frame.owner + const slot = rounds.find((r) => !r.live) + if (!owner || !slot) return null + + // Ahead of the nose, so the round is never born already touching the hull + // that fired it. + _muzzle.copy(owner.position).addScaledVector(frame.forward, owner.radius + ROUND_RADIUS * 1.6) + + slot.live = true + slot.position.copy(_muzzle) + // A fraction of the ship's own velocity, so firing while running away does + // not leave the round hanging behind you looking silly. + slot.velocity.copy(frame.forward).multiplyScalar(ROUND_SPEED).addScaledVector(owner.velocity, 0.2) + slot.life = ROUND_LIFETIME + slot.faction = owner.faction + slot.spin = 0 + slot.core.position.copy(_muzzle) + slot.halo.position.copy(_muzzle) + slot.core.visible = true + slot.halo.visible = true + + charges-- + refreshAvoidance() + return { kind: 'launch', position: slot.position } + } + + function detonate(round: Round, frame: BfgFrame): BfgEvent { + let enemiesHit = 0 + let kills = 0 + let selfHit = false + + for (const target of frame.targets) { + if (!target.alive || !target.targetable) continue + + const distance = target.position.distanceTo(round.position) + const fraction = blastFraction(Math.max(0, distance - target.radius)) + if (fraction <= 0) continue + + const own = target.faction === round.faction + const damage = BLAST_DAMAGE * fraction * (own ? SELF_DAMAGE : 1) + + // Shove first: a ship killed by the blast should still be thrown by it, + // and `takeDamage` may flip `alive` before we get there. + _push.subVectors(target.position, round.position) + if (_push.lengthSq() < 1e-6) _push.copy(frame.forward) + target.velocity.addScaledVector(_push.normalize(), BLAST_KNOCKBACK * fraction) + + const before = target.alive + // A fusion blast is not a bolt. Shield is for the gunfight; standing in + // this sphere is supposed to hurt, including the pilot who fired it. + target.takeDamage(damage, round.faction, true) + + if (own) { + selfHit = true + } else { + enemiesHit++ + if (before && !target.alive) kills++ + } + } + + // The shockwave sets off anything armed nearby. Chained mines do not add + // their own damage — the blast has already been applied over that volume, + // and double-dipping would make a minefield detonation wildly swingy. + let minesChained = 0 + const field = frame.minefield + if (field) { + for (const mine of field.mines) { + if (!mine.live) continue + if (mine.position.distanceTo(round.position) > BLAST_RADIUS) continue + field.detonate(mine) + minesChained++ + } + } + + round.live = false + round.core.visible = false + round.halo.visible = false + refreshAvoidance() + + return { + kind: 'detonate', + position: round.position, + enemiesHit, + kills, + selfHit, + minesChained, + } + } + + /** True when the round has run into something solid this frame. */ + function contact(round: Round, frame: BfgFrame): boolean { + for (const target of frame.targets) { + if (!target.alive || !target.targetable) continue + if (target.position.distanceTo(round.position) <= target.radius + ROUND_RADIUS) return true + } + for (const hazard of frame.hazards) { + if (round.position.distanceTo(hazard.center) <= hazard.radius + ROUND_RADIUS) return true + } + const field = frame.minefield + if (field && field.findContact(round.position, ROUND_RADIUS)) return true + return round.position.length() >= frame.arenaLimit + } + + return { + group, + get charges() { + return charges + }, + get spool() { + return spoolTimer / SPOOL_TIME + }, + get spooling() { + return spoolTimer > 0 + }, + get roundsInFlight() { + return rounds.reduce((n, r) => n + (r.live ? 1 : 0), 0) + }, + avoidance, + + wouldCharge(hold, owner) { + return spoolTimer > 0 || canBegin(hold, owner) + }, + + update(dt, frame) { + const events: BfgEvent[] = [] + + /* ---- Trigger ------------------------------------------------------ */ + + if (recovery > 0) recovery = Math.max(0, recovery - dt) + if (!frame.hold) needsRelease = false + + const canSpool = canBegin(frame.hold, frame.owner) + + if (canSpool) { + spoolTimer += dt + if (spoolTimer >= SPOOL_TIME) { + spoolTimer = 0 + const event = launch(frame) + if (event) events.push(event) + // A launch consumes the trigger. Holding the button down does not + // immediately start winding the next one — let go and mean it. + recovery = ABORT_RECOVERY + needsRelease = true + } else { + events.push({ kind: 'spool', progress: spoolTimer / SPOOL_TIME }) + } + } else if (spoolTimer > 0) { + // Released early, died mid-charge, or ran the arena out of round slots. + // The charge is kept: punishing a mispress with a permanent loss of one + // of two shots would make people never touch the button. + spoolTimer = 0 + recovery = ABORT_RECOVERY + events.push({ kind: 'abort' }) + } + + /* ---- Rounds ------------------------------------------------------- */ + + for (const round of rounds) { + if (!round.live) continue + + round.position.addScaledVector(round.velocity, dt) + round.life -= dt + round.spin += dt + + if (round.life <= 0 || contact(round, frame)) { + events.push(detonate(round, frame)) + } + } + + return events + }, + + reset() { + charges = BFG_CHARGES + spoolTimer = 0 + recovery = 0 + needsRelease = false + this.clear() + }, + + syncVisual(dt) { + for (const round of rounds) { + if (!round.live) continue + round.core.position.copy(round.position) + round.halo.position.copy(round.position) + round.core.rotation.y += dt * 3.1 + round.core.rotation.x += dt * 1.7 + // Breathe, so a slow round still reads as something under pressure + // rather than a ball someone threw. + const pulse = 1 + Math.sin(round.spin * 14) * 0.09 + round.halo.scale.setScalar(pulse) + } + }, + + clear() { + for (const round of rounds) { + round.live = false + round.core.visible = false + round.halo.visible = false + } + refreshAvoidance() + }, + + dispose() { + coreGeometry.dispose() + haloGeometry.dispose() + for (const round of rounds) { + ;(round.core.material as THREE.Material).dispose() + ;(round.halo.material as THREE.Material).dispose() + } + }, + } +} diff --git a/src/game/controls.ts b/src/game/controls.ts index 5c018c2..3a049c9 100644 --- a/src/game/controls.ts +++ b/src/game/controls.ts @@ -39,6 +39,7 @@ export function createPilot(): Pilot { throttle: LAUNCH_THROTTLE, fire: false, dash: false, + secondary: false, aim: null, spread: 0, } @@ -66,6 +67,7 @@ export function createPilot(): Pilot { controls.roll = state.roll controls.fire = state.fire controls.dash = state.dash + controls.secondary = state.secondary return controls }, @@ -76,6 +78,7 @@ export function createPilot(): Pilot { controls.roll = 0 controls.fire = false controls.dash = false + controls.secondary = false }, } } diff --git a/src/game/fx.ts b/src/game/fx.ts index 6ef8f8d..c0578b7 100644 --- a/src/game/fx.ts +++ b/src/game/fx.ts @@ -53,6 +53,12 @@ export interface Fx { warpIn(at: THREE.Vector3, color: THREE.Color): void /** A power-up pod being absorbed: one tight ring and a bright puff. */ collect(at: THREE.Vector3, color: THREE.Color): void + /** + * BFG detonation: a white core, a coloured fireball and three rings leaving + * at different speeds. Stacked rings rather than one big one because a single + * expanding circle reads as a decal, and three reads as a pressure wave. + */ + blast(at: THREE.Vector3, color: THREE.Color, radius: number): void update(dt: number, camera: THREE.Camera): void clear(): void dispose(): void @@ -208,6 +214,22 @@ export function createFx(): Fx { ring(at, color, 100, 0.42) }, + blast(at, color, radius) { + // Tight and short. The first cut of this threw 600 particles out past the + // kill radius for two and a half seconds, and the reward for landing the + // best shot in the game was a screen you could not see out of. + const white = new THREE.Color(0xffffff) + emit(at, white, 140, radius * 1.2, 7, 0.35, 4, 10) + emit(at, color, 220, radius * 0.55, 9, 0.9, 1.8, 18) + emit(at, color, 80, radius * 0.22, 15, 1.5, 1, 40) + // The middle ring stops exactly on the damage boundary. A shockwave drawn + // wider than it kills teaches the wrong distance, and this is a weapon + // where the distance you keep is the whole decision. + ring(at, white, radius * 0.55, 0.45) + ring(at, color, radius, 0.8) + ring(at, color, radius * 1.25, 1.2) + }, + update(dt, camera) { for (let i = 0; i < MAX_PARTICLES; i++) { if (life[i] <= 0) { diff --git a/src/game/game.ts b/src/game/game.ts index 2101730..74de9f6 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -28,6 +28,7 @@ import { STREAM, subRng, type Rng } from '../core/rng' import type { MatchResult, RunResult, SeatLine } from '../core/scores' import { otherShips, SHIPS, type ShipId } from '../ships/specs' import { + ARENA_HARD_LIMIT, ARENA_RADIUS, PLAYER_SPAWN_LOOK, type Environment, @@ -44,6 +45,7 @@ import { type PickupKind, } from '../world/pickups' import { EnemyPilot } from './ai' +import { BLAST_RADIUS, createBfg, SPOOL_THROTTLE_CAP, type Bfg } from './bfg' import { createBolts, FACTION_AI, FACTION_ENVIRONMENT, FACTION_PLAYER, type Bolts, type Faction } from './bolts' import { FEED_RING, NOBODY, THE_ARENA, type KillEvent, type LockRef, type SeatState, type ShipState, type SquadronState, type WorldSnapshot } from '../net/snapshot' import { createChaseCamera, type ChaseCamera } from './chase' @@ -174,6 +176,10 @@ const WRECK_SPARK_RATE = 26 * The big one at `WRECK_TUMBLE` is the moment the ship itself goes; the two * after it are cook-offs in the debris. */ +const BFG_FLASH = new THREE.Color(0x9dff3b) +/** How often the spool ratchet ticks at empty charge. Tightens as it fills. */ +const CHARGE_TICK = 0.17 + const DEATH_BLASTS: { at: number; scale: number; spread: number; shake: number; big: boolean }[] = [ { at: 0, scale: 0.55, spread: 10, shake: 1, big: false }, { at: 0.26, scale: 0.7, spread: 14, shake: 1.1, big: false }, @@ -443,6 +449,10 @@ export interface RunSnapshot { * question from "where is my next gun buff". */ pickups: Record + /** BFG rounds left for this seat. */ + bfgCharges: number + /** BFG spool progress, 0..1. */ + bfgSpool: number } export interface GameDeps { @@ -463,6 +473,19 @@ export function createGame(deps: GameDeps): Game { const fx: Fx = createFx() scene.add(bolts.mesh, fx.group) + /** + * One BFG per seat, created in `start`. Player-only in the original sense: + * humans have it, the squadron does not. Each seat carries its own two + * charges so a second stick is not sharing the first one's magazine. + */ + let bfgs: Bfg[] = [] + let chargeTimer = 0 + /** + * True while a BFG blast is applying damage, so a sphere that catches three + * hulls counts as one accuracy hit rather than three. + */ + let resolvingBlast = false + const chase: ChaseCamera = createChaseCamera(camera) // The listener, rewritten by `start` to whichever seat this machine presents. @@ -821,7 +844,11 @@ export function createGame(deps: GameDeps): Game { const direct = seatOf(seats, from) if (direct) { lastHitter.set(self, direct) - creditHit(direct, amount) + // A BFG blast that catches three hulls is one shot, not three. Points + // still land per hull; the accuracy numerator is bumped once in + // `resolveBfg` after the sphere has finished. + if (resolvingBlast) creditDamage(direct, amount) + else creditHit(direct, amount) return } const owed = hitCredit(from, self) @@ -972,6 +999,117 @@ export function createGame(deps: GameDeps): Game { audio.pickup(pod.kind === 'overdrive') } + /* ------------------------------------------------------------------------ */ + /* BFG */ + /* ------------------------------------------------------------------------ */ + + /** + * Spooling the BFG costs you everything else. Cold guns, no dash and a + * throttle ceiling is what turns "press the big button" into a decision + * about where you are willing to be for the next second and a half. + * + * Gated on the *current* trigger via `wouldCharge`, not last tick's + * `spooling` flag. `Ship.step` runs before `resolveBfg`, so a first-frame + * check against `spooling` alone lets a ready gun fire — and a Hornet can + * dash on that same frame. + */ + function applyBfgInterlock(seat: Participant, flown: { fire: boolean; dash: boolean; throttle: number; secondary: boolean }): void { + const bfg = bfgs[seat.index] + if (!bfg) return + if (!bfg.wouldCharge(flown.secondary, seat.ship)) return + flown.fire = false + flown.dash = false + flown.throttle = Math.min(flown.throttle, SPOOL_THROTTLE_CAP) + } + + /** + * Runs the secondary weapon and turns its events into noise, light and score. + * Kept here rather than inside the weapon so `bfg.ts` stays pure maths and + * runs headless. + */ + function resolveBfg(): void { + resolvingBlast = true + for (let i = 0; i < seats.length; i++) { + const seat = seats[i] + const bfg = bfgs[i] + if (!bfg || seat.phase.kind !== 'flying') continue + const ship = seat.ship + ship.forward(_forward) + const events = bfg.update(STEP, { + owner: ship.alive ? ship : null, + forward: _forward, + hold: seat.lastControls.secondary, + targets: boltTargets, + hazards: environment.hazards, + minefield: environment.minefield, + arenaLimit: ARENA_HARD_LIMIT, + }) + + const mine = seat === local() + for (const event of events) { + switch (event.kind) { + case 'spool': { + if (!mine) break + chargeTimer -= STEP + if (chargeTimer <= 0) { + audio.charge(event.progress) + chargeTimer = CHARGE_TICK * (1 - event.progress * 0.6) + } + break + } + case 'abort': { + if (mine) chargeTimer = 0 + break + } + case 'launch': { + // A launch is one shot for accuracy. Left out, a pilot could farm + // the stat by opening every fight with a round they never aimed. + ship.shotsFired++ + if (mine) { + chargeTimer = 0 + audio.siege() + fx.spark(event.position, BFG_FLASH, 30) + chase.shake(0.5) + hud.callout('BFG AWAY', '#9dff3b', 0.9) + } + break + } + case 'detonate': { + fx.blast(event.position, BFG_FLASH, BLAST_RADIUS) + if (mine) audio.detonation() + + const watcher = local() + if (watcher) { + const distance = event.position.distanceTo(watcher.ship.position) + chase.shake(distance < BLAST_RADIUS * 2 ? 3.4 : 1.1) + } + + if (event.minesChained > 0) rebuildAvoidList() + if (event.enemiesHit > 0) seat.hits++ + + if (mine) { + if (event.selfHit) hud.callout('CAUGHT THE BLAST', '#ff3b4e', 1.4) + else if (event.kills > 1) hud.feed(`MULTIKILL ×${event.kills}`) + else if (event.enemiesHit === 0) hud.feed('BFG · NOTHING IN THE SPHERE') + } + break + } + } + } + } + resolvingBlast = false + } + + function bfgHazards(): Hazard[] { + let extra: Hazard[] | null = null + for (const bfg of bfgs) { + if (bfg.avoidance.length === 0) continue + if (!extra) extra = avoidList.slice() + extra.push(...bfg.avoidance) + } + return extra ?? avoidList + } + /* ------------------------------------------------------------------------ */ /* Targeting */ /* ------------------------------------------------------------------------ */ @@ -1085,6 +1223,11 @@ export function createGame(deps: GameDeps): Game { } seats = [] localIndex = 0 + for (const bfg of bfgs) { + scene.remove(bfg.group) + bfg.dispose() + } + bfgs = [] bolts.clear() fx.clear() boltTargets = [] @@ -1297,6 +1440,8 @@ export function createGame(deps: GameDeps): Game { solarExposure: 0, overdrive: null, shield: null, + bfgCharges: bfgs[seat.index]?.charges ?? 0, + bfgSpool: 0, target: null, }) } @@ -1494,6 +1639,7 @@ export function createGame(deps: GameDeps): Game { const s = seats[seat] if (!s || s.phase.kind !== 'flying' || !s.ship.alive) return recordControls(s, controls) + applyBfgInterlock(s, s.lastControls) s.ship.step(s.lastControls, STEP, dryCtx) } @@ -1934,6 +2080,7 @@ export function createGame(deps: GameDeps): Game { // The hull flies the *record*, not the caller's struct: admission — `aim` // dropped, `spread` zeroed — happens in `recordControls`, and flying its // output is what makes the record the truth rather than a copy of it. + applyBfgInterlock(seat, seat.lastControls) seat.ship.step(seat.lastControls, STEP, ctx) } @@ -1941,10 +2088,11 @@ export function createGame(deps: GameDeps): Game { consumed before the next pilot's turn. */ squadron.length = 0 for (const pilot of pilots) squadron.push(pilot.ship) + const avoidNow = bfgHazards() for (const pilot of pilots) { const quarry = nearestSeat(pilot.ship.position) if (!quarry) continue - const controls = pilot.think(quarry, squadron, avoidList, STEP) + const controls = pilot.think(quarry, squadron, avoidNow, STEP) pilot.ship.step(controls, STEP, ctx) } @@ -1954,6 +2102,11 @@ export function createGame(deps: GameDeps): Game { if (hit.target) audio.hit() } + /* BFG. After everyone has moved, so a round detonates against final + positions, and before mines so a chained field is already gone by the + time contact is tested. */ + resolveBfg() + /* Mines. Checked after everyone has moved, so contact is resolved against final positions rather than a stale frame. */ resolveMines() @@ -2141,6 +2294,7 @@ export function createGame(deps: GameDeps): Game { } for (const pilot of pilots) pilot.ship.syncVisual(alpha) bolts.render(alpha) + for (const bfg of bfgs) bfg.syncVisual(frameDt) fx.update(frameDt, camera) // After `syncVisual`, and at the same blend, so the camera follows the pose @@ -2221,6 +2375,8 @@ export function createGame(deps: GameDeps): Game { expiring: self.shieldTimer <= TIMED_WARN_AT, } : null, + bfgCharges: bfgs[watcher.index]?.charges ?? 0, + bfgSpool: bfgs[watcher.index]?.spool ?? 0, target: targetReadout(watcher), }) hud.updateContacts(contactBuffer, camera) @@ -2292,7 +2448,8 @@ export function createGame(deps: GameDeps): Game { const direct = seatOf(seats, from) if (direct && direct !== seat) { lastHitter.set(self, direct) - creditHit(direct, amount) + if (resolvingBlast) creditDamage(direct, amount) + else creditHit(direct, amount) } // Feedback is the drawn seat's, and only the drawn seat's. Another // participant being hit shakes their camera, on their machine. @@ -2365,6 +2522,14 @@ export function createGame(deps: GameDeps): Game { environment.pickups.reset() rebuildAvoidList() + bfgs = seats.map(() => { + const bfg = createBfg(BFG_FLASH.getHex()) + scene.add(bfg.group) + return bfg + }) + chargeTimer = 0 + resolvingBlast = false + hud.setShip(localSpec) hud.show() hud.callout('ENGAGE', `#${localSpec.accent.toString(16).padStart(6, '0')}`, 1.6) @@ -2462,6 +2627,8 @@ export function createGame(deps: GameDeps): Game { shield: self.shieldTimer, target: bearing, pickups: nearestPods, + bfgCharges: bfgs[seat.index]?.charges ?? 0, + bfgSpool: bfgs[seat.index]?.spool ?? 0, } }, @@ -2502,6 +2669,11 @@ export function createGame(deps: GameDeps): Game { scene.remove(bolts.mesh, fx.group) bolts.dispose() fx.dispose() + for (const bfg of bfgs) { + scene.remove(bfg.group) + bfg.dispose() + } + bfgs = [] }, } } diff --git a/src/game/hud.ts b/src/game/hud.ts index dede145..6e495a0 100644 --- a/src/game/hud.ts +++ b/src/game/hud.ts @@ -12,6 +12,7 @@ import * as THREE from 'three' import type { ShipSpec } from '../ships/specs' +import { BFG_CHARGES } from './bfg' const MAX_CONTACTS = 8 /** Fraction of the half-viewport where edge arrows sit. */ @@ -121,6 +122,10 @@ export interface HudFrame { */ overdrive: HudBuff | null shield: HudBuff | null + /** BFG rounds left this run. */ + bfgCharges: number + /** BFG spool progress, 0..1. */ + bfgSpool: number target: HudTarget | null } @@ -226,6 +231,15 @@ export function createHud(parent: HTMLElement): Hud { const odUi = buffBlock('OVERDRIVE', 'overdrive') const shUi = buffBlock('SHIELD', 'shield') + // The BFG readout is two numbers a pilot needs at a glance and never has time + // to read: how far along the spool is, and whether there is a second shot. + const bfgLabel = el('div', 'hud-label', 'BFG · HOLD F') + const bfgGauge = el('div', 'gauge bfg') + const bfgFill = el('i') + bfgGauge.append(bfgFill) + const bfgPips = el('div', 'bfg-pips') + for (let i = 0; i < BFG_CHARGES; i++) bfgPips.append(el('i')) + tl.append( el('div', 'hud-label', 'AIRFRAME'), shipName, @@ -236,6 +250,9 @@ export function createHud(parent: HTMLElement): Hud { quirkGauge, odUi.block, shUi.block, + bfgLabel, + bfgGauge, + bfgPips, ) const tr = el('div', 'hud-corner hud-tr') @@ -384,6 +401,8 @@ export function createHud(parent: HTMLElement): Hud { const view = new THREE.Vector3() let lastPipCount = -1 + /** BFG pips are only repainted when a round is spent. */ + let lastBfgCharges = -1 return { root, @@ -406,6 +425,7 @@ export function createHud(parent: HTMLElement): Hud { ? 'NANITE REPAIR' : 'PHASE DASH' lastPipCount = -1 + lastBfgCharges = -1 }, update(frame) { @@ -418,6 +438,18 @@ export function createHud(parent: HTMLElement): Hud { quirkFill.style.width = `${frame.quirkValue * 100}%` quirkGauge.classList.toggle('hot', frame.quirkAlarming) + bfgFill.style.width = `${frame.bfgSpool * 100}%` + bfgGauge.classList.toggle('spooling', frame.bfgSpool > 0) + bfgGauge.classList.toggle('spent', frame.bfgCharges === 0) + if (frame.bfgCharges !== lastBfgCharges) { + const children = bfgPips.children + for (let i = 0; i < children.length; i++) { + children[i].classList.toggle('dead', i >= frame.bfgCharges) + } + bfgLabel.textContent = frame.bfgCharges > 0 ? 'BFG · HOLD F' : 'BFG · EXPENDED' + lastBfgCharges = frame.bfgCharges + } + scoreValue.textContent = frame.score.toLocaleString() multValue.textContent = `×${frame.multiplier.toFixed(2)}` bestValue.textContent = `BEST ${frame.best.toLocaleString()}` diff --git a/src/game/intent.ts b/src/game/intent.ts index 70d8e8c..097aef5 100644 --- a/src/game/intent.ts +++ b/src/game/intent.ts @@ -120,13 +120,14 @@ export function rampThrottle(held: number, wanted: unknown, dt: number): number * A packet that is not an object at all — `undefined` because the tick never * arrived, `null`, a number — is a *late* tick, and the answer to a late tick * is to hold the last intent: deflection and throttle carry on, so a hull mid- - * turn keeps turning for a dropped frame instead of snapping level. The two - * triggers do **not** carry: a dropped connection must not keep a gun firing or - * a dash queued on the last thing its owner said before they vanished. + * turn keeps turning for a dropped frame instead of snapping level. The three + * triggers do **not** carry: a dropped connection must not keep a gun firing, a + * dash queued, or a BFG spooling on the last thing its owner said before they + * vanished. * - * `fire` and `dash` are admitted only as the literal `true`. A truthy string is - * not a trigger pull, and the alternative — `Boolean(x)` — would let `"false"` - * fire. + * `fire`, `dash` and `secondary` are admitted only as the literal `true`. A + * truthy string is not a trigger pull, and the alternative — `Boolean(x)` — + * would let `"false"` fire. */ export function admitIntent(raw: unknown, held: Controls, dt: number, out: Controls): Controls { if (typeof raw !== 'object' || raw === null) { @@ -136,6 +137,7 @@ export function admitIntent(raw: unknown, held: Controls, dt: number, out: Contr out.throttle = held.throttle out.fire = false out.dash = false + out.secondary = false out.aim = null out.spread = 0 return out @@ -147,6 +149,7 @@ export function admitIntent(raw: unknown, held: Controls, dt: number, out: Contr out.throttle = rampThrottle(held.throttle, claim.throttle, dt) out.fire = claim.fire === true out.dash = claim.dash === true + out.secondary = claim.secondary === true out.aim = null out.spread = 0 return out diff --git a/src/game/roster.ts b/src/game/roster.ts index 4c1e776..8c494f3 100644 --- a/src/game/roster.ts +++ b/src/game/roster.ts @@ -168,6 +168,7 @@ function freshControls(): Controls { throttle: LAUNCH_THROTTLE, fire: false, dash: false, + secondary: false, aim: null, spread: 0, } @@ -244,6 +245,7 @@ export function recordControls(seat: Participant, c: Controls): void { held.throttle = c.throttle held.fire = c.fire held.dash = c.dash + held.secondary = c.secondary held.aim = null held.spread = 0 } diff --git a/src/game/ship.ts b/src/game/ship.ts index 93651d0..75ecc8b 100644 --- a/src/game/ship.ts +++ b/src/game/ship.ts @@ -38,6 +38,8 @@ export interface Controls { throttle: number fire: boolean dash: boolean + /** BFG trigger — held to spool, released to abort. */ + secondary: boolean /** * Fire direction override. The player always shoots along the nose (`null`); * the AI shoots along a lead solution, which may be slightly off-nose. @@ -652,23 +654,27 @@ export class Ship implements BoltTarget { return true } - takeDamage(amount: number, from: Faction): void { + takeDamage(amount: number, from: Faction, pierceShield = false): void { if (!this.alive || amount <= 0) return /** * A held Shield refuses the damage outright — bolts, mines, station - * scrapes, the star, all of it. + * scrapes, the star, all of it. A BFG blast is the one exception: the + * Shield is for the gunfight, and a fusion sphere that a shielded pilot + * can stand in for free is a button you press on cooldown. `pierceShield` + * is that exception, and the BFG is the only caller. * - * Three things deliberately do *not* happen here. `sinceHit` is not reset, - * because nothing reached the hull and a shielded Drone should keep - * repairing. `onDamaged` does not fire, because that callback is what - * credits a hit to the shooter, and a bolt that accomplished nothing is not - * a hit landed — letting it through would inflate the accuracy stat exactly - * the way sear damage used to. And the ship stays `targetable`, so bolts - * still arrive and splash rather than passing through: a shield you cannot - * see working is a shield the player will not believe in. + * Three things deliberately do *not* happen here when the Shield holds. + * `sinceHit` is not reset, because nothing reached the hull and a shielded + * Drone should keep repairing. `onDamaged` does not fire, because that + * callback is what credits a hit to the shooter, and a bolt that + * accomplished nothing is not a hit landed — letting it through would + * inflate the accuracy stat exactly the way sear damage used to. And the + * ship stays `targetable`, so bolts still arrive and splash rather than + * passing through: a shield you cannot see working is a shield the player + * will not believe in. */ - if (this.shieldTimer > 0) { + if (this.shieldTimer > 0 && !pierceShield) { this.onShielded?.(this, amount) return } diff --git a/src/net/session.ts b/src/net/session.ts index a4f1358..de69084 100644 --- a/src/net/session.ts +++ b/src/net/session.ts @@ -212,7 +212,7 @@ interface Peer { } function neutral(): Controls { - return { pitch: 0, yaw: 0, roll: 0, throttle: 0.6, fire: false, dash: false, aim: null, spread: 0 } + return { pitch: 0, yaw: 0, roll: 0, throttle: 0.6, fire: false, dash: false, secondary: false, aim: null, spread: 0 } } export function createHost(options: HostOptions): Host { diff --git a/src/net/wire.ts b/src/net/wire.ts index 32238b4..03b6132 100644 --- a/src/net/wire.ts +++ b/src/net/wire.ts @@ -162,10 +162,10 @@ export class ByteReader { /* ---- Intent frames ------------------------------------------------------- */ /** The one wire format for a client's intent. Bumped when the layout changes. */ -export const INTENT_VERSION = 1 +export const INTENT_VERSION = 2 -/** Fixed size: version, seat, tick, four floats, two flags. */ -export const INTENT_FRAME_BYTES = 1 + 1 + 4 + 4 * 4 + 2 +/** Fixed size: version, seat, tick, four floats, three flags. */ +export const INTENT_FRAME_BYTES = 1 + 1 + 4 + 4 * 4 + 3 /** * What a client says about one tick. @@ -187,7 +187,7 @@ export function encodeIntent(seat: number, tick: number, c: Controls, w = new By w.u8(seat) w.u32(tick) w.f32(c.pitch).f32(c.yaw).f32(c.roll).f32(c.throttle) - w.bool(c.fire).bool(c.dash) + w.bool(c.fire).bool(c.dash).bool(c.secondary) return w.bytes() } @@ -217,6 +217,7 @@ export function decodeIntent(bytes: Uint8Array, held: Controls, dt: number, out: throttle: r.f32(), fire: r.bool(), dash: r.bool(), + secondary: r.bool(), } r.finish() return { seat, tick, controls: admitIntent(claim, held, dt, out) } diff --git a/src/style.css b/src/style.css index 8fa1b78..6884dc1 100644 --- a/src/style.css +++ b/src/style.css @@ -638,6 +638,44 @@ body { animation: crit-pulse 0.55s steps(2, end) infinite; } +/* BFG: the fill is the spool, the pips are the ammo. */ +.gauge.bfg i { + background: var(--lime); + box-shadow: 0 0 12px var(--lime); +} + +.gauge.bfg.spooling i { + animation: bfg-spool 0.35s steps(2, end) infinite; +} + +.gauge.bfg.spent { + opacity: 0.35; +} + +@keyframes bfg-spool { + 50% { + opacity: 0.55; + } +} + +.bfg-pips { + display: flex; + gap: 5px; + margin-top: 4px; +} + +.bfg-pips i { + width: 22px; + height: 4px; + background: var(--lime); + box-shadow: 0 0 8px var(--lime); +} + +.bfg-pips i.dead { + background: rgba(255, 255, 255, 0.14); + box-shadow: none; +} + .readout-row { display: flex; justify-content: space-between;