b3d-destroyable
A thing that can take damage and be destroyed — the scene-side bridge to the pure
CombatWorld (see destroyable.ts / COMBAT-DESIGN.md). Drop it into a <tosi-b3d>
and it registers a Destroyable in the scene's combat world, drives a placeholder
cube mesh (real meshes/GLB later), and runs its death outcome when killed — whether
by a direct hit or a chain reaction resolved in the combat tick.
Demo
Click any cube to damage it (1/click) — it flashes on a hit and dies at 0 hp. The front grid are independent targets; the back row is chain-linked, so killing the leftmost cascades the reaction down the line (chains are wired after mount, since they reference combat ids that only exist once the targets have mounted).
import { b3d, b3dDestroyable, b3dLight, b3dSkybox, b3dGround } from 'tosijs-3d'
const grid = []
for (let i = 0; i < 12; i++) {
grid.push(b3dDestroyable({ x: (i % 4) * 1.6 - 2.4, y: 0.5, z: Math.floor(i / 4) * 1.6, capacity: 3, color: '#cc4444' }))
}
const chainRow = []
for (let i = 0; i < 6; i++) {
chainRow.push(b3dDestroyable({ x: i * 1.6 - 4, y: 0.5, z: -3, capacity: 4, color: '#e0a020' }))
}
const scene = b3d(
{
sceneCreated(el, BABYLON) {
const cam = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, Math.PI / 3.2, 15, new BABYLON.Vector3(0, 0.5, -0.5), el.scene)
cam.attachControl(el.querySelector('canvas'), true)
el.setActiveCamera(cam)
// click a cube → damage it
el.scene.onPointerDown = (_evt, pick) => {
if (!pick.hit || !pick.pickedMesh) return
const t = [...grid, ...chainRow].find((c) => c.mesh === pick.pickedMesh)
if (t) t.damage(1)
}
},
},
b3dLight({ y: 1, intensity: 0.85 }),
b3dSkybox({ timeOfDay: 10 }),
b3dGround({ width: 24, height: 24, color: '#5a6b52' }),
...grid,
...chainRow,
)
preview.append(scene)
// Wire the chain row once every target has mounted (its combat id exists then).
const wireChain = () => {
if (chainRow.some((c) => !c.combatId)) { requestAnimationFrame(wireChain); return }
for (let i = 0; i < chainRow.length - 1; i++) {
chainRow[i].setChain([{ target: chainRow[i + 1].combatId, amount: 99, delay: 0.15 }])
}
}
wireChain()
tosi-b3d { width: 100%; height: 100%; }
Death outcomes
What happens when it dies is configurable — the default just removes the mesh, but you can compose:
explode="on"— shatter the mesh into flying fragments (b3d-exploder;explodeForcetunes it).deathBlast="on"— detonate a real AOE warhead at the death point afterblastDelay(default 100 ms). This is a second, distinct chain mechanism fromchain:chainis direct HP transfer to named targets;deathBlastis a falloff + line-of-sight explosion that ripples out and can set off any nearby destroyable — which may blast its neighbours, cascading. (blastDamage/blastFullRadius/blastRadius.)whenDestroyedcallback (set in code) + the bubblingdestroyedevent — the seam for putting a linked player/vehicle into a dead state, spawning loot, or swapping in a wreck model.
Demo — a field of fuel drums that chain-explode
Click any drum — it explodes, and the blast sets off its neighbours in a spreading
chain reaction (each drum both explodes and fires a deathBlast).
import { b3d, b3dDestroyable, b3dLight, b3dSkybox, b3dGround } from 'tosijs-3d'
const drums = []
for (let i = 0; i < 48; i++) {
drums.push(b3dDestroyable({
x: (i % 8) * 1.5 - 5.25, y: 0.5, z: Math.floor(i / 8) * 1.5 - 3.75,
size: 0.9, capacity: 6, color: '#d06020',
explode: 'on', explodeForce: 7,
deathBlast: 'on', blastDamage: 30, blastFullRadius: 1.2, blastRadius: 2.4,
}))
}
const scene = b3d(
{
sceneCreated(el, BABYLON) {
const cam = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, Math.PI / 3, 18, new BABYLON.Vector3(0, 0.5, 0), el.scene)
cam.attachControl(el.querySelector('canvas'), true)
el.setActiveCamera(cam)
el.scene.onPointerDown = (_evt, pick) => {
if (!pick.hit || !pick.pickedMesh) return
const t = drums.find((d) => d.mesh === pick.pickedMesh)
if (t) t.damage(99)
}
},
},
b3dLight({ y: 1, intensity: 0.85 }),
b3dSkybox({ timeOfDay: 10 }),
b3dGround({ width: 30, height: 30, color: '#5a6b52' }),
...drums,
)
preview.append(scene)
tosi-b3d { width: 100%; height: 100%; }
It participates in the floating origin: because AbstractMesh treats the
x/y/z attributes as the source of truth for the mesh position, this uses
onOriginShift to shift BOTH the mesh node and its x/z attributes on a rebase
(NOT registerWorldRoot, which would leave the attributes stale so a later render
would un-shift the mesh).
Attributes: capacity, armor, regenRate, regenDelay, protectedBy,
protection, the death-outcome knobs (explode/explodeForce, deathBlast +
blastDamage/blastFullRadius/blastRadius/blastDelay), plus size/color for
the placeholder cube and the usual x/y/z/meshName. Set .chain (a ChainLink[])
in code for direct-transfer chain reactions, or whenDestroyed for a death hook. Call
.damage(n) to hurt it (a warhead will do this on contact).