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) |