Bauen auf Voxel-Terrain in Babylon.js · Teil 8 von 10 der Serie
Teil 8: Schutt
Mach aus ruhenden Stücken Schutt: fest für die Spielfigur und für die Stücke, die noch fallen, in jedem Winkel, nicht zum Bebauen, Block für Block abzubauen, in Stücke zerfallend, wo er sich teilt, und wieder fallend, wenn verschwindet, worauf er liegt.

Quest-Ziele
- Ein gedrehtes Stück in seinem eigenen Rahmen nach seiner nächsten Oberfläche fragen
- Schutt fest machen, für die Spielfigur und für fallende Stücke
- Auf einen Block Schutt zielen und ihn abbrechen
- Schutt in die Gruppen teilen, die sich noch berühren
- Schutt wecken, wenn der Boden oder die Blöcke unter ihm verschwinden
Ein Stück, das in Teil 7 zur Ruhe gekommen ist, ist ein Geist: Du siehst es und gehst hindurch. Dieser Teil macht daraus Schutt. Schutt ist fest, für dich und für jedes Stück, das auf ihm landet, so schräg, wie er heruntergekommen ist. Er gehört nicht mehr zum Gitter, also kann nichts auf ihm gebaut werden, aber er lässt sich abbauen, Block für Block. Und Schutt ist ein ruhender Körper, keiner, der für immer stehen geblieben ist: Grab den Boden unter ihm weg, und er fällt wieder.
Ein gedrehter fester Körper
Schutt liegt in jedem Winkel, aber seine Blöcke sind noch die Gitterblöcke, die sie waren, im eigenen Rahmen des Stücks. Um ein Stück nach der Oberfläche zu fragen, die einem Punkt am nächsten ist, dreh also den Punkt in diesen Rahmen: Zieh die Position des Körpers ab, dreh ihn mit der Konjugierten der Lage zurück und addiere die Mitte. Dort sind die Blöcke wieder ein Gitter, und die Suche aus Teil 3 funktioniert unverändert, im eigenen Speicher des Stücks. Seine Normale wird wieder in die Welt hinausgedreht.
Die Suche im Gitter zieht in eine eigene Funktion, für den Speicher der Welt und für den jedes Stücks. Solids bekommt eine Liste anderer fester Körper, jedes Mal erfragt, denn Schutt kommt und geht. Ersetze in src/solids.ts alles nach blockDistance():
/** The nearest surface of the blocks in a store, if one is within `reach` meters. */
export function nearestBlock(store: BlockStore, x: number, y: number, z: number, reach: number): Surface | null {
let nearest: Surface | null = null;
// The blocks with a cell within reach, each once.
const seen = new Set<Block>();
const from = (c: number) => Math.floor((c - reach) / CELL);
const to = (c: number) => Math.floor((c + reach) / CELL);
for (let k = from(z); k <= to(z); k += 1) {
for (let j = from(y); j <= to(y); j += 1) {
for (let i = from(x); i <= to(x); i += 1) {
const block = store.at(i, j, k);
if (!block || seen.has(block)) continue;
seen.add(block);
// Out of a block only where no other block fills the cells beyond.
const surface = blockDistance(block, x, y, z, (side) => !covered(store, block, SIDES[side]));
if (surface.distance < reach && (!nearest || surface.distance < nearest.distance)) nearest = surface;
}
}
}
return nearest;
}
/** Something else that is solid, such as rubble: it finds its own nearest surface. */
export interface Solid {
nearest(x: number, y: number, z: number, reach: number): Surface | null;
}
/** Everything solid: the terrain, the blocks, and whatever `others` hands out. */
export class Solids {
/** More solids, asked for each time, as they come and go: the rubble. */
others: () => Iterable<Solid> = () => [];
private readonly field: Field;
private readonly store: BlockStore;
constructor(field: Field, store: BlockStore) {
this.field = field;
this.store = store;
}
/** The nearest surface to a point, if one is within `reach` meters. */
nearest(x: number, y: number, z: number, reach: number): Surface | null {
let nearest: Surface | null = null;
// The terrain: its density over how fast the density changes is about the distance
// to its surface, as the player worked it out so far.
const { density, gradient } = this.field.probe(x, y, z);
const steepness = Math.hypot(...gradient);
if (steepness > 0 && -density / steepness < reach) {
const normal = gradient.map((g) => -g / steepness) as Surface['normal'];
nearest = { distance: -density / steepness, normal };
}
// The blocks, and the other solids.
const surfaces = [nearestBlock(this.store, x, y, z, reach)];
for (const solid of this.others()) surfaces.push(solid.nearest(x, y, z, reach));
for (const surface of surfaces) {
if (surface && (!nearest || surface.distance < nearest.distance)) nearest = surface;
}
return nearest;
}
}Ein Stück wird für jeden Punkt jedes fallenden Stücks und für die Spielfigur gefragt, also prüft es zuerst seine Hüllkugel: Ist der Punkt weiter von der Mitte entfernt als der fernste Kontaktpunkt des Stücks plus die Reichweite, gibt es nichts zu finden.
Das Stück als Schutt
Das Stück behält jetzt seinen eigenen Speicher, für die Suchen, und seine Meshdaten, um es dort neu zu beleuchten, wo es zur Ruhe kommt: das Licht aus Teil 2, an den Vertices gelesen, wo sie jetzt in der Welt sind. In Ruhe kann man auf es zielen. Ein Stück kann außerdem in der Lage eines anderen Körpers beginnen, was das Zerbrechen von Schutt braucht. Aktualisiere in src/pieces.ts die Importe:
import { TransformNode, Vector3, type Material, type Mesh, type Scene } from '@babylonjs/core/pure';
import { BlockStore, CELL, cellKey, type Block } from './blocks.ts';
import { meshBlocks, type BlockMesh } from './blockMesher.ts';
import { Body, type BodyPart } from './body.ts';
import { CHUNK_SIZE } from './grid.ts';
import { MATERIALS } from './materials.ts';
import { createMesh } from './meshData.ts';
import { shapeDistance, shapeOf } from './shapes.ts';
import { lightAt } from './skyLight.ts';
import { nearestBlock, type Solids, type Surface } from './solids.ts';
import { looseGroups } from './support.ts';
import type { World } from './world.ts';Ersetze die Klasse Piece:
/**
* A group of blocks that broke off. Its blocks keep the places they had in the grid: that
* is the piece's own frame. A body moves and turns it, and the mesh hangs under a node
* that follows the body. At rest, it is rubble: solid, but not to build on.
*/
export class Piece {
readonly blocks: Block[];
/** The piece's own store, in its own frame. */
readonly own = new BlockStore();
readonly body: Body;
readonly node: TransformNode;
/** How far its points reach from its middle. */
readonly radius: number;
private readonly mesh: Mesh;
private readonly data: BlockMesh;
private readonly world: World;
/** With `pose`, the piece starts where that body is, turned as it is. */
constructor(blocks: Block[], scene: Scene, material: Material, world: World, pose?: Body) {
this.blocks = blocks;
this.world = world;
for (const block of blocks) this.own.add(block);
this.body = new Body(blocks.map(partOf), contactPoints(blocks, this.own));
if (pose) {
this.body.orientation.copyFrom(pose.orientation);
this.body.position.copyFrom(pose.toWorld(this.body.center));
}
this.radius = Math.max(...this.body.points.map((point) => Vector3.Distance(point, this.body.center)));
// The sides between its blocks are left out.
this.data = meshBlocks(blocks, this.own)!;
this.mesh = createMesh('piece', this.data, scene);
this.mesh.setVerticesData('material', this.data.materials, false, 1);
this.mesh.setVerticesData('projection', this.data.projections, false, 1);
this.mesh.material = material;
this.mesh.metadata = { piece: this };
this.mesh.isPickable = false;
this.light();
// The mesh is in the grid's meters: moved back by the middle, the node turns it
// about the middle.
this.node = new TransformNode('piece', scene);
this.mesh.parent = this.node;
this.mesh.position = this.body.center.negate();
this.node.rotationQuaternion = this.body.orientation.clone();
this.follow();
}
/** Puts the node where the body is. */
follow(): void {
this.node.position.copyFrom(this.body.position);
this.node.rotationQuaternion!.copyFrom(this.body.orientation);
}
/** A point of the world in the piece's own frame. */
toLocal(point: Vector3): Vector3 {
return point
.subtract(this.body.position)
.applyRotationQuaternion(this.body.orientation.conjugate())
.addInPlace(this.body.center);
}
/** The nearest surface of the piece to a point of the world, if within reach. */
nearest(x: number, y: number, z: number, reach: number): Surface | null {
const point = new Vector3(x, y, z);
if (Vector3.Distance(point, this.body.position) > this.radius + reach) return null;
const local = this.toLocal(point);
const surface = nearestBlock(this.own, local.x, local.y, local.z, reach);
if (!surface) return null;
const normal = Vector3.FromArray(surface.normal).applyRotationQuaternion(this.body.orientation);
return { distance: surface.distance, normal: normal.asArray() as Surface['normal'] };
}
/** The block under a point of its surface, where `normal` points out. */
blockAt(point: Vector3, normal: Vector3): Block | undefined {
const inside = this.toLocal(point.subtract(normal.scale(0.01)));
return this.own.at(Math.floor(inside.x / CELL), Math.floor(inside.y / CELL), Math.floor(inside.z / CELL));
}
/** At rest it is rubble: it can be aimed at, and it gets the light where it lies. */
rest(): void {
this.mesh.isPickable = true;
this.light();
}
/** Sets it falling again. */
wake(): void {
this.body.wake();
this.mesh.isPickable = false;
}
dispose(): void {
this.node.dispose();
}
/** The light of the place where the piece is now, from the grid of its middle's chunk. */
private light(): void {
const count = this.data.positions.length / 3;
const positions = new Float32Array(count * 3);
const normals = new Float32Array(count * 3);
for (let i = 0; i < count; i += 1) {
const local = Vector3.FromArray(this.data.positions, i * 3);
const normal = Vector3.FromArray(this.data.normals, i * 3).applyRotationQuaternion(this.body.orientation);
this.body.toWorld(local).toArray(positions, i * 3);
normal.toArray(normals, i * 3);
}
const [x, y, z] = this.body.position.asArray().map((c) => Math.floor(c / CHUNK_SIZE));
const grid = this.world.lightGrid({ x, y, z });
const light = grid ? lightAt(grid, { x: 0, y: 0, z: 0 }, positions, normals) : new Float32Array(count).fill(1);
this.mesh.setVerticesData('light', light, false, 1);
}
}toLocal()isttoWorld()rückwärts.nearest()ist die Frage des festen Körpers: der Kugeltest, dann der Speicher.blockAt()findet den Block unter einem Punkt der Oberfläche, wie es das Zielen aus Teil 4 im Gitter tut.light()dreht jeden Vertex und jede Normale in die Welt und liest das Gitter des Chunks, in dem die Mitte liegt.
Schutt zerbrechen
Auf Schutt wird nicht gebaut, aber ein Linksklick bricht einen Block von ihm ab. Was übrig ist, kann auseinanderfallen: Brich die Mitte eines Balkens aus Schutt heraus, und er ist zwei Hälften. Sie werden gefunden, wie Teil 6 lose Gruppen findet, im eigenen Speicher des Stücks, wobei nichts etwas hält: Jede Gruppe, die sich noch berührt, wird ein eigenes Stück, in der Lage des alten Stücks. Alle wachen auf und kommen wieder zur Ruhe: Die Hälfte, die auf dem abgebrochenen Block lag, kippt um.
Schutt wecken
Schutt liegt auf etwas. Wenn ein Block weggenommen oder der Boden gegraben wird, wird der Schutt nahe dieser Stelle geweckt und fällt, wenn er muss. Hat sich unter ihm nichts geändert, ruht er binnen einer halben Sekunde wieder. Das ist billiger, als für jedes Stück Schutt auszurechnen, worauf es liegt.
Ersetze die Klasse Pieces. Sie gibt ihren Schutt an die festen Körper und hält die Liste einmal pro Frame bereit, denn die festen Körper fragen sie oft:
/** The pieces that broke off: falling, or at rest as rubble. */
export class Pieces {
readonly all: Piece[] = [];
/** The pieces at rest, as of the last update. */
rubble: Piece[] = [];
private readonly scene: Scene;
private readonly material: Material;
private readonly world: World;
private readonly solids: Solids;
constructor(scene: Scene, material: Material, world: World, solids: Solids) {
this.scene = scene;
this.material = material;
this.world = world;
this.solids = solids;
// Rubble is solid, for the player and for the pieces still falling.
solids.others = () => this.rubble;
}
/** How many pieces are falling. */
get falling(): number {
return this.all.length - this.rubble.length;
}
/** A loose group leaves the grid and falls as one piece. */
breakOff(group: Block[], store: BlockStore): void {
for (const block of group) store.remove(block);
this.all.push(new Piece(group, this.scene, this.material, this.world));
}
/**
* Breaks a block off a piece. What is left falls again, in as many pieces as it falls
* apart into: the groups of blocks that still touch, found as the support finds loose
* groups, with nothing holding them.
*/
breakBlock(piece: Piece, block: Block): void {
this.remove(piece);
piece.own.remove(block);
const left = piece.blocks.filter((other) => other !== block);
for (const group of looseGroups(piece.own, () => false, left)) {
this.all.push(new Piece(group, this.scene, this.material, this.world, piece.body));
}
}
/** Wakes the rubble near a box of the world, whose ground or blocks went away. */
wakeNear(low: Vector3, high: Vector3): void {
for (const piece of this.rubble) {
const { position } = piece.body;
const nearest = Vector3.Clamp(position, low, high);
if (Vector3.Distance(position, nearest) <= piece.radius + 0.5) piece.wake();
}
}
/** Whether a block would go into rubble: whether the middles of its cells would. */
overlaps(block: Block): boolean {
for (const piece of this.rubble) {
for (let k = 0; k < block.size; k += 1) {
for (let j = 0; j < block.size; j += 1) {
for (let i = 0; i < block.size; i += 1) {
const [x, y, z] = [block.x + i + 0.5, block.y + j + 0.5, block.z + k + 0.5].map((c) => c * CELL);
const surface = piece.nearest(x, y, z, CELL);
if (surface && surface.distance < 0) return true;
}
}
}
}
return false;
}
/** Once a frame: moves the falling pieces. */
update(dt: number): void {
for (const piece of [...this.all]) {
if (piece.body.resting) continue;
piece.body.update(dt, this.solids);
piece.follow();
if (piece.body.resting) piece.rest();
if (piece.body.position.y < BOTTOM) this.remove(piece);
}
this.rubble = this.all.filter((piece) => piece.body.resting);
}
private remove(piece: Piece): void {
piece.dispose();
this.all.splice(this.all.indexOf(piece), 1);
this.rubble = this.rubble.filter((other) => other !== piece);
}
}overlaps() hält neue Blöcke aus dem Schutt heraus: Ein Block darf nicht dorthin, wo die Mitte einer seiner Zellen in einem Stück liegt.
Auf Schutt zielen
Ein Strahl kann jetzt Schutt treffen. Sein Mesh trägt das Stück in seinen Metadaten, und das Stück findet den Block unter dem getroffenen Punkt. Ersetze src/aim.ts:
import { Vector3, type Camera, type Scene } from '@babylonjs/core/pure';
import { CELL, type Block, type BlockStore } from './blocks.ts';
import type { Piece } from './pieces.ts';
/** Where the camera looks: at a side of a block's box, at a block of rubble, or at the ground. */
export type Aim =
| { kind: 'block'; block: Block; axis: number; dir: number; point: Vector3 }
| { kind: 'rubble'; piece: Piece; block: Block; point: Vector3 }
| { kind: 'ground'; point: Vector3; normal: Vector3 };
/** What the camera's ray hits first within `reach` meters. */
export function aimAt(scene: Scene, camera: Camera, store: BlockStore, reach: number): Aim | null {
const hit = scene.pickWithRay(camera.getForwardRay(reach));
if (!hit?.pickedPoint || !hit.pickedMesh) return null;
const point = hit.pickedPoint;
const normal = hit.getNormal(true) ?? Vector3.Up();
const piece = (hit.pickedMesh.metadata as { piece?: Piece } | null)?.piece;
if (piece) {
const block = piece.blockAt(point, normal);
if (block) return { kind: 'rubble', piece, block, point };
}
if (hit.pickedMesh.metadata === 'blocks') {
// A point just inside the surface that was hit is in the block's cells.
const inside = point.subtract(normal.scale(0.01));
const block = store.at(Math.floor(inside.x / CELL), Math.floor(inside.y / CELL), Math.floor(inside.z / CELL));
if (block) {
// The side of the box the point is nearest to, also on a rounded surface: the
// axis along which it lies furthest from the block's middle.
const meters = block.size * CELL;
const local = [point.x - block.x * CELL, point.y - block.y * CELL, point.z - block.z * CELL].map(
(c) => c / meters - 0.5,
);
const axis = [0, 1, 2].reduce((best, a) => (Math.abs(local[a]) > Math.abs(local[best]) ? a : best));
return { kind: 'block', block, axis, dir: Math.sign(local[axis]) || 1, point };
}
}
return { kind: 'ground', point, normal };
}Das Block-Werkzeug behandelt Schutt eigens: kein Geist, eine Markierung, gedreht, wie der Schutt liegt, und nur die linke Taste tut etwas. Ergänze in src/buildTool.ts die Felder von BlockTool:
/** Breaks a block off rubble. */
breakRubble: (piece: Piece, block: Block) => void = () => {};
/** Whether the camera looks at rubble. */
atRubble = false;In update() wird Schutt behandelt, bevor die Platzierung ausgerechnet wird. Ersetze die Zeilen von if (!aim) bis zu der, die die Platzierung ausrechnet, beide eingeschlossen:
this.atRubble = aim?.kind === 'rubble';
if (!aim) {
this.clicked = null;
return;
}
if (aim.kind === 'rubble') {
// Rubble isn't built on: a left click only breaks blocks off it.
this.markRubble(aim.piece, aim.block);
const action = this.clicked ?? (now - this.lastEdit >= REPEAT ? this.held : null);
this.clicked = null;
if (action !== 'break') return;
this.lastEdit = now;
this.breakRubble(aim.piece, aim.block);
return;
}
const block = this.placement(aim);Die Markierung für einen Gitterblock ist wieder gerade: Ergänze nach placeMarker() für das Ziel this.target.rotationQuaternion = null;. Die Markierung für Schutt sitzt in der Mitte des Blocks, gedreht wie das Stück:
/** Marks a block of rubble, turned as the rubble lies. */
private markRubble(piece: Piece, block: Block): void {
const half = (block.size * CELL) / 2;
const middle = new Vector3(block.x * CELL + half, block.y * CELL + half, block.z * CELL + half);
this.target.position.copyFrom(piece.body.toWorld(middle));
this.target.rotationQuaternion = piece.body.orientation.clone();
this.target.scaling.setAll(half * 2 + 0.02);
this.target.isVisible = true;
}placement() nimmt jetzt nur Blöcke und den Boden: private placement(aim: Exclude<Aim, { kind: 'rubble' }>): Block. Importiere Vector3 aus Babylon.js und den Typ Piece.
In src/main.ts wecken Speicher und Welt den Schutt. Ersetze vom Kommentar darüber, was die Blöcke hält, bis zum Handler des Edits:
// What holds the blocks: after a block is broken or the ground dug, the blocks around
// are checked, and the groups nothing holds any more break off and fall. Rubble near
// the change falls again too: it may have lain on what went.
const support = new Support(blocks.store, field);
// Something to try the rules on, in front of the player.
buildSample(blocks.store, field, 96, 158);
const pieces = new Pieces(scene, blockMaterial, world, solids);
blocks.store.onRemove = (block) => {
support.removed(block);
const low = new Vector3(block.x, block.y, block.z).scaleInPlace(CELL);
pieces.wakeNear(low, low.add(new Vector3(1, 1, 1).scaleInPlace(block.size * CELL)));
};
world.onEdit = (box) => {
field.forget();
support.groundChanged(box);
pieces.wakeNear(Vector3.FromArray(box.low), Vector3.FromArray(box.high));
};Nach dem Erzeugen der Werkzeuge zerbricht Schutt, und nichts kommt in ihn hinein:
// Aiming at a block shows what would fall with it. Rubble breaks apart.
blockTool.wouldFall = (block) => support.wouldFall(block);
blockTool.breakRubble = (piece, block) => pieces.breakBlock(piece, block);
// A block may not go into rubble, nor where the player stands: balls at the feet, the
// middle and the head must stay clear of it. Flying, the camera goes through anything.
blockTool.canPlace = (block) => {
if (pieces.overlaps(block)) return false;
if (mode === 'fly') return true;
const { x, y, z } = player.position;
return [RADIUS, HEIGHT / 2, HEIGHT - RADIUS].every(
(height) => blockDistance(block, x, y + height, z).distance >= RADIUS,
);
};Das HUD sagt, was ein Linksklick mit Schutt tut, und zählt ihn:
blocks:
`Blocks: ${MATERIALS[blockTool.material].name}, ${blockTool.size * CELL} m, ${blocks.store.count} placed` +
(blockTool.falling > 0 ? `\nBreaking this drops ${blockTool.falling} more` : '') +
(blockTool.atRubble ? '\nRubble: left click breaks it up' : ''),
(pieces.all.length > 0 ? `${pieces.falling} falling, ${pieces.rubble.length} rubble\n` : '') +Ausprobieren
Schlag den untersten Block der Säule über dem Boden heraus und lass sie herunterkommen. Geh zum Schutt und kletter hinauf: Er trägt dich in dem Winkel, in dem er liegt. Versuch, auf ihm zu bauen: kein Geist, nur eine weiße Markierung, gedreht wie der Block. Brich dich mit Linksklicks Block für Block hindurch; wo die Säule in zwei Teile zerfällt, kommt jede Hälfte für sich zur Ruhe. Grab dann mit dem Terrain-Werkzeug ein Loch unter ein Stück Schutt, und es fällt hinein.
