SOURCE / PINNED RELEASE
Made of little things.
Impact Yard
- Release
- 09fe6aebec3c…
- Author-recorded commit
- 0696a4211733…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-0b3d7b60562
Archive hash verified: 823f5de0d2677ef2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { Body, Box, ContactMaterial, ConvexPolyhedron, Cylinder, GSSolver, Material, Plane, RaycastVehicle, SAPBroadphase, Vec3, World } from 'cannon-es';
export type PropKind = 'crate' | 'brick' | 'barrel' | 'timber' | 'domino';
export type Prop = { body: Body; kind: PropKind; size: [number, number, number]; start: Vec3; group: number; variant: number };
export type DriveInput = { throttle: number; steer: number; brake: boolean };
export const FIXED_STEP = 1 / 120;
export const GROUPS = [
{ name: 'Crate stacks', short: 'CRATES', x: 0, z: -9, width: 9, depth: 8 },
{ name: 'Masonry wall', short: 'MASONRY', x: -17, z: -15, width: 12, depth: 8 },
{ name: 'Barrel pyramid', short: 'BARRELS', x: 15, z: -22, width: 9, depth: 8 },
{ name: 'Crate tower', short: 'TOWER', x: -19, z: 10, width: 9, depth: 8 },
{ name: 'Domino run', short: 'DOMINO RUN', x: -30, z: 12.2, width: 5, depth: 19 },
{ name: 'Timber tower', short: 'TIMBER', x: 26, z: 16, width: 7, depth: 8 },
{ name: 'Smash arch', short: 'SMASH ARCH', x: -8, z: -28, width: 10, depth: 7 },
];
// Shared by the collider and its rendered mesh. The entrance top is at y=0;
// all its thickness is underground, so the bumper cannot strike a raised lip.
export const RAMP = {
x: 18, z: -4, width: 6, length: 14, height: 2.8,
vertices: [[-3, -0.15, -7], [3, -0.15, -7], [3, -0.15, 7], [-3, -0.15, 7],
[-3, 2.8, -7], [3, 2.8, -7], [3, 0, 7], [-3, 0, 7]],
faces: [[0, 1, 2, 3], [4, 7, 6, 5], [0, 4, 5, 1], [3, 2, 6, 7], [0, 3, 7, 4], [1, 5, 6, 2]],
};
export function createSimulation() {
const world = new World({ gravity: new Vec3(0, -9.81, 0), allowSleep: true });
world.broadphase = new SAPBroadphase(world);
(world.solver as GSSolver).iterations = 18;
(world.solver as GSSolver).tolerance = 0.001;
world.defaultContactMaterial.friction = 0.55;
world.defaultContactMaterial.restitution = 0.03;
world.defaultContactMaterial.contactEquationStiffness = 1e8;
world.defaultContactMaterial.contactEquationRelaxation = 3;
const groundMaterial = new Material('concrete');
const propMaterial = new Material('props');
const carMaterial = new Material('car');
const dominoMaterial = new Material('smooth panels with rubber feet');
world.addContactMaterial(new ContactMaterial(groundMaterial, propMaterial, { friction: 0.58, restitution: 0.06 }));
world.addContactMaterial(new ContactMaterial(propMaterial, propMaterial, { friction: 0.55, restitution: 0.04 }));
world.addContactMaterial(new ContactMaterial(carMaterial, propMaterial, { friction: 0.35, restitution: 0.08 }));
world.addContactMaterial(new ContactMaterial(carMaterial, groundMaterial, { friction: 0.3, restitution: 0.01 }));
world.addContactMaterial(new ContactMaterial(dominoMaterial, groundMaterial, { friction: 1.1, restitution: 0.02 }));
world.addContactMaterial(new ContactMaterial(dominoMaterial, dominoMaterial, { friction: 0.15, restitution: 0.15 }));
const ground = new Body({ mass: 0, material: groundMaterial, shape: new Plane() });
ground.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
ground.aabbNeedsUpdate = true;
world.addBody(ground);
const barriers: Body[] = [];
for (const [x, z, sx, sz] of [[0, -39, 39, 0.5], [0, 39, 39, 0.5], [-39, 0, 0.5, 39], [39, 0, 0.5, 39]]) {
const body = new Body({ mass: 0, material: groundMaterial, shape: new Box(new Vec3(sx, 0.7, sz)), position: new Vec3(x, 0.7, z) });
world.addBody(body);
barriers.push(body);
}
const ramp = new Body({ mass: 0, material: groundMaterial,
shape: new ConvexPolyhedron({ vertices: RAMP.vertices.map(([x, y, z]) => new Vec3(x, y, z)), faces: RAMP.faces.map(face => [...face]) }),
position: new Vec3(RAMP.x, 0, RAMP.z) });
world.addBody(ramp);
const props: Prop[] = [];
function add(kind: PropKind, x: number, y: number, z: number, size: [number, number, number], mass: number, group: number) {
const shape = kind === 'barrel' ? new Cylinder(size[0] / 2, size[0] / 2, size[1], 16) : new Box(new Vec3(size[0] / 2, size[1] / 2, size[2] / 2));
const body = new Body({ mass, material: kind === 'domino' ? dominoMaterial : propMaterial, shape, position: new Vec3(x, y, z), linearDamping: 0.015, angularDamping: 0.025, sleepSpeedLimit: kind === 'domino' ? 0.015 : 0.15, sleepTimeLimit: 0.8 });
world.addBody(body);
props.push({ body, kind, size, start: body.position.clone(), group, variant: props.length % 4 });
}
for (let level = 0; level < 4; level++) {
const width = 5 - level;
for (let x = 0; x < width; x++) for (let depth = 0; depth < 2; depth++) {
add('crate', (x - (width - 1) / 2) * 1.44, 0.7 + level * 1.405, -9 + depth * 1.43, [1.4, 1.4, 1.4], 32, 0);
}
}
for (let level = 0; level < 5; level++) for (let x = 0; x < 7; x++) {
add('brick', -17 + (x - 3) * 1.43 + (level % 2) * 0.36, 0.36 + level * 0.725, -15, [1.4, 0.72, 0.9], 75, 1);
}
for (let level = 0; level < 3; level++) for (let x = 0; x < 4 - level; x++) for (let z = 0; z < 2; z++) {
add('barrel', 15 + (x - (3 - level) / 2) * 1.16, 0.74 + level * 1.485, -22 + z * 1.16, [1.12, 1.48, 1.12], 38, 2);
}
for (let level = 0; level < 6; level++) for (let x = 0; x < 2; x++) for (let z = 0; z < 2; z++) {
add('crate', -19 + (x - 0.5) * 1.44, 0.7 + level * 1.405, 10 + (z - 0.5) * 1.44, [1.4, 1.4, 1.4], 32, 3);
}
// A small gap relative to panel height lets one falling slab tip the next.
for (let i = 0; i < 14; i++) {
add('domino', -30, 1.7, 20 - i * 1.2, [2.8, 3.4, 0.4], 52, 4);
}
// Alternating solid timbers rest on each other without invisible anchors.
for (let level = 0; level < 9; level++) for (let lane = 0; lane < 3; lane++) {
const crosswise = level % 2 === 0;
add('timber', 26 + (crosswise ? 0 : (lane - 1) * 1.22), 0.275 + level * 0.555,
16 + (crosswise ? (lane - 1) * 1.22 : 0), crosswise ? [3.6, 0.55, 1.18] : [1.18, 0.55, 3.6], 24, 5);
}
// Take out either pier and the lintel and its loose cargo fall with it.
for (const side of [-1, 1]) for (let level = 0; level < 3; level++) {
add('brick', -8 + side * 2.8, 0.5 + level * 1.005, -28, [1.4, 1, 1.8], 90, 6);
}
add('timber', -8, 3.32, -28, [7.4, 0.6, 1.8], 90, 6);
for (let i = 0; i < 4; i++) add('crate', -8 + (i - 1.5) * 1.45, 4.325, -28, [1.4, 1.4, 1.4], 32, 6);
const chassis = new Body({ mass: 950, material: carMaterial, linearDamping: 0.025, angularDamping: 0.35, allowSleep: false });
chassis.addShape(new Box(new Vec3(0.88, 0.3, 1.85)));
chassis.addShape(new Box(new Vec3(0.69, 0.28, 0.8)), new Vec3(0, 0.52, 0.2));
const vehicle = new RaycastVehicle({ chassisBody: chassis, indexRightAxis: 0, indexUpAxis: 1, indexForwardAxis: 2 });
for (const [x, z] of [[-0.95, -1.22], [0.95, -1.22], [-0.95, 1.22], [0.95, 1.22]]) {
vehicle.addWheel({
radius: 0.4, directionLocal: new Vec3(0, -1, 0), axleLocal: new Vec3(-1, 0, 0),
chassisConnectionPointLocal: new Vec3(x, 0.04, z), suspensionStiffness: 34,
suspensionRestLength: 0.34, frictionSlip: 2.8, dampingRelaxation: 2.8,
dampingCompression: 4.5, maxSuspensionForce: 50000, rollInfluence: 0.08,
maxSuspensionTravel: 0.24, customSlidingRotationalSpeed: -25, useCustomSlidingRotationalSpeed: true,
});
}
vehicle.addToWorld(world);
let steering = 0;
let peakImpact = 0;
let elapsed = 0;
let accumulator = 0;
chassis.addEventListener('collide', (event: { body: Body; contact: { getImpactVelocityAlongNormal(): number } }) => {
if (event.body.mass > 0) peakImpact = Math.max(peakImpact, Math.abs(event.contact.getImpactVelocityAlongNormal()));
});
function resetBody(body: Body, position: Vec3) {
body.position.copy(position);
body.previousPosition.copy(position);
body.interpolatedPosition.copy(position);
body.quaternion.set(0, 0, 0, 1);
body.previousQuaternion.copy(body.quaternion);
body.interpolatedQuaternion.copy(body.quaternion);
body.velocity.setZero(); body.angularVelocity.setZero(); body.force.setZero(); body.torque.setZero();
body.aabbNeedsUpdate = true;
body.wakeUp();
}
function recover() {
// Recovery is an explicit user action; impacts never reposition bodies.
resetBody(chassis, new Vec3(0, 1.1, 17));
steering = 0;
vehicle.wheelInfos.forEach((_, i) => { vehicle.setSteeringValue(0, i); vehicle.applyEngineForce(0, i); vehicle.setBrake(0, i); });
vehicle.updateVehicle(FIXED_STEP);
}
function rebuild() {
for (const prop of props) resetBody(prop.body, prop.start);
recover(); peakImpact = 0; elapsed = 0; accumulator = 0;
}
function step(dt: number, input: DriveInput) {
accumulator += Math.min(dt, 0.05);
while (accumulator >= FIXED_STEP) {
const speed = chassis.velocity.length();
const targetSteering = input.steer * (0.48 / (1 + speed * 0.055));
steering += (targetSteering - steering) * 0.065;
vehicle.setSteeringValue(steering, 0); vehicle.setSteeringValue(steering, 1);
const engine = input.throttle * 3100 * Math.max(0, 1 - speed / (input.throttle < 0 ? 12 : 29));
for (let i = 0; i < 4; i++) {
vehicle.applyEngineForce(input.brake ? 0 : engine, i);
vehicle.setBrake(input.brake ? 70 : input.throttle === 0 ? 0.7 : 0, i);
}
world.step(FIXED_STEP);
elapsed += FIXED_STEP;
accumulator -= FIXED_STEP;
}
}
function stats() {
const displaced = props.filter(p => p.body.position.distanceTo(p.start) > 0.85);
return { speed: chassis.velocity.length() * 3.6, displaced: displaced.length, total: props.length, groups: GROUPS.map((_, i) => displaced.filter(p => p.group === i).length), peakImpact: peakImpact * 3.6, elapsed };
}
recover();
return { world, chassis, vehicle, props, barriers, ramp, step, stats, recover, rebuild };
}
export type Simulation = ReturnType<typeof createSimulation>;