b3d-launcher

Fires ballistic projectiles — the scene-side shoot loop, bridging the pure ballistics.ts integrator + the pure resource.ts ammo pool to Babylon (see COMBAT-DESIGN.md). Each shot is a small mesh flown by ballisticStep (gravity + drag), swept-collision raycast every frame from its previous point to its new one, and on impact it fires a warhead (detonateWarhead) — so a direct hit or a near miss both do AOE damage to whatever's in blast range. Ammo is a Resource (finite, optionally recharging); fireRate gates the cadence.

Demo

Steer the gun with A/D (left stick), hold the right trigger (or F / the glass B button) to fire a stream of shells — the standard controller, so the same controls work on keyboard, touch, and in VR. Left-drag orbits the view. Shells arc under gravity and blast the wide cube field — a direct hit kills, a near miss chips the neighbours. Tune muzzle speed, fire rate, drag and the warhead in the ⚙ panel.

import { b3d, b3dController, b3dLauncher, b3dDestroyable, b3dLight, b3dSkybox, b3dGround, label3d, slider3d } from 'tosijs-3d'
import { orbitCam } from 'demo-utils'
import { tosi } from 'tosijs'

// Unique tosi() key per demo — tosi() is a singleton keyed by path, so two demos on the
// same page both using `s` would clobber each other (the missile demo's `s` has no
// muzzleSpeed → the gun bound to undefined → NaN velocity → invisible shells).
const { launcherGun: s } = tosi({ launcherGun: { muzzleSpeed: 30, fireRate: 5, drag: 0.01, damage: 20, blastRadius: 3 } })
const launcher = b3dLauncher({ x: 0, y: 0.6, z: -8, muzzleSpeed: s.muzzleSpeed })

// A wide, shallow field so steering the gun left/right sweeps across it.
const targets = []
for (let i = 0; i < 24; i++) {
  targets.push(b3dDestroyable({ x: (i % 8) * 1.5 - 5.25, y: 0.4, z: Math.floor(i / 8) * 1.6, size: 0.8, capacity: 10, color: '#cc4444' }))
}

const scene = b3d(
  {
    gamepad: 'left_stick,right_trigger',
    scenePanelOpen: true,
    scenePanel: () => [
      label3d({ text: 'Launcher', bold: true }),
      slider3d({ label: 'muzzle speed', value: s.muzzleSpeed, min: 8, max: 50, step: 1 }),
      slider3d({ label: 'fire rate', value: s.fireRate, min: 1, max: 12, step: 1 }),
      slider3d({ label: 'drag', value: s.drag, min: 0, max: 0.05, step: 0.002 }),
      slider3d({ label: 'damage', value: s.damage, min: 1, max: 60, step: 1 }),
      slider3d({ label: 'blast radius', value: s.blastRadius, min: 0.5, max: 8, step: 0.5 }),
    ],
    sceneCreated(el, BABYLON) {
      orbitCam(el, { alpha: -Math.PI / 2, beta: Math.PI / 3.4, radius: 20, target: [0, 0.5, 0] })
    },
  },
  b3dLight({ y: 1, intensity: 0.85 }),
  b3dSkybox({ timeOfDay: 10 }),
  b3dGround({ width: 40, height: 40, color: '#5a6b52' }),
  b3dController({
    mapping: 'biped',
    drive(input, dt) {
      launcher.ry += input.turn * dt * 70 // steer azimuth (A/D · stick · VR)
      if (input.shoot > 0.5 || input.sprint > 0.5) {
        launcher.muzzleSpeed = s.muzzleSpeed.value
        launcher.fireRate = s.fireRate.value
        launcher.drag = s.drag.value
        launcher.damage = s.damage.value
        launcher.blastRadius = s.blastRadius.value
        launcher.fire() // fire where the barrel points (right trigger · F · glass button)
      }
    },
  }),
  launcher,
  ...targets,
)
preview.append(scene)
tosi-b3d { width: 100%; height: 100%; }

Guided missiles

fireAt(targetMesh) launches a homing missile instead of a dumb shell — it leads the target and curves onto it (pure interceptLead + steerToward), holding missileSpeed, turning within turnRate. Hold the right trigger (or F) to loose missiles at the orbiting cube; they bend to chase it and detonate on contact. The target respawns at a fresh altitude each time you destroy it. Drop turnRate and watch them overshoot a hard-turning target.

