From f7bef3e6fd6fdb3399ec59db4505959885a869b2 Mon Sep 17 00:00:00 2001 From: Stephen DeLorme Date: Sun, 6 Sep 2026 15:15:59 -0400 Subject: [PATCH] Fix joined-player weapon sound and muzzle presentation Signed-off-by: Stephen DeLorme --- README.md | 13 ++- scripts/mutate.mjs | 60 +++++++++++++- scripts/simcheck.ts | 138 ++++++++++++++++++++++++++++++-- src/game/bolts.ts | 9 ++- src/game/game.ts | 50 ++++++++---- src/game/weapon-presentation.ts | 42 ++++++++++ src/net/session.ts | 2 +- src/net/snapshot.ts | 6 +- 8 files changed, 288 insertions(+), 32 deletions(-) create mode 100644 src/game/weapon-presentation.ts diff --git a/README.md b/README.md index 22cb6d2..bbe7a61 100644 --- a/README.md +++ b/README.md @@ -269,14 +269,21 @@ None is configured by default, because a relay is exactly the infrastructure thi trying not to run. **A joined player's stick is attached to their ship.** A client flies its own hull the moment -the stick moves — `Game.predict` steps that one seat locally on the same flight model, guns into -nothing — and the host's snapshot carries, per seat, the client intent tick it last flew +the stick moves — `Game.predict` steps that one seat locally on the same flight model — +and the host's snapshot carries, per seat, the client intent tick it last flew (`ackTick`). On every snapshot the client resets to the host's truth and `Game.reconcile`s by replaying its unacknowledged intents on top, keeping the previous pose where the hull was last drawn so a correction slides over one frame rather than snapping. Flight is deterministic, so on a clean wire there is nothing to correct: `simcheck` asserts the host's truth lands within 0.1 units of what the client predicted for every acknowledged intent, and that a client with prediction off -trails by the wire's latency. Bolts and hits are never predicted; they arrive with the truth. +trails by the wire's latency. Fresh local shots produce cosmetic tracers at the predicted +muzzle and one local laser sound per volley. Reconciliation is silent; snapshots restore the +weapon cooldown along with the hull. A volley counter prevents corrections from presenting +the same shot twice. Delayed authoritative bolts from that seat remain in the snapshot but +are hidden while prediction is active. Other bolts are drawn from snapshots as before. +Cosmetic tracers can stop against visible geometry but cannot damage anything: hits, damage, +and scoring remain entirely authoritative. Protocol and snapshot version 3 carry the cooldown; +both browsers must load the same version. **The picture moves to the frame's clock, not the wire's.** A wire delivers to its own rhythm — two snapshots in one tick, none the next — and a client that applied each as it arrived drew to diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index 7947aa5..825af82 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -352,8 +352,62 @@ 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)', - to: ' recordControls(s, controls)', + from: ' s.ship.step(s.lastControls, STEP, watching ? predictionCtx : dryCtx)', + to: '', + }, + { + name: 'correction replays audible effects', + file: 'src/game/game.ts', + from: 'audio: { ...audio, laser() {}, dash() {}, overheat() {} },', + to: 'audio,', + }, + { + name: 'correction leaves the predicted weapon clock running', + file: 'src/game/game.ts', + from: ' ship.fireTimer = s.fireTimer', + to: '', + }, + { + name: 'fresh prediction still shoots into nothing', + file: 'src/game/game.ts', + from: 'bolts: { ...bolts, fire: weapons.fire },', + to: 'bolts: { ...bolts, fire() {} },', + }, + { + name: 'the delayed local bolts are drawn twice', + file: 'src/game/bolts.ts', + from: 'if (omitFaction !== undefined && pool[i].faction === omitFaction)', + to: 'if (false)', + }, + { + name: 'the client draws cosmetic and authoritative local shots together', + file: 'src/game/game.ts', + from: 'bolts.render(alpha, presentingWeapons ? watcher.faction : undefined)', + to: 'bolts.render(alpha)', + }, + { + name: 'a correction presents the same volley again', + file: 'src/game/weapon-presentation.ts', + from: 'fresh = volley > presented', + to: 'fresh = true', + }, + { + name: 'a joined player hears the remote laser pitch', + file: 'src/game/weapon-presentation.ts', + from: 'audio.laser(true)', + to: 'audio.laser(false)', + }, + { + name: 'a cosmetic collision calls authoritative damage', + file: 'src/game/weapon-presentation.ts', + from: 'bolts.update(dt, visible, hazards)', + to: 'bolts.update(dt, targets, hazards)', + }, + { + name: 'a fresh match keeps the previous volley watermark', + file: 'src/game/weapon-presentation.ts', + from: 'clear() { bolts.clear(); presented = 0; volley = 0; fresh = false },', + to: 'clear() { bolts.clear() },', }, { name: 'the host never acknowledges an intent', @@ -908,7 +962,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 = 653 +const EXPECTED_ASSERTIONS = 672 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 c15196f..ea13236 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -32,7 +32,8 @@ import { import { decodeIntent, encodeIntent, INTENT_FRAME_BYTES, INTENT_VERSION } from '../src/net/wire' import { createLoopback } from '../src/net/channel' import { createMatchLobby } from '../src/net/lobby' -import { decodeHello, decodeLobby, encodeHello, encodeLobby, type LobbyState } from '../src/net/session' +import { createWeaponPresentation } from '../src/game/weapon-presentation' +import { decodeHello, decodeLobby, encodeHello, encodeLobby, PROTOCOL_VERSION, type LobbyState } from '../src/net/session' import { modeFromLocation } from '../src/net/browser' import { createLinkMonitor, type LinkReport } from '../src/net/link' import { createClient, createHost, decodeResult, decodeWelcome, encodeResult, encodeWelcome, FRAME, SNAPSHOT_DEPTH, SNAPSHOT_QUEUE } from '../src/net/session' @@ -2820,6 +2821,7 @@ function shipStateFixture(seed: number): ShipState { overdriveTimer: f(21), shieldTimer: f(22), solarExposure: f(23), + fireTimer: f(24), shotsFired: seed * 97, } } @@ -3866,8 +3868,8 @@ function testTheStickIsAttachedToTheShip(): void { check('a seat that does not exist is not predicted, and nothing throws', !threw) check('a seat that does is', p1.z !== p0.z || p1.y !== p0.y) - // Guns into nothing: a predicted shot must leave the bolt pool empty, or the - // host's next restore would flicker it out and the truth re-fire it later. + // Prediction must leave the authoritative bolt pool empty. Cosmetic tracers + // live separately and never appear in a captured world. // Past the warp-in first: a hull cannot fire for its first 0.85 s, and a // burst shorter than that would prove nothing either way. for (let i = 0; i < 120; i++) solo.predict(0, controls({ fire: true, throttle: 1 })) @@ -3876,7 +3878,7 @@ function testTheStickIsAttachedToTheShip(): void { for (let i = 0; i < 30; i++) solo.step([controls({ fire: true, throttle: 1 })]) const steppedBolts = solo.capture().bolts.length check('the predicted trigger was actually pulled', shotsPredicted > 0, `${shotsPredicted} shots`) - check('a predicted shot fires no bolt', predictedBolts === 0, `${predictedBolts} bolts in the pool`) + check('a predicted shot fires no authoritative bolt', predictedBolts === 0, `${predictedBolts} bolts in the pool`) check('while a stepped one does', steppedBolts > 0, `${steppedBolts}`) solo.dispose() } @@ -7438,7 +7440,7 @@ function testTheWingLaunchesItsReservations(): void { bad.pump() check('an old protocol is refused without reserving a seat', reason === 1 && !bad.a.open && badWing.state().seats[1].pilot === 'ai') let malformed = 0 - for (const bytes of [new Uint8Array([FRAME.HELLO, 2, 99]), new Uint8Array([FRAME.HELLO, 2]), new Uint8Array([...encodeHello(), 0])]) { + for (const bytes of [new Uint8Array([FRAME.HELLO, PROTOCOL_VERSION, 99]), new Uint8Array([FRAME.HELLO, PROTOCOL_VERSION]), new Uint8Array([...encodeHello(), 0])]) { try { decodeHello(bytes) } catch { malformed++ } } check('unknown, short, and trailing hull claims fail decoding', malformed === 3) @@ -7462,6 +7464,130 @@ function testTheWingLaunchesItsReservations(): void { JSON.stringify(modeFromLocation('?host=' + v)) === '{"kind":"host","guest":"wasp","seats":2}')) } +/** A tracer is a drawing; even a collision cannot call into authoritative damage. */ +function testPredictedWeaponsAreOnlyPresentation(): void { + section('Predicted weapons present a volley once without deciding damage') + const audio = silentAudio(), weapons = createWeaponPresentation(audio) + const matrix = new THREE.Matrix4() + const visible = () => { + weapons.render(1) + let count = 0 + for (let i = 0; i < weapons.mesh.count; i++) { + weapons.mesh.getMatrixAt(i, matrix) + if (matrix.determinant() !== 0) count++ + } + return count + } + const shot = { origin: new THREE.Vector3(), direction: new THREE.Vector3(0, 0, -1), + speed: 100, damage: 99, faction: humanFaction(1), color: new THREE.Color('cyan') } + weapons.begin(0) + weapons.fire(shot) + weapons.fire({ ...shot, origin: new THREE.Vector3(1, 0, 0) }) + weapons.laser() + check('two muzzles make two tracers but one sound', visible() === 2 && audio.laserCount === 1) + weapons.begin(0); weapons.fire(shot); weapons.laser() + check('revisiting a predicted volley does not repeat its effects', visible() === 2 && audio.laserCount === 1) + weapons.confirm(5); weapons.begin(4); weapons.fire(shot); weapons.laser() + check('taking over an acknowledged volley does not replay it', visible() === 2 && audio.laserCount === 1) + let damageCalls = 0 + weapons.advance(0.1, [{ position: new THREE.Vector3(0, 0, -5), radius: 3, + alive: true, targetable: true, faction: FACTION_AI, takeDamage() { damageCalls++ } }], []) + check('a visible collision consumes tracers without calling real damage', visible() === 0 && damageCalls === 0) + weapons.clear(); weapons.begin(0); weapons.fire(shot); weapons.laser() + check('a new match can present its first volley again', visible() === 1 && audio.laserCount === 2) + weapons.advance(3, [], []) + check('missed cosmetic tracers expire', visible() === 0) + weapons.dispose() + + const bolts = createBolts() + bolts.fire(shot); bolts.fire({ ...shot, faction: FACTION_AI }) + const capture = () => { + const live: string[] = [] + bolts.each((slot, bolt) => live.push(JSON.stringify({ slot, ...bolt }))) + return live.join('|') + } + const before = capture() + bolts.render(1, humanFaction(1)) + bolts.mesh.getMatrixAt(0, matrix) + const localHidden = matrix.determinant() === 0 + bolts.mesh.getMatrixAt(1, matrix) + check('prediction hides only the delayed local bolts', localHidden && matrix.determinant() !== 0) + check('hiding a bolt cannot alter authoritative state', capture() === before) + bolts.render(1); bolts.mesh.getMatrixAt(0, matrix) + check('a host still draws its authoritative local bolts', matrix.determinant() !== 0) + bolts.dispose() +} + +function testClientWeaponsUnderLatency(): void { + section('Joined Wasp weapons stay at the muzzle and do not sound on replay') + for (const loss of [0, 0.25]) { + const scene = new THREE.Scene(), audio = silentAudio(), pitches: boolean[] = [] + audio.laser = local => { audio.laserCount++; pitches.push(local) } + const hostGame = newMatch(), game = newMatch({ scene, audio }) + const host = createHost({ game: hostGame, setup: { ships: ['hornet', 'wasp'], seed: 876, respawn: true }, backfill: false }) + host.start() + const wire = createLoopback({ latency: 6, loss, seed: 123 }) + const client = createClient({ game, channel: wire.b }) + host.accept(wire.a) + for (let i = 0; i < 7; i++) wire.pump() + const mesh = scene.getObjectByName('predicted-bolts') as THREE.InstancedMesh + const authority = scene.children.find(o => o instanceof THREE.InstancedMesh && o !== mesh) as THREE.InstancedMesh + const hull = new Ship(SHIPS.wasp, humanFaction(1)) + const seen = new Set(), matrix = new THREE.Matrix4(), origin = new THREE.Vector3() + let births = 0, muzzleError = 0, replaySounds = 0, delayedLocal = 0, duplicates = 0 + for (let i = 0; i < 180; i++) { + client.tick(controls({ fire: true, throttle: 1 })) + const predicted = game.capture().seats[1]?.ship + game.render(1, 0) + for (const bolt of game.capture().bolts) { + if (bolt.faction !== humanFaction(1)) continue + delayedLocal++ + authority.getMatrixAt(bolt.slot, matrix) + if (matrix.determinant() !== 0) duplicates++ + } + if (predicted) { + const q = new THREE.Quaternion(predicted.quaternion.x, predicted.quaternion.y, predicted.quaternion.z, predicted.quaternion.w) + const p = new THREE.Vector3(predicted.position.x, predicted.position.y, predicted.position.z) + for (let slot = 0; slot < mesh.count; slot++) { + mesh.getMatrixAt(slot, matrix) + if (matrix.determinant() === 0 || seen.has(slot)) continue + seen.add(slot); births++ + origin.setFromMatrixPosition(matrix) + muzzleError = Math.max(muzzleError, Math.min(...hull.visual.muzzles.map(m => origin.distanceTo(m.clone().applyQuaternion(q).add(p))))) + } + } + const sounds = audio.laserCount + host.tick(controls({ throttle: .6 })); wire.pump() + replaySounds += audio.laserCount - sounds + } + const hostShots = hostGame.snapshot(1)?.shotsFired ?? 0 + console.log(` weapons loss=${loss}: host=${hostShots}, sounds=${audio.laserCount}, tracers=${births}, muzzle error=${muzzleError}`) + check(`latency/loss ${loss}: firing is audible at the Wasp cadence`, hostShots > 15 && audio.laserCount >= hostShots - 3 && audio.laserCount <= hostShots + 4) + check(`latency/loss ${loss}: every sound is a fresh local volley`, pitches.length > 15 && pitches.every(Boolean) && replaySounds === 0) + check(`latency/loss ${loss}: every tracer starts at the predicted muzzle`, births > 15 && births === audio.laserCount * SHIPS.wasp.barrels && muzzleError < .001) + check(`latency/loss ${loss}: delayed authoritative shots cannot double the picture`, delayedLocal > 15 && duplicates === 0) + host.close(); game.dispose(); hostGame.dispose(); hull.dispose() + } + + const audio = silentAudio(), game = newMatch({ audio }) + game.start({ ships: ['hornet', 'wasp'], local: 1, seed: 876 }) + const state = game.capture() + state.seats[1].ship.warpTimer = 0 + state.seats[1].ship.fireTimer = .1 + const replay = Array.from({ length: 18 }, () => controls({ fire: true, dash: true })) + let dashes = 0, overheats = 0 + // Supply counters when constructing a second game because contexts bind the methods. + const silent = newMatch({ audio: { ...audio, dash() { dashes++ }, overheat() { overheats++ } } }) + silent.start({ ships: ['hornet', 'wasp'], local: 1, seed: 876 }) + silent.apply(state); silent.reconcile(1, replay) + const first = silent.capture().seats[1].ship + silent.apply(state); silent.reconcile(1, replay) + const second = silent.capture().seats[1].ship + check('correction restores the weapon clock before replay', first.shotsFired > 0 && first.shotsFired === second.shotsFired && first.fireTimer === second.fireTimer) + check('replay emits no laser, dash, or overheat audio', audio.laserCount === 0 && dashes === 0 && overheats === 0) + game.dispose(); silent.dispose() +} + console.log('NEON ORBIT — headless simulation checks') testPlayerBoltsKillEnemies() testHullBarFadeCurve() @@ -7522,6 +7648,8 @@ testARunMatchesItsRecordedBaseline() testOneFrameDepictsOneInstant() testALinkThatDropsIsNoticed() testTheWingLaunchesItsReservations() +testPredictedWeaponsAreOnlyPresentation() +testClientWeaponsUnderLatency() console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) failed.`) process.exit(failures === 0 ? 0 : 1) diff --git a/src/game/bolts.ts b/src/game/bolts.ts index 5c94455..dcc5a55 100644 --- a/src/game/bolts.ts +++ b/src/game/bolts.ts @@ -166,7 +166,7 @@ export interface Bolts { * smoothly — the one thing on screen moving faster than anything else, and * the one thing not smoothed. */ - render(alpha: number): void + render(alpha: number, omitFaction?: Faction): void clear(): void dispose(): void /** @@ -394,8 +394,11 @@ export function createBolts(): Bolts { return hits }, - render(alpha) { - for (let i = 0; i < MAX_BOLTS; i++) writeInstance(i, pool[i], alpha) + render(alpha, omitFaction) { + for (let i = 0; i < MAX_BOLTS; i++) { + if (omitFaction !== undefined && pool[i].faction === omitFaction) mesh.setMatrixAt(i, hidden) + else writeInstance(i, pool[i], alpha) + } mesh.instanceMatrix.needsUpdate = true mesh.instanceColor!.needsUpdate = true }, diff --git a/src/game/game.ts b/src/game/game.ts index 2101730..42d7d00 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -23,6 +23,7 @@ import * as THREE from 'three' import type { Audio } from '../core/audio' +import { createWeaponPresentation } from './weapon-presentation' import type { Input } from '../core/input' import { STREAM, subRng, type Rng } from '../core/rng' import type { MatchResult, RunResult, SeatLine } from '../core/scores' @@ -460,8 +461,10 @@ export function createGame(deps: GameDeps): Game { const { scene, camera, environment, input, audio, hud } = deps const bolts: Bolts = createBolts() + const weapons = createWeaponPresentation(audio) + let presentingWeapons = false const fx: Fx = createFx() - scene.add(bolts.mesh, fx.group) + scene.add(bolts.mesh, weapons.mesh, fx.group) const chase: ChaseCamera = createChaseCamera(camera) @@ -475,19 +478,20 @@ export function createGame(deps: GameDeps): Game { bolts, localFaction: FACTION_PLAYER, } - /** - * The context a *predicted* step flies in: the same arena, but the guns fire - * into nothing. A client predicts its own flight so the stick feels - * immediate; it does not predict its bolts, because the host's snapshot - * restores the whole pool every tick and a locally fired bolt would flicker - * out and reappear a round trip later. Bolts arrive with the truth. - */ + // Reconciliation advances weapon clocks but must never replay their effects. const dryCtx: ShipContext = { hazards: environment.hazards, - audio, + audio: { ...audio, laser() {}, dash() {}, overheat() {} }, bolts: { ...bolts, fire() {} }, localFaction: FACTION_PLAYER, } + // Only a newly sampled input presents a local shot. Tracers are separate from + // the authoritative pool, so snapshot restore cannot erase or duplicate them. + const predictionCtx: ShipContext = { + ...dryCtx, + audio: { ...audio, laser: weapons.laser, overheat: weapons.overheat }, + bolts: { ...bolts, fire: weapons.fire }, + } /** * The roster. Empty between matches, which is the state `player === null` used @@ -1086,6 +1090,8 @@ export function createGame(deps: GameDeps): Game { seats = [] localIndex = 0 bolts.clear() + weapons.clear() + presentingWeapons = false fx.clear() boltTargets = [] contactBuffer.length = 0 @@ -1487,14 +1493,21 @@ export function createGame(deps: GameDeps): Game { * What a joined client does with its own intent instead of waiting a round * trip to see it: the hull moves now, on the same flight model the host runs, * and the host's next snapshot either lands exactly where this predicted — - * the normal case, since flight is deterministic — or corrects it. Flight - * only: the guns fire into `dryCtx`, and nothing here decides a hit. + * the normal case, since flight is deterministic — or corrects it. Fresh + * local shots get cosmetic tracers and sound; nothing here decides a hit. */ function predict(seat: number, controls: Controls): void { const s = seats[seat] - if (!s || s.phase.kind !== 'flying' || !s.ship.alive) return + if (!s || paused) return + const watching = seat === localIndex + if (watching) { + presentingWeapons = true + weapons.advance(STEP, boltTargets, environment.hazards) + } + if (s.phase.kind !== 'flying' || !s.ship.alive) return recordControls(s, controls) - s.ship.step(s.lastControls, STEP, dryCtx) + if (watching) weapons.begin(s.ship.shotsFired) + s.ship.step(s.lastControls, STEP, watching ? predictionCtx : dryCtx) } /** @@ -1587,6 +1600,7 @@ export function createGame(deps: GameDeps): Game { overdriveTimer: ship.overdriveTimer, shieldTimer: ship.shieldTimer, solarExposure: ship.solarExposure, + fireTimer: ship.fireTimer, shotsFired: ship.shotsFired, } } @@ -1616,6 +1630,7 @@ export function createGame(deps: GameDeps): Game { ship.overdriveTimer = s.overdriveTimer ship.shieldTimer = s.shieldTimer ship.solarExposure = s.solarExposure + ship.fireTimer = s.fireTimer ship.shotsFired = s.shotsFired } @@ -1748,6 +1763,7 @@ export function createGame(deps: GameDeps): Game { const state = s.seats[i] const ship = seat.ship writeShip(ship, state.ship) + if (i === localIndex) weapons.confirm(state.ship.shotsFired) // The HUD reads the seat's flown throttle, and a mirror flies nothing. seat.lastControls.throttle = state.throttle acks[i] = state.ackTick @@ -2140,7 +2156,8 @@ export function createGame(deps: GameDeps): Game { } } for (const pilot of pilots) pilot.ship.syncVisual(alpha) - bolts.render(alpha) + bolts.render(alpha, presentingWeapons ? watcher.faction : undefined) + weapons.render(alpha) fx.update(frameDt, camera) // After `syncVisual`, and at the same blend, so the camera follows the pose @@ -2278,6 +2295,8 @@ export function createGame(deps: GameDeps): Game { feedSeq = 0 feedSeen = 0 ctx.localFaction = seats[localIndex].faction + dryCtx.localFaction = ctx.localFaction + predictionCtx.localFaction = ctx.localFaction for (const seat of seats) { const ship = seat.ship @@ -2499,8 +2518,9 @@ export function createGame(deps: GameDeps): Game { dispose() { clearArena() - scene.remove(bolts.mesh, fx.group) + scene.remove(bolts.mesh, weapons.mesh, fx.group) bolts.dispose() + weapons.dispose() fx.dispose() }, } diff --git a/src/game/weapon-presentation.ts b/src/game/weapon-presentation.ts new file mode 100644 index 0000000..7dcc5c4 --- /dev/null +++ b/src/game/weapon-presentation.ts @@ -0,0 +1,42 @@ +/** Local predicted tracers and sound. Never part of a snapshot or a damage decision. */ +import type { Audio } from '../core/audio' +import { createBolts, type BoltTarget, type FireRequest } from './bolts' +import type { Hazard } from '../world/environment' + +export function createWeaponPresentation(audio: Audio) { + const bolts = createBolts() + bolts.mesh.name = 'predicted-bolts' + let presented = 0 + let volley = 0 + let fresh = false + + return { + mesh: bolts.mesh, + // An acknowledged volley may already have been presented before correction. + confirm(shots: number) { presented = Math.max(presented, shots) }, + begin(shots: number) { + volley = shots + 1 + fresh = volley > presented + }, + fire(request: FireRequest) { + if (fresh) bolts.fire({ ...request, damage: 0 }) + }, + laser() { + if (!fresh) return + presented = volley + audio.laser(true) + }, + overheat() { if (fresh) audio.overheat() }, + advance(dt: number, targets: readonly BoltTarget[], hazards: Hazard[]) { + // Stop a tracer against the visible world without invoking a Ship callback. + const visible = targets.map(t => ({ + position: t.position, radius: t.radius, alive: t.alive, + targetable: t.targetable, faction: t.faction, takeDamage() {}, + })) + bolts.update(dt, visible, hazards) + }, + render: bolts.render, + clear() { bolts.clear(); presented = 0; volley = 0; fresh = false }, + dispose: bolts.dispose, + } +} diff --git a/src/net/session.ts b/src/net/session.ts index 383a697..c3cb0f7 100644 --- a/src/net/session.ts +++ b/src/net/session.ts @@ -41,7 +41,7 @@ import type { Channel } from './channel' import { decodeSnapshot, encodeSnapshot, type WorldSnapshot } from './snapshot' import { ByteReader, ByteWriter, decodeIntent, encodeIntent } from './wire' -export const PROTOCOL_VERSION = 2 +export const PROTOCOL_VERSION = 3 /** Ticks between repeated hellos while a client waits for its welcome. */ export const HELLO_EVERY = 30 diff --git a/src/net/snapshot.ts b/src/net/snapshot.ts index 9442240..d84992d 100644 --- a/src/net/snapshot.ts +++ b/src/net/snapshot.ts @@ -28,7 +28,7 @@ import { FACTION_AI, type Faction } from '../game/bolts' import { SHIP_ORDER, type ShipId } from '../ships/specs' import { ByteReader, ByteWriter } from './wire' -export const SNAPSHOT_VERSION = 2 +export const SNAPSHOT_VERSION = 3 export interface Vec3 { x: number @@ -62,6 +62,7 @@ export interface ShipState { overdriveTimer: number shieldTimer: number solarExposure: number + fireTimer: number shotsFired: number } @@ -175,7 +176,7 @@ function writeShip(w: ByteWriter, s: ShipState): void { w.f32(s.warpTimer).f32(s.flash).f32(s.sinceHit) w.f32(s.heat).f32(s.heatLocked).f32(s.dashTimer).f32(s.dashCooldown) w.f32(s.overdriveTimer).f32(s.shieldTimer).f32(s.solarExposure) - w.u32(s.shotsFired) + w.f32(s.fireTimer).u32(s.shotsFired) } function readShip(r: ByteReader): ShipState { @@ -197,6 +198,7 @@ function readShip(r: ByteReader): ShipState { overdriveTimer: r.f32(), shieldTimer: r.f32(), solarExposure: r.f32(), + fireTimer: r.f32(), shotsFired: r.u32(), } }