import { b3d, b3dController, b3dLauncher, b3dDestroyable, b3dLight, b3dSkybox, b3dGround, label3d, slider3d } from 'tosijs-3d'
import { orbitCam } from 'demo-utils'
import { tosi } from 'tosijs'

// fireRate 2.5 (a missile every 0.4s) with a slower cruise keeps 2–3 missiles in the
// air at once, chasing the target together before it's destroyed.
// Distinct tosi() key from the gun demo above (shared-singleton gotcha — see there).
const { launcherMissile: s } = tosi({ launcherMissile: { missileSpeed: 16, turnRate: 3, fireRate: 2.5 } })
const launcher = b3dLauncher({ x: 0, y: 0.6, z: 0, missileSpeed: s.missileSpeed, turnRate: s.turnRate, fireRate: s.fireRate, blastRadius: 3 })

// Shared so the orbit loop (in sceneCreated) and the controller's drive both reach it.
const state = { target: null }

const scene = b3d(
  {
    gamepad: 'right_trigger',
    scenePanelOpen: true,
    scenePanel: () => [
      label3d({ text: 'Missile', bold: true }),
      slider3d({ label: 'missile speed', value: s.missileSpeed, min: 8, max: 40, step: 1 }),
      slider3d({ label: 'turn rate', value: s.turnRate, min: 0.5, max: 8, step: 0.25 }),
      slider3d({ label: 'fire rate', value: s.fireRate, min: 0.5, max: 5, step: 0.5 }),
    ],
    sceneCreated(el, BABYLON) {
      orbitCam(el, { alpha: -Math.PI / 2.2, beta: Math.PI / 3, radius: 30, target: [0, 4, 0] })
      let a = 0, baseY = 4
      // Respawn the target on death at a fresh (hittable) altitude.
      const spawn = () => {
        baseY = 3 + Math.random() * 7 // ~3–10m: high enough to lead, low enough to reach
        const t = b3dDestroyable({ meshName: 'drone', x: 12, y: baseY, z: 0, size: 1.4, capacity: 40, color: '#3388dd', explode: 'on' })
        el.appendChild(t)
        return t
      }
      state.target = spawn()
      el.addEventListener('destroyed', () => {
        const dead = state.target; state.target = null
        if (dead) dead.remove()
        setTimeout(() => { state.target = spawn() }, 400)
      })
      el.scene.onBeforeRenderObservable.add(() => {
        a += el.scene.getEngine().getDeltaTime() / 1000
        const t = state.target
        if (!t || t.dead || !t.mesh) return
        t.x = Math.cos(a * 0.7) * 12
        t.z = Math.sin(a * 0.7) * 12
        t.y = baseY + Math.sin(a * 1.5) * 1.2
      })
    },
  },
  b3dLight({ y: 1, intensity: 0.85 }),
  b3dSkybox({ timeOfDay: 10 }),
  b3dGround({ width: 50, height: 50, color: '#5a6b52' }),
  b3dController({
    mapping: 'biped',
    drive(input) {
      const t = state.target
      if ((input.shoot > 0.5 || input.sprint > 0.5) && t && !t.dead && t.mesh) {
        launcher.missileSpeed = s.missileSpeed.value
        launcher.turnRate = s.turnRate.value
        launcher.fireRate = s.fireRate.value
        launcher.fireAt(t.mesh) // launch a homing missile (F · glass button · XR trigger)
      }
    },
  }),
  launcher,
)
preview.append(scene)
tosi-b3d { width: 100%; height: 100%; }

Attributes

Attribute Default Description
muzzleSpeed 30 Launch speed (units/sec) along the fire direction
fireRate 5 Max shots per second (cadence gate)
missileSpeed 22 Cruise speed of a guided shot (fireAt)
turnRate 3 Guided-missile agility (rad/sec)
ammo 40 Magazine capacity (a Resource)
reloadRate 8 Ammo regenerated per second (0 = no reload)
reloadDelay 1 Seconds after firing before reload resumes
gravity -9.81 Vertical acceleration on the shell
drag 0.01 Quadratic drag coefficient (0 = vacuum)
mass 1 Shell mass (higher flies flatter/further under drag)
projRadius 0.12 Shell visual radius
projColor '#ffdd55' Shell emissive colour
maxLifetime 6 Seconds before an un-impacted shell self-disposes
damage 20 Warhead full damage (see b3d-warhead)
fullRadius 1 Warhead full-damage radius
blastRadius 3 Warhead falloff radius
los 'on' Warhead line-of-sight gating
x,y,z 0 Launcher position (muzzle offset forward from here